访问地址:https://ai-annotation.pages.dev/
Users/baidu/Desktop/ke/ai/tools/ai-annotation-platform

AI 训练数据标注平台
读懂代码 → 理解业务 → 能向面试官清楚讲解
一、项目定位与业务背景
这个项目解决什么问题
大模型需要大量高质量的人工标注数据来训练和优化,外部标注平台有两个核心问题:
- 定制化差:公司的业务场景特殊,外部平台的标注类型和工作流不匹配
- 数据安全风险:训练数据往往包含公司核心业务信息,上传到外部平台有泄露风险
所以选择自建内部标注工具。
标注数据的价值链条(面试必懂)
标注员标注数据 → 质检员审核 → 合格数据进入训练集 → 模型迭代 → AI 效果提升
标注数据的质量直接决定模型质量。标注一致性得分(Cohen's Kappa)从 0.71 提升到 0.85,意味着不同标注员对同一条数据的判断更一致,训练数据的噪声更少,模型学到的规律更准确。
四种标注类型的业务场景
| 标注类型 | 业务场景 | 产出数据用途 |
|---|---|---|
| 对话偏好(RLHF) | 两个 AI 回复选更好的 | 训练奖励模型,用于 RLHF 对齐 |
| 文本分类 | 给文本打情感/类别标签 | 训练分类模型或微调大模型 |
| 命名实体识别 | 标注文本中的人名/机构/疾病等 | 训练 NER 模型 |
| 图片目标检测 | 在图片上画框标注物体 | 训练 YOLO 等目标检测模型 |
二、技术架构
整体架构
┌───────────────────────────────────────────────┐
│ 前端层 │
│ Vue3 + Vite + Element Plus + Fabric.js │
│ │
│ 任务中心 → 标注作业 → 质检管理 │
│ (领取) (标注) (审核/打回) │
└───────────────────┬───────────────────────────┘
│ Pinia 状态共享
▼
┌───────────────────────────────────────────────┐
│ 任务状态机(stores/task.js) │
│ pending → in_progress → reviewing → │
│ approved / rejected │
└───────────────────────────────────────────────┘
技术选型说明
| 技术 | 选型 | 选择原因 |
|---|---|---|
| 框架 | Vue3 | Composition API 适合复杂逻辑拆分 |
| Canvas | Fabric.js v5 | 成熟的 Canvas 封装,二次开发成本低 |
| 状态管理 | Pinia | 任务状态需要跨页面共享,用 Pinia 管理状态机 |
| 组件库 | Element Plus | 表格、弹窗等 B 端组件丰富 |
目录结构详解
src/
├── stores/
│ └── task.js ← 任务状态机(唯一的全局状态)
├── utils/
│ └── mockData.js ← 所有 Mock 数据
├── components/layout/
│ └── BasicLayout.vue ← 侧边栏 + Header + 快捷键说明
└── views/
├── task-list/ ← 任务中心(总览、领取、进度)
├── rlhf/ ← 对话偏好标注
├── classification/ ← 文本分类标注
├── ner/ ← 命名实体识别
├── image-annotation/← 图片目标检测(Canvas 核心模块)
└── quality-check/ ← 质检管理
三、任务状态机(stores/task.js)
状态流转图
领取任务
[pending] ──────────────────→ [in_progress]
待领取 标注中
│
│ 提交批次
▼
[reviewing]
待质检
╱ ╲
质检通过 ╱ ╲ 质检打回
▼ ▼
[approved] [rejected]
已通过 已打回
│
│ 标注员修改后重提交
▼
[reviewing]
代码讲解
// stores/task.js
export const useTaskStore = defineStore('task', () => {
const tasks = ref([...mockTasks])
// 领取任务:pending → in_progress
function claimTask(taskId) {
const task = tasks.value.find(t => t.id === taskId)
if (task && task.status === 'pending') {
task.status = 'in_progress'
}
}
// 提交任务:in_progress → reviewing(进入质检队列)
function submitTask(taskId) {
const task = tasks.value.find(t => t.id === taskId)
if (task) {
task.status = 'reviewing'
task.done = task.total // 标记全部完成
}
}
// 质检通过:reviewing → approved
function approveTask(taskId, passRate) {
const task = tasks.value.find(t => t.id === taskId)
if (task) {
task.status = 'approved'
task.passRate = passRate // 记录通过率
}
}
// 质检打回:reviewing → rejected(标注员需要重新处理)
function rejectTask(taskId, passRate) {
const task = tasks.value.find(t => t.id === taskId)
if (task) {
task.status = 'rejected'
task.passRate = passRate
}
}
return { tasks, claimTask, submitTask, approveTask, rejectTask, updateProgress }
})
面试讲解要点:用 Pinia 管理任务状态,是因为任务状态需要在任务列表页、各标注页、质检页之间共享。标注员在 RLHF 页面提交批次,任务列表页的状态要同步更新,用 Pinia 就不需要通过路由传参或事件总线来同步。
四、对话偏好标注(RLHF)
什么是 RLHF(面试必懂)
RLHF(Reinforcement Learning from Human Feedback,基于人类反馈的强化学习)是训练 ChatGPT、Claude 等对话模型的核心技术之一。
简单说分三步:
- 让 AI 对同一个问题生成多个回复
- 人工标注哪个回复更好(这就是这个模块在做的事)
- 用标注数据训练一个"奖励模型",再用奖励模型优化对话模型
标注员的工作质量直接影响奖励模型的准确性,所以标注界面的设计非常重要——界面越流畅,标注员效率越高,数据质量越稳定。
关键代码讲解
1. 撤销栈设计
// history 是撤销栈,存储每次提交前的状态
const history = ref([])
function submitCurrent() {
if (!canSubmit.value) return
// 提交前先把当前状态压栈
history.value.push({
index: currentIndex.value,
choice: choice.value,
comment: comment.value,
scores: { ...scores.value } // 深拷贝,否则引用会被后续修改
})
// 保存标注结果
const item = items.value[currentIndex.value]
item.choice = choice.value
item.comment = comment.value
item.scores = { ...scores.value }
item.status = 'done'
// 重置状态进入下一条
choice.value = null
comment.value = ''
scores.value = {}
moveToNext()
}
// 撤销:弹出栈顶状态,恢复到上一步
function undo() {
if (!history.value.length) return
const last = history.value.pop()
// 恢复数据
items.value[last.index].choice = null
items.value[last.index].status = 'pending'
// 跳回到被撤销的条目
currentIndex.value = last.index
choice.value = null // 不恢复 choice,让标注员重新选
comment.value = last.comment
scores.value = { ...last.scores }
}
面试讲解要点:撤销不恢复 choice 值是故意的。如果撤销后自动填上之前的选择,标注员可能没注意就直接提交了,没有达到重新思考的目的。让标注员重新选择,才能提高数据质量。
2. 全局快捷键(不影响输入框)
function handleKeydown(e) {
// 关键:输入框聚焦时禁用快捷键
// 否则在批注框里打字 "A" 会触发选择 A 的操作
const tag = document.activeElement?.tagName
if (tag === 'TEXTAREA' || tag === 'INPUT') return
const key = e.key.toUpperCase()
if (key === 'A') { e.preventDefault(); selectChoice('A') }
else if (key === 'B') { e.preventDefault(); selectChoice('B') }
else if (key === 'S') { e.preventDefault(); selectChoice('S') }
else if (e.code === 'Space') { e.preventDefault(); skip() }
else if (e.key === 'Enter') { e.preventDefault(); submitCurrent() }
else if (e.ctrlKey && key === 'Z') { e.preventDefault(); undo() }
}
// 挂载和卸载时注册/注销事件监听
onMounted(() => window.addEventListener('keydown', handleKeydown))
onUnmounted(() => window.removeEventListener('keydown', handleKeydown))
面试讲解要点:e.preventDefault() 的作用是阻止浏览器的默认行为。比如 Space 键默认会让页面滚动,Enter 键默认会提交表单,Ctrl+Z 默认是浏览器的撤销。接管这些快捷键后必须阻止默认行为,否则会出现"功能生效了但页面也在乱动"的问题。
3. 条目导航逻辑
// 找下一个"未完成且未跳过"的条目
function moveToNext() {
for (let i = currentIndex.value + 1; i < items.value.length; i++) {
if (items.value[i].choice === null && !skippedItems.value.includes(i)) {
currentIndex.value = i
return
}
}
// 没有找到下一条,说明全部完成或全部跳过
currentIndex.value = items.value.length // 触发"全部完成"状态
}
五、命名实体识别(NER)
什么是 NER(面试必懂)
命名实体识别(Named Entity Recognition)是从文本中识别出有意义的实体,比如人名、地名、机构名、疾病、药品等。
例如:
张伟于2024年11月在北京协和医院确诊为2型糖尿病
↓ 标注后
[张伟](人名) 于 [2024年11月](时间) 在 [北京协和医院](机构) 确诊为 [2型糖尿病](疾病)
标注好的数据用于训练 NER 模型,模型就能自动从新文本中识别这些实体。
关键代码讲解
1. 鼠标选区自动打标
function handleSelection() {
const selection = window.getSelection()
if (!selection || selection.isCollapsed) return // 没有选中任何内容
const selectedText = selection.toString().trim()
if (!selectedText) return
const range = selection.getRangeAt(0)
const container = textRef.value // 标注文本区域的 DOM 元素
// 确保选区在标注区域内,防止用户选中页面其他地方的文字
if (!container || !container.contains(range.commonAncestorContainer)) return
// 计算选区在原始文本中的字符位置
// 思路:创建一个从文本起点到选区起点的 Range,取其文本长度
const preRange = document.createRange()
preRange.selectNodeContents(container)
preRange.setEnd(range.startContainer, range.startOffset)
const start = preRange.toString().length
const end = start + selectedText.length
// 检查是否和已有实体重叠
const overlap = current.value.entities.some(e =>
(start >= e.start && start < e.end) || // 新实体起点在已有实体内
(end > e.start && end <= e.end) || // 新实体终点在已有实体内
(start <= e.start && end >= e.end) // 新实体完全包含已有实体
)
if (overlap) {
ElMessage.warning('选区与已有实体重叠,请先删除再重新标注')
selection.removeAllRanges()
return
}
// 添加实体
current.value.entities.push({
start, end,
text: selectedText,
type: activeType.value // 使用当前选中的实体类型
})
selection.removeAllRanges() // 清除选区高亮
}
面试讲解要点:计算选区位置用的是"前置 Range 法"——创建一个从文本开头到选区起点的 Range,它的文本长度就是 start 位置。这个方法比遍历 DOM 节点计算偏移要可靠,因为标注文本区域里有 <span> 标签(已标注的实体高亮),直接用节点偏移会出错。
2. 分段渲染(实体高亮显示)
// 把文本按已标注实体切成若干段,有实体的段加高亮样式
const renderedSegments = computed(() => {
if (!current.value) return []
const text = current.value.text
// 按起始位置排序,保证从左到右渲染
const entities = [...current.value.entities].sort((a, b) => a.start - b.start)
const segments = []
let pos = 0 // 当前处理到的位置
for (const ent of entities) {
// 实体前面的普通文本
if (ent.start > pos) {
segments.push({ text: text.slice(pos, ent.start), entity: null })
}
// 实体本身
const typeInfo = entityTypes.find(t => t.value === ent.type)
segments.push({
text: text.slice(ent.start, ent.end),
entity: typeInfo,
entityIndex: current.value.entities.indexOf(ent)
})
pos = ent.end
}
// 最后一段普通文本
if (pos < text.length) {
segments.push({ text: text.slice(pos), entity: null })
}
return segments
})
面试讲解要点:不能直接修改文本字符串来加高亮(比如插入 HTML 标签),因为那样会破坏 start/end 位置的计算。正确做法是保持原始文本不变,用 computed 动态生成渲染数据,Vue 的响应式系统会自动在实体增减时更新渲染。
六、图片目标检测(Canvas 标注引擎)
这是整个项目技术含量最高的模块,也是面试最容易被深问的部分。
Fabric.js 是什么
Fabric.js 是一个基于 HTML5 Canvas 的 JavaScript 库,提供了对象模型(矩形、圆形、多边形、图片等)、事件系统、选择/缩放/旋转交互,以及序列化/反序列化能力。
直接用原生 Canvas API 做标注工具,需要自己实现:鼠标事件处理、对象碰撞检测、选中状态管理、坐标转换等,工作量巨大。Fabric.js 把这些都封装好了,我们只需要在它的基础上做定制。
深度二次封装了什么
1. 重写控件样式
// Fabric 默认的控制锚点是方块,视觉上不够精准
// 重写为圆点,并删除旋转控制点
fabric.Object.prototype.set({
cornerStyle: 'circle', // 锚点形状改为圆形
cornerSize: 8, // 锚点大小
cornerColor: '#fff', // 锚点填充色(白色)
cornerStrokeColor: '#409eff', // 锚点边框(蓝色)
transparentCorners: false, // 不透明
borderColor: '#409eff', // 选中时的边框颜色
borderScaleFactor: 1.5 // 边框线宽
})
// 删除旋转控制点(标注框不需要旋转)
// mtr 是 Fabric 内置的旋转控件的 key 名
fabric.Object.prototype.controls.mtr = new fabric.Control({ visible: false })
面试讲解要点:修改 fabric.Object.prototype 是全局修改,影响所有 Fabric 对象。这里故意做全局修改,因为整个标注工具里所有矩形的交互样式都应该一致。如果只想影响特定对象,应该在创建对象时单独设置。
2. 绘制新标注框(mousedown → mousemove → mouseup)
// mousedown:记录起点,创建临时预览矩形
fabricCanvas.on('mouse:down', (opt) => {
if (opt.target) return // 点击已有对象,进入选择模式,不绘制
isDrawing = true
const pointer = fabricCanvas.getPointer(opt.e)
startPoint = { x: pointer.x, y: pointer.y }
// 临时预览矩形用虚线边框,视觉上区分"绘制中"和"已完成"
activeRect = new fabric.Rect({
left: startPoint.x, top: startPoint.y,
width: 0, height: 0,
fill: 'rgba(64,158,255,0.1)',
stroke: getLabel(activeLabel.value)?.color,
strokeWidth: 2,
strokeDashArray: [5, 3], // 虚线:[线段长度, 间隔长度]
selectable: false, // 绘制过程中不可选中
evented: false // 绘制过程中不响应事件
})
fabricCanvas.add(activeRect)
})
// mousemove:实时更新矩形大小,处理反向绘制
fabricCanvas.on('mouse:move', (opt) => {
if (!isDrawing || !activeRect) return
const pointer = fabricCanvas.getPointer(opt.e)
const width = pointer.x - startPoint.x
const height = pointer.y - startPoint.y
// 处理反向绘制(鼠标向左上方拖动时,left/top 需要更新)
activeRect.set({
left: width < 0 ? pointer.x : startPoint.x,
top: height < 0 ? pointer.y : startPoint.y,
width: Math.abs(width),
height: Math.abs(height)
})
// requestRenderAll 比 renderAll 性能更好,它会在下一帧统一渲染
fabricCanvas.requestRenderAll()
})
// mouseup:完成绘制,替换为正式矩形
fabricCanvas.on('mouse:up', () => {
if (!isDrawing) return
isDrawing = false
// 过滤掉太小的框(宽高都小于 10px 视为误触)
if (!activeRect || activeRect.width < 10 || activeRect.height < 10) {
if (activeRect) fabricCanvas.remove(activeRect)
activeRect = null
return
}
const labelInfo = getLabel(activeLabel.value)
// 移除临时虚线矩形,添加正式实线矩形
fabricCanvas.remove(activeRect)
const rect = new fabric.Rect({
left: activeRect.left, top: activeRect.top,
width: activeRect.width, height: activeRect.height,
fill: labelInfo.color + '20', // 颜色 + 20% 透明度(十六进制 20 ≈ 12%)
stroke: labelInfo.color,
strokeWidth: 2,
strokeDashArray: null, // 正式矩形用实线
label: activeLabel.value, // 自定义属性,存储标注类型
hasRotatingPoint: false // 禁用旋转
})
fabricCanvas.add(rect)
fabricCanvas.setActiveObject(rect) // 绘制完成后自动选中新框
syncAnnotations() // 同步到 annotations 数组
pushSnapshot() // 压快照(用于撤销)
activeRect = null
fabricCanvas.requestRenderAll()
})
3. 性能优化:关闭自动渲染
// 初始化时关闭自动渲染
fabricCanvas = new fabric.Canvas(canvasRef.value, {
width: CANVAS_WIDTH,
height: CANVAS_HEIGHT,
renderOnAddRemove: false // 关键:关闭每次 add/remove 后的自动重绘
})
// 好处:批量 add 多个对象时,不会触发多次重绘
// 只在最后手动调一次 requestRenderAll()
// 对于有几百个标注框的图片,性能差距非常明显
面试讲解要点:renderOnAddRemove: false 是最重要的性能优化。默认情况下,每调用一次 canvas.add(),Fabric 就会重绘一次整个 Canvas。如果一张图片有 100 个标注框,从 JSON 恢复时要 add 100 次,就会触发 100 次重绘。关掉之后,批量 add 完调一次 requestRenderAll(),只渲染一次。
4. 撤销/重做(JSON 快照方案)
let snapshotStack = [] // 快照栈
// 每次操作完成(mouseup)后压栈
function pushSnapshot() {
// canvas.toJSON(['label']) 序列化整个 Canvas 状态
// 传入 ['label'] 是为了把自定义属性 label 也序列化进去
// 否则恢复快照后 label 信息丢失,导致标注类型消失
const json = fabricCanvas.toJSON(['label'])
snapshotStack.push(json)
if (snapshotStack.length > 30) snapshotStack.shift() // 最多 30 帧
}
// 撤销
function undoBox() {
if (snapshotStack.length > 1) {
snapshotStack.pop() // 丢弃当前状态
const prevJson = snapshotStack[snapshotStack.length - 1]
// 从快照恢复 Canvas
fabricCanvas.loadFromJSON(prevJson, () => {
fabricCanvas.requestRenderAll()
syncAnnotations() // 同步到 annotations 数组
})
} else if (snapshotStack.length === 1) {
// 只剩一帧,清空所有标注框(保留底图)
const objects = fabricCanvas.getObjects().filter(obj => obj.label)
objects.forEach(obj => fabricCanvas.remove(obj))
fabricCanvas.requestRenderAll()
annotations.value = []
snapshotStack = []
}
}
面试讲解要点:快照方案的核心是"序列化整个 Canvas 状态",实现简单但有体积大的问题。当标注框很多时,每个快照的 JSON 体积会比较大。优化方向是"增量快照"——只记录本次操作变更的对象,而不是全量序列化。真实项目中,如果单张图片的标注框经常超过 50 个,应该考虑增量方案。
5. 同步 Fabric 对象到数据数组
// Fabric 对象和 annotations 数组要保持同步
// annotations 是给 Vue 模板渲染标注列表用的
function syncAnnotations() {
// 只取有 label 属性的对象(排除底图等非标注对象)
const objects = fabricCanvas.getObjects().filter(obj => obj.label)
annotations.value = objects.map(obj => ({
label: obj.label,
left: obj.left,
top: obj.top,
// 注意:obj.width 是原始宽度,scaleX 是缩放比例
// 实际显示宽度 = width * scaleX
width: obj.width * obj.scaleX,
height: obj.height * obj.scaleY
}))
}
面试讲解要点:Fabric 的对象有 width、height、scaleX、scaleY 四个属性,真实宽度是 width * scaleX。这是很多人用 Fabric 时踩的坑——用鼠标拖拽缩放标注框时,width 不会变,变的是 scaleX,直接用 width 会得到错误的尺寸。
七、质检管理
业务流程
质检员看到待质检任务 → 逐条审核标注结果 → 标记通过/不通过 → 统计通过率
→ 通过率 ≥ 80%:整批通过 → 进入训练集
→ 通过率 < 80%:打回 → 标注员重新处理不通过的条目
关键代码讲解
实时通过率预估
// 随着质检员逐条标记,实时更新通过率
const passRatePreview = computed(() => {
if (!selectedTask.value || !reviewedCount.value) return 0
// 已标记通过的条目数 / 已审核总数
const passed = selectedTask.value.items.filter(i => i.qcResult === 'pass').length
return Math.round(passed / reviewedCount.value * 100)
})
// 标记通过
function markPass(item) {
item.qcResult = 'pass'
// computed 自动重新计算 passRatePreview
}
// 标记不通过(必须填写原因)
function markFail(item) {
if (!item.qcComment) {
ElMessage.warning('请填写不通过原因')
return
}
item.qcResult = 'fail'
}
面试讲解要点:质检员在标记过程中实时看到通过率预估,是一个重要的 UX 设计。它让质检员在审核过程中就能判断这批数据整体质量如何,如果审核到一半通过率已经低于 60%,就可以提前决定打回,不需要审完全部。
八、面试常见问题与标准答案
Q1:这个项目解决了什么业务问题?
公司自研大模型需要持续迭代训练数据,外部平台定制化差、有数据安全风险,所以自建了这套内部标注工具。平台支持对话偏好(RLHF)、文本分类、命名实体识别、图片目标检测四种标注类型,支撑了大概 40 名标注员的日常作业,日均处理量从 5500 条提升到 8000 条。
Q2:Fabric.js 你做了哪些二次封装?
做了三块: 第一,重写了控件样式,把默认的方形锚点改成圆点,删除了旋转控制点,标注框不需要旋转这个功能反而容易误触; 第二,实现了撤销/重做,Fabric 没有内置 undo,我用 JSON 快照方案做了,每次操作后调 canvas.toJSON() 压栈,撤销时取上一个快照恢复,最多保留 30 帧; 第三,做了性能优化,关闭了 renderOnAddRemove,改为手动控制渲染时机,鼠标移动过程中只调 requestRenderAll(),mouseup 后才触发完整渲染。
Q3:撤销为什么用快照而不用 Command 模式?
两种方案各有优劣。快照方案(JSON 序列化)实现简单,不需要为每种操作定义反向操作,适合快速迭代。Command 模式每个操作都是可序列化的对象,内存占用小,支持 redo,适合大型应用。我选快照方案是因为标注工具的交互类型固定(画框、移动、缩放、删除),操作数量也不会很大,快照方案完全够用。如果要优化,可以把全量快照改成增量快照,只记录每次变更的对象。
Q4:NER 的选区位置是怎么计算的?
用"前置 Range 法"。创建一个从文本容器开头到选区起点的 Range,取这个 Range 的 toString().length,就是选区起点的字符位置。这个方法的优势是不受 DOM 结构影响——标注区域里有 <span> 标签用于高亮已有实体,如果直接用节点偏移量计算会出错,前置 Range 法直接拿文本长度,绕开了这个问题。
Q5:任务状态为什么用 Pinia 管理?
任务状态需要在三个地方共享:任务列表页(显示状态)、各标注页(提交后更新状态)、质检页(通过/打回后更新状态)。如果不用全局状态,需要通过路由参数或 provide/inject 传递,逻辑会分散在各个组件里。用 Pinia 把状态机集中管理,每个页面直接调用对应的方法(claimTask、submitTask、approveTask),状态变更自动同步到所有用到这个状态的地方。
Q6:标注数据最终用什么格式导出?
图片标注导出 JSON,格式是 { image: 文件名, annotations: [{ label: 类型, bbox: [x, y, w, h] }] },坐标是 Canvas 像素坐标。对接训练框架时需要按图片实际尺寸做归一化,因为 Canvas 展示时做了缩放,直接用像素坐标会有偏差。NER 导出的是 { text: 原文, entities: [{ start, end, text, type }] } 格式,和 HuggingFace 的 NER 数据集格式兼容。
九、对接真实后端的替换指南
1. 任务列表接口
// 当前
import { mockTasks } from '@/utils/mockData.js'
const tasks = ref([...mockTasks])
// 替换为
onMounted(async () => {
const { data } = await axios.get('/api/tasks', {
params: { assignee: currentUser.id }
})
tasks.value = data
})
2. 标注数据提交
// RLHF 标注结果提交(当前是本地状态更新)
// 替换为
async function submitCurrent() {
await axios.post('/api/annotations/rlhf', {
taskId: currentTaskId,
itemId: current.value.id,
choice: choice.value,
scores: scores.value,
comment: comment.value
})
// 提交成功后再更新本地状态
item.choice = choice.value
}
3. 图片标注数据同步
// 当前是导出 JSON 文件
// 真实项目改为提交到后端
async function submitCurrent() {
await axios.post('/api/annotations/image', {
taskId: currentTaskId,
imageId: images.value[currentIndex.value].id,
annotations: annotations.value.map(ann => ({
label: ann.label,
bbox: [Math.round(ann.left), Math.round(ann.top),
Math.round(ann.width), Math.round(ann.height)]
}))
})
images.value[currentIndex.value].status = 'done'
}
4. WebSocket 任务状态推送
质检打回/通过后,标注员需要实时收到通知:
// 在 main.js 里初始化 WebSocket 连接
const ws = new WebSocket('wss://your-api/ws')
ws.onmessage = (event) => {
const { type, taskId } = JSON.parse(event.data)
if (type === 'TASK_REJECTED') {
ElNotification({
title: '任务被打回',
message: `任务 ${taskId} 质检未通过,请修改后重新提交`,
type: 'warning'
})
// 同步更新 Pinia 状态
taskStore.rejectTask(taskId)
}
}
__