返回笔记首页

源码

主题配置

ai-annotation-platform.zip

访问地址:https://ai-annotation.pages.dev/

Users/baidu/Desktop/ke/ai/tools/ai-annotation-platform

源码+技术文档详细说明

AI 训练数据标注平台

读懂代码 → 理解业务 → 能向面试官清楚讲解


一、项目定位与业务背景

这个项目解决什么问题

大模型需要大量高质量的人工标注数据来训练和优化,外部标注平台有两个核心问题:

  1. 定制化差:公司的业务场景特殊,外部平台的标注类型和工作流不匹配
  2. 数据安全风险:训练数据往往包含公司核心业务信息,上传到外部平台有泄露风险

所以选择自建内部标注工具。

标注数据的价值链条(面试必懂)

plain
标注员标注数据 → 质检员审核 → 合格数据进入训练集 → 模型迭代 → AI 效果提升

标注数据的质量直接决定模型质量。标注一致性得分(Cohen's Kappa)从 0.71 提升到 0.85,意味着不同标注员对同一条数据的判断更一致,训练数据的噪声更少,模型学到的规律更准确。

四种标注类型的业务场景

标注类型 业务场景 产出数据用途
对话偏好(RLHF) 两个 AI 回复选更好的 训练奖励模型,用于 RLHF 对齐
文本分类 给文本打情感/类别标签 训练分类模型或微调大模型
命名实体识别 标注文本中的人名/机构/疾病等 训练 NER 模型
图片目标检测 在图片上画框标注物体 训练 YOLO 等目标检测模型

二、技术架构

整体架构

plain
┌───────────────────────────────────────────────┐
│                  前端层                         │
│  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 端组件丰富

目录结构详解

plain
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

状态流转图

plain
领取任务
  [pending] ──────────────────→ [in_progress]
  待领取                         标注中
                                    │
                                    │ 提交批次
                                    ▼
                              [reviewing]
                               待质检
                              ╱         ╲
                    质检通过 ╱           ╲ 质检打回
                           ▼             ▼
                      [approved]      [rejected]
                       已通过          已打回
                                         │
                                         │ 标注员修改后重提交
                                         ▼
                                     [reviewing]

代码讲解

javascript
// 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 等对话模型的核心技术之一。

简单说分三步:

  1. 让 AI 对同一个问题生成多个回复
  2. 人工标注哪个回复更好(这就是这个模块在做的事)
  3. 用标注数据训练一个"奖励模型",再用奖励模型优化对话模型

标注员的工作质量直接影响奖励模型的准确性,所以标注界面的设计非常重要——界面越流畅,标注员效率越高,数据质量越稳定。

关键代码讲解

1. 撤销栈设计

javascript
// 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. 全局快捷键(不影响输入框)
javascript
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. 条目导航逻辑
javascript
// 找下一个"未完成且未跳过"的条目
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)是从文本中识别出有意义的实体,比如人名、地名、机构名、疾病、药品等。

例如:

plain
张伟于2024年11月在北京协和医院确诊为2型糖尿病
↓ 标注后
[张伟](人名) 于 [2024年11月](时间) 在 [北京协和医院](机构) 确诊为 [2型糖尿病](疾病)

标注好的数据用于训练 NER 模型,模型就能自动从新文本中识别这些实体。

关键代码讲解

1. 鼠标选区自动打标

javascript
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. 分段渲染(实体高亮显示)
javascript
// 把文本按已标注实体切成若干段,有实体的段加高亮样式
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. 重写控件样式

javascript
// 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)
javascript
// 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. 性能优化:关闭自动渲染
javascript
// 初始化时关闭自动渲染
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 快照方案)
javascript
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 对象到数据数组
javascript
// 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 的对象有 widthheightscaleXscaleY 四个属性,真实宽度是 width * scaleX。这是很多人用 Fabric 时踩的坑——用鼠标拖拽缩放标注框时,width 不会变,变的是 scaleX,直接用 width 会得到错误的尺寸。


七、质检管理

业务流程

plain
质检员看到待质检任务 → 逐条审核标注结果 → 标记通过/不通过 → 统计通过率
  → 通过率 ≥ 80%:整批通过 → 进入训练集
  → 通过率 < 80%:打回 → 标注员重新处理不通过的条目

关键代码讲解

实时通过率预估

javascript
// 随着质检员逐条标记,实时更新通过率
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. 任务列表接口

javascript
// 当前
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. 标注数据提交

javascript
// 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. 图片标注数据同步

javascript
// 当前是导出 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 任务状态推送

质检打回/通过后,标注员需要实时收到通知:

javascript
// 在 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)
  }
}

__