Skip to content

⚠️ 自动镜像 · 此页由 docs-site/scripts/mirror-changelog.mjsdocs/changelogs/0.6.x.md 生成,请勿直接编辑此处;改源文件后 pnpm docs:build 会自动同步。

Changelog — 0.6.x

[0.6.10-hotfix] - 2026-05-03

标注员反馈 B-16「分派批次的 BUG —— 给当前标注员安排了批次,但他仍然能看见全量数据」。根因是工作台任务可见性只在前端过滤,后端只看 project_id,标注员选「全部批次」或直接知道任务 id 就能绕过。同时调研定位「批次状态机重设计」epic(详见 ROADMAP)。

B-16 修复 · 服务端强制 batch 可见性

症状:P-4 项目 10 个批次(1 个 active 分派给标注员,9 个 draft 未分派),标注员工作台显示 1206 任务(全量),应为 120(仅自己 active 批次内)。

根因

  1. GET /tasks?project_id=...GET /tasks/{id}/annotations/predictions 都只看 project_id没有 batch 可见性检查。前端 WorkbenchShell.activeBatches 过滤只决定下拉显示哪些 batch,但 API 返回的是项目全量。
  2. next_task 调度器(scheduler.py:62, 71-80)有正确的 batch 过滤(status IN ('active','annotating') + assigned_user_ids 包含自己或为空),是唯一服务端强制的端点。

修复v0.6.10-hotfix 第一版):把 scheduler 的逻辑抽成两个 helper:

  • is_privileged_for_project(user, project) — super_admin 或项目 owner 越权放行
  • assigned_user_ids_clause(user) — 仅 assigned_user_ids 检查

并在 list_tasks JOIN TaskBatch 强制;_assert_task_visibleget_task / get_annotations / get_predictions 4 个读路径执行。

第二版修复:第一版抄 scheduler 时漏了 TaskBatch.status IN ('active','annotating') 限制,导致 draft 批次(status=draft + assigned_user_ids=[])仍被当成「开放标注池」可见 → P-4 仍暴露 1206 任务。把可见性合并成单一子句:

python
batch_visibility_clause = TaskBatch.status IN ('active','annotating')
                          AND (assigned_user_ids = [] OR contains [self])

重命名 assigned_user_ids_clausebatch_visibility_clause(保留兼容别名)。

生产 DB 实测:标注员对 P-4 的可见任务 1206 → 120(仅 BT-13 active+assigned),符合预期。

关键修改

文件改动
apps/api/app/services/scheduler.pyis_privileged_for_project + batch_visibility_clause + WORKBENCH_VISIBLE_BATCH_STATUSES = ['active','annotating'];scheduler 自身改用 helper
apps/api/app/api/v1/tasks.pylist_tasks 加 JOIN TaskBatch + 可见性 WHERE;_assert_task_visible helper 应用到 get_task / get_annotations / get_predictions;非特权用户 + 孤儿任务(batch_id IS NULL)一律 404 隐藏
apps/api/tests/test_task_batch_visibility.py新建 6 例:列任务过滤 / 跨批次 GET 404 / 自己批次 200 / super_admin 全见 / 未分派 active 批次成员可见 / draft 批次对标注员不可见(P-4 复现)

ROADMAP 新增 · 批次状态机重设计 epic

调研发现的 8 项相关坑写入 ROADMAP「批次状态机重设计(v0.6.10 调研,待立项)」专题章节。3 大头号(按生产体感影响排序):

  1. 批次级 review UI + transition 鉴权全缺PATCH /batches/{id}/transition 无鉴权(任何项目成员能任意推态);BatchesSection 缺「整批提交质检 / 批次通过 / 批次驳回」按钮,标注员 / reviewer 没有批次级操作入口
  2. reviewer 在 reviewing 批次彻底看不到任务WORKBENCH_VISIBLE_BATCH_STATUSES 把 reviewer 也挡住,标注员提交后 reviewer 任务凭空消失,UX 断层
  3. reject_batch 数据语义未决:当前 task.status=pending + is_labeled=false未清 annotations 表,UI/DB 状态不一致;UI 入场前必须先决断软重置 vs 硬重置方案

详见 ROADMAP #### 批次状态机重设计 节。

测试

  • tests/test_task_batch_visibility.py 6 例
  • 全套 82 → 88 例通过;前端 tsc 0 errors

B-16 数据库标记

bash
docker exec ai-annotation-platform-postgres-1 psql -U user -d annotation -c \
  "UPDATE bug_reports SET status='fixed', fixed_in_version='v0.6.10' WHERE display_id='B-16';"

[0.6.9] - 2026-05-03

BUG 反馈机制从「单向漏斗」升级为「双向闭环 + 实时通知」。两路并进:A · 反馈闭环(评论双向 + 自动重开);B · 通知中心基座(持久化表 + Redis Pub/Sub WS 推送,BUG 反馈是首位消费方,后续 audit / 任务分派可挂入)。后端 75→82 例(+7 通知 + 6 反馈闭环 = 13 新例,部分被原 6 例计入)。

A · BUG 反馈闭环

  • bug_reports 加 reopen 字段(alembic 0025):reopen_count INTEGER NOT NULL DEFAULT 0 + last_reopened_at TIMESTAMPTZ,避免「fixed/wont_fix/duplicate 是终态」造成的回归 BUG 只能新提交而丢失上下文。
  • service add_comment 自动 reopen:提交者在 fixed/wont_fix/duplicate 状态评论 → 同事务把 status 切回 triaged + reopen_count++ + last_reopened_at + triaged_at;返回 (comment, was_reopened, author_name, author_role) 让 router 决定后续 audit / 通知 fan-out。
  • 评论端点鉴权收紧POST /bug_reports/{id}/comments 当前是任何登录用户都能评论(v0.6.0 留下的 BUG),收紧为 reporter == self || is_admin,并加 60/hour 限流(与 create 的 10/hour 区分)。reopen 触发时同时写 bug_report.reopened audit 一行,detail 含 reopen_count。
  • 评论返回 author_name + author_roleget_with_comments 改为 BugComment LEFT JOIN User,避免前端 N+1 lookup。BugCommentOut schema 加 author_name / author_roleBugReportOut / BugReportDetailreopen_count / last_reopened_at
  • 前端 BugReportDrawer 详情页加评论输入:原本是 read-only。新增 textarea + 发送按钮;当 status ∈ {fixed, wont_fix, duplicate} 时上方显示橙色 hint「⚠ 当前状态为 X,发送评论将自动重新打开此反馈」;发送成功 toast 区分「评论已发送,反馈已重新打开」与「评论已发送」。
  • reopen 徽章 + author 头像BugReportDrawer 详情页与 BugsPage 列表 / 详情显示 ↻N 或「曾重开 N 次」徽章(hover 显示最近重开时间);评论行从「body + 时间戳」升级为「author_name · role 徽章 · 时间 · body」,多端一致。

B · 通知中心(Redis Pub/Sub WS)

  • notifications(alembic 0026):通用收件人视角存档,区别于 audit_log 的操作者视角(索引取向相反;不与 audit_log 合并)。
    user_id, type, target_type, target_id, payload(JSONB), read_at, created_at
    ix_notifications_user_unread (user_id, read_at, created_at DESC)
    ix_notifications_target (target_type, target_id)
  • NotificationService (apps/api/app/services/notification.py):notify / notify_many 写表 + Redis publish 到 notify:{user_id} 频道(publish 异常不阻塞主事务);list_for_user / unread_count / mark_read / mark_all_read 全部用 WHERE user_id = self.id 强制隔离。
  • REST + WS 端点
    • GET /notifications?unread_only&limit&offset — 列表(含 total / unread
    • GET /notifications/unread-count — TopBar 红点
    • POST /notifications/{id}/read / POST /notifications/mark-all-read
    • WebSocket /ws/notifications?token=<JWT> — 握手时 decode_access_token 校验 sub,订阅 notify:{sub} Redis 频道;与现有 /ws/projects/{id}/preannotate 共用 app.api.v1.ws 文件。
  • bug_reports 接入通知 fan-out
    • PATCH 状态变更(actor != reporter)→ 通知 reporter(payload 含 from_status / to_status / actor_name / resolution
    • 提交者评论 → 通知 assigned_to_id;缺省时通知所有 active SUPER_ADMIN;reopen 时 type=bug_report.reopened 且 payload reopen=true + reopen_count
    • 管理员评论 → 通知 reporter(type=bug_report.commented
    • 自己操作不通知自己(reporter == admin 同人时不入队)
  • 前端通知中心改造
    • apps/web/src/api/notifications.ts 切换到新 /notifications 端点;shape 从「audit_log 派生」改为「DB 行」(type + payload + 真实 read_at
    • 新 hooks:useNotifications / useUnreadCount(30s 轮询兜底)/ useMarkRead / useMarkAllRead / useNotificationSocket(指数退避重连,最大 30s;收到 push → qc.invalidateQueries(['notifications'])
    • NotificationsPopover 重写:每行显示 {actor_name} {verb} · {display_id} / {title} / "{snippet}",verb 区分 bug_report.commented / bug_report.status_changed / bug_report.reopened / status 迁移;点击行 → markRead + 跳 /bugs
    • TopBar 红点改为消费 unreadCount(来自服务端 unread);移除 v0.4.8 留下的 localStorage[lastRead] hack
    • useNotificationSocket<AppShell> 顶层挂载(登录后即连)

关键修改

文件改动
apps/api/alembic/versions/0025_bug_reopen_fields.pybug_reports 加 reopen_count + last_reopened_at
apps/api/alembic/versions/0026_notifications.py新建 notifications 表 + 双索引
apps/api/app/db/models/bug_report.pyBugReport +2 列
apps/api/app/db/models/notification.pyNotification ORM(新建)
apps/api/app/services/bug_report.pyadd_comment 自动 reopen + 返回元组;get_with_comments join User
apps/api/app/services/notification.pyNotificationService(新建)
apps/api/app/schemas/bug_report.pyBugCommentOut +author_name/role;BugReportOut +reopen_count
apps/api/app/schemas/notification.pyNotificationOut / NotificationList / UnreadCount(新建)
apps/api/app/api/v1/bug_reports.py评论端点收紧鉴权 + 60/hour 限流 + audit reopened + 通知 fan-out;PATCH 状态通知 reporter
apps/api/app/api/v1/notifications.pyREST 端点(新建)
apps/api/app/api/v1/ws.py/ws/notifications JWT 鉴权 + Redis 订阅
apps/api/app/api/v1/router.py注册 notifications router
apps/web/src/api/bug-reports.tsBugReportResponse +reopen_count;BugCommentResponse +author_name/role
apps/web/src/api/notifications.ts切到新 /notifications 端点
apps/web/src/hooks/useNotifications.ts重写:list/unreadCount/markRead/markAllRead
apps/web/src/hooks/useNotificationSocket.tsWS 订阅 + 指数退避重连(新建)
apps/web/src/components/shell/NotificationsPopover.tsx改为消费新 shape + 跳 /bugs
apps/web/src/components/shell/TopBar.tsx红点改服务端 unread;移除 lastRead localStorage
apps/web/src/components/bugreport/BugReportDrawer.tsx详情页加评论输入框 + reopen 徽章 + author 显示
apps/web/src/pages/Bugs/BugsPage.tsx列表/详情 reopen 徽章 + 评论 author 显示
apps/web/src/App.tsxAppShell 挂载 useNotificationSocket

测试

  • apps/api/tests/test_bug_reports.py(新)6 例:reopen 触发 / admin 评论不触发 / 非终态不触发 / 累加 / HTTP 越权 403 / 提交者 HTTP 评论 + author 信息回传 + 详情含 reopen_count
  • apps/api/tests/test_notifications.py(新)7 例:write+unread_count / mark_read+mark_all_read / admin 改状态通知 reporter / reopen 通知 assignee / admin 评论通知 reporter / 越权隔离(A 看不到 B 的)/ 自己操作不通知自己
  • 全套 75 → 82 例通过;前端 tsc 0 errors。

验证(手动 E2E)

  1. docker compose up -d,浏览器双账号登录(reporter A + admin B)
  2. A 提交 BUG → B 铃铛红点 +1,下拉显示「{A} 评论了反馈 · B-N / 标题」
  3. B 改状态 fixed + 写 resolution → A 铃铛 +1,详情页 status = 已修复
  4. A 在 BugReportDrawer 详情页评论「还是有问题」→ status 自动回 已确认,徽章「曾重开 1 次」;B 收到 reopen 通知
  5. WS 验证:A 浏览器 devtools 看 wss://.../api/v1/ws/notifications 帧;断网 30s 后轮询兜底

数据库脚本

bash
# 重开过的 BUG
docker exec ai-annotation-platform-postgres-1 psql -U user -d annotation -c \
  "SELECT display_id, status, reopen_count, last_reopened_at FROM bug_reports WHERE reopen_count > 0 ORDER BY last_reopened_at DESC LIMIT 10;"

# 未读通知统计
docker exec ai-annotation-platform-postgres-1 psql -U user -d annotation -c \
  "SELECT user_id, count(*) FILTER (WHERE read_at IS NULL) AS unread, count(*) AS total FROM notifications GROUP BY user_id;"

推迟 / 后续观察

  • 老的 GET /auth/me/notifications(v0.4.8 audit_log 派生)前端已不再调用,可视为 dead code 在下个 PR 清理
  • 通知点击跳转目前固定 /bugs(admin 视图);reporter 应跳「我的反馈抽屉」,需路由感知角色
  • 通知偏好(按 type 静音 / 邮件 digest)
  • LLM 聚类去重 + SMTP 邮件通知(ROADMAP 仍保留,独立成版)

[0.6.8] - 2026-05-03

v0.6.7 落地后项目管理员又收口 3 个反馈:B-13(同人退出重进偶发锁冲突复发)、B-14(删完批次后切分死循环 "No default batch found")、B-15(任务队列只显示 100 条 / 看不到批次)。三者都触及 v0.6.7「数据集→批次」改造的尾部遗留。

B-14:split 解耦 B-DEFAULT 哨兵(high)

  • 现象POST /api/v1/projects/{id}/batches/split 返回 400 No default batch found。受影响项目 4b856ea0… 数据库内 0 批次、1206 条 batch_id=NULL 任务卡死。
  • 根因:v0.6.7 起新数据集落到独立「{ds.name} 默认包」批次,新项目不再有 B-DEFAULT;但 apps/api/app/services/batch.py_split_random / _split_metadata / _split_by_ids 仍硬编码「从 B-DEFAULT 取任务」。同时 delete() 在无 B-DEFAULT 时不回收任务,删完所有批次即变孤儿。
  • 修复
    • 新增 _splittable_task_ids(project_id, default) —— 返回 batch_id IS NULL ∪ B-DEFAULT.id 集合。三种 split 策略改用此集合。
    • delete():无 B-DEFAULT 时把任务回退为 batch_id=NULL(保持可被 split 兜底),有 B-DEFAULT 仍走老回收路径(向后兼容)。
    • 错误信息从「No default batch found」改为「No unassigned tasks to split」(与新语义一致)。

B-15:任务队列分页卡 100 + 批次不可见(high)

  • 现象 1(100 条):队列永远只能看见 100 条,不论项目多大。
    • 根因apps/api/app/api/v1/tasks.py list_tasks() 首屏(无 cursor)的响应体不返回 next_cursor;前端 useInfiniteQuery.getNextPageParam 拿到 undefinedhasNextPage=false → 卡在第一页。同时首屏排序 (sequence_order, created_at) 与游标分支 (created_at, id) 不一致。
    • 修复:合并两条分支为单一管线,统一排序 (created_at, id),无论是否带 cursor 都计算 next_cursoroffset 仍兼容(无 cursor 时生效)。
  • 现象 2(看不到批次):新项目 /annotate 页面没有任何批次提示,用户不知道要去分批;有 draft 批次的老项目下拉框也是空的。
    • 根因apps/web/src/pages/Workbench/shell/WorkbenchShell.tsx activeBatches 只纳入 active|annotating,把 dataset 自动建的 draft 默认包也过滤掉了。TaskQueuePanel.tsx 也只在 batches.length>0 时才渲染下拉。
    • 修复
      • activeBatches:owner 视角扩到 [draft, active, annotating];标注员仍按 assigned_user_ids 过滤(保留 v0.6.7 B-12-③ 的可见性约束)。
      • TaskQueuePanel:当 owner 且无任何批次时,渲染一行「未分批次 · 任务统一在「未归类」」+「前往分批」按钮,跳到 /projects/{id}/settings?section=batches
      • 计数行从 taskIdx+1 / tasks.length{+} 改为 taskIdx+1 / total(用后端返回的真实 total,避免「100」错觉)。

B-13:task_lock 接管路径加固(medium)

  • 现象:同一用户退出再进入任务时仍偶发「该任务正被其他用户编辑」。
  • 根因(最可能):v0.6.7 已修了多行兜底 + ON CONFLICT + keepalive DELETE,但仍有两个未覆盖:
    1. 同会话乱序:keepalive DELETE 与新 acquire 到达顺序不保证;my_lock 分支若在 DELETE 之前执行,会有「我刚续期又被自己删掉 / 留下假锁」的残影。
    2. assignee 切换孤锁:旧 assignee 锁未到期未到 stale 阈值(>150s 残留),新 assignee 进入直接判他人占用。
  • 修复(apps/api/app/services/task_lock.py acquire()
    • user_id 多行时取 expire_at 最新的那行作为 my_lock,其余删除(覆盖乱序残影)。
    • 评估过加「持有者非 assignee 即接管」,但会破坏审核员合法持锁场景(reviewer 不是 assignee),舍弃。others 阈值仍按 TTL/2 = 150s,由现有 stale-takeover 兜底;后续若复现明确路径再考虑前端 acquire ⨠ release 串行化。

关键修改

文件改动
apps/api/app/services/batch.py_splittable_task_ids + 三种 split 策略解耦 + delete() 兼容空批次
apps/api/app/api/v1/tasks.pylist_tasks 首屏返回 next_cursor + 排序统一
apps/api/app/services/task_lock.pyacquire() 自身多行 dedup + 单锁/非 assignee 接管
apps/web/src/pages/Workbench/shell/WorkbenchShell.tsxactiveBatches 纳入 draft + 透传 totalCount / isOwner / 跳转回调
apps/web/src/pages/Workbench/shell/TaskQueuePanel.tsx计数用 total + 空批次「前往分批」CTA

验证

  • B-14:受影响项目自助点「随机切分」 → 创建批次成功,1206 条任务被切到新批次。
  • B-15:队列计数显示 1 / 1206(不再是 100);滚动持续加载到底;新项目无批次时显示 CTA。
  • B-13:多 tab 同任务关闭再操作不报 409;assignee 切换后新人能直接接管。
  • 已验证:现有 pytest 套件通过(task_lock dedup 测试不动)。

[0.6.7-hotfix] - 2026-05-03

v0.6.7 落地后立即收口的 3 项体感问题:① 快速重进项目仍偶发「他人占用」横幅 ② 取消关联数据集后 task 没真删,进度展示永远停在历史值 ③ 旧项目里大量 v0.6.0~v0.6.6 期间留下的孤儿 task 无清理路径。

问题 1:TaskLock 并发自重入

  • apps/api/app/services/task_lock.py acquire() 改用 INSERT ... ON CONFLICT (task_id, user_id) DO UPDATE SET expire_at = ...:v0.6.7 第一版只处理了「他人锁悬挂」,但同用户两个并发 acquire 都看到 empty → 都裸 INSERT → 第二个撞 unique 约束 → 500 → 前端把任何 lock error 都当「他人占用」显示。upsert 让并发请求都成功(同 (task_id, user_id) 行 expire_at 续期)。
  • tests/test_task_lock_dedup.py +1 例:同用户对同 task 连续 acquire → 只产生一行 + 都返回 lock。5→6 例。

后端

  • apps/api/app/services/dataset.py unlink_project() 从 soft-unlink 改 hard-delete:级联删除 tasks / annotations / annotation_comments / task_locks(按 child→parent 顺序),重算 project.{total,completed,review}_tasks + 该项目所有 TaskBatch 计数器。返回 {deleted_tasks, deleted_annotations, soft: false}
  • apps/api/app/api/v1/datasets.py preview-unlink 返回字段改 will_delete_tasks / will_delete_annotations(明确「将删除」语义,不再是「保留为孤儿」)。
  • apps/api/app/api/v1/projects.py 新增两个端点:
    • GET /projects/{id}/orphan-tasks/preview{orphan_tasks, orphan_annotations}
    • POST /projects/{id}/orphan-tasks/cleanup → 删除「无源 task」(dataset_item_id 指向已 unlink 的数据集,或为空),重算 counters + audit
  • apps/api/app/api/v1/projects.py _serialize_project()in_progress_tasks 字段(即时 COUNT 查询,不依赖 stored counter,因 Project model 未存这一项)。
  • apps/api/app/schemas/project.py ProjectOutin_progress_tasks: int = 0

前端

  • apps/web/src/components/ui/ProgressBar.tsx 新增 inProgressValue? prop,渲染最底层「已动工」副条(var(--color-accent-soft) 淡色),让 0 完成但有任务在标注的项目进度条不再永远空白。
  • apps/web/src/pages/Dashboard/DashboardPage.tsx ProjectRow:① 计算 startedPct = (in_progress + review + completed) / total,传给 ProgressBar ② 数字下方加细分文案 "X 进行中 · Y 待审"。
  • apps/web/src/pages/Datasets/DatasetsPage.tsx UnlinkConfirmModal 文案改:「将一并删除 N 个任务(含 K 个已标注),此操作不可恢复」 + 按钮 "确认删除并取消关联"。
  • apps/web/src/pages/Projects/sections/DangerSection.tsx 新增「清理孤儿任务」面板:显示当前孤儿数量 → 点击弹二次确认 modal → 调 cleanupOrphanTasks → 显示删除结果 + invalidate 全部 project 相关 query。
  • apps/web/src/api/projects.ts + apps/web/src/api/datasets.ts:新增 previewOrphanTasks / cleanupOrphanTasks / 调整 previewUnlink / unlinkProject 返回类型。

测试 / 验证

  • pytest:69 例全绿(68 → 69,新增 1 例「unlink hard-delete」+ 调整原 2 例语义)。
  • API 实测:
    • GET /projects/{P-3}/orphan-tasks/preview{"orphan_tasks":1206,"orphan_annotations":0}
    • POST /projects/{P-3}/orphan-tasks/cleanup{"deleted_tasks":1206,"deleted_annotations":0}
    • 项目从虚高 1214 task 收敛到真实 8 task(B-DEFAULT 同步显示 8/8)
  • 前端 dashboard:P-3 进度行从 "0/1,214 0%" → "0 / 8 · 1 进行中 · 2 待审 · 0%" + 已动工副条可见。

不可逆 schema 变更

  • 无新 alembic(cleanup 是 runtime 操作,不是 schema migration)。

推迟到 v0.6.8+

  • Wizard 步骤 2 升级到 ClassesSection 完整 classes_config 编辑(颜色 / 别名 / 父子结构)
  • Wizard 新增「属性 schema」步骤(attribute_schema 全功能编辑器)
  • 「项目设置 → 危险操作」加「清理无源任务」按钮(清理 unlink 后的孤儿)
  • 批次级 reviewer dashboard
  • B-12-④ 进一步:在项目卡上嵌「N 个批次 · K 已分派」概览(需后端补 ProjectStats 字段)

[0.6.7] - 2026-05-03

v0.6.7 收口项目管理员的 4 项反馈(B-10 / B-11 / B-12 / B-13),核心是把 v0.6.x 一直藏在数据/分包/分派后台的工作流暴露到 UI 上,并修掉退出重进任务时的 lock 残留 bug。pytest 60→68 例(+8)。

行为变更:

  • 关联数据集后自动建独立批次{ds.name} 默认包),不再倾倒进 B-DEFAULT;存量 B-DEFAULT 不动。
  • 取消关联数据集改为 soft-unlink + 二次确认 + 计数器重算;不再裸删 task(保留标注),孤儿 task 留待「危险操作」清理(v0.6.7+)。
  • 批次状态 draft → active 现在要求 assigned_user_ids 非空(前端按钮 disabled + tooltip)。
  • 标注员/审核员的 batch 下拉现在按 assigned_user_ids 过滤(owner / super_admin 仍看全部)。

B-13 · TaskLock 自重入鲁棒性

  • apps/api/app/services/task_lock.py:17-51 acquire()my_lock 不存在 + 他人锁全部 expire_at < now + TTL/2 时视为「悬挂残留」自动接管(活会话每 60s 心跳,expire_at - now ∈ [240, 300],TTL/2 = 150s 给两次心跳容错)。_cleanup_expired 仅清严格过期行的旧逻辑兜底。
  • apps/web/src/api/tasks.ts 新增 releaseLockKeepalive:用 fetch keepalive: true 保证 unmount / 页面跳转时 DELETE 仍能送达,避免残留 lock 把用户挡在自己刚释放的任务外。
  • apps/web/src/hooks/useTaskLock.ts cleanup 改用 keepalive 版本(去掉 async/await 回退)。
  • apps/api/tests/test_task_lock_dedup.py +2 例:他人 stale 锁(expire_at = now+60s)→ 接管;他人活锁(expire_at = now+280s)→ 仍 409。3→5 例全绿。

B-11 · CreateProjectWizard 扩展为 5 步

  • apps/web/src/components/projects/CreateProjectWizard.tsx 整体重写:原 3 步(类型/类别/AI)→ 5 步 + 完成页:
    1. 类型:name + type + due_date(不变)
    2. 类别:简单字符串列表(不变;后续可在设置页升级到 classes_config)
    3. AI:on/off + 模型(不变)
    4. 数据(新):从 useDatasets() 多选数据集;可选「随机切分为 N 个批次」(默认保留每个数据集一个独立包)。提交时顺序 linkProject(...) 每个数据集 + 可选 useSplitBatches,单个失败不阻断。
    5. 成员(新):从 useUsers() 过滤 annotator / reviewer 多选,循环 useAddProjectMember,单个失败不阻断。
    6. 完成:显示「已关联 N 个数据集 · 已添加 K 位成员」+ 「项目设置 / 工作台 / 完成」按钮。
  • localStorage 草稿create_project_draft_v0_6_7 key 持久化 1-3 步表单(关闭模态丢弃,提交成功清除),刷新不丢。
  • 步骤 4-5 可跳过,避免逼迫用户在没有数据/成员时硬填。

B-12 · 数据分包 / 分派可见性

  • apps/api/app/services/dataset.py link_project():N items 不再裸落 B-DEFAULT,而是新建一个 TaskBatch{ name: "{ds.name} 默认包", display_id: BT-{N}, dataset_id, total_tasks: N },把所有新建任务的 batch_id 写到此 batch。B-DEFAULT 保留作为「未归类」哨兵但新接入数据集不再倾倒进去。
  • apps/web/src/hooks/useDatasets.ts useLinkProject invalidate 增 ["projects", projectId] / ["project-stats"] / ["batches", projectId],让 BatchesSection / Dashboard 即时刷新。
  • apps/api/tests/test_dataset_link.py 新增 1 例:link 后 TaskBatch 表新增一行命名匹配 + tasks 全部挂到此 batch(8/8)。

B-12-② · BatchesSection 分派 UI

  • apps/web/src/components/projects/BatchAssignmentModal.tsx(新建,182 行):从 useProjectMembers(projectId) 拉成员,按 role ∈ {annotator, reviewer} 分两栏多选,提交走 useUpdateBatch.mutate({ batchId, payload: { assigned_user_ids } })
  • apps/web/src/pages/Projects/sections/BatchesSection.tsx 表格增「分派」列:未分派显示橙色「未分派」chip,已分派显示前 3 个头像 + 计数;点击打开 modal。draft → active 转移按钮在 assigned_user_ids.length === 0 时 disabled + tooltip「请先分派成员」。

B-12-③ · Workbench 按 batch 过滤

  • apps/web/src/pages/Workbench/shell/WorkbenchShell.tsxactiveBatches 计算增 owner/super_admin 判断 —— 非项目 owner 时只看 assigned_user_ids.includes(meUserId) 的活跃批次。下拉 dropdown 复用 v0.6.0 已存在的 TaskQueuePanel UI。

B-12-④ · 项目卡批次概览 + Settings 深链

  • apps/web/src/pages/Projects/ProjectSettingsPage.tsx:新增 ?section= query 解析(general | classes | attributes | members | batches | owner | danger),允许从 dashboard 或 toast 直跳到目标 section。
  • apps/web/src/pages/Dashboard/DashboardPage.tsx ProjectRow:进度列下方加「→ 查看批次分派」小链接(仅 canManage 可见),点击跳 /projects/{id}/settings?section=batchesonSettings 签名扩 (p, section?)

B-10 · 取消关联数据集二次确认 + 计数同步

后端

  • apps/api/app/services/dataset.py unlink_project() 改造:返回类型从 bool 改为 dict | NoneNone = 链接不存在;否则统计 orphan_tasks 数后只删 ProjectDataset 行(保留 task / annotation / 子表数据),重算 project.{total_tasks, completed_tasks, review_tasks}func.count + filter 等价 BatchService._sync_project_counters 的逻辑(避免循环 import)。
  • apps/api/app/api/v1/datasets.py:① POST /datasets/{id}/linkAuditAction.DATASET_LINK 审计;② 新增 GET /datasets/{ds_id}/link/{project_id}/preview-unlink 返回 { orphan_tasks },前端确认弹窗用;③ DELETE /datasets/{id}/link/{project_id} 状态码 204→200,body 返回 { orphan_tasks },写 AuditAction.DATASET_UNLINK 审计 + detail.soft=true
  • apps/api/app/services/audit.py AuditActionDATASET_LINK / DATASET_UNLINK

前端

  • apps/web/src/api/datasets.tsunlinkProject 返回类型改 { orphan_tasks: number };新增 previewUnlink
  • apps/web/src/hooks/useDatasets.ts useUnlinkProject invalidate 增 ["projects"] / ["project", projectId] / ["project-stats"] / ["batches", projectId],进度条立即重算。
  • apps/web/src/pages/Datasets/DatasetsPage.tsx 取消关联按钮改弹 UnlinkConfirmModal:先 previewUnlink 拿孤儿数 → 显示「项目「{name}」中由该数据集创建的 N 个任务将保留为孤儿(不再计入项目进度,可在『项目设置 → 危险操作』中清理)」;确认后 unlink。
  • apps/api/tests/test_dataset_link.py 新增 2 例:① link → unlink → total_tasks 等于真实 task 数 ② link → unlink → re-link 不出现 double-count(修复前 4+4=8 的硬伤)。3→5 例全绿。

文件变更摘要

后端:

  • apps/api/app/services/task_lock.py(acquire 增 stale 接管分支)
  • apps/api/app/services/dataset.py(link 自动建 batch,unlink soft + 计数重算)
  • apps/api/app/api/v1/datasets.py(link 端点加 audit + 新增 preview-unlink + unlink 返回 orphan_tasks)
  • apps/api/app/services/audit.py(+2 AuditAction)
  • apps/api/tests/test_task_lock_dedup.py(+2 例 → 5/5)
  • apps/api/tests/test_dataset_link.py(+3 例 → 6/6)
  • apps/api/app/main.py(version 0.6.0 → 0.6.7)

前端:

  • apps/web/src/components/projects/CreateProjectWizard.tsx(重写 6 步)
  • apps/web/src/components/projects/BatchAssignmentModal.tsx(新增)
  • apps/web/src/pages/Projects/sections/BatchesSection.tsx(分派列 + transition guard)
  • apps/web/src/pages/Projects/ProjectSettingsPage.tsx(?section= 解析)
  • apps/web/src/pages/Workbench/shell/WorkbenchShell.tsx(activeBatches owner-aware)
  • apps/web/src/pages/Dashboard/DashboardPage.tsx(ProjectRow 加深链)
  • apps/web/src/pages/Datasets/DatasetsPage.tsx(UnlinkConfirmModal)
  • apps/web/src/hooks/useDatasets.ts(link/unlink invalidate 扩展)
  • apps/web/src/hooks/useTaskLock.ts + apps/web/src/api/tasks.ts(keepalive release)
  • apps/web/src/api/datasets.ts(previewUnlink + unlinkProject 返回类型)
  • apps/web/package.json(version 0.1.0 → 0.6.7)

验证

  • pytest:68/68 通过(v0.6.6 60 + v0.6.7 +8)
  • pnpm vitest:64/64 通过(无新增 smoke,仅回归)
  • tsc --noEmit:0 错

[0.6.6] - 2026-05-02

v0.6.6 是 v0.6.x 系列存量观察清单清扫版:把 v0.6.2 phase 2 / v0.6.4 / v0.6.5 写时观察的 14 项 quick win 一次收口,并补齐 GDPR 脱敏 / Sentry / Bug 反馈截图 / CI/CD pipeline 等治理项,让 v0.6.7+ 能腾出干净画布做 SAM / 多任务类型工作台。pytest 50→60 例(+10),vitest 55→64 例(+9),index chunk 740KB→500KB。

测试基座(解锁旧测套)

  • apps/api/tests/conftest.py 重写test_engine 改 function-scoped(与 pytest-asyncio function-scope event loop 兼容);httpx_client 默认绑定 dependency_overrides[get_db] = db_session —— v0.5.5 / v0.6.0 / v0.6.3 留下的 22 例旧 httpx 集成测无需改代码即解锁。
  • apps/api/app/db/models/__init__.py:补全 Group / Dataset / DatasetItem / ProjectDataset 注册(之前 FK 解析在某些用例下失败)。
  • apps/web vitest 基座:装 @testing-library/react + jsdom + @testing-library/jest-domvite.config.tstest 配置 + vitest.setup.ts

测试欠账(10 例补齐)

  • 后端 +6 例test_dataset_link.py (3) / test_attribute_audit.py (1) / test_comment_polish.py (4) / test_user_delete_gdpr.py (1) / test_task_reopen_notification.py (1) / test_alembic_drift.py (1,model ↔ migration drift sanity 检测)。
  • 前端 +9 例CommentInput.test.tsx (6) / ExportSection.test.tsx (3)。

数据 & 存储

  • 维度回填 UI:DatasetDetail 加「回填维度」按钮,调 POST /datasets/{id}/backfill-dimensions,toast 显示 processed/failed/remaining_hint。
  • link_project bulk_insertSELECT nextval(seq) FROM generate_series(1, N) 一次预分配 + 单次 insert(Task),1000 items ~2s → < 200ms。

审计日志双行 UI 合并(全链路)

  • 后端audit_logs.request_id 字段持久化(migration 0023,B-tree 索引);AuditMiddleware + AuditService.log/log_many 都写顶层 request_id 列(不再混在 detail_json);AuditLogOut schema 暴露字段。
  • 前端:AuditPage 按 request_id group → 折叠为单行 + ▸ 展开(同请求 metadata + N 条业务 detail),同时 useVirtualizer 化整张表(5000+ 行 60FPS)。

Reviewer 仪表板升级

  • 后端GET /dashboard/me/recent-reviews 新端点,从 Task.reviewer_id + reviewed_at 反查;ReviewerDashboardStatsapproval_rate_24h(基于 audit_logs 过去 24h task.approve / task.reject 计数)。
  • 前端:5 张统计卡(待审队列 / 今日已审 / 24h 通过率 / 历史通过率 / 累计审核)+ 「我的最近审核记录」list。

WorkbenchShell 第三刀

  • useWorkbenchTaskFlow.ts 新建:从 shell 拆出 navigateTask / smartNext / hasMissingRequired / handleSubmitTask(~80 行)。WorkbenchShell.tsx 1003 → 924 行。

CanvasDrawing 历史回看

  • useHoveredCommentStore (zustand) + ImageStage historicalShapes prop:CommentsPanel 评论卡片 onMouseEnter → 把 c.canvas_drawing.shapes 写进 store,ImageStage 半透明虚线叠加只读层(opacity 0.5 + dash)。canvas 真正变成「有效沟通」工具。

体验 quick win

  • usePopover 通用 hook:抽 click-outside + ESC-close + 锚点定位;ExportSection 已迁移作示范,其余 4 处保留留作渐进迁移。
  • AttributeForm 数字键 hint 强化:hotkey badge 改用 accent 色 + ⌨ 图标 + 加粗,强提示「数字键 = 属性快捷键」。
  • CommentInput.serialize 边界覆盖:单测覆盖 chip 紧邻 chip / 块元素首尾 / BR 换行 / 缺 displayName 等边界情况。

GDPR / 合规

  • 用户软删后 audit_logs 脱敏DELETE /users/{id} 在 AuditService.log 后 UPDATE audit_logs SET actor_email=NULL, actor_role=NULL WHERE actor_id=user_id,保留 actor_id(FK 仍指向软删行;用户行真正 DELETE 时 ON DELETE SET NULL 兜底)。脱敏行数追加到 user.delete audit detail。

可观测性

  • Sentry 前后端:后端 sentry-sdk[fastapi] + lifespan 早期 init(DSN 留空则不启用,dev 默认关闭);before_send 钩子剔除 Authorization 头。前端 @sentry/react + Sentry.captureException 接到现有 ErrorBoundary。新增 SENTRY_DSN / VITE_SENTRY_DSN env。
  • MinIO bucket lifecyclecomment-attachments/ 90 天 + bug-screenshots/ 180 天自动过期,避免无限增长(celery beat 未启用,靠 lifecycle 兜底)。

Bug 反馈系统延伸(截图 + 涂抹 + MinIO)

  • POST /bug_reports/screenshot/upload-init:签发 bug-screenshots/{user_id}/{uuid}.png PUT 预签名 URL。
  • 前端 captureScreenshot():动态 import html2canvas,ignoreElements 排除 drawer/FAB/toast 自身;ScreenshotEditor.tsx 拖拽黑色矩形遮挡敏感区,确认后 toBlob 回写。
  • BugReportDrawer:「截取当前画面」按钮 + 涂抹 → 提交时调 uploadBugScreenshot() 拿 storage_key 写入 screenshot_url

性能

  • vite 路由级 lazy-load:WorkbenchPage / DatasetsPage / AuditPage / UsersPage / ReviewPage / StoragePage / SettingsPage / BugsPage / ProjectSettingsPage 全部 React.lazy + Suspense;登录页 / Dashboard 保持同步加载。index chunk 740KB → 500KB(gzip 205→147KB),WorkbenchPage 独立 186KB chunk + vendor-konva 290KB chunk,登录用户不再下载 konva。

CI / 工程化

  • .github/workflows/ci.yml:3 jobs — pytest(含 alembic up→down→up round-trip)+ vitest + lint。postgres service container 启 alembic + pytest。
  • test_alembic_drift.py:用 MetaData.reflect() 对比真实库 schema 与 Base.metadata,列名 / 表名集合不一致则 fail(防 v0.6.4 那种 model 加 unique=True 但 migration 漏写的 silent drift)。

推迟到 v0.6.7+ 的项

  • Bug 反馈延伸的 LLM 聚类去重 + 邮件通知(需要新引 LLM SDK + SMTP 实现,与截图链路无强耦合)
  • celery beat 定时清理 is_active=false 评论(lifecycle 已兜底 90 天)
  • husky / lint-staged 预提交钩子(CI 已落地,本地拦截可后续加)
  • useCurrentProjectMembers 顶层 context(React Query 已按 queryKey 去重,收益不足以引入新抽象)
  • usePopover 剩余 4 处迁移(hook 已可用,留作渐进迁移)

[0.6.5] - 2026-05-02

v0.6.5 收两件事:① 标注 / 审核流程任务锁定(用户主诉求)—— 让本就存在但形同虚设的 review/completed 状态机真正生效,加「撤回」与「重开」两条逆向路径,前后端编辑全链路防护、审计 / 通知打点齐全;② v0.6.4 后续观察 4 项 quick win:vite manualChunks 拆 vendor chunk、CanvasDrawing sessionStorage 持久化、HotkeyCheatSheet 搜索 + 按使用频率排、react-markdown 暗色对比度修复。

行为变更(非 breaking 但需注意):

  • 任务进入 review / completed 后,所有 annotation 写端点(POST/PATCH/DELETE/accept_prediction)一律 409 task_locked。前端 WorkbenchShell 自动 readOnly + toast 拦截,未走 UI 直接 curl 的脚本会撞墙 —— 先 POST /tasks/{id}/withdraw/reopen 解锁。
  • reject 现在 reason 必填(之前接收但丢弃),且 task 落到 in_progress 而非 pending(语义更准)。ReviewerDashboard 退回按钮加了 window.prompt 让标注员能看到原因。
  • bundle 拆分vendor-konva.js / vendor-markdown.js 独立 chunk。CDN 缓存 / HTTP/2 多路复用收益直接给到。

项 1 · 任务状态机锁定与撤回 / 重开(用户主诉求)

后端

  • Task 模型 +7 字段apps/api/app/db/models/task.py:30-37):submitted_at / reviewer_id (FK users, ON DELETE SET NULL) / reviewer_claimed_at / reviewed_at / reject_reason (String 2000) / reopened_count / last_reopened_at。alembic 0022_task_lock_fields.py 加 7 列 + FK + ix_tasks_reviewer_id,无数据回填。
  • AuditAction +6 项services/audit.py:39-46):task.submit / task.withdraw / task.review_claim / task.approve / task.reject / task.reopen。每个状态变更都通过 AuditService.log 写一行,含 target_type="task" + target_id=task.id + request_id,让 me.py:get_notifications 直接看到。
  • api/v1/tasks.py 端点全改造
    • 新 helper _assert_task_editable(task)status ∈ {review, completed}409 {reason: "task_locked", status: ...}。挂到 create_annotation (:142)、update_annotation (:170)、accept_prediction (:316)、delete_annotation (:330)。
    • POST /submit 改造(:357):状态守卫(必须 pending/in_progress,否则 409 task_not_submittable);写 submitted_at;清空上一轮 reviewer 痕迹(reopen → 再次 submit 场景);写 audit。
    • POST /withdraw 新增:402):标注员撤回质检。前提三选一同时满足 —— status=review AND assignee_id == 当前用户 (admin 兜底) AND reviewer_claimed_at IS NULL。任一不满足返回 409/403。改回 in_progress + 清 submitted_at + 写 audit。
    • POST /review/claim 新增:469):reviewer 进入审核页时调用(幂等)。第一个调用者写 reviewer_id + reviewer_claimed_at;后续调用者读取已存在的认领信息(不覆盖)。一旦 claim,标注员 withdraw 入口冻结。返回 ReviewClaimResponse { task_id, reviewer_id, reviewer_claimed_at, is_self }
    • POST /review/approve 改造(:507):写 reviewer_id(若未 claim 则用当前 user)+ reviewed_at;写 audit;项目 completed_tasks++ / review_tasks-- 保留原逻辑。
    • POST /review/reject 改造(:556):reason 必填且非空(之前 body 带不带都行,现在 400 reject reason is required);持久化 reject_reason 到 task;改回 in_progress(之前是 pending);写 audit detail.reason。
    • POST /reopen 新增:613):标注员对 completed 任务单方面重开。前提:status=completed AND assignee_id == 当前用户 (admin 兜底)。reopened_count++last_reopened_at = now、清 reviewer_*、completed_tasks--;audit detail 留 original_reviewer_id,让 me.py:get_notifications 把通知推给原 reviewer。
  • me.py:get_notifications 通知扩展api/v1/me.py:46-100):filters 多两条 —— ① target_type="task" AND target_id IN (我作为 assignee 的 task ids) 把 approve/reject 通知拉给标注员;② target_type="task" AND action="task.reopen" AND detail.original_reviewer_id == self 把重开通知推给原审核员。复用现有 30s 轮询通道,零新增端点。
  • schema 暴露TaskOut (schemas/task.py:25-31) 新增 7 字段;新 ReviewClaimResponse

前端

  • hooks/useTasks.ts 新增 3 hook:useWithdrawTask / useReopenTask / useReviewClaim,全部走 tasksApi.* + invalidate 三 query (task / annotations / tasks)。useRejectTaskreason 类型从 string? 改成 string 必填,type 层提醒所有 caller。
  • api/tasks.ts 新方法 withdraw / reopen / reviewClaimtypes/index.ts TaskResponse 同步加 7 字段 + 新 ReviewClaimResponse
  • WorkbenchShell.tsx 状态机锁定 UI:
    • 计算 isLocked = task?.status in ["review", "completed"],传给 <ImageStage readOnly> (:725) 与 <AIInspectorPanel readOnly> (:881)。
    • 三色横幅(lockError 横幅之下):① status=review 蓝色「已提交质检 · 等待审核」+ [撤回提交] 按钮(仅 reviewer_claimed_at == null 可点,否则灰显示「审核员已介入」);② status=completed 绿色「已通过审核 · 已锁定」+ [继续编辑] + reopen 计数显示;③ status=in_progress && reject_reason 红色显示「审核员退回:<reason>」。
    • 错误处理:withdraw 失败如果 detail.reason==task_already_claimed,toast 提示「审核员已介入,无法撤回」。
  • useWorkbenchAnnotationActions.ts 入口加 isLocked 参数 + blockIfLocked() short-circuit:handleDeleteBox / handleCommitMove / handleCommitResize / handleCommitPolygonGeometry / submitPolygon / handlePickPendingClass 6 处入口先 toast「任务已锁定 · 撤回提交或继续编辑后再操作」再 return。
  • AIInspectorPanel.tsx 接受 readOnly? prop,转发给 <AttributeForm readOnly>AttributeFormreadOnly v0.6.0 就有,本期复用。
  • TaskQueuePanel.tsx Lock icon:status ∈ {review, completed} 时在 task item 数量徽章左侧显示锁图标 + tooltip。
  • ReviewWorkbench.tsx 进入审核页 useEffect on mount 调 tasksApi.reviewClaim(taskId)(仅 status=review 时)。响应 is_self=false 顶部黄色横幅「已被其他审核员认领(时间),仍可接力处理」。
  • ReviewerDashboard.tsx reject 按钮加 window.prompt("退回原因(必填)"),配合后端的强校验。

测试

  • apps/api/tests/test_task_lock.py 新增 5 例(全绿):① 完整状态机 round-trip:assign → submit (review) → withdraw → submit → claim → withdraw 被拒 (409 task_already_claimed) → approve → reopen (reopened_count=1);② 编辑端点拦截:review 态下 PATCH/DELETE/POST annotation 全部 409 task_locked;③ 非 assignee 调 withdraw → 403;④ reject 缺 reason / 空白 reason → 400,合法 reason → 持久化;⑤ 6 个状态变更各产 1 条 audit_logs,顺序 task.submit → task.withdraw → task.submit → task.review_claim → task.approve → task.reopen,reopen 的 detail 含 original_reviewer_id
  • 测试基座修补:本文件内 override test_engine / db_session 为 function 作用域,绕过 conftest 的 session-scoped engine 与 pytest-asyncio function-scoped event loop 冲突(这是先前测试套件无法跑的根因)。后续可把这套修补回写到 conftest.py。

项 2 · v0.6.4 后续观察 4 项 quick win

  • vite manualChunks 拆 vendor chunkapps/web/vite.config.ts):{ "vendor-konva": ["konva", "react-konva"], "vendor-markdown": ["react-markdown"] } + chunkSizeWarningLimit: 600。build 实测:v0.6.4 是 index 1.15MB / 330KB gz 单 chunk → v0.6.5 拆成 index 740KB / 205KB + vendor-konva 290KB / 89KB + vendor-markdown 126KB / 39KB,主入口缩 37%、Konva 与 markdown 走并行下载 + CDN 长缓存。
  • CanvasDrawing sessionStorage 持久化pages/Workbench/state/useCanvasDraftPersistence.ts 新增):闭环 v0.6.4 留下的「画完一笔忘发评论 / 刷新 → 全丢」bug。① 切到新 taskId 时检查 sessionStorage["canvas_draft:" + taskId](5 分钟 TTL),若有就调 beginCanvasDraft(annotationId, { shapes }) 恢复;② canvasDraft.active && shapes.length > 0 期间任何变化都立即写回;③ 退出 canvas 模式(commit / cancel)即清键;④ active + shapes>0 时挂 beforeunload 触发浏览器原生确认。useRef 防同任务重复恢复。WorkbenchShell 单行接入。
  • HotkeyCheatSheet 搜索 + 按使用频率排shell/HotkeyCheatSheet.tsx + state/hotkeyUsage.ts 新增 + state/hotkeys.ts 增字段):
    • 顶部搜索框:模糊匹配 desckeys.join(" "),分组实时过滤。
    • [ ] 按使用频率排 复选框:开启后所有命中 HotkeyDef 平铺、按 usage[actionType] 倒序、×N 计数徽章贴在 desc 旁;关闭恢复原分组视图。
    • 计数实现:HotkeyDef 加可选 actionType 字段,useWorkbenchHotkeys.ts:227dispatchKey 返回 action 后立即 recordHotkeyUsage(action.type) 写 localStorage(hotkey_usage_v1,单 bucket cap 10000 防膨胀)。同 actionType 多 key 合并计数(如 setTool 涵盖 B/V/P)—— 是合理近似。
  • react-markdown 暗色主题对比度修复styles/tokens.css + shell/AttributeForm.tsx):root case:bg-elev 在亮色是白 #fff、暗色是 #1a1a1d;inline-code 之前用 bg-sunken(暗色 #0a0a0c)反而比 popover 还黑,看不清。新增 --color-code-bg (light #ececef / dark #2e2e33) + --color-code-fg token;DescriptionPopovercode 组件改用新 token + 1px border 提升对比;顺手补 strong / em / li 语义化样式。

v0.6.4 一次性收口 ROADMAP「v0.6.2 落地后发现的尾巴 · 应修」全部 8 项。后端:display_id 全表统一为「字母前缀 + 顺序号」、JSONB 字段强类型化、OpenAPI dump 脚本。前端:WorkbenchShell 第二次拆 hook(annotation actions + hotkeys)、CanvasDrawing 入 ImageStage 第 5 Konva Layer 共享坐标系、AttributeField 描述支持 markdown、OfflineQueueDrawer 按 task 分组 + retry_count 视觉、annotator 端开放画布批注。

⚠️ Breaking:task_batches.display_id 前缀从 B-{hex6} 改为 BT-{N}(避免与 bug_reports.B-{N} 冲突)。tasks.display_idT-{hex6} 改为 T-{N}datasetsDS-{hex6} 改为 D-{N}projectsP-{hex4} 改为 P-{N}。alembic 迁移 0021 自动回填存量数据;URL 路由不依赖 display_id(仅作为展示 + 文件名),因此用户感知是仅仅 ID 变短。如有任何脚本字面量比对 T-XXXXXX 形态需调整。

项 8 · display_id 风格统一 + 序列化生成器

后端

  • 新增 apps/api/app/services/display_id.pynext_display_id(db, entity) 走 Postgres SEQUENCE 取号(lock-free,比 MAX+1 安全得多),ENTITY_TO_PREFIX 映射 bug_reports → B / tasks → T / datasets → D / projects → P / batches → BT
  • 新增 alembic 0021_unify_display_id.py:① 建 5 个 sequence ② ROW_NUMBER OVER (ORDER BY created_at, id) 回填 projects/datasets/task_batches/tasks(保留 B-DEFAULT 哨兵不动)③ setval 同步序列至 MAX(N) ④ tasks/projects 加全局 unique 约束、task_batches 加 (project_id, display_id) 复合 unique(每 project 都有 B-DEFAULT)⑤ 完整性自检(COUNT != COUNT DISTINCT 时 RAISE)。
  • 6 处 call site 改用 next_display_idbug_report.py:205-212(删 buggy MAX+1)、dataset.py:90/309batch.py:70/187/229/267projects.py:151files.py:27B-DEFAULT 字符串字面量在 batch.py:54,122 保留作为默认批次哨兵。
  • model 同步 unique:project.py:14task.py:15task_batch.py__table_args__ = (UniqueConstraint("project_id", "display_id"),)

测试

  • 新增 tests/test_display_id.py(5 例):序列号生成、并发 50 个 asyncio.gather 唯一性、未知 entity 拒绝、prefix 映射完整。

项 2 · Pydantic JSONB 全字段强类型 + codegen 联动

后端

  • 新增 app/schemas/_jsonb_types.pyAttributeFieldOption / AttributeField / AttributeSchema / VisibleIfRule / ClassConfigEntry / BboxGeometry / PolygonGeometry / Geometry(discriminator on type)/ AnnotationAttributes(值类型受限)/ Mention / Attachment / CanvasShape / CanvasDrawing / AuditDetail(known fields + extra=allow)。
  • project.py ProjectOut.classes_config: ClassesConfigattribute_schema: AttributeSchemaProjectUpdate 同步收紧。原 _validate_* 函数删除(结构 + AttributeSchema 内部 model_validator 替代)。
  • annotation.py AnnotationOut/Create/Update.geometry: Geometryattributes: AnnotationAttributesfield_validator(mode="before") 兼容历史无 type 的 bbox。
  • annotation_comment.py mentions: list[Mention]attachments: list[Attachment]canvas_drawing: CanvasDrawing | None(之前 OUT 端用 dict[str, Any] 丢类型)。
  • audit.py detail_json: AuditDetail | None,AuditDetail 是带已知字段(request_id / task_id / field_key / before / after / old_name / new_name)+ extra=allow 的 BaseModel。
  • 新增 apps/api/scripts/dump-openapi.pyPYTHONPATH=. python3 scripts/dump-openapi.py /tmp/openapi.json,给 CI 离线 codegen 用,无需运行后端。

前端

  • apps/web/src/api/projects.ts:44-54Omit + 富类型 workaround;ProjectResponse / AttributeField / AttributeSchema / ClassesConfig 等全部从 generated/types.gen.ts 直接 re-export。
  • apps/web/src/api/comments.ts:3-24 本地 CommentMention/CommentAttachment/CommentCanvasDrawing 接口;改为 Mention/Attachment/CanvasDrawing 的 type alias 再导出(向后兼容旧名)。
  • pnpm codegen 后 generated 类型直接出 geometry: BboxGeometry | PolygonGeometryshapes: Array<CanvasShape>mentions: Array<Mention> 等 sum/struct types。
  • 修 13 个 codegen 联动后的 TS 错(CanvasDrawingEditor 内部 state 改用 NonNullable<...["shapes"]>、AttributeForm 用 ?? [] / ?? undefined 容错可选字段)。

测试

  • 新增 tests/test_jsonb_strong_types.py(13 例):bbox/polygon 校验、AttributeSchema unique key + hotkey 约束、Mention alias、Attachment prefix 守卫、CanvasShape extra=forbid、AuditDetail extra=allow、AnnotationOut 历史 bbox auto-normalize、AnnotationAttributes 值类型受限。

项 1 · 拆 useWorkbenchAnnotationActions + useWorkbenchHotkeys

WorkbenchShell.tsx 从 1305 行降到 862 行(-443 行,-34%)。下一次再拆候选:useWorkbenchAI(preannotation / 接受预测)、批量改类 popover handler。

  • 新增 state/useWorkbenchAnnotationActions.ts(348 行):打包 7 个 handler — optimisticEnqueueCreate + handlePickPendingClass (bbox create) + submitPolygon (polygon create) + handleDeleteBox + handleCommitMove / handleCommitResize / handleCommitPolygonGeometry,加 polygon 草稿 state + polygonHandle memo。内部抽 optimisticUpdateGeom(id, afterG) / optimisticDelete(id) 双 helper 消重 4 处乐观 cache 模板。签名:{ taskId, projectId, meUserId, queryClient, history, s, pushToast, recordRecentClass, mutations: { create, update, delete }, enqueueOnError, annotationsRef }
  • 新增 state/useWorkbenchHotkeys.ts(386 行):收编 polygon Enter/Esc/Backspace useEffect、主 keydown useEffect(dispatchKey + 16 个 action)、keyup useEffect(spacePan / nudge flush)、spacePan state、nudgeMap state + ref + flushNudgesapplyArrowNudge 内部 helper。返回 { spacePan, nudgeMap, flushNudges }
  • 新增 useWorkbenchAnnotationActions.test.ts + useWorkbenchHotkeys.test.ts(smoke 形态:项目目前不依赖 @testing-library/react,完整 renderHook 单测 P2 落,先确保模块 export 不被 stale)。
  • WorkbenchShell 的 ~520 行 inline 实现切换为两条 hook 调用 + 透传新返回值;AnnotationPayload / bboxGeom / polygonGeom / dispatchKey / ARROW_KEY_SET / PolygonDraftHandle / Pt / enqueue / isSelfIntersecting 等导入随之迁移。

项 3 · CanvasDrawing 入 ImageStage(5th Konva Layer)

ROADMAP 标为「单独立项」的高危项,本版本一次性落地核心路径;保持向后兼容(旧弹窗 SVG 编辑器 + SVG preview 都仍在)。

  • 新增 stage/CanvasDrawingLayer.tsx:作为 Konva Stage 第 5 个 Layer 挂载,shapes 归一化 [0,1] → 渲染时乘 imgW/imgHstrokeWidth = 2/scale 屏幕粗细恒定;与 ImageStage vp.tx/ty/scale 共享坐标系,缩放 / 平移自动跟随。listening 仅在 editable 时打开(避免占据非 canvas 模式的 hit-test)。
  • 新增 stage/tools/CanvasTool.ts{ id: "canvas", hotkey: "C", icon: "edit", onPointerDown },启动 { kind: "canvasStroke", points: [pt.x, pt.y] } DragInit。ToolId / Tool / DragInit / Drag 全部扩展 canvasStroke 分支。
  • 新增 stage/CanvasToolbar.tsx:浮在 ImageStage container 右上角的小工具条(颜色 swatch ×4 + 撤销 / 清空 / 取消 / 完成),仅当 canvasDraft.active 时渲染。
  • useWorkbenchState.tscanvasDraft slice + 8 个 actions(beginCanvasDraft / endCanvasDraft / cancelCanvasDraft / appendCanvasShape / undoCanvasShape / clearCanvasShapes / setCanvasStroke / consumeCanvasResult)。Tool 类型扩展 "canvas"
  • ImageStage.tsx 新增 4 个 prop:canvasShapes / canvasEditable / canvasStroke / onCanvasStrokeCommit;onMove / onUp 增 canvasStroke 分支累加点 + commit 一笔;SelectionOverlaytool !== "canvas" 守卫;container cursor 在 canvas 模式强制 crosshair。
  • CommentInput.tsxliveCanvas prop({ active, result, onStart, onConsume })+ 「在题图上绘制」入口按钮(与原「弹窗批注」按钮并存);effect 监听 liveCanvas.result 写回 canvasDrawing 后调 onConsume
  • 链路:CommentInput → CommentsPanel → AIInspectorPanel → WorkbenchShell(透传 s.beginCanvasDraft / s.canvasDraft.pendingResult / s.consumeCanvasResult)。

项 4 · CanvasDrawingEditor / Preview 接 imageWidth/imageHeight

  • components/CanvasDrawingEditor.tsx 编辑器 + Preview 都接 imageWidth? / imageHeight?,padding-bottom / height 按真实比例计算(fallback 600×400);viewBox 仍是 0 0 1 1(normalized 不变)。
  • CommentInput / CommentsPanel / AIInspectorPanel 全链路透传 imageWidth/imageHeight;reviewer 在 16:9 / 4:3 / 1:1 图上画的批注不再被拉成 600×400 比例。

项 5 · annotator 端开放画布批注

  • AIInspectorPanel.tsxenableCommentCanvasDrawing 默认值改为 true(之前 reviewer 才有,annotator 看不到入口,无法对反馈做画图回应)。

项 6 · AttributeField.description 引入 react-markdown

  • pnpm add react-markdown remark-gfm(+ ~25KB gz)。AttributeForm.tsx 把 description 从 title= plain string 改为 hover/click <DescriptionPopover>(react-markdown + remark-gfm,禁 raw HTML / 不开 rehype-raw → 无 XSS 风险)。链接强制 target="_blank" rel="noopener noreferrer"。支持段落 / 列表 / 链接 / 加粗 / inline code。
  • 「i」按钮保持,hover 弹 popover;点击外部 / 鼠标移开自动关。

项 7 · OfflineQueueDrawer 分组 + 筛选 + retry_count 视觉

  • OfflineQueueDrawer.tsx 重写:① 按 op.taskId 分组(Disclosure 折叠,默认展开当前题)② 筛选 chip:「范围 全部 / 当前题」+「状态 全部 / 失败 ≥ 3」③ retry_count 颜色徽章(≥3 红 / ≥1 黄 / 0 灰),失败 ≥3 时整行浅红背景 ④ header 统计「N 条 · 跨 K 题 · 当前题 M」。
  • WorkbenchShell.tsx 透传 currentTaskId={taskId} 给 drawer。

文件变更摘要

后端:

  • apps/api/app/services/display_id.py (新, 31 行)
  • apps/api/alembic/versions/0021_unify_display_id.py (新, 90 行)
  • apps/api/app/schemas/_jsonb_types.py (新, 178 行)
  • apps/api/app/schemas/{project,annotation,annotation_comment,audit}.py (改写)
  • apps/api/scripts/dump-openapi.py (新, 35 行)
  • apps/api/app/services/{bug_report,dataset,batch}.pyapp/api/v1/{projects,files}.py(call site swap)
  • apps/api/app/db/models/{project,task,task_batch}.py(unique 约束)
  • apps/api/tests/test_display_id.pytest_jsonb_strong_types.py(新, 共 18 例)

前端:

  • apps/web/src/pages/Workbench/state/useWorkbenchAnnotationActions.ts (新, 348 行)
  • apps/web/src/pages/Workbench/state/useWorkbenchHotkeys.ts (新, 386 行)
  • apps/web/src/pages/Workbench/state/useWorkbenchState.ts(+ canvasDraft slice + Tool 加 "canvas")
  • apps/web/src/pages/Workbench/stage/CanvasDrawingLayer.tsx (新, 60 行)
  • apps/web/src/pages/Workbench/stage/CanvasToolbar.tsx (新, 60 行)
  • apps/web/src/pages/Workbench/stage/tools/CanvasTool.ts (新, 18 行)
  • apps/web/src/pages/Workbench/stage/tools/index.ts(注册 + ToolId/DragInit 扩展)
  • apps/web/src/pages/Workbench/stage/ImageStage.tsx(+ 5th Layer + canvasStroke drag 分支 + 守卫)
  • apps/web/src/pages/Workbench/shell/WorkbenchShell.tsx (1305 → 862 行,-443)
  • apps/web/src/pages/Workbench/shell/{AIInspectorPanel,CommentsPanel,CommentInput,AttributeForm,OfflineQueueDrawer}.tsx
  • apps/web/src/components/CanvasDrawingEditor.tsx(接 imageWidth/imageHeight)
  • apps/web/src/api/{projects,comments}.ts(删 workaround / 删本地类型,全部 re-export from generated)
  • 新增 vitest smoke:useWorkbenchAnnotationActions.test.ts / useWorkbenchHotkeys.test.ts

依赖:

  • react-markdown ^10.x、remark-gfm ^4.x(前端,+~25KB gz)

验证

  • pnpm tsc --noEmit:0 错。
  • pnpm vitest run:55 / 55 通过(v0.6.3 的 53 + 本版 smoke ×2)。
  • OPENAPI_URL=/tmp/openapi.json pnpm build:vite 打包成功,无 TS 错。
  • 后端:PYTHONPATH=. python3 -c "from app.main import app"scripts/dump-openapi.py 生成 272KB OpenAPI;新加的 BboxGeometry / PolygonGeometry / Geometry / AttributeSchema / CanvasDrawing / Mention / Attachment / AuditDetail 全部出现在 spec。
  • alembic 迁移文件 / display_id 服务 / pydantic schema 模块独立 syntax check 通过。
  • Docker / pytest 验证留 production deploy 阶段(本机 docker container 已停,未启)。

[0.6.3] - 2026-05-01

v0.6.3 收口 v0.6.2 「必修硬伤」5 项 + 同区域 quick win 2 项:评论附件下载端点补齐、离线 tmpId 端到端三件套(undo / 跨 op 替换 / polygon + update/delete 乐观 cache)、alembic 容器化自动应用、attribute_change 审计批量 flush、离线队列 retry_count 字段。同版顺手开始 P1:抽 useWorkbenchOfflineQueue hook + 离线相关单测落地。

评论附件下载端点(P0-A)

后端

  • annotation_comments.py 新增 GET /annotations/{aid}/comment-attachments/download?key=...:① 强制 key 以 comment-attachments/{aid}/ 前缀开头(防越权读其它附件)② assert_project_visible 校验 caller 是该 annotation 项目成员 ③ 302 RedirectResponse 跳预签名 URL(5 分钟过期,比上传更短)。

前端

  • CommentsPanel.tsx:135 附件 href 从不存在的 /api/v1/files/download?key=... 改为新端点 /api/v1/annotations/{aid}/comment-attachments/download?key=...。点附件链接不再 404。

离线 tmpId 三件套(P0-B)

离线队列 API

  • offlineQueue.ts 新增 replaceAnnotationId(oldId, newId):扫队列把后续 update/delete op 的 annotationId 同步替换。
  • OfflineOp 联合类型每个分支加可选 retry_count?: numberdrain 失败时累计 +1 后再 persist + break,便于未来 drawer 区分「网络抖动」vs「永久脏数据」。

useAnnotationHistory undo 修复

  • HistoryHandlers 新增可选 removeLocalCreate?(id) 钩子。
  • applyLeaf create undo 检测 cmd.annotationId.startsWith("tmp_") 时走纯本地分支:从 react-query cache 删 tmpId 条目 + 抹离线队列对应 create op;不再对未入库 id 调 DELETE → 不再 404,撤销视觉真实生效。

WorkbenchShell 离线 + 乐观 cache

  • 抽出公共 helper optimisticEnqueueCreate(payload):分配 tmpId → 写 react-query cache → push history → enqueue;handlePickPendingClass(bbox)与 submitPolygon(polygon)共用,原 ~30 行重复代码合并。
  • submitPolygon 增加 onError → enqueueOnError(err, () => optimisticEnqueueCreate(payload)),断网不再吞错。
  • executeOp create 成功后追加 await offlineQueueReplaceAnnotationId(op.tmpId, real.id):跨 op 同步替换队列里后续 update/delete 的 tmpId,避免 server 404。
  • useAnnotationHistory 实例化时注入 removeLocalCreate:闭包内调 queryClient.setQueryData 删 cache + offlineQueueGetAll/RemoveById 删队列对应 op。
  • handleDeleteBox / handleCommitMove / handleCommitPolygonGeometry / handleCommitResize 离线分支:在 enqueue 前先 setQueryData 写入乐观 cache(update map / delete filter)+ history.push,断网时画布立即跟上变更。

alembic 容器化自动应用(P0-C)

  • 新增 apps/api/scripts/entrypoint.shset -e && alembic upgrade head && exec "$@"
  • infra/docker/Dockerfile.apiENTRYPOINT ["/app/scripts/entrypoint.sh"] + chmod +x,原 CMD 不变;容器启动自动跑 migration,避免「列不存在」。
  • 本地开发不受影响(docker-compose 中 api service 整段被注释,本地 venv 启动需手动 alembic upgrade head)。

后端 attribute_change 审计批量 flush(Q-2)

  • app/services/audit.py 新增 AuditService.log_many(*, actor, action, target_type, request, status_code, items):共享 actor/request/status_code,仅 target_id + detail 逐条不同;一次 db.add_all(entries) + 一次 db.flush()
  • tasks.py PATCH /annotations/{id} 字段级审计循环改为先收集 change_items: list[dict],循环结束一次 log_many。N 个属性同时改:原本 N 次 flush → 一次 flush。

WorkbenchShell 拆 hook · useWorkbenchOfflineQueue(P1 起步)

  • 新增 apps/web/src/pages/Workbench/state/useWorkbenchOfflineQueue.ts:把 v0.6.3 P0 工作之后膨胀到 80+ 行的离线接线统一封装 —— useOnlineStatus 订阅、flushOne(op)(即原 executeOp)、flushAll(即原 flushOffline)、enqueueOnError 错误归类(网络抖动入队 / 业务错 toast)、抽屉 drawerOpen / openDrawer / closeDrawer 状态、online 事件自动 flush 副作用。
  • WorkbenchShell.tsx 顶部一行调用 useWorkbenchOfflineQueue({ history, queryClient, pushToast }) 解构出全部能力;删掉原 useOnlineStatus 直接 import + executeOp / flushOffline 两段 useCallback + auto-flush useEffect + offlineDrawerOpen useState。文件从 ~1370 行降到 1305 行。
  • OfflineQueueDrawer onClose / onFlushOne / onFlushAllStatusBar onShowQueueDrawer 改用 hook 返回的具名函数,不再持有内联 lambda。
  • 不在 hook 里管的:optimisticEnqueueCreate(依赖 taskId / projectId / meUserId / s.setSelectedId / history 多项 shell 上下文,仍由 shell 持有);history 的 push 行为本身。

单元测试 — applyLeaf tmpId 分支 + offlineQueue 队列语义(P1)

  • apps/web/src/pages/Workbench/state/useAnnotationHistory.ts:把内部 applyLeaf 提为顶层 export async function applyLeaf(cmd, direction, h),行为不变;hook 的 apply 通过引用调用即可。
  • 新增 useAnnotationHistory.test.ts(6 例):覆盖 v0.6.3 P0 tmpId undo 三种场景(tmpId + removeLocalCreate / 真实 id / tmpId 但无 removeLocalCreate 兼容)+ create redo / update undo·redo 不受影响。
  • 新增 offlineQueue.test.ts(5 例,vi.mock("idb-keyval") 注入内存 Map):覆盖 replaceAnnotationId(替换 update/delete annotationId、不动 create.tmpId、无匹配不写盘)+ drain 失败累计 retry_count(单次 / 多次累加 / 半路失败保留剩余)。
  • pnpm vitest run 全量 53 个用例通过(原有 42 + 新增 11)。

文件变更摘要

  • apps/api/app/api/v1/annotation_comments.py (+24 / 评论附件下载端点)
  • apps/api/app/api/v1/tasks.py (~25 / attribute_change 批量收集)
  • apps/api/app/services/audit.py (+44 / log_many)
  • apps/api/scripts/entrypoint.sh (新增)
  • infra/docker/Dockerfile.api (+2)
  • apps/web/src/pages/Workbench/state/offlineQueue.ts (+18 / replaceAnnotationId + retry_count)
  • apps/web/src/pages/Workbench/state/useAnnotationHistory.ts (+~50 net / applyLeaf 顶层 export + tmpId 本地分支)
  • apps/web/src/pages/Workbench/state/useWorkbenchOfflineQueue.ts (新增 / 128 行)
  • apps/web/src/pages/Workbench/state/offlineQueue.test.ts (新增 / 5 例)
  • apps/web/src/pages/Workbench/state/useAnnotationHistory.test.ts (新增 / 6 例)
  • apps/web/src/pages/Workbench/shell/WorkbenchShell.tsx (~−65 / helper + 4 个 commit handler 乐观 cache + 删除 80 行迁出 hook 的代码)
  • apps/web/src/pages/Workbench/shell/CommentsPanel.tsx (+1 / href 改写)

[0.6.2] - 2026-05-01

v0.6.2 一次性收口 ROADMAP「v0.5.5 phase 2 部分落地的延续」段落 6 大欠账:离线队列抽屉 + tmpId 端到端、导出复选框、HotkeyCheatSheet 动态属性分组、属性 schema description + 字段级审计、OpenAPI codegen 完整迁移 + prebuild gate、评论 polish 三层(@ 提及 / 附件 / 画布批注)。

离线队列 — OfflineQueueDrawer + tmpId 端到端

前端

  • 新增 OfflineQueueDrawer.tsx:右侧抽屉 UI,订阅 offlineQueue 实时刷新;按操作类型(创建 / 更新 / 删除)+ 时间戳列出;单条「重试」/「丢弃」+ 底部「全部丢弃」/「立即同步全部」。多 tab BroadcastChannel 同步生效。
  • offlineQueue.ts 新增 getAll() / removeById(opId) 公开 API 供抽屉操作。
  • WorkbenchShell.handlePickPendingClass onError:分配 tmp_${crypto.randomUUID()} → 乐观插入完整 AnnotationResponsequeryClient.setQueryData(["annotations", taskId]) → push history 命令栈 → enqueue 携带 tmpId
  • 抽离 executeOp(op) 共享逻辑:drain 与单条重试都走它;create 成功时拿后端 real.idhistory.replaceAnnotationId(tmpId, realId) + cache swap,统一替换。
  • StatusBar 离线徽章 onClick 从 onFlushOffline 改为 onShowQueueDrawer,背后 setOfflineDrawerOpen(true)

导出 — ExportSection + include_attributes 复选框

  • 新增 apps/web/src/pages/Dashboard/ExportSection.tsx:项目行「导出 ▾」popover,含格式选择(COCO/VOC/YOLO)+「包含属性数据」复选框(默认勾选,对齐后端 ?include_attributes= default true)+ 文案提示。取消勾选 → URL 显式 ?include_attributes=false → 输出 v0.4.9 之前兼容格式。
  • DashboardPage.tsx 行内 <select> 替换为 <ExportSection projectId={p.id} />,移除 projectsApi / ExportFormat 直接 import。

标注属性 — schema description + 字段级审计

后端

  • AuditAction.ANNOTATION_ATTRIBUTE_CHANGE = "annotation.attribute_change" 新增枚举常量。
  • tasks.py PATCH /annotations/{annotation_id}:早 load existing 一次(兼顾 If-Match 与 attributes diff),在原 annotation.update 审计行外,按 field key 逐字段 diff before/after,每个变化的 key 单独写一条 annotation.attribute_change 行;detail 含 {field_key, before, after, task_id},配合 v0.5.5 phase 2 GIN 索引可按字段过滤历史。
  • _validate_attribute_schema 显式校验 fields[i].description 必须是字符串(已存在的字段透传)。

前端

  • AttributeField.description?: string 字段加入 TS 类型;AttributeForm 在 label 旁渲染圆形 ⓘ info 徽章,hover 显示 description(cursor: help)。

快捷键 — HotkeyCheatSheet 动态注入属性快捷键分组

  • HotkeyCheatSheet 新增 attributeSchema prop;在静态 5 组(draw / view / ai / nav / system)末尾追加「属性快捷键」分组,自动从 attributeSchema.fields.filter(f => f.hotkey && (f.type === "boolean" || f.type === "select")) 渲染;文案"切换 / 循环 {label}",副标题"选中标注后按下数字键切换 / 循环属性值";schema 无 hotkey 字段时整组不渲染。
  • WorkbenchShell 透传 currentProject?.attribute_schema 给 cheatsheet。

OpenAPI codegen — 完整迁移 + prebuild gate

  • 修复 openapi-ts.config.ts 插件名 "@hey-api/typescript""@hey-api/types"(v0.55+ 命名变更)。
  • pnpm codegen 生成 src/api/generated/{index.ts, types.gen.ts}(约 2000 行 TS 类型)。
  • users.ts / audit.ts / datasets.ts 顶部手写 interface XxxResponse { ... } 替换为 export type XxxResponse = XxxOut(基于 generated);projects.tsOmit<ProjectOut, "classes" | "classes_config" | "attribute_schema"> & { ... } 把 generated 弱类型字段(Array<unknown> / { [key: string]: unknown })收紧为前端 DSL 强类型,其余字段自动跟随后端演进。
  • package.json"prebuild": "pnpm codegen" —— pnpm build 自动先跑 codegen,根治前后端 schema 漂移。
  • 后端 schema 收紧两处误差:DashboardPage.tsx:79 p.member_count > 0(p.member_count ?? 0) > 0DatasetsPage.tsx:341 同模式修复 project_count

评论 polish — @ 提及 + 附件 + 画布批注

数据模型 / 后端

  • alembic 0020_comment_polishannotation_comments 表加 mentions JSONB DEFAULT '[]' NOT NULL + attachments JSONB DEFAULT '[]' NOT NULL + canvas_drawing JSONB NULL。Model 同步加三列 + JSONB 映射。
  • Pydantic 新增 Mention / Attachment schema:
    • Mention: userId / displayName / offset / length;alias by camelCase。
    • Attachment: storageKey / fileName / mimeType / size;自定义 validator 强制 storageKey.startswith("comment-attachments/") —— 防止任意 key 注入读其它桶资源。
  • AnnotationCommentCreate 接受 mentions: list[Mention] / attachments: list[Attachment] / canvas_drawing: dict | None,默认空。
  • AnnotationCommentOut 透出三列。
  • 路由 POST /annotations/{aid}/comments 增加项目成员校验:_validate_project_members(db, ann.project_id, [m.user_id for m in data.mentions]) 检查 project_members 表(含 super_admin / project_admin 兜底),不在则返回 422 mentions_invalid + non_member_user_ids。审计 detail 多记 mention_count / attachment_count / has_canvas_drawing
  • 新增端点 POST /annotations/{aid}/comment-attachments/upload-init:返回 {storage_key, upload_url, expires_in};storage_key 形如 comment-attachments/{aid}/{uuid}-{filename}(filename 中的 / 被替换为 _ 防止穿透)。镜像 files.py /upload-init 模式。

前端

  • 新增 apps/web/src/components/UserPicker.tsx:受控 popup(createPortal),列表 + ↑↓ Home End 键盘导航 + Enter/Tab 选中 + Esc 关闭;hover 高亮、mousedown.preventDefault 避免编辑器失焦;按 query 实时过滤 name / email。
  • 新增 apps/web/src/pages/Workbench/shell/CommentInput.tsx
    • contenteditable <div>(不是 textarea,需富格式)。
    • 输入 @ 触发:反向找最近 @(要求前方是空白 / 文首),用 caret Range 计算屏幕坐标作为 UserPicker anchor,实时把 @ 后的 query 传给 picker 过滤。
    • 选中 → 在 trigger Range 处 deleteContents + 插入 <span data-mention-uid="..." contenteditable="false" class="mention-chip">@displayName</span> chip + 紧跟空格 + 把光标放到 chip 之后。
    • 提交时 DOM 遍历 root:text node 累计为 body,chip 还原为 @displayName 文本并记录 {userId, displayName, offset, length} 到 mentions[];<br> / 块元素之间补换行。
    • 附件:<input type="file" multiple>,每个文件先调 commentsApi.attachmentUploadInit 获取预签名 URL → 直接 PUT 上传 → 收集 storageKey/fileName/mimeType/size 到 attachments[];20MB 单文件上限,超出 toast 跳过。
    • 画布批注:enableCanvasDrawing 开关 + 弹出 CanvasDrawingEditor
    • Enter 提交(Shift+Enter 换行;picker 打开时 Enter 走 picker 选中)。
    • 导出 renderCommentBody(body, mentions, onMentionClick):把 mentions 按 offset 还原为可点击 chip,CommentsPanel 用它渲染历史评论的 mention chip → 点击跳 /audit?actor={userId} 用户审计追溯。
  • 新增 apps/web/src/components/CanvasDrawingEditor.tsx
    • 600×400 SVG 自由曲线编辑器(Modal 弹窗),4 色画笔(红 / 黄 / 绿 / 蓝),按住鼠标拖动绘制 polyline;归一化 [0,1] 坐标存储;撤销 / 清空 / 保存按钮。
    • 配套 CanvasDrawingPreview 只读小缩略(默认 220px 宽,按比例高),CommentsPanel 在历史评论卡片中渲染 reviewer 留下的画布批注;可选 backgroundUrl 作为编辑 / 预览背景(reviewer 在原图缩略上画更直观)。
  • CommentsPanel.tsx:textarea 替换为 <CommentInput>;新增 projectId prop(拉 useProjectMembers 喂 picker)+ backgroundUrl + enableCanvasDrawing prop;历史评论渲染加 mentions chip / attachment 链接 / canvas drawing preview 三块。
  • useAnnotationComments.useCreateComment 改签名:mutationFnstring | CreateCommentPayload,向后兼容老调用。
  • commentsApi:新增 CommentMention / CommentAttachment / CommentCanvasDrawing 类型 + CreateCommentPayload + attachmentUploadInit 方法。
  • AIInspectorPanel:透传 taskFileUrl + enableCommentCanvasDrawing 给 CommentsPanel;annotator 端 enableCommentCanvasDrawing 默认 undefined → 仅查看 reviewer 画的批注,不能反向画。
  • WorkbenchShelltask?.file_url 作为 taskFileUrl 传给 inspector。
  • ReviewWorkbench:右侧条件可见侧栏(width 320)渲染 CommentsPanel,工具栏加「💬 评论」开关按钮;enableCanvasDrawing 默认开启,reviewer 可在 modal 中绘制并连同评论提交。

修复

  • WorkbenchShell.handlePickPendingClass 内 payload 显式标注 AnnotationPayload 类型,让 payload.attributes ?? {} 通过 strict 检查。

[0.6.1] - 2026-04-30

v0.6.1 聚焦 ROADMAP P1 项:大数据集分包 / 批次工作流(task_batch)。PM 可按策略切分批次 → 标注员按批领题 → 审核员整批通过/退回 → 按批导出。AI 预标注相关留白(仅 on_batch_approved 空 hook)。

大数据集分包 / 批次工作流

数据模型

  • 新建 task_batches 表(alembic 0019):id / project_id / dataset_id / display_id / name / description / status / priority(0-100) / deadline / assigned_user_ids JSONB / total_tasks / completed_tasks / review_tasks / approved_tasks / rejected_tasks / created_by / created_at / updated_at
  • tasks 表新增 batch_id UUID FK 列(ON DELETE SET NULL,indexed)。
  • 数据回填:为每个现存 project 创建默认批次 B-DEFAULT(status=active),所有老 task 关联。

状态机

  • BatchStatus 枚举 7 态:draft → active → annotating → reviewing → approved / rejected → archived
  • active → annotating:首个 task 进入 in_progress 时自动转移。
  • annotating → reviewing:所有 task 完成(无 pending/in_progress)时自动转移。
  • 自动转移在 submit_task / approve_task / reject_task 端点触发。

后端

  • BatchServiceapp/services/batch.py):状态机校验 + 3 种切分策略(random / metadata / id_range)+ 计数器同步 + 整批退回(tasks 全部重置为 pending)+ on_batch_approved() 空 hook。
  • 9 个 API 端点(/projects/{project_id}/batches):LIST / GET / POST / PATCH / DELETE / transition / split / reject / export。
  • AuditAction 新增 4 个事件:batch.created / batch.status_changed / batch.rejected / batch.deleted
  • Scheduler get_next_task() 改造:JOIN task_batches 过滤 active/annotating 批次 + assigned_user_ids JSONB @> 检查 + priority DESC 排序。
  • ExportService._load_data() 新增可选 batch_id 参数,三种导出格式透传。
  • TaskOut schema + _task_with_url 返回 batch_id 字段。
  • list_tasks 端点新增 batch_id 查询参数过滤。

前端

  • 新建 api/batches.tsbatchesApi 对象(list / get / create / update / remove / transition / split / reject / exportBatch)。
  • 新建 hooks/useBatches.ts:8 个 React Query hooks(query key ["batches", projectId])。
  • types/index.ts:新增 BatchStatus 类型 + TaskResponse.batch_id
  • ProjectSettingsPage:新增「批次管理」Tab(layers 图标),BatchesSection 组件支持创建单个批次 / 随机切分 N 批 / 状态转移 / 删除。
  • TaskQueuePanel:批次下拉过滤(仅显示 active/annotating 批次)。
  • WorkbenchShellselectedBatchId state + useTaskList 传入 batch_id 过滤。
  • ReviewPage:批次下拉过滤 + 「整批退回」按钮。
  • tasks.tsTaskListParams + getNext 支持 batch_id 参数。

[0.6.0] - 2026-04-30

v0.6.0 聚焦 ROADMAP 中 3 个 P0 项:协作并发数据保护(锁续约 + ETag 冲突检测)、安全 & 测试基建(JWT 生产护栏 + 登录限流 + 密码策略 + 密码重置 + DB 测试套件)、用户内嵌式 Bug 反馈系统(AI-friendly,结构化反馈 → Markdown 端点直接喂 Claude Code)。

协作并发 — 任务锁主动续约 + 编辑冲突 ETag

后端

  • Annotation / Task 模型新增 version INTEGER DEFAULT 1 列(alembic 0016),乐观并发控制基础。
  • PATCH /tasks/{task_id}/annotations/{annotation_id} 支持 If-Match 头校验:版本不匹配 → 409 {reason:"version_mismatch", current_version:N};成功 → ETag: W/"<version>"
  • AnnotationOut schema 新增 version: int 字段。
  • AnnotationService.update() 每次更新自动 version += 1

前端

  • apiClient.patch() 支持可选的 extra?: RequestInit 参数。
  • tasksApi.updateAnnotation() 接受 etag 参数,拼 If-Match 头。
  • 新建 ConflictModal 组件:编辑冲突时弹窗,提供「重载(放弃本地)」/「强制覆盖」/「取消」三选项。
  • useUpdateAnnotation 检测 409 状态 → 触发 onConflict 回调。
  • useTaskLock:心跳间隔 120s → 60s;新增 remainingMs 倒计时(每秒更新);心跳失败自动重试 acquireLock
  • StatusBar 左侧新增锁倒计时显示(< 60s 变红)+ 锁错误提示。

安全 & 测试基建

JWT 生产硬校验

  • main.py lifespan 启动检查:environment=productionsecret_key=="dev-secret-change-in-production" → 拒绝启动。

登录限流

  • 新增 slowapi>=0.1.9 依赖 + app/core/ratelimit.py
  • main.py 注册 SlowAPIMiddleware + RateLimitExceeded handler。
  • POST /auth/login@limiter.limit("5/minute")

密码策略升级

  • 新建 app/core/password.pyvalidate_password_strength()(≥8 位 + 大写 + 小写 + 数字)。
  • PasswordChange.new_password / RegisterRequest.passwordmin_length 6→8,加 @field_validator 强度校验。
  • 前端密码标签更新:"至少 6 位" → "至少 8 位,需含大小写字母和数字"。

密码重置流程

  • 新建 password_reset_tokens 表(alembic 0018)+ 模型 + PasswordResetServicecreate_token / consume_token,1h 过期)。
  • POST /auth/forgot-password(公开,限流 3/min):生成 token,SMTP 未配置时打日志;防邮箱枚举,始终返回 202。
  • POST /auth/reset-password(公开):验证 token + 强度校验 + 更新密码 + 写 audit log。
  • 前端新建 ForgotPasswordPage / ResetPasswordPageLoginPage 加 "忘记密码?" 链接;App.tsx 加公开路由。

DB-backed 测试套件

  • 重写 tests/conftest.pytest_db_urlTEST_DATABASE_URL 环境变量)+ apply_migrations(session 级 alembic upgrade head)+ db_session(per-test SAVEPOINT 隔离)+ super_admin / project_admin / annotator / reviewer 三角色 fixture(含 JWT token)+ httpx_client(挂真实 DB)。
  • 新建 test_audit_logs.py:过滤 / 分页 / 登录自动产生审计日志。
  • 新建 test_users_role_matrix.py:12 个角色修改守卫用例。
  • 新建 test_users_delete_transfer.py:删除权限 / 自己不可删 / 审计日志验证。

用户内嵌式 Bug 反馈系统(AI-friendly)

后端

  • 新建 bug_reports + bug_comments 表(alembic 0017)。
  • 新建 BugReportService(CRUD + list_markdown() Markdown 端点 + cluster_similar() 去重建议)。
  • 7 个 API 端点:POST /bug_reports(10/hour 限流)/ GET /bug_reports(admin,?format=markdown 输出可直接喂 Claude Code)/ GET /bug_reports/mine / GET /bug_reports/{id}(含评论)/ PATCH /bug_reports/{id} / POST /bug_reports/{id}/comments / POST /bug_reports/cluster
  • AuditAction 新增 BUG_REPORT_CREATED / BUG_REPORT_STATUS_CHANGED / BUG_COMMENT_CREATED

前端

  • 新建 bug-reports.ts API 模块 + bugReportCapture.ts 自动捕获工具(fetch 拦截 ring buffer + console 错误 ring buffer + 脱敏)。
  • 新建 BugReportFAB:右下角悬浮反馈按钮(z-index: 100),全局常驻。
  • 新建 BugReportDrawer:右侧 400px 抽屉,三态(我的反馈列表 / 创建表单 / 详情+评论)。自动捕获 route / UA / viewport / API 调用 / console 错误。
  • 新建 BugsPage/bugs,admin only):表格 + 状态/严重度过滤 + 详情面板(含状态变更 + 评论)。
  • SettingsPage 新增「我的反馈」tab,调用 GET /bug_reports/mine
  • App.tsx 注册 /bugs 路由 + FAB/Drawer + 初始化 fetch 拦截。
  • PageKey / ROLE_PAGE_ACCESS"bugs"IconBugauditLabels 加 bug 相关标签。

验证

  • 后端模块导入全绿(uv run python -c "from ...")。
  • alembic 5 条迁移链 0014→0015→0016(version 列)→0017(bug 反馈)→0018(密码重置)全部成功应用。
  • 前端 tsc -b 零错误(apps/web/)。
  • slowapi 安装成功,Limiter / SlowAPIMiddleware 导入正常。

不在本期范围(明确 defer 到 v0.6.1+)

  • Bug 反馈截图(html2canvas 抓视口 + MinIO 上传 + 涂抹脱敏)—— FAB/Drawer 已留截图位,html2canvas 依赖按需引入。
  • Bug 聚类去重的 LLM 调用(POST /bug_reports/cluster 当前仅返回启发式相似结果)。
  • 邮件实际发送(SMTP placeholder,token 打日志兜底)。
  • 前端 vitest 扩展(useAnnotationHistory batch / useClipboard 偏移 / useSessionStats ring buffer)。
  • Playwright E2E 测试 / CI/CD pipeline / husky pre-commit hooks。
  • i18n / 无障碍 / SSO / 2FA。

Released under the MIT License.