Files
DadaoTask/2.Design/ROADMAP-M0-M1.md
T
2026-10-29 01:53:47 +08:00

28 KiB
Raw Blame History

实施清单(M0–M2)

来源:2.Design/PRODUCT-PLAN.md v1.0 第 12 节 粒度:每项 0.3–1.5 人日,2 人并行 ≈ 2 周一个里程碑 状态标记:[ ] 未开始 [~] 进行中 [x] 完成

最后一次核对:M0 骨架已于本机跑通(33 条测试全绿、ruff 通过、真机 HTTP 冒烟通过)。 标 [x] 的都是真跑过的,不是"代码写完了"。写代码不等于完成,代码没有义务在第一次运行时正确。


M0 · 骨架(第 1–2 周,10 人日)

里程碑目标:一个能注册、能建团队、能证明"两个租户互相看不见"的空壳,部署在服务器上,CI 全绿。

0.1 仓库与约定(0.5d)

  • 目录结构:backend/ web/ android/ deploy/ 2.Design/
  • AGENTS.md:提交纪律(改完必 push)、前端产物只能由 HEAD 构建、文档落点
  • .gitignore / .editorconfig / README.md
  • 分支策略:main 受保护,功能走 feat/*;提交信息 feat(module): 中文描述
  • PR 模板(.gitea/PULL_REQUEST_TEMPLATE.md)
  • 验收:Gitea 上能看到仓库 ✅(http://192.168.8.160:8418/Tatta/DadaoTask.git),PR 模板待补

0.2 本地环境(1d)

  • deploy/docker-compose.dev.yml:pgvector/pgvector:pg16 + redis:7(未含 minio,M0 不用对象存储)
  • postgres 初始化脚本:建 dadao_owner / dadao_app 两个角色 + 库 owner + REVOKE CREATE
  • .env.example 全量注释
  • CREATE EXTENSION vector, pg_trgm, pgcrypto(推迟到 M3 知识库:M0 的迁移不依赖任何扩展)
  • 偏差(重要):本机 Docker Desktop 起不来(com.docker.service 未运行、需管理员、WSL 报 E_ACCESSDENIED), M0 是用便携版 PostgreSQL 16.4 跑通的:解压到 .local/pg,initdb 后由后台作业前台运行 postgres.exe -p 5433。 步骤见 §「环境现状与阻塞」。换机器/换人仍以 docker-compose 为准。

0.3 配置与日志(0.5d)

  • pydantic-settings 分层配置(dev/test/prod),validate_runtime() 启动即校验
  • 结构化日志(JSON)+ request_id 贯穿 + 敏感字段脱敏(password/token/api_key/secret)
  • 验收:✅ request_id 出现在响应头与所有日志行;ENV=prod 且密钥仍是默认值 → 启动直接失败

0.4 数据库基座(1d)

  • alembic init,env.py 读 MIGRATION_DATABASE_URL(owner 角色)
  • 基础表迁移:workspace app_user member invitation audit_log
  • 通用列约定:id uuid、created_at、updated_at、version;deleted_at 按表按需(workspace 有)
  • 空库 alembic upgrade head 一次成功
  • downgrade 回滚实测(代码写了,没跑过)
  • 踩坑记录:asyncpg 走预备语句,一条 execute 只能一条 SQL。多语句 DDL 必须拆开逐条执行,否则报 cannot insert multiple commands into a prepared statement。

0.5 租户上下文 + RLS(1.5d)★ 核心

  • 请求依赖:解析 JWT → 校验成员身份 → set_config('app.ws_id', …, true)(事务级,自动随事务失效)
  • 租户表启用 RLS:workspace member invitation audit_log(策略见 backend/alembic/versions/0001_init.py)
  • 仓储基类 Repo(session, ws_id) 强制 ws_id 必填、无默认值
  • 应用角色 dadao_app 非 owner、非 superuser、无 BYPASSRLS,且无 DDL 权限
  • 测试:跨租户读 → 报 3003 NOT_A_MEMBER(不泄露对方工作区是否存在);DB 层未绑上下文 → 0 行
  • 变异测试:手工删掉一条 where 条件,确认测试会变红(验证测试真的有牙)
  • 已知取舍:app_user 表不加 RLS——登录时按手机号查用户发生在任何租户绑定之前,加了策略会直接查不到。 代价是"手机号是否已注册"在应用层可探测;缓解手段:错误信息统一、加登录限流。这条记在 D4 风险里,M4 前必须处理。

0.6 认证(1.5d)

  • 手机号 + 验证码注册/登录(dev 固定码 123456 并回显;ENV=prod 禁止 SMS_PROVIDER=dev)
  • JWT:access 30min / refresh 14d(计划里写的 15min/30d,实际取宽了一点,理由是移动端弱网刷新成本高)
  • 发码节流(SMS_THROTTLE_SECONDS,默认 60s,测试可置 0)
  • 邮箱 + 密码(argon2)(未做,M1 有空再说;手机号登录先行)
  • refresh 令牌落库可撤销(未做,当前是无状态 JWT;被移出团队的成员靠"每次请求查 member 表"兜底,refresh 本身不可撤销)
  • 验收:✅ 注册 → 登录 → 刷新 → /auth/me 全链路通;错误码 2010/2011/2012 语义清晰

0.7 权限守卫(0.5d)

  • 四个角色:owner / admin / member / guest,ROLE_RANK = {owner:4, admin:3, member:2, guest:1}
  • 依赖注入统一执行:require_role(min)、AdminDep、OwnerDep
  • 细则:只有 owner 能邀请 admin;guest 不能邀请;非成员一律 3003
  • 偏差:没做 (资源, 动作) → 最低角色 的 PERMISSIONS 字典,M0 只有"工作区"一个资源域, 字典是过度设计。M1 引入 item 时再上。
  • 验收:✅ member 调邀请接口 → 3004;跨租户 → 3003

0.8 接口规范(0.5d)

  • 统一响应体 {code, message, data, request_id}
  • 错误码表(暂存在 backend/app/errors.py,M1 抽到 2.Design/API.md;已按 1xxx 通用 / 2xxx 认证 / 3xxx 租户 / 4xxx 并发 分段)
  • 写接口支持 Idempotency-Key(存储 TTL 24h;只缓存 <400,5xx 与 4xx 都不缓存)
  • 分页:cursor((created_at, id) 复合游标)+ limit ≤ 100
  • 已知限制:同 key 并发请求有竞态窗口,靠后续唯一键约束兜底;M0 不解决。

0.9 健康与版本(0.3d)

  • /health(DB 探活)、/version(commit / build_time / db_role 自检)
  • 偏差:commit 来自 BUILD_COMMIT 环境变量(部署时注入),本机默认 unknown;Redis 未探活(dev 不用 Redis)
  • 验收:真机 curl /health → {"status":"ok","db":true};/version 显示 db_role 为 dadao_app 且三项全 false ✅

0.10 CI(0.7d)

  • ruff + pytest(.github/workflows/ci.yml,起 pgvector 服务 + 建角色 + 跑迁移 + 跑测试)
  • mypy(宽松起步)——未接
  • alembic check 迁移一致性
  • CI 在 Gitea 上真正跑过一次(当前只写了配置;Gitea Actions runner 是否启用未确认)
  • 验收:坏代码推上去 CI 变红 ❌ 未验证

0.11 部署(1d)

  • deploy/docker-compose.prod.yml + nginx 反代 + TLS
  • 端口分配登记到 2.Design/DEPLOY.md(避开 PosSync 的 5888 / 5176)
  • 备份脚本(pg_dump + 保留 14 天)
  • 验收:从零一台机器 30 分钟内起完;重启后数据在;备份可恢复

0.12 三端契约(0.5d)

  • 导出 openapi.json,生成 TS 与 Kotlin 客户端
  • make sdk 一键重生成
  • 验收:改一个后端字段,两端编译报错

0.13 客户端壳(1d)

  • Web:Vite + React18 + TS + Tailwind,路由(/login / /w/:wsId /join/:wsId/:code)、请求封装(信封解包 / 幂等键 / 401 清理)
  • Android:Compose 工程(AGP 8.7.3 / Kotlin 2.0.21 / minSdk 26)、登录页、工作区页、明文流量只对 10.0.2.2 放行
  • Web 真实构建验证(npm run build)——依赖正在通过镜像安装,待跑
  • Android 真实编译(本机无 Android SDK;gradle-wrapper.jar 未入库,需 Studio 补)
  • 两端联调"登录 → 建工作区 → 列表"

不阻塞 M0 交付,但必须在写 integration 表之前做完,否则接法选错要返工。详见 PRODUCT-PLAN.md §10.3。

  • 用 DSH 插件(@michengai/dsh-im-connect)实际扫码绑定一个微信号,观察 bot 身份归属
  • 用第二个微信号私聊该 bot,确认多用户共用一号时消息是否互不串
  • 压一下:连续 20 条消息的延迟与是否触发风控;断网 5 分钟后 syncBuf 能否续上
  • 记录 bot_token 失效表现(多久失效、失效后是报错还是静默)
  • 产出:2.Design/SPIKE-WECHAT-ILINK.md
  • 验收:文档里三个问题都有实测结论,不是推测

环境现状与阻塞(2026-10-28 实测)

换机器前先读这一节,能省半天。

项 结论 绕过办法
Docker Desktop 起不来:服务未运行需管理员、WSL E_ACCESSDENIED 用便携 PG 16.4(.local/pg,见下)
外网 TLS 被拦:schannel SEC_E_NO_CREDENTIALS、基础连接已经关闭 走 HTTP 镜像:PyPI 用 http://mirrors.aliyun.com/pypi/simple/,npm 用 http://mirrors.huaweicloud.com/repository/npm/
pip 缓存 装包时写缓存会卡死(无输出、CPU 空转) 必须加 --no-cache-dir;装包命令输出要重定向到文件再 tail,不要管道截断
pg_ctl could not create restricted token: error code 87 直接跑 postgres.exe,且必须放在后台作业里(父进程退出会连带杀死)
fnsvr-01 (192.168.8.160) 只开放 8418(Gitea) / 3306(MySQL) / 8432(PG 17.6,dbadmin/superuser) 用途定死:只有 dadaotask 与 dadaotask_test 两个库,不再往上装扩展;能力:无 pgvector,只有 pg_trgm/pgcrypto
Android 无 JDK、无 Gradle(java -version/gradle -v 都不存在),SDK 平台目录在但缺构建链 装 JDK 17 + Gradle 8.x 后 ./gradlew assembleDebug;当前安卓代码未被编译器验证过
向量库 Docker 起不来 → deploy/docker-compose.vector.yml 写了但没跑过 等 Docker 可用(或换一台机器)再 up -d,端口 5434

便携 PG 起法(本机专用):

# 后台作业里跑(id 会变),别用 Start-Process —— 父进程退出会把它带走
.\.local\pg\pgsql\bin\postgres.exe -D .\.local\pg\pgdata -p 5433 `
  -c listen_addresses=127.0.0.1 -c fsync=off -c synchronous_commit=off

M1 · 任务域(第 3–4 周,10 人日)

里程碑目标:自己开始每天用它记待办,连续 3 天不别扭。

进度口径:[x] = 已实现且验证过(测试/构建/冒烟),[ ] = 未做。每项后面写证据或偏差原因。

1.1 数据(1d)

  • item tag item_tag comment activity 迁移(backend/alembic/versions/0002_tasks.py,revision 0002 ← 0001)
  • 索引:ix_item_ws_status_due (ws,status,due_at)、ix_item_ws_assignee、ix_item_ws_updated、ix_item_ws_sort
  • [~] 中文全文:没用 zhparser/pg_bigm,改用 pg_trgm + ILIKE。理由:服务器是 Alpine 上的 PG 17,pg_available_extensions 只有 pg_trgm/pgcrypto,装 zhparser 要编译 + 改镜像;trigram 对中文按 3 字符切,子串匹配够用,且 gin_trgm_ops 索引能救性能。真正需要"相关度排序"时再上 zhparser(M3 知识库阶段重评)。
  • 偏差:没有 item_assignee 关联表,item.assignee_id 单列。理由:M1 只有"一个任务一个负责人",多人协作由工单阶段(M2)的 item_link 承担;提前上关联表会让每条查询都多一次 join。
  • RLS:五张新表全部 ENABLE ROW LEVEL SECURITY + workspace_id = app_ws_id(),并 GRANT 给 dadao_app

1.2 领域(1.5d)

  • 状态机 backend/app/domain/task.py:inbox→todo→doing→done(+ blocked/canceled,done→todo 可重开);非法流转抛 5002 并回 allowed("你可以去 canceled/todo"),不用前端猜
  • 所有变更写 activity(created / updated / status_changed{from,to} / commented,payload 是 JSONB)
  • version 乐观锁:UPDATE ... WHERE version = ? 看 rowcount,冲突回 4009 + current_version(实测 stale 请求拿到 current=4)
  • done 写 completed_at,重开清空;deleted_at 软删,view=deleted 才看得到

1.3 API(1.5d)

  • 增删改查(backend/app/api/items.py)、POST /{id}/status、软删(带 version)
  • 视图:inbox|active|today|week|done|canceled|all|deleted;today/week 按 Asia/Shanghai 切日界(settings.default_timezone),不按 UTC
  • 过滤:status(可重复)、assignee=me|<uuid>、tag、q(标题+正文 ILIKE,实测中文 周报 命中)
  • 批量状态变更(POST /items/batch-status,逐条返回成败,不做全回滚)+ 看板数据 GET /items/board
  • [~] 搜索返回高亮片段:未做,只返回命中行。理由:高亮要在客户端按 query 二次切词,纯展示逻辑,等 Web 有真实使用反馈再补
  • 分页:(sort_key DESC, id DESC) keyset 游标;sort=due 不分页(NULLS LAST 会把 NULL 行切碎,只有今日/逾期短列表用)

1.4 Web(3d)

  • 视图 tab(今天/进行中/收件箱/已完成/全部)+ 搜索框 + 回车快速新建(web/src/pages/Tasks.tsx)
  • 勾选完成/重开、收下(inbox→todo)、开工(todo→doing)、阻塞
  • 看板拖拽(web/src/pages/Board.tsx):HTML5 拖放,四列,非法流转当场拦下并说明原因;勾选后可批量移动
  • 详情侧栏可编辑(web/src/components/TaskDetail.tsx):标题/优先级/截止时间/标签/备注/评论,全部落库;409 时自动刷新为最新内容
  • 快速录入语法:#标签 !高/!中/!低/!1..!3(web/src/lib/parse.ts,语法从标题里剥离)
  • 键盘流:j/k 选择、Enter 完成、e 编辑、Esc 关闭
  • npm run build 通过:41 modules,195.20 kB │ gzip 63.73 kB
  • [~] @人 指派:未做。理由:重名的人比标签多,悄悄指错负责人比不支持更糟;等 M2 有成员选择器再做
  • [~] 子任务(一层):未做,排到 M2 与工单的 item_link 一起做——两者本质是同一种"事项之间的边"

1.5 安卓(1.5d)

  • 任务列表 + 详情弹窗 + 勾选完成 + 快速新建(android/.../ui/TaskListScreen.kt)
  • 视图切换(今天/进行中/收件箱/已完成)、乐观锁冲突提示与自动刷新
  • 未编译:本机没有 JDK、没有 Gradle(java -version / gradle -v 均不存在), Android SDK 平台目录存在但缺构建工具链。代码按 Compose 常规写法写,尚未经过编译器验证。 补 JDK 17 + Gradle 后第一件事就是 ./gradlew assembleDebug,那时的报错才算数。
  • 下拉刷新、只读缓存(Room)未做

1.6 通知与提醒(0.8d)

  • 站内通知表与未读角标 —— 主动推迟到 M2:通知的绝大多数触发源是工单(被指派、被催、验收), M1 只有自己的任务,做出来也没人收到东西。表随 M2 的工单一起建,避免建两次。
  • 每日摘要邮件(同上推迟)

1.7 测试与验收(0.7d)

  • backend/tests/test_items.py 13 个用例:默认收件箱、状态机 happy path 与非法流转、版本冲突、 跨租户 3003 且他人列表为空、搜索+标签、today 时区边界、软删、评论与事件流、游标翻页、 批量改状态的部分成功、批量非法流转、看板分列
  • 全量 pytest -q → 46 passed;ruff check + ruff format --check 全 0
  • Playwright 真浏览器 e2e(web/e2e/smoke.mjs,7 项断言全绿):登录 → 建工作区 → 快速录入解析 #标签/!优先级 → 详情抽屉保存并回写列表 → 看板拖拽改状态 → 非法拖拽被拦 → 控制台无报错
  • 性能:1000 条任务下 P50 —— 今天 43.1ms / 进行中 42.4ms / 收件箱 42.9ms / 全部 38.4ms / 搜索 45.0ms / 看板 55.4ms(含本机网络与鉴权中间件,是下限)

M1 完成定义(DOD)

  • 连续 3 天所有待办都记在里面,没有回到微信/备忘录 —— 只有真实使用能证伪,代码侧无法替代
  • 冷启动到"建完第一个任务"< 90 秒(新账号实测)
  • 1000 条任务下四视图首屏 < 300ms —— 实测 P50 38–55ms,最慢的看板 55.4ms
  • 跨租户测试、越权测试全绿(test_tenant_isolation.py + test_items.py 的跨租户与批量用例)

M2 · 工单协同(第 5–6 周,10 人日)

2.1 数据(1d)

  • item.kind='ticket' 落地;迁移 0003_tickets.py 重写 item 的状态约束为任务∪工单并集 (旧约束只允许任务六态,工单的 open/accepted/... 会被直接拦下)
  • ticket_ext(item_id 既是主键又是外键,天然 1:1):需求人/办理人/原任务指针/受理·提交·验收时间/退回次数/验收结论
  • item_link(父子、相关,禁止自环)与 notification(只发给当事人)
  • 三张表全部 ENABLE ROW LEVEL SECURITY + 同租户策略 + GRANT 给应用角色

2.2 领域(1.5d)

  • 工单状态机 open→accepted→in_progress→pending_verify→done(可 canceled),默认拒绝
  • 动作→角色规则:受理/开工/提交限办理人;验收限需求人,办理人不能自验(6004);改派/取消限需求人或办理人
  • 驳回回 in_progress 而不是 open,reject_count 累加
  • 升级不搬数据:原任务变 blocked,验收通过后自动回流 todo

2.3 API(1.5d)

  • POST /items/{id}/promote、/ticket/{accept,start,submit,verify,reassign,cancel}
  • GET /tickets?role=assigned|requested|all&status=
  • GET|POST /items/{id}/links、DELETE /items/{id}/links/{link_id}
  • GET /notifications、/unread-count、/read、/read-all
  • 抽出 app/serializers.py:任务与工单共用一份序列化,根除"工单响应缺 tags 导致前端白屏"

2.4 Web(3d)

  • 工单页 /w/:wsId/tickets:派给我的 / 我发起的 / 全部,状态过滤,列表内联快操作
  • 详情抽屉:按角色显示受理、开工、提交、验收、驳回、改派、取消;工单态顶部显示需求人/办理人/退回次数
  • 任务详情底部"升级成工单"面板(从成员里选人,成功后提示"这条任务现在处于被阻塞")
  • 导航加未读角标(30 秒对齐一次,操作后立刻刷新)
  • 登录支持 ?next= 回跳,邀请链接不再"登录完掉回首页"

2.5 安卓(1.5d)

  • 工单列表与动作 —— 只做到任务列表;本机无 JDK/Gradle,仍是未编译状态

2.6 测试与验收(0.7d)

  • backend/tests/test_tickets.py 11 个用例:升级建单+原任务阻塞+通知、全流程验收回流、驳回、 自验 403、非法流转、重复升级、非成员指派、角色作用域、跨租户、关系挂载、通知已读、响应必带 tags
  • 全量 pytest -q → 57 passed;ruff check / format --check 全 0
  • web/e2e/tickets.mjs 双浏览器上下文双用户 e2e,8 项断言全绿: 升级 → B 受理/开工/提交 → A 验收通过 → 原任务回流"待办" → 两端控制台零报错
  • web/e2e/smoke.mjs 回归复跑(抽屉多了"选人"下拉后修正选择器)7 项全绿

2.7 通知真实投递

  • 站内通知已有;微信/飞书推送推迟到 M6(M2 只要"在系统里能看到该我办了")

M2 完成定义(DOD)

  • 两个人能跑完一次真实协助:升级 → 受理 → 办理 → 提交 → 验收 → 原任务自动解锁
  • 办理人无法自验、跨租户看不到工单、通知只给当事人
  • 真实用一周:所有"需要别人帮忙"的事都走工单,不再私聊催(需要真实团队)

M3 · 知识库(第 7–8 周,10 人日)

# 任务 人日 状态
3.1 knowledge_doc(1:1 于 item,source_kind + source_item_id 必填约束)+ 迁移 0004 + RLS 1.0 [x] 已跑通,隔离测试覆盖
3.2 状态机 draft / in_review / published / archived,发布要 owner/admin 0.5 [x] 5 个动作,非法动作回 allowed_from + allowed_actions
3.3 结构化编辑:标题 / 正文 / 摘要 / 类型,乐观锁,已发布禁改 1.0 [x] 含 4009 冲突用例
3.4 闭环沉淀:工单验收通过自动生成草稿(generated_by=rule),同一来源只生成一次 1.5 [x] 只转述不推断,正文带"请先核对再发布"
3.5 手动沉淀:任务/工单详情里"沉淀成知识" 0.5 [x] 有 e2e
3.6 手记:没有来源的知识(唯一允许 source_kind=manual 的入口) 0.5 [x] 手记页 + 列表
3.7 检索:标题/正文/摘要子串 + 类型/状态筛选 + 游标分页 1.0 [x] trigram 索引在 0002 已建
3.8 切片与向量索引(chunk 表、pgvector HNSW) 2.0 [ ] 见下方说明
3.9 安卓知识列表 1.0 [ ] 与工单一起延后到编译链就绪
3.10 文档与 e2e 1.0 [x] web/e2e/knowledge.mjs 7 项断言绿

M3 完成定义(DOD)

  • 一件事办完 → 自动出现一份草稿 → 核对 → 发布,全程不用手动复制粘贴
  • 每条知识都能追到来源(除手记),点开能看到来源事项
  • 非管理员发布被拒;跨租户看不到别人的知识
  • 向量切片与语义检索(需要独立 pgvector 实例真正跑起来)
  • 真实用两周:新同事靠知识库自己解决过一次问题(需要真实团队)

关于 3.8 的说明:本机 Docker 起不来、fnsvr-01 上也没有 pgvector 扩展, 所以 deploy/docker-compose.vector.yml 与 VECTOR_DATABASE_URL 只有配置,没有跑过。 按"写完不算完成"的规矩,它在真跑起来之前保持未勾。切片表设计要点已写进 2.Design/PRODUCT-PLAN.md 的 D8,落地时不要改 EMBEDDING_DIM(改了要重算全量)。


关键验收用例(M0–M3 必须通过)

# 用例 期望 现状
1 租户 A 用户请求租户 B 的 workspace id 3003 未加入(不泄露存在性) ✅ 已覆盖
2 同一手机号登录两次 同一个 user_id,不重复建号 ✅ 已覆盖
3 被移出团队的成员拿旧 token 请求 3003;refresh 也失效 [~] access 已拒,refresh 不可撤销(见 0.6)
4 并发两次提交同一任务的不同状态 一次成功,一次 409 并可重试 ✅ 已覆盖(版本冲突用例)
5 网络中断后重新提交 幂等键保证不产生两条 ✅ 已覆盖
6 删除任务后全局搜索 搜不到,且回收站可恢复 [ ] M3
7 停掉 Redis /health 变 503,业务接口给明确错误而非 500 [ ] dev 用内存实现,未做
8 部署后 /version commit 与仓库 HEAD 一致 [~] 机制已有,等部署验证
9 办理人验收自己的工单 403 6004,不能自己给自己过 ✅ 已覆盖
10 工单验收通过后原任务 自动从"被阻塞"回到"待办",事件流留痕 ✅ 已覆盖(含 e2e)
11 工单验收通过后知识库 自动出现一份草稿,且只生成一次 ✅ 已覆盖
12 成员发布知识 403 7004;owner 发布成功且记录发布时间 ✅ 已覆盖
13 知识有没有来源 非手记必须有 source_item_id,否则 7005 / 库级约束拒绝 ✅ 已覆盖

下一步排期建议

已完成   0.1–0.9 骨架与认证 + 1.1–1.7 任务域 + 2.1–2.6 工单协同
         + 3.1–3.7、3.10 知识库 + 4.1–4.6 界面重做(Web 与安卓)+ 安卓编译链
待补     0.11 部署 / 0.12 契约 / 0.14 微信 spike / CI 在 Gitea 实跑
         / 3.8 向量索引(等 Docker 或独立实例)
第9–10周 M4 AI:结构化录入、人物工作侧写、主动提醒、20 条黄金用例回归

4. 界面重做(Web + 安卓,2 人日,2026-10-28)

用户原话:"现在不管是 web 还是 app 的 UI 样式太丑了,非常的简陋,不符合当下的设计美学。" 重做不是换配色,而是先立设计系统、再让页面去用它,否则下次还是东拼西凑。

4.1 设计系统(两端共用一套 token)

  • 色板:纸白 paper、墨绿 primary、陶土橙 clay,语义色 sage/wheat/rust/sky(Web tailwind.config.js,安卓 ui/Theme.kt 同名同值)
  • 字号阶:11/12/13/14/15/18/22/26/36,正文 14、辅助 11/12;细描边 + 极轻投影 + 14dp 圆角
  • 组件:卡片 / 按钮(primary、ghost、subtle、clay、danger)/ 状态 pill / 输入 / 空态 / 骨架屏 / 抽屉
  • 图标:Web 用自绘线性图标集(components/icons.tsx),安卓用 Material Icons + 少量自绘,线宽一致

4.2 Web

  • AppShell:左侧固定侧栏(工作区切换、五项导航、快捷键提示、用户),移动端转顶部横条 + 横向导航
  • 六个页面全部重做:登录(品牌面板 + 表单)、首页(问候 + 工作区卡片)、任务(视图 tab + 快速录入 + 圆勾选 + 骨架屏)、看板(四列 + 拖拽高亮 + 空列提示)、工单、知识、成员、邀请落地
  • 详情抽屉:Sheet 组件承载,工单态显示需求人/办理人/退回次数/验收结论 + 动作按钮
  • 验证:tsc -b 0 错、vite build 45 modules(241.82 kB │ gzip 76.70 kB)、三套 e2e(7 + 8 + 7 项)全绿
  • 深色模式:未做(token 已留出语义位,等有用户提需求)
  • 无障碍:只做了焦点环与 aria-label,未做完整键盘可达性与对比度审计

4.3 安卓

  • WorkspaceScaffold:顶部工作区 + 未读角标,底部四 tab(看板不占 tab,屏幕放不下)
  • 任务(列表/看板切换、快速录入、勾选、详情底部抽屉)、工单(角色 chip + 按状态动作 + 验收对话框)、知识(四 tab + 记一条 + 详情动作)、成员(邀请链接分享)
  • 任务详情抽屉里可升级成工单、沉淀成知识(此前安卓缺这两个入口,是功能欠账)
  • 编译链固化:android/scripts/emulator.ps1 一键构建 + 装机 + 起模拟器;assembleDebug → APK 10.1 MB
  • 模拟器(API 35)实跑:登录 → 工作区 → 任务勾选 → 工单 → 知识 → 成员,截图存 2.Design/screens/android-*.png
  • [~] 看板不做拖拽:手机屏幕上一列已占 76% 宽,拖拽命中率太差;改为点卡片选目标列,功能等价
  • 离线缓存(Room)、图片附件、消息推送:均未做,M5 再议

5. 富文本与附件(M2.5)

5.1 决策:正文存 Markdown,不存 HTML

  • 任务备注、工单说明与验收结论、知识正文、评论,统一 *_md 字段存 Markdown
  • 渲染器自研零依赖(web/src/lib/markdown.ts),先转义再上标签,链接协议白名单 http(s)/mailto//
  • 不做 contenteditable 富文本:它产出 HTML,导出/检索/喂 LLM 都要再清洗,且 dangerouslySetInnerHTML 面积更大
  • 表格与 HTML 片段:暂不支持,按原样当文本(等真有需求再加)

5.2 附件

  • 后端:attachment 表 + RLS + 迁移 0005;POST/GET/DELETE 与 download/raw 两个读取口
  • 本地磁盘存储(backend/app/services/storage.py),key 由内容 sha256 决定;换对象存储只改这一个文件
  • 上限 MAX_UPLOAD_MB=20,声明大小与流式读取双重拦截,超限 8002
  • 列表带 attachment_count,详情带 attachments,避免一屏 N 次请求
  • 下载走带 Authorization 的 fetch 转 blob;<img> 直链走 raw?token=,只允许本工作区成员
  • 图片直链改一次性签名 URL(当前 token 放 query,上生产前必须换)
  • 缩略图、图片压缩、病毒扫描:未做

5.3 编辑器

  • RichTextEditor:工具栏(粗体/斜体/标题/列表/引用/代码/链接/图片)+ Ctrl+B/Ctrl+I + 预览切换
  • 粘贴与拖拽上传图片,自动在光标处插入 ![文件名](attachmentUrl(...))
  • AttachmentPanel:上传/下载/删除(删除二次确认,对应后端 ?confirm=true)
  • 安卓:本轮未做富文本编辑,只做只读渲染 + 附件下载(M5 再议)

5.4 验证

  • 后端 pytest → 70 passed(新增附件 5 个用例:上传下载、超限、跨租户、删除确认与权限、raw 直链鉴权)
  • web/e2e/richtext.mjs → 9 项全绿(渲染、贴图上传后 /raw 返回 200、附件落库后重开仍在、控制台无报错)
  • 既有三套 e2e 回归通过(smoke 7 / tickets 8 / knowledge 7)
  • tsc -b 0 错;vite build 255.07 kB │ gzip 80.85 kB