⚠️ 自动镜像 · 此页由
docs-site/scripts/mirror-changelog.mjs从docs/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 批次内)。
根因:
GET /tasks?project_id=...、GET /tasks/{id}、/annotations、/predictions都只看project_id,没有 batch 可见性检查。前端WorkbenchShell.activeBatches过滤只决定下拉显示哪些 batch,但 API 返回的是项目全量。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_visible 在 get_task / get_annotations / get_predictions 4 个读路径执行。
第二版修复:第一版抄 scheduler 时漏了 TaskBatch.status IN ('active','annotating') 限制,导致 draft 批次(status=draft + assigned_user_ids=[])仍被当成「开放标注池」可见 → P-4 仍暴露 1206 任务。把可见性合并成单一子句:
batch_visibility_clause = TaskBatch.status IN ('active','annotating')
AND (assigned_user_ids = [] OR contains [self])重命名 assigned_user_ids_clause → batch_visibility_clause(保留兼容别名)。
生产 DB 实测:标注员对 P-4 的可见任务 1206 → 120(仅 BT-13 active+assigned),符合预期。
关键修改
| 文件 | 改动 |
|---|---|
apps/api/app/services/scheduler.py | 抽 is_privileged_for_project + batch_visibility_clause + WORKBENCH_VISIBLE_BATCH_STATUSES = ['active','annotating'];scheduler 自身改用 helper |
apps/api/app/api/v1/tasks.py | list_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 大头号(按生产体感影响排序):
- 批次级 review UI + transition 鉴权全缺:
PATCH /batches/{id}/transition无鉴权(任何项目成员能任意推态);BatchesSection 缺「整批提交质检 / 批次通过 / 批次驳回」按钮,标注员 / reviewer 没有批次级操作入口 - reviewer 在 reviewing 批次彻底看不到任务:
WORKBENCH_VISIBLE_BATCH_STATUSES把 reviewer 也挡住,标注员提交后 reviewer 任务凭空消失,UX 断层 reject_batch数据语义未决:当前task.status=pending + is_labeled=false但未清 annotations 表,UI/DB 状态不一致;UI 入场前必须先决断软重置 vs 硬重置方案
详见 ROADMAP #### 批次状态机重设计 节。
测试
tests/test_task_batch_visibility.py6 例- 全套 82 → 88 例通过;前端 tsc 0 errors
B-16 数据库标记
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.reopenedaudit 一行,detail 含 reopen_count。 - 评论返回 author_name + author_role:
get_with_comments改为BugComment LEFT JOIN User,避免前端 N+1 lookup。BugCommentOutschema 加author_name/author_role,BugReportOut/BugReportDetail加reopen_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-readWebSocket /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且 payloadreopen=true+reopen_count - 管理员评论 → 通知 reporter(type=
bug_report.commented) - 自己操作不通知自己(reporter == admin 同人时不入队)
- PATCH 状态变更(actor != reporter)→ 通知 reporter(payload 含
- 前端通知中心改造:
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 + 跳/bugsTopBar红点改为消费unreadCount(来自服务端unread);移除 v0.4.8 留下的localStorage[lastRead]hackuseNotificationSocket在<AppShell>顶层挂载(登录后即连)
关键修改
| 文件 | 改动 |
|---|---|
apps/api/alembic/versions/0025_bug_reopen_fields.py | bug_reports 加 reopen_count + last_reopened_at |
apps/api/alembic/versions/0026_notifications.py | 新建 notifications 表 + 双索引 |
apps/api/app/db/models/bug_report.py | BugReport +2 列 |
apps/api/app/db/models/notification.py | Notification ORM(新建) |
apps/api/app/services/bug_report.py | add_comment 自动 reopen + 返回元组;get_with_comments join User |
apps/api/app/services/notification.py | NotificationService(新建) |
apps/api/app/schemas/bug_report.py | BugCommentOut +author_name/role;BugReportOut +reopen_count |
apps/api/app/schemas/notification.py | NotificationOut / NotificationList / UnreadCount(新建) |
apps/api/app/api/v1/bug_reports.py | 评论端点收紧鉴权 + 60/hour 限流 + audit reopened + 通知 fan-out;PATCH 状态通知 reporter |
apps/api/app/api/v1/notifications.py | REST 端点(新建) |
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.ts | BugReportResponse +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.ts | WS 订阅 + 指数退避重连(新建) |
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.tsx | AppShell 挂载 useNotificationSocket |
测试
apps/api/tests/test_bug_reports.py(新)6 例:reopen 触发 / admin 评论不触发 / 非终态不触发 / 累加 / HTTP 越权 403 / 提交者 HTTP 评论 + author 信息回传 + 详情含 reopen_countapps/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)
docker compose up -d,浏览器双账号登录(reporter A + admin B)- A 提交 BUG → B 铃铛红点 +1,下拉显示「{A} 评论了反馈 · B-N / 标题」
- B 改状态 fixed + 写 resolution → A 铃铛 +1,详情页 status = 已修复
- A 在 BugReportDrawer 详情页评论「还是有问题」→ status 自动回 已确认,徽章「曾重开 1 次」;B 收到 reopen 通知
- WS 验证:A 浏览器 devtools 看
wss://.../api/v1/ws/notifications帧;断网 30s 后轮询兜底
数据库脚本
# 重开过的 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返回 400No 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拿到undefined→hasNextPage=false→ 卡在第一页。同时首屏排序(sequence_order, created_at)与游标分支(created_at, id)不一致。 - 修复:合并两条分支为单一管线,统一排序
(created_at, id),无论是否带 cursor 都计算next_cursor。offset仍兼容(无 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,但仍有两个未覆盖:
- 同会话乱序:keepalive DELETE 与新 acquire 到达顺序不保证;my_lock 分支若在 DELETE 之前执行,会有「我刚续期又被自己删掉 / 留下假锁」的残影。
- 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.py | list_tasks 首屏返回 next_cursor + 排序统一 |
apps/api/app/services/task_lock.py | acquire() 自身多行 dedup + 单锁/非 assignee 接管 |
apps/web/src/pages/Workbench/shell/WorkbenchShell.tsx | activeBatches 纳入 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 例。
问题 2 + 3:unlink 改 hard-delete + 孤儿任务清理
后端
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 ProjectOut加in_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:用 fetchkeepalive: true保证 unmount / 页面跳转时 DELETE 仍能送达,避免残留 lock 把用户挡在自己刚释放的任务外。apps/web/src/hooks/useTaskLock.tscleanup 改用 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 步 + 完成页:- 类型:name + type + due_date(不变)
- 类别:简单字符串列表(不变;后续可在设置页升级到 classes_config)
- AI:on/off + 模型(不变)
- 数据(新):从
useDatasets()多选数据集;可选「随机切分为 N 个批次」(默认保留每个数据集一个独立包)。提交时顺序linkProject(...)每个数据集 + 可选useSplitBatches,单个失败不阻断。 - 成员(新):从
useUsers()过滤 annotator / reviewer 多选,循环useAddProjectMember,单个失败不阻断。 - 完成:显示「已关联 N 个数据集 · 已添加 K 位成员」+ 「项目设置 / 工作台 / 完成」按钮。
- localStorage 草稿:
create_project_draft_v0_6_7key 持久化 1-3 步表单(关闭模态丢弃,提交成功清除),刷新不丢。 - 步骤 4-5 可跳过,避免逼迫用户在没有数据/成员时硬填。
B-12 · 数据分包 / 分派可见性
B-12-① · link_project 自动建命名 batch
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 useLinkProjectinvalidate 增["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.tsx:activeBatches计算增 owner/super_admin 判断 —— 非项目 owner 时只看assigned_user_ids.includes(meUserId)的活跃批次。下拉 dropdown 复用 v0.6.0 已存在的TaskQueuePanelUI。
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=batches,onSettings签名扩(p, section?)。
B-10 · 取消关联数据集二次确认 + 计数同步
后端
apps/api/app/services/dataset.py unlink_project()改造:返回类型从bool改为dict | None,None= 链接不存在;否则统计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}/link增AuditAction.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 AuditAction增DATASET_LINK/DATASET_UNLINK。
前端
apps/web/src/api/datasets.ts:unlinkProject返回类型改{ orphan_tasks: number };新增previewUnlink。apps/web/src/hooks/useDatasets.ts useUnlinkProjectinvalidate 增["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/webvitest 基座:装@testing-library/react+jsdom+@testing-library/jest-dom,vite.config.ts加test配置 +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_projectbulk_insert:SELECT 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);AuditLogOutschema 暴露字段。 - 前端:AuditPage 按
request_idgroup → 折叠为单行 + ▸ 展开(同请求 metadata + N 条业务 detail),同时 useVirtualizer 化整张表(5000+ 行 60FPS)。
Reviewer 仪表板升级
- 后端:
GET /dashboard/me/recent-reviews新端点,从 Task.reviewer_id + reviewed_at 反查;ReviewerDashboardStats增approval_rate_24h(基于 audit_logs 过去 24htask.approve/task.reject计数)。 - 前端:5 张统计卡(待审队列 / 今日已审 / 24h 通过率 / 历史通过率 / 累计审核)+ 「我的最近审核记录」list。
WorkbenchShell 第三刀
useWorkbenchTaskFlow.ts新建:从 shell 拆出navigateTask/smartNext/hasMissingRequired/handleSubmitTask(~80 行)。WorkbenchShell.tsx 1003 → 924 行。
CanvasDrawing 历史回看
useHoveredCommentStore(zustand) + ImageStagehistoricalShapesprop: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.deleteaudit detail。
可观测性
- Sentry 前后端:后端
sentry-sdk[fastapi]+ lifespan 早期 init(DSN 留空则不启用,dev 默认关闭);before_send 钩子剔除 Authorization 头。前端@sentry/react+Sentry.captureException接到现有 ErrorBoundary。新增SENTRY_DSN/VITE_SENTRY_DSNenv。 - MinIO bucket lifecycle:
comment-attachments/90 天 +bug-screenshots/180 天自动过期,避免无限增长(celery beat 未启用,靠 lifecycle 兜底)。
Bug 反馈系统延伸(截图 + 涂抹 + MinIO)
POST /bug_reports/screenshot/upload-init:签发bug-screenshots/{user_id}/{uuid}.pngPUT 预签名 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)一律 409task_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。alembic0022_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,否则 409task_not_submittable);写submitted_at;清空上一轮 reviewer 痕迹(reopen → 再次 submit 场景);写 audit。POST /withdraw新增(:402):标注员撤回质检。前提三选一同时满足 ——status=reviewANDassignee_id == 当前用户(admin 兜底) ANDreviewer_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 带不带都行,现在 400reject reason is required);持久化reject_reason到 task;改回in_progress(之前是pending);写 audit detail.reason。POST /reopen新增(:613):标注员对completed任务单方面重开。前提:status=completedANDassignee_id == 当前用户(admin 兜底)。reopened_count++、last_reopened_at = now、清 reviewer_*、completed_tasks--;audit detail 留original_reviewer_id,让me.py:get_notifications把通知推给原 reviewer。
- 新 helper
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)。useRejectTask的reason类型从string?改成string必填,type 层提醒所有 caller。api/tasks.ts新方法withdraw/reopen/reviewClaim;types/index.tsTaskResponse同步加 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/handlePickPendingClass6 处入口先 toast「任务已锁定 · 撤回提交或继续编辑后再操作」再 return。AIInspectorPanel.tsx接受readOnly?prop,转发给<AttributeForm readOnly>。AttributeForm的readOnlyv0.6.0 就有,本期复用。TaskQueuePanel.tsxLock icon:status ∈ {review, completed}时在 task item 数量徽章左侧显示锁图标 + tooltip。ReviewWorkbench.tsx进入审核页 useEffect on mount 调tasksApi.reviewClaim(taskId)(仅status=review时)。响应is_self=false顶部黄色横幅「已被其他审核员认领(时间),仍可接力处理」。ReviewerDashboard.tsxreject 按钮加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 chunk(
apps/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增字段):- 顶部搜索框:模糊匹配
desc或keys.join(" "),分组实时过滤。 [ ] 按使用频率排复选框:开启后所有命中 HotkeyDef 平铺、按usage[actionType]倒序、×N计数徽章贴在 desc 旁;关闭恢复原分组视图。- 计数实现:
HotkeyDef加可选actionType字段,useWorkbenchHotkeys.ts:227在dispatchKey返回 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-fgtoken;DescriptionPopover的code组件改用新 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_id从T-{hex6}改为T-{N}、datasets从DS-{hex6}改为D-{N}、projects从P-{hex4}改为P-{N}。alembic 迁移 0021 自动回填存量数据;URL 路由不依赖 display_id(仅作为展示 + 文件名),因此用户感知是仅仅 ID 变短。如有任何脚本字面量比对T-XXXXXX形态需调整。
项 8 · display_id 风格统一 + 序列化生成器
后端
- 新增
apps/api/app/services/display_id.py:next_display_id(db, entity)走 PostgresSEQUENCE取号(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_id:bug_report.py:205-212(删 buggyMAX+1)、dataset.py:90/309、batch.py:70/187/229/267、projects.py:151、files.py:27。B-DEFAULT字符串字面量在batch.py:54,122保留作为默认批次哨兵。 - model 同步 unique:
project.py:14、task.py:15、task_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.py:AttributeFieldOption/AttributeField/AttributeSchema/VisibleIfRule/ClassConfigEntry/BboxGeometry/PolygonGeometry/Geometry(discriminator ontype)/AnnotationAttributes(值类型受限)/Mention/Attachment/CanvasShape/CanvasDrawing/AuditDetail(known fields + extra=allow)。 project.pyProjectOut.classes_config: ClassesConfig、attribute_schema: AttributeSchema;ProjectUpdate同步收紧。原_validate_*函数删除(结构 + AttributeSchema 内部 model_validator 替代)。annotation.pyAnnotationOut/Create/Update.geometry: Geometry、attributes: AnnotationAttributes;field_validator(mode="before")兼容历史无 type 的 bbox。annotation_comment.pymentions: list[Mention]、attachments: list[Attachment]、canvas_drawing: CanvasDrawing | None(之前 OUT 端用dict[str, Any]丢类型)。audit.pydetail_json: AuditDetail | None,AuditDetail 是带已知字段(request_id / task_id / field_key / before / after / old_name / new_name)+extra=allow的 BaseModel。- 新增
apps/api/scripts/dump-openapi.py:PYTHONPATH=. python3 scripts/dump-openapi.py /tmp/openapi.json,给 CI 离线 codegen 用,无需运行后端。
前端
- 删
apps/web/src/api/projects.ts:44-54的Omit + 富类型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 | PolygonGeometry、shapes: 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 +polygonHandlememo。内部抽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)、spacePanstate、nudgeMapstate + ref +flushNudges、applyArrowNudge内部 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/imgH,strokeWidth = 2/scale屏幕粗细恒定;与 ImageStagevp.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.ts加canvasDraftslice + 8 个 actions(beginCanvasDraft / endCanvasDraft / cancelCanvasDraft / appendCanvasShape / undoCanvasShape / clearCanvasShapes / setCanvasStroke / consumeCanvasResult)。Tool类型扩展"canvas"。ImageStage.tsx新增 4 个 prop:canvasShapes / canvasEditable / canvasStroke / onCanvasStrokeCommit;onMove / onUp 增canvasStroke分支累加点 + commit 一笔;SelectionOverlay加tool !== "canvas"守卫;container cursor 在 canvas 模式强制 crosshair。CommentInput.tsx加liveCanvasprop({ 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.tsx把enableCommentCanvasDrawing默认值改为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}.py、app/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.py、test_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}.tsxapps/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:抽
useWorkbenchOfflineQueuehook + 离线相关单测落地。
评论附件下载端点(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?: number;drain失败时累计+1后再persist+ break,便于未来 drawer 区分「网络抖动」vs「永久脏数据」。
useAnnotationHistory undo 修复
HistoryHandlers新增可选removeLocalCreate?(id)钩子。applyLeafcreate 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)),断网不再吞错。executeOpcreate 成功后追加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.sh:set -e && alembic upgrade head && exec "$@"。 infra/docker/Dockerfile.api加ENTRYPOINT ["/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 +offlineDrawerOpenuseState。文件从 ~1370 行降到 1305 行。OfflineQueueDraweronClose / onFlushOne / onFlushAll与StatusBar 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.handlePickPendingClassonError:分配tmp_${crypto.randomUUID()}→ 乐观插入完整AnnotationResponse到queryClient.setQueryData(["annotations", taskId])→ push history 命令栈 → enqueue 携带tmpId。- 抽离
executeOp(op)共享逻辑:drain 与单条重试都走它;create 成功时拿后端real.id→history.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新增attributeSchemaprop;在静态 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.ts用Omit<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:79p.member_count > 0→(p.member_count ?? 0) > 0;DatasetsPage.tsx:341同模式修复project_count。
评论 polish — @ 提及 + 附件 + 画布批注
数据模型 / 后端
- alembic
0020_comment_polish:annotation_comments表加mentions JSONB DEFAULT '[]' NOT NULL+attachments JSONB DEFAULT '[]' NOT NULL+canvas_drawing JSONB NULL。Model 同步加三列 +JSONB映射。 - Pydantic 新增
Mention/Attachmentschema: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 兜底),不在则返回 422mentions_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}用户审计追溯。
- contenteditable
- 新增
apps/web/src/components/CanvasDrawingEditor.tsx:- 600×400 SVG 自由曲线编辑器(Modal 弹窗),4 色画笔(红 / 黄 / 绿 / 蓝),按住鼠标拖动绘制 polyline;归一化 [0,1] 坐标存储;撤销 / 清空 / 保存按钮。
- 配套
CanvasDrawingPreview只读小缩略(默认 220px 宽,按比例高),CommentsPanel 在历史评论卡片中渲染 reviewer 留下的画布批注;可选backgroundUrl作为编辑 / 预览背景(reviewer 在原图缩略上画更直观)。
CommentsPanel.tsx:textarea 替换为<CommentInput>;新增projectIdprop(拉useProjectMembers喂 picker)+backgroundUrl+enableCanvasDrawingprop;历史评论渲染加 mentions chip / attachment 链接 / canvas drawing preview 三块。useAnnotationComments.useCreateComment改签名:mutationFn接string | CreateCommentPayload,向后兼容老调用。commentsApi:新增CommentMention/CommentAttachment/CommentCanvasDrawing类型 +CreateCommentPayload+attachmentUploadInit方法。AIInspectorPanel:透传taskFileUrl+enableCommentCanvasDrawing给 CommentsPanel;annotator 端enableCommentCanvasDrawing默认 undefined → 仅查看 reviewer 画的批注,不能反向画。WorkbenchShell把task?.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端点触发。
后端
BatchService(app/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()改造:JOINtask_batches过滤 active/annotating 批次 +assigned_user_idsJSONB@>检查 +priority DESC排序。 ExportService._load_data()新增可选batch_id参数,三种导出格式透传。TaskOutschema +_task_with_url返回batch_id字段。list_tasks端点新增batch_id查询参数过滤。
前端
- 新建
api/batches.ts:batchesApi对象(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 批次)。WorkbenchShell:selectedBatchIdstate +useTaskList传入batch_id过滤。ReviewPage:批次下拉过滤 + 「整批退回」按钮。tasks.ts:TaskListParams+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>"。AnnotationOutschema 新增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.pylifespan 启动检查:environment=production且secret_key=="dev-secret-change-in-production"→ 拒绝启动。
登录限流
- 新增
slowapi>=0.1.9依赖 +app/core/ratelimit.py。 main.py注册SlowAPIMiddleware+RateLimitExceededhandler。POST /auth/login加@limiter.limit("5/minute")。
密码策略升级
- 新建
app/core/password.py:validate_password_strength()(≥8 位 + 大写 + 小写 + 数字)。 PasswordChange.new_password/RegisterRequest.password的min_length6→8,加@field_validator强度校验。- 前端密码标签更新:"至少 6 位" → "至少 8 位,需含大小写字母和数字"。
密码重置流程
- 新建
password_reset_tokens表(alembic 0018)+ 模型 +PasswordResetService(create_token/consume_token,1h 过期)。 POST /auth/forgot-password(公开,限流 3/min):生成 token,SMTP 未配置时打日志;防邮箱枚举,始终返回 202。POST /auth/reset-password(公开):验证 token + 强度校验 + 更新密码 + 写 audit log。- 前端新建
ForgotPasswordPage/ResetPasswordPage;LoginPage加 "忘记密码?" 链接;App.tsx加公开路由。
DB-backed 测试套件
- 重写
tests/conftest.py:test_db_url(TEST_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.tsAPI 模块 +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";Icon加Bug;auditLabels加 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 扩展(
useAnnotationHistorybatch /useClipboard偏移 /useSessionStatsring buffer)。 - Playwright E2E 测试 / CI/CD pipeline / husky pre-commit hooks。
- i18n / 无障碍 / SSO / 2FA。