Compare commits

...
76 Commits
Author SHA1 Message Date
Steven 4b7627d70e fix(knowledge): 切视图或检索时清掉批量选中,别搬走看不见的条目
CI / backend (push) Canceled after 0s
2026-11-07 01:57:22 +08:00
Steven 28eab4ae5f feat(knowledge): 知识页支持刷新列表与批量移动知识
CI / backend (push) Canceled after 0s
2026-11-07 01:50:37 +08:00
Steven dcd77c6b35 feat(knowledge): 批量把知识移到目录或其它工作区 2026-11-07 01:50:31 +08:00
Steven 4e3f56af79 docs(roadmap): 记一下 0.3.0 发版,模拟器上把应用内升级走通了
CI / backend (push) Canceled after 0s
2026-11-06 12:41:59 +08:00
Steven 377dabcc9e chore(app): 安卓 0.3.0,跟着这一轮知识库与登录的改动走
CI / backend (push) Canceled after 0s
versionCode 3 / versionName 0.3.0。这一版安卓端能用的东西比 0.2.0 多不少:
知识是备忘录(分类、标签、筛选、搜索)、知识能删、公众号链接抓成知识之后在手机上也读得到,
登录改成手机号或邮箱 + 密码(生产已经关掉短信验证码,旧包只能走验证码,登不进去)。
2026-11-06 12:31:25 +08:00
Steven 3e29ef5ee2 docs(roadmap): 知识标签与删除的生产实拍,附截图脚本
CI / backend (push) Canceled after 0s
2026-11-06 02:20:09 +08:00
Steven 690779c5fb fix(wechat): 公众号新版页面抓不到正文,改从 cgiDataNew 里读
CI / backend (push) Canceled after 0s
用户发来 https://mp.weixin.qq.com/s/ZEOsi4R4JcmZ_mhS6qpcww,存进库的正文只有 67 个字符
("原文链接 + 一个句号")。复盘发现公众号换了渲染方式:新版页面把整篇塞进
window.cgiDataNew 这个 JS 对象由前端渲染,页面里根本没有 id="js_content";
三种 UA 拿到的字节数完全一样,也不是 UA 的问题。

- 从 JS 字符串字面量里读正文(content_noencode / desc),按 \xNN、\uNNNN 反转义,
  话题标签是双重转义过的 HTML,先 unescape 再走 DOM。
- 配图字段排在文字之后,所以前后各找 2500 字符合图;剩下没用过的 mmbiz 图按
  12 张上限补在末尾(图文型文章靠就近配对会漏)。
- 解析顺序:旧 DOM → 新版 JS 数据 → 通用抽取,一层层退。

实测两篇:2373 字符 + 12 张图、2772 字符 + 13 张图,作者也都取到了。

同时补上用户提的两件:

- 知识能删了:DELETE /knowledge/{id}?confirm=true,软删 + 真删检索切片,
  只有作者或 owner/admin 能删;列表卡片(悬停出现)和阅读页动作行各一个入口。
  删完详情也回 7001,否则收藏的链接还能打开一篇已删除的知识。
- 分类下面有标签清单了:GET /knowledge/tags 按标签数"有多少条知识用了它",
  侧栏渲染成 #标签 + 条数,点一下筛选、再点取消。
2026-11-06 02:08:36 +08:00
Steven 626f83fb9c feat(wechat): 微信里发一条链接,正文和配图抓下来存成知识
CI / backend (push) Canceled after 0s
公众号的文章也能抓:走 mp.weixin.qq.com 的 DOM(#js_content),
按 data-src 取图,不靠通用抽取器——它会把公众号的图全丢掉。

正文里的外链图下载进我们自己的存储(最多 12 张、单张 8 MB),
地址改成 /attachments/{id}/raw?签名:公众号那边一换图,外链就是一排红叉。

抓取入口谁都能触发,所以只允许公网地址(内网段、回环、链路本地、
保留段、解析不出的域名一律拒绝),否则就是个 SSRF 跳板。

抓不到要说人话:"这个链接没抓下来(原因)。想直接记成任务的话,
加「任务:」前缀再发一次。" 反爬验证页、SPA 空壳都走这条路。

- 新增 services/article.py:extract_url / parse / fetch / store_images / compose_body
- wechat_intake:整条消息是链接时走抓取,落知识草稿,回「已存成知识草稿:标题」
- 依赖 trafilatura==2.0.0(装法见 requirements.txt 注释:必须逐个装再 --no-deps)
- 测试 9 例:链接判定、公众号解析、验证页、通用页裁导航、SSRF 拒绝、图片本地化、失败提示
2026-11-06 00:54:21 +08:00
Steven f6e7616756 fix(integrations): 「接收中」跨进程一致,不再跟着 gunicorn 进程抖动
CI / backend (push) Canceled after 0s
上一版用 `manager.polling()` 判"有没有人在收消息",那是进程内状态:请求落到持有者
回答 true、落到备用进程回答 false,页面上就会每两秒闪一次"已停止"。

改成查数据库里的 advisory lock(`pg_locks`,objsubid=2 的两段式锁),谁抢到租约所有
进程都看得见。`lease_held()` 自己开一条短会话查,不复用请求会话——接口里常在
`await session.commit()` 之后才拼返回值,那时原事务已经关了。
2026-11-05 23:39:33 +08:00
Steven 2170a0664d fix(integrations): 重启后机器人要能自己接回来
CI / backend (push) Canceled after 0s
上一版把双进程抢会话修了,但生产重启后仍然不收消息:`start_all()` 是进程启动时的
查询,那一刻没有工作区上下文,`bot_channel` 的 RLS 把行全藏起来,一行都查不到,
于是"页面上显示已连接,实际上没人在拉消息"。

- 迁移 0016 补一条只读策略:`current_setting('app.bot_list') = 'on'` 时允许 SELECT
  (只开 SELECT,防止顺手拿它改别人的通道);
- `start_all()` 走 `active_channel_ids()`,先设这个 GUC 再列通道;
- 补测试 `test_startup_sees_logged_in_channels`。
2026-11-05 23:33:44 +08:00
Steven 07b01db7cc fix(integrations): 一个通道只让一个进程收消息,gunicorn 双进程不再抢同一条会话
CI / backend (push) Canceled after 0s
生产是 `gunicorn -w 2`,两个进程各自跑 lifespan,start_all() 会把同一个通道的
长轮询拉两遍:消息被其中一个消费、回复从另一个发出,游标还会互相覆盖。

改成 Postgres 会话级 advisory lock 做租约:抢到的进程收消息,另一个每 10 秒重试,
主进程一挂连接断、锁释放,备用进程立刻接上。页面上的"接收中"只认抢到租约的那个
(`manager.polling()`),不再把等锁的也算进去。

补测试 `test_only_one_process_holds_the_polling_lease`:
第一个抢到、第二个抢不到、第一个放手后第三个能接上。
2026-11-05 23:28:35 +08:00
Steven 1e8cd01b3d fix(integrations): 扫码确认后不再 500,回话不带链接,栏目改叫「机器人」
CI / backend (push) Canceled after 0s
1. 扫码确认那一刻页面报「服务器内部错误」:`bot_channel` 的轮询口子直接
   `current_setting('app.bot_channel', true)::uuid`,而事务级 set_config 提交后
   会把连接上的变量还原成空串而不是 NULL,空串 cast 就 InvalidTextRepresentation。
   迁移 0015 改成 nullif(...,'')::uuid,与 app_ws_id() 同一写法。
2. 微信回话只回「已保存任务:…」/「已保存知识草稿:…」,不再带站点链接
   (PUBLIC_BASE_URL 是内网地址,手机上打不开)。
3. 栏目名「接入」改名「机器人」;页面标题与提示文案同步。

pytest 151 passed;ruff check/format 干净;tsc 0;vite build 通过;
web/e2e/integrations.mjs 7 项全绿。
2026-11-05 23:22:49 +08:00
Steven 8e3dc65cc6 fix(integrations): 上游扫码状态叫 wait,页面认的是 waiting,先对齐再翻
CI / backend (push) Canceled after 0s
2026-11-05 23:06:57 +08:00
Steven 8436c0edbf feat(integrations): 微信要先扫码配对,改成 iLink 扫码登录 + 长轮询
CI / backend (push) Canceled after 0s
上一版按"webhook + URL 里带令牌"自己编了一套,用户当场指出微信必须先扫码配对。
现在照腾讯 iLink Bot 协议自己做客户端:取二维码 → 手机扫码确认 → 存加密登录态 →
后端长轮询收消息 → 用 context_token 回话。独立实现,不依赖 DSH 的运行环境。

后端
- wechat_ilink.py:get_bot_qrcode / get_qrcode_status / getupdates / sendmessage,
  bot_token 用 Fernet(密钥由 JWT_SECRET 派生)加密入库,日志与接口都不回明文
- wechat_intake.py:一句话 → 任务或知识草稿,前缀识别、#标签、首行标题
- wechat_bot.py:每条通道一个轮询任务,启动时按 status='active' 恢复;
  message_id 去重;errcode -14 转 expired 让用户重新扫
- api/integrations.py 重写成扫码三接口 + 停用/断开/最近消息
- 迁移 0013(登录态列)、0014(轮询入口按 app.bot_channel 主键的 RLS 口子)

Web
- 接入页改成扫码流程:页面里画出二维码、每 2 秒问一次状态、连上显示微信用户与最近消息

验证
- pytest 149 passed(新增 test_wechat_bot 9 例)
- ruff check / ruff format --check 干净;tsc 0;vite build ✓
- web/e2e/integrations.mjs 7 项 ALL PASS(拿的是微信真二维码)

教训:接入第三方协议先去已有实现里找协议,别从直觉倒推。
2026-11-05 23:03:18 +08:00
Steven d21394a5ac docs(roadmap): 补微信接入的生产实拍,记下回调地址漏前缀的坑
CI / backend (push) Canceled after 0s
2026-11-05 22:08:24 +08:00
Steven c194609193 fix(integrations): 回调地址漏了 /api/v1,生产上打过去是 405
CI / backend (push) Canceled after 0s
生产把应用挂在域名根、路由带 /api/v1 前缀,request.base_url 里没有前缀,
拼出来的地址少了这一段;本地被 Vite 的代理掩盖了。改成从 base_url 截站点根、
前缀一律取 settings.api_prefix,并补一条断言把这个差异钉住。
2026-11-05 22:06:37 +08:00
Steven 90b99f8253 feat(integrations): 微信里发一句话,变成一条任务或知识
CI / backend (push) Canceled after 0s
一条通道一张令牌,机器人把消息 POST 到 /integrations/clawbot/{token},
正文按前缀落到任务或知识草稿:没有前缀就是任务,「知识:」「笔记:」记成知识,
「帮助」只回用法。首行当标题、其余当正文,`#标签` 摘出来挂到事项上。
同一个 msg_id 只处理一次——微信会重推,重推不能变成两条任务。

为什么不塞进 system_setting 的 JSON:一个工作区可能挂多个通道,还得能单独停用。
令牌库里只存 sha256,只在创建/重新生成时显示一次;收消息时还不知道是哪个工作区,
所以 bot_channel 的 RLS 额外放行 token_hash = current_setting('app.bot_token_hash'),
读到通道再 bind_tenant 进上下文,按通道创建者记账。

- 后端:models_integrations.py(bot_channel / bot_message)、迁移 0012(RLS)、
  api/integrations.py(收消息 + 管理接口)、错误码 9002/9003
- Web:/w/:wsId/integrations「接入」页——建通道、复制回调地址与令牌、最近消息、
  停用/换令牌/删除,只有 owner/admin 能改
- 测试:test_clawbot.py 10 例;web/e2e/integrations.mjs 7 项(冒充 clawbot 真推一条)

验证:pytest 150 passed,ruff check/format 全绿,tsc 0,vite build 通过,e2e 7 项全绿。
2026-11-05 22:04:34 +08:00
steven 33a3fc03e0 fix(tasks): 任务页「全部」挪到第一个 tab
CI / backend (push) Canceled after 0s
先看全貌再收窄更顺;Web 默认仍停在「今天」,安卓默认仍是「进行中」,都不靠下标取默认值。
两端一起改,免得同一页两个顺序。
2026-11-05 18:54:59 +08:00
steven 2a9d72a974 fix(android): 知识正文支持表格与删除线,加载提示挪到标题行
CI / backend (push) Canceled after 0s
- 加 markwon ext-tables / ext-strikethrough:同一个 markdown 在 Web 里是表格,
  在 app 里原来会把 | 管道符原样铺出来,现在按 Web 的纸面色画(1dp 浅线、表头浅底)
- 「载入中…」原来挂在分类 chip 行末尾,chip 横向滚动时被挤出屏幕,等于没提示;
  挪到「知识」标题右侧,任何滚动位置都看得见
2026-11-05 17:54:53 +08:00
steven 29805bd61e feat(android): 登录改手机号/邮箱+密码并支持邮箱注册,知识分类可改名配色删除
CI / backend (push) Canceled after 0s
- 生产已关短信登录,登录页先读 /auth/methods 再决定入口:默认手机号/邮箱+密码,
  后端开着短信时才出现验证码登录;邮箱账号 phone 为空,User.phone 改可空
- 新增邮箱验证码注册(一步建号即登录态)
- 知识:分类色点/选中描边/加载态,管理面板可改名、选色(10 色板或手填 hex)、
  建子分类、删除(知识回到未分类),卡片上边线用分类色
- 正文 16sp / 行高 1.7,列表标题 SemiBold,与 Web 阅读排版对齐
- README 更新登录方式与结构说明
2026-11-05 15:55:31 +08:00
Steven 2c338490e5 fix(auth): 令牌到期自动续上,不再用半小时就被踢回登录页
CI / backend (push) Canceled after 0s
服务端一直签 access(30 分钟)和 refresh(14 天)两个令牌,但两个客户端拿到 401 都是直接清
令牌跳登录页,refresh 从来没被用过——"14 天有效"只是纸面参数,真实寿命就是 30 分钟。

- web/client.ts:请求前剩不到 90 秒先换;撞 401 先换再重放一次原请求;并发共用一次刷新;
  上传附件同一条路
- android/Api.kt:call 拆 call/callOnce,2002 时用 Mutex 串行刷新后重放
- 后端 /auth/refresh 看一眼账号状态:停用的账号不能靠 refresh 一直续(2012)
- 新增 session-refresh.mjs:假 access + 真 refresh 要能留住人,两个都假才回登录页
2026-11-03 22:53:43 +08:00
Steven 02ef47e753 docs(roadmap): 换成放宽后的生产阅读页实拍
CI / backend (push) Canceled after 0s
2026-11-03 12:25:19 +08:00
Steven da31083df2 fix(knowledge): 阅读页正文列放宽,宽屏上不再留白比字多
CI / backend (push) Canceled after 0s
1440 的屏幕上 40rem 正文只有 640px,两边各空出半屏,用户直接发截图说"留白比内容都多"。
正文列 56rem(约 55 个汉字一行)、卡片 78rem,行宽到头但不用来回扫行。
e2e 的宽度断言跟着改成 720–920px,避免以后再窄回去没人拦。
2026-11-03 12:23:53 +08:00
Steven 35fdea7260 test(web): e2e 与投图脚本改成密码登录,补生产的阅读页实拍
CI / backend (push) Canceled after 0s
生产短信登录已经关了,脚本里那条发码路径跑不通;统一走 PASSWORD,读不到才回落到短信。
2026-11-03 12:15:31 +08:00
Steven d34a6ac69e feat(knowledge): 阅读改成独立整页,抽屉只用来编辑
CI / backend (push) Canceled after 0s
知识原来一律在抽屉里打开,正文贴着屏幕右半边,长文读十几屏眼睛一直在右边。
阅读是「坐下来读一会儿」,编辑是「改两句就走」,两件事不该共用一个容器。

- 新增阅读页 /w/:wsId/knowledge/:itemId:正文列限到 40rem 居中,28px 标题,
  更新/发布/切片行,标签与来源,动作行放编辑与发布/归档/恢复
- 列表页与搜索结果改成跳阅读页,不再弹抽屉
- KnowledgeDrawer 加 initialEditing:进来就是编辑器,保存或取消直接关
- 文案常量搬到 lib/knowledge.ts,列表页与阅读页共用一份

顺带修三个 e2e 脚本:新建后进阅读页没有「关闭」按钮可点、侧栏色点按钮让树
顺序断言多出空串、翻页脚本还在用已关闭的短信登录(改密码登录)。
2026-11-03 12:12:51 +08:00
Steven 127303b7aa docs(roadmap): 记下邮箱注册的生产验收,附注册页实拍
CI / backend (push) Canceled after 0s
2026-11-03 01:06:59 +08:00
Steven b5170ad1ce feat(auth): 邮箱验证码注册,生产重新有了自助入口
CI / backend (push) Canceled after 0s
短信登录关掉后,生产没有任何注册入口——这一版补上最普通的那条路:
填邮箱、收验证码、设密码,注册完直接进首页。

先发码再注册,不是"注册时顺带发信"

  POST /auth/email/code 只做三件事:查重、限流、发码。
  填完密码才被告知"这个邮箱注册过了"最气人,所以占用(2019)在发码前就回。

邮箱既是账号名也是唯一联系方式,所以必须真验一次码

  验证通过时间落 app_user.email_verified_at,/auth/me 回 email_verified。
  "随手填一个"会让账号变成没人能找回的孤岛。

app_user.phone 改为可空(迁移 0011)

  邮箱注册的账号本来就没有手机号。user.phone 对这类账号回空串,
  成员列表同理,不再伪装成一串星号。

防刷

  同邮箱 60 秒一次(2022)、验证码 10 分钟有效、错 5 次即作废(2021)。
  6 位数可以被刮,不设上限等于把注册口子交给脚本。

本机联调开关 EMAIL_CODE_DEV_ECHO

  为 true 时把码回给调用方且不发真信——否则每跑一次 e2e 就往真实收件箱塞一封。
  生产打开会让 validate_runtime() 拒绝启动。

前端

  新增 /register(邮箱、验证码、密码、确认、昵称;60 秒倒计时;
  服务端关着注册时整页只说"联系管理员开通"),登录页按 /auth/methods
  决定要不要显示「还没有账号?用邮箱注册」。

验证

  pytest 139 passed、ruff check / format 全绿
  tsc --noEmit 0、vite build 通过
  e2e register.mjs 7 项:登录页入口 → 拿码 → 注册落进首页 → 退出用邮箱+密码登回来
    → 同一邮箱再发码回 2019 → 控制台零报错
  e2e login-email-smtp.mjs 15 项回归通过(含真发一封测试信)
  e2e account.mjs 14 项、knowledge.mjs 全绿

顺带修的两处测试

  test_account 的字段白名单补 email_verified(接口确实多了这个字段);
  test_config_prod 用 Settings(_env_file=None, ...) 构造,别被本机 .env 里的
  EMAIL_CODE_DEV_ECHO=true 带偏——那个开关在生产是硬错误,本机却是跑 e2e 必需的。
2026-11-03 01:04:26 +08:00
Steven c0c5230fd1 docs(roadmap): 补登录方式与系统设置的上线情况,附生产实拍
CI / backend (push) Canceled after 0s
2026-11-03 00:07:53 +08:00
Steven 742af4dd02 test(web): 账号页按钮改名后顺手修 e2e,别让脚本等一个不存在的按钮 2026-11-03 00:07:53 +08:00
Steven 7558637567 feat(auth): 短信登录默认关,换成手机号/邮箱 + 密码,发信邮箱进后台
CI / backend (push) Canceled after 0s
用户要求:内测的固定验证码 123456 等于"知道手机号就能进任何账号",先关掉;
登录改用账号密码,并多一种"邮箱当账号名"的方式;发信邮箱要能在后台改。

短信那条路(默认关):
- `SMS_LOGIN_ENABLED=false`,`/auth/sms/send` 与 `/auth/login` 统一回 2017,不是静默失败;
- 生产校验改成"开着短信登录就必须配真通道",不开时 provider 是 dev 也不拦;
- 新增 `GET /auth/methods`,登录页据此渲染,关着的路不出现(少一次"点进去才发现不能用")。

邮箱登录:
- `app_user.email` + `uq_app_user_email`(`lower(email)` 部分索引),登录与绑定都归一成小写;
- `POST /auth/login/password` 收 `phone` 或 `email`(二选一),失败统一 2013、连错 10 次锁 15 分钟;
- 绑定在 `PATCH /auth/me`:格式错 2018、被占用 2019、留空串表示解绑。

系统设置(跨租户,只有平台管理员能改):
- 新表 `system_setting`(不挂租户、无 RLS,写权限由 `deps.platform_admin` 把关);
- `GET/PUT /settings/smtp`、`POST /settings/smtp/test`,授权码只写不读,留空表示沿用旧值;
- `services/mail.py` 只用标准库 smtplib:465 走 SSL、587 走 STARTTLS,认证/拒收/网络失败都归 2020 但消息不同;
- Web 新增「系统设置」页(状态卡 + 测试发信),侧栏只对平台管理员显示入口;账号页补「邮箱」。

验证:pytest 130 passed、ruff check/format 全绿、tsc 通过、vite build 通过;
`web/e2e/login-email-smtp.mjs` 15 项全绿(含用 126 授权码真发一封测试邮件)。

顺带记下的两个坑:`platform_admin_phones` 是 list,pydantic-settings 会按 JSON 解析,
要用 `NoDecode` 交给 before-validator 拆逗号;迁移号写成 0009 会撞既有的 0009,一并改成 0010。
2026-11-03 00:00:11 +08:00
Steven 23687d6e37 style(web): 任务与工单列表标题也加重一档,三个列表看起来是一套
知识页的标题改成 15px/semibold 之后,任务和工单还是 text-sm font-medium,
同一个产品里扫列表时视觉层级不一致。这里统一成 `text-[15px] font-semibold leading-6`,
理由和知识页一样:标题是卡片上唯一要一眼看到的东西,不能和类型、分类、标签那排 chip 等重。
2026-11-02 23:59:59 +08:00
Steven 0660e5cbda fix(web): 知识卡片标题加重一档,扫列表时先看到标题而不是一排 chip
CI / backend (push) Canceled after 0s
2026-11-02 23:02:25 +08:00
steven 2032e9d981 fix(knowledge): 选中分类加左侧色条与更实的描边,管理入口常显
CI / backend (push) Canceled after 0s
2026-11-02 20:42:44 +08:00
steven 0b4e829d8b test(knowledge): 颜色校验走 422,修正过长行
CI / backend (push) Canceled after 0s
2026-11-02 19:34:20 +08:00
steven 71982f9fa4 feat(knowledge): 分类可改名/配色/删除,选中态与加载态更清楚
CI / backend (push) Canceled after 0s
分类之前只能新增,建错了只能干看着。补上三件事:

- knowledge_category 加 color 列(迁移 0009,CHECK 卡 #RRGGBB),
  PATCH 支持改名与改色,空串表示取消上色;知识列表带出分类色,前端不用再查。
- 分类行悬停出「⋯」:改名、选色板/手填色值、建子分类、删除(有知识时确认后 force)。
- 选中分类改成主色浅底 + 描边 + 色点,列表头显示当前筛选;切分类时列表淡出并转圈,
  不再一动不动等请求回来。

顺带给卡片画分类色上边线:扫一眼就能分出哪几条属于同一类。
2026-11-02 19:24:44 +08:00
steven d7c9670880 feat(design): 主色改靛蓝 #4F46E5,Web 与安卓及 Logo 同步
CI / backend (push) Canceled after 0s
- primary 换成 Indigo 阶(600 即 #4F46E5),中性色转 Slate 轴、纸面更冷更白
- 实心主色面(主按钮、工作区选中)用 600,链接/浅底文字仍用 700 保对比度
- 信息色 sky 调深到 #16697C,与靛蓝同屏能分清
- Logo / favicon / PWA 图标 / 安卓自适应图标统一为靛蓝底 + 白线 + 淡紫点,tools/make-icons.py 重新生成
- 文档与 e2e 断言同步(主题色 #4f46e5)
2026-11-02 15:33:26 +08:00
steven 288e27e465 fix(web): 知识正文按阅读字号排版,重写 Markdown 样式
CI / backend (push) Canceled after 0s
- 正文从 14px 提到 16px / 1.85,不再比编辑器(0.9rem)还小
- 标题靠字重与留白分层,h2 加细分隔线;行内代码、代码块、引用、表格、图片统一重排
- 正文限宽 42rem 居中,长段落不用来回扫行
- 评论与工单说明走 prose-dadao--sm(14px),抽屉里不喧宾夺主
2026-11-02 14:29:35 +08:00
steven 684b9b2604 fix(web): PWA manifest 主题色跟随新主色
CI / backend (push) Canceled after 0s
安装到主屏后的启动背景与状态栏色此前仍是旧的墨绿/暖白;e2e 断言与文档同步更新。
2026-11-01 19:17:35 +08:00
steven 4796b695a3 fix(design): 微调中性色与信息色,蓝系下对比度达标
CI / backend (push) Canceled after 0s
ink-500 加深到 4.9:1,sky 往青绿拉开与主色区分;sky 芯片文字 4.6:1。安卓 Theme.kt 同步。
2026-11-01 19:14:54 +08:00
steven b3cd115bd9 feat(design): 主色由墨松绿改为景泰蓝蓝系,Logo 与图标同步
CI / backend (push) Canceled after 0s
纸白底改冷调与主色同轴;语义色 sage/wheat/rust 不变,sky 提亮与主色区分。Logo 三段线改浅蓝白、圆点改亮蓝;favicon/PWA 图标/安卓自适应图标/状态栏一并重绘,新增 tools/make-icons.py 作为唯一出处。
2026-11-01 18:48:26 +08:00
Steven 694b643683 test(web): 用界面把知识列表翻到底,确认「加载更多」不再 500
CI / backend (push) Canceled after 0s
2026-11-01 02:58:11 +08:00
Steven b662429842 fix(knowledge): 翻页游标的字段名写错,点「加载更多」必 500
CI / backend (push) Canceled after 0s
知识列表拿游标做 keyset 分页时写成了 `cur.item_id`,而 `Cursor` 只有
`created_at` 和 `id` 两个字段——属性不存在,直接 AttributeError,
前端只看到"服务器内部错误"。

库里不足一页(默认 30 条)时永远走不到这条分支,所以一直没暴露;
旧备忘导进来 84 条之后,用户点「加载更多」就撞上了。

补一条回归用例:造 25 条、每页 10 条翻到底,断言不重不漏、末页游标为空。
2026-11-01 02:55:23 +08:00
Steven d24be46d8c docs(roadmap): PWA 的截图放进 2.Design/screens,文档里指对地方
CI / backend (push) Canceled after 0s
2026-10-31 04:35:49 +08:00
Steven 483b554c3c feat(web): Web 装到桌面(PWA),带安装引导与发版提示
CI / backend (push) Canceled after 0s
用 vite-plugin-pwa 生成 manifest 与 Service Worker,不自己写缓存逻辑。

- 装到桌面:manifest 名字/主题色/三个图标(any + maskable + apple-touch-icon),
  图标和页头、安卓桌面是同一枚;首页底部给安装引导,能装就弹「安装」,
  iOS 给「分享 → 添加到主屏幕」,关掉记在 localStorage
- 发版提示:registerType=prompt,收到新版本只在底部提示,点「刷新」才换。
  故意不自动刷新——Vditor、图表是懒加载分片,后台换 chunk 会让面板 404
- 缓存边界:只预缓存页面骨架(约 0.8MB),/api/ 一律 NetworkOnly,
  离线宁可报错也不端旧数据;/vditor/ 走 CacheFirst,不拖慢首次安装
- 顺手修:UpdatePrompt 居中改用 flex 包一层,animate-slide-up 的 keyframes
  自带 transform,会和 -translate-x-1/2 抢属性,手机窄屏会把卡片推出屏幕

验证:web/e2e/pwa.mjs 19 项全绿(manifest/SW 激活/预缓存/安装引导/iOS 文案/
发版提示/断网仍能打开);tsc 通过、vite build 通过。

已知限制:Service Worker 只在 https 或 localhost 注册,生产入口还是 http,
所以内网 IP 上装不了——需要域名 + 证书反代或隧道,已写进 DEPLOY.md 第 6 节。
2026-10-31 04:33:43 +08:00
Steven 370d8923c8 test(web): 生产核对脚本自己等节流,别把 429 当成登录坏了 2026-10-31 04:33:34 +08:00
Steven 429905892c docs(roadmap): 记下账号与外壳这一轮,包括生产上抓到的 http 安全上下文 bug
CI / backend (push) Canceled after 0s
2026-10-31 04:17:33 +08:00
Steven e3f1310cff fix(web): 内网 http 下写请求与复制邀请链接都会失败
CI / backend (push) Canceled after 0s
crypto.randomUUID() 与 navigator.clipboard 只在安全上下文(https / localhost)里存在,
生产入口是纯 http,于是:

- 所有 POST/PATCH/DELETE 在拼 Idempotency-Key 时直接抛 TypeError,
  登录页只显示"登录失败",GET 一切正常,所以这个坑一直没暴露
- 「复制邀请链接」永远回"浏览器不允许自动复制"

改成幂等键自己拼一个 v4(getRandomValues 不受安全上下文限制,退回 Math.random),
复制走 navigator.clipboard → document.execCommand("copy") 两级降级。

顺带说明:这是生产上"能看不能写"的真正原因,不是权限或接口问题。
2026-10-31 04:13:13 +08:00
Steven 096acb00e7 feat(web): 首页换成和工作区同一套外壳,补上账号设置页
CI / backend (push) Canceled after 0s
首页原来是居中窄栏,点进工作区又变全屏,进出像换了个网站。现在把首页也做成
全宽 + 左侧栏(SideRail 抽出来给首页和账号页共用),工作区卡片改成网格,
新建工作区是网格里的虚线卡片——宽度不再跳。

账号页 /account:基本信息(头像首字母、昵称、只读手机号、加入时间)、
登录密码(设置/修改 + 确认输入)、退出登录。工作区侧栏底部的用户块点进去就是它。

e2e account.mjs 14 项全绿,其中一条断言进工作区后侧栏宽度和首页差值 < 1px。
2026-10-31 04:05:05 +08:00
Steven a5510cdb79 feat(auth): 手机号也能用密码登录,验证码之外多一条路
短信通道挂了人就进不来,所以给账号加第二把自助的钥匙:设过密码的用密码进,
没设过的照旧只用验证码,不强制。

- app_user.password_hash(迁移 0008 加列)+ scrypt 哈希(标准库实现,
  n=2^14/r=8/p=1,存 scrypt$n$r$p$salt$hash),不引 passlib
- POST /auth/login/password:没设过密码与密码错都回 2013,不区分账号是否存在;
  15 分钟内连错 10 次 → 2016,期间正确密码也挡住
- POST /auth/password:设/改共用,改要先验旧密码,新旧相同算弱密码 2015
- PATCH /auth/me 改昵称;用户输出走白名单 _user_out,密码哈希一个字节都不出去
- 登录页顶部「验证码 / 密码」两段切换

tests/test_account.py 10 项;全量 pytest 113 passed,ruff 全绿。
2026-10-31 04:04:55 +08:00
Steven 29aae12224 fix(app): 开完安装权限回到 App,再点那个按钮要能装上
CI / backend (push) Canceled after 0s
只做了"送去系统设置",没接"回来":用户开完开关回到对话框,
按钮还是「去开启安装权限」,而 openInstallPermission() 发现已授权就直接 return,
点了毫无反应——只能杀掉 App 重来。

现在点之前先复查 canInstall():权限开了就直接交给安装器,没开才跳设置页。

顺带补上模拟器上的全流程验证与截图(弹窗、权限提示、系统安装器、首页入口)。
2026-10-31 03:25:11 +08:00
Steven 7668799040 feat(app): 安卓应用内升级,安装包由后端发布
CI / backend (push) Canceled after 0s
需求是"改完 Android 端怎么送到手机上"。不发应用商店,也不想每次手动传包,
于是做了最小闭环:后端存一份安装包清单,App 启动时查、下载、交给系统安装器。

后端
- 新增 /app/release/latest 与 /app/release/download/{file},都免登录:
  还没装上新包的客户端未必带着有效令牌,检查更新不该被登录挡住
- download_url 用相对路径,客户端拿自己配置的 API 地址拼——同一份清单要在
  内网、公网、模拟器之间共用,写死域名只会让其中一个坏掉
- has_update 只比对 version_code,不比版本名字符串(0.10.0 与 0.9.0 的字典序是坑)
- 下载只认清单里登记的文件名,含 ..%2F 一律 9001
- scripts/publish_apk.py 负责复制 APK 并写 latest.json(sha256/size/published_at),
  backend/static/apk/ 不进版本库

安卓
- AppUpdater:查清单 → 下到应用自己的 updates 目录(先写 .part 再改名)→
  校验 sha256 → 经 FileProvider 交给系统安装器
- UpdateDialog:版本号、更新说明、下载进度、失败重试;「允许安装未知应用」
  是系统开关,只跳设置页,不代点
- 启动时自动查一次,首页底部另有「检查更新」入口
- 图标换成和 Web 的 <Logo/> 同一套图形:墨绿圆角底、三条米白圆头线、陶土点;
  同时出 mipmap 各密度 PNG、自适应图标与 512px 预览
- versionCode=2 / versionName=0.2.0;发布签名从 android/keystore.properties 读,
  没有该文件时回落调试签名,保证别人 clone 下来也能 assembleRelease

文档
- API.md 第 7 节:两个接口、清单字段、签名与 sha256 的约定、9001
- DEPLOY.md 第 5 节:出包命令(JDK 17 + Gradle 8.9 + 干净的 GRADLE_USER_HOME)、
  发布清单、传到服务器、验证
- ROADMAP 第 6 节记录本次范围与本机踩过的构建环境坑

验证
- 后端 pytest 103 passed;ruff check / format --check 全绿
- assembleRelease 成功,app-release.apk 7.4 MB,apksigner 验签显示 CN=Dadao Task
- 本地与生产 GET /app/release/latest?version_code=1 → has_update=true,
  version_code=2 → false;下载返回 200 / application/vnd.android.package-archive
2026-10-31 03:15:00 +08:00
Steven 35fe4d92cb fix(android): 知识 tab 顺序与 Web 对齐,「全部」默认选中
CI / backend (push) Canceled after 0s
- knowledgeTabs 改成 all/draft/published/archived,tab 默认 "all"。
- 空态文案按 tab 区分(全部 / 某一类 / 某个分类),不再写死"待核对"。
- 与 Web 版同一行为:进知识库先看全部,待核对是待办而不是门面。
2026-10-31 02:27:55 +08:00
Steven 50227c2114 feat(knowledge): 「全部」排到 tab 第一位并默认选中,正文编辑区跟着屏高走
CI / backend (push) Canceled after 0s
- 知识列表 tab 顺序改成 全部 / 待核对 / 已发布 / 已归档,默认停在「全部」:
  进知识库先看"我这儿都有些什么",待核对是待办,不该当门面。
- 正文编辑器不再是写死的 240px:新增 useEditorHeight(reserve),
  按 window.innerHeight 减掉标题/分类/标签/附件/底部按钮算高度
  (新建留 420、编辑留 340,最低 360、最高 960,窗口 resize 时重算),
  新建面板与详情编辑两处共用,长文一屏能写完一段。
- 截图脚本 shots-knowledge-view.mjs 支持 EDIT=1 出编辑态图、ALL=1 留在默认 tab;
  新增 2.Design/screens/knowledge-tab-all.png、knowledge-edit-tall.png。
- 验证:tsc --noEmit 通过、vite build ✓、e2e knowledge.mjs 9 项与
  knowledge-notebook.mjs 17 项全绿(含"侧栏分类按树排""已发布也有编辑")。
2026-10-31 02:25:25 +08:00
Steven b29286778f fix(knowledge): 分类侧栏按树的顺序排,已发布的知识也能直接改
CI / backend (push) Canceled after 0s
两个都是"看着不对劲"的问题:

1. 侧栏把二级分类全堆在一级分类后面(按 depth 排序的副作用),
   「技术」和它的上级「我的备忘」隔着十几行,看着像一棵假树。
   后端改成按树的先序返回(父分类后紧跟它的子分类,兄弟仍按 sort_order、name),
   前端照 depth 缩进就是个真树;二级以下再加一个「·」标记,小屏也分得清。

2. 点开已发布的知识只有「归档」,没有改的入口——因为后端原来硬性拒绝
   修改已发布内容(7003)。但"发布"是给团队看的,写错了总要能改:
   现在已发布的可以直接 PATCH,改完立刻重建检索切片(chunk_count 当场更新),
   状态和版本号照常推进;归档的仍然要先「恢复成草稿」再改。

Web 与安卓同步:抽屉/详情里已发布也显示「编辑」,安卓分类标签区不再锁。

验证:pytest 95 passed(新增"分类按树返回"用例、发布后可改用例)、
ruff 全绿、tsc 0、vite build ✓;`knowledge-notebook.mjs` 17 项、
`knowledge.mjs` 9 项 e2e 全绿。安卓仍未编译(JDK 25 / Gradle 8.7 不兼容)。
2026-10-31 02:06:49 +08:00
Steven 441b1b93e1 feat(knowledge): 点开知识先看到排好版的正文,要改再点「编辑」
CI / backend (push) Canceled after 0s
之前抽屉一打开就是 Vditor 编辑器,正文是一堆 `##` 和 `-` 的原文,
导入的 84 条旧备忘看着尤其像草稿。现在分成「看」和「写」两态:

- 默认只读:按 Markdown 渲染(markdown-it + DOMPurify),分类与标签是 chip,
  附件列表只读;底部是「编辑」和状态动作,右侧显示发布于何时。
- 点「编辑」才挂编辑器;「取消」直接丢弃未保存改动(重新拉一次详情)。
- 安卓详情页同样用 Markwon 把正文排版后再显示,要改走「编辑正文」。

顺带修了 `web/e2e/knowledge.mjs` 里一条早就失效的断言:它等的是
「已发布 · 版本 N」,但界面上没有这行字(git 历史里也没有),
改成等抽屉底部的「发布于 …」。

验证:pytest 94 passed、ruff 全绿、tsc 0、vite build ✓;
`knowledge-notebook.mjs` 16 项、`knowledge.mjs` 7 项 e2e 全绿。
安卓本轮仍未编译(本机 JDK 25 与 Gradle 8.7/AGP 8.7.3 不兼容)。
2026-10-31 01:34:25 +08:00
Steven bbd30ec85e feat(knowledge): 导入脚本能指定环境与账号,生产也导了一份
CI / backend (push) Canceled after 0s
同一套备忘录要在 dev 和生产各导一次,所以把写死的东西参数化:
--api 指定目标环境、--phone 指定记到谁名下、--workspace 指定工作区名。

令牌缓存按「环境 + 手机号」分文件(.local/memo-token-<phone>-<host>.txt):
之前只按账号存,混着用会出现"刚登录完下一个请求就 401"——dev 的令牌拿不到生产的库。

生产(openclaw-01)实测:13605513683 名下「我的备忘」84 条(83 发布 + 1 归档)、
18 个分类,时间戳与 dev 一致。
2026-10-31 00:46:01 +08:00
Steven 039e5b903c feat(knowledge): 旧 memos 备忘搬进知识库的导入脚本
CI / backend (push) Canceled after 0s
一次性的搬迁脚本:memo_app(MySQL)→ 知识库。放在 scripts/ 下是为了留个凭据,
说明这 84 条备忘是怎么变成知识的,以及哪些东西刻意没搬。

- 走 HTTP 接口而不是直连库:直接 INSERT 会绕过版本号、审计和切片导入,
  导进去的知识搜不到——检索只认 published 且有 chunk 的文档。
- 分类照搬两层(接口上限三级),标签照搬,全部按 note 发布,归档那条保持归档。
- 正文里的图片文件在旧机器上已经找不到了,所以把 ![](path) 换成一行
  「原图未迁移:…(旧路径 …)」,不留碎图,也不丢掉原路径。
- 按标题去重、分类复用,中断后可以重跑;token 缓存到 .local/ 避免撞上验证码节流。
- 旧库时间戳是 UTC,接口不接受客户端指定 created_at,所以额外生成一条 SQL
  把 item.created_at/updated_at 与 published_at 对回原值。

实测:84 条(83 published + 1 archived)、18 个分类、语义检索可命中。
2026-10-31 00:26:14 +08:00
steven 8b6594bf10 fix(deploy): 脚本先快照再执行,避免 git reset 改写正在运行的自身
CI / backend (push) Canceled after 0s
2026-10-30 22:25:30 +08:00
steven 2ff1f6e9cd fix(deploy): CORS 用 JSON 数组、Redis 口令说明,部署时自动刷新 systemd 单元
CI / backend (push) Canceled after 0s
2026-10-30 22:01:00 +08:00
steven 2a118ad407 fix(deploy): venv 引导(缺 ensurepip)与国内镜像
CI / backend (push) Canceled after 0s
2026-10-30 21:53:42 +08:00
steven ea5d28304e fix(db): 0006/0007 迁移补上 APP_ROLE 导入 2026-10-30 21:53:42 +08:00
steven 57767c77a1 feat(deploy): openclaw-01 生产部署脚本、systemd 单元与部署文档 2026-10-30 18:49:31 +08:00
steven 9ac3b46430 feat(auth): 内测登录改为显式开关,并支持后端单端口托管前端产物 2026-10-30 18:49:30 +08:00
steven 2dbe4cf675 feat(db): 迁移里的数据库角色名改为环境变量注入 2026-10-30 18:49:30 +08:00
Steven 7c84ff3b8d feat(knowledge): 知识能挂附件,新建时就能传
CI / backend (push) Canceled after 0s
知识是备忘录,备忘录不能只有字:手册、日志、导出表格都得能挂上去。
附件本来就落在 item 上(`ALLOWED_KINDS` 早含 knowledge),缺的是"新建时
还没有条目 id"这一段。

- 懒建草稿:`KnowledgeComposer` 第一次传附件(或往正文贴图)时先 `POST /knowledge`
  存一条草稿拿到 id,`draftingRef` 去重防止贴图与上传同时打出两条草稿;
  之后的保存走 `PATCH /knowledge/{id}`。没传过附件就取消,库里不留空条目。
- `RichTextEditor` 增 `ensureItemId`:没有 itemId 时先把草稿存下来再传图,
  于是"新建时不能贴图"这条限制也没了;工具栏上传按钮按是否有落点显隐。
- 新建面板加了附件区:有草稿 id 就是正常的 `AttachmentPanel`(上传/下载/删),
  没有就先给一个「添加附件」按钮,点一下建草稿。
- 安卓知识详情加附件区:列表(文件名 + 大小)、`OpenDocument` 上传任意文件、
  点「打开」用浏览器看签名直链。编辑器原本只能插图,现在手册/日志也能传。
- 文档:API.md 说明附件三类事项共用同一套接口,以及懒建草稿的先后顺序;
  ROADMAP 加 3.13。

验证:后端 pytest 94 passed、ruff 全绿;Web `tsc --noEmit` 通过、`vite build` 通过;
`knowledge-notebook.mjs` 15 项全绿(新增「新建面板里能上传附件」「详情抽屉里能看到
刚传的附件」「附件挂在知识上,详情回读得到」)。安卓端仍是 JDK 25 + Gradle 8.7
不兼容,本次未编译验证。
2026-10-30 16:18:32 +08:00
Steven ffae7e380f fix(knowledge): PATCH 知识时带上 doc_type 直接 500
`patch_knowledge` 把 `doc_type`/`summary` 也塞进了给 `items` 表的 UPDATE,
于是 SQLAlchemy 报 `CompileError: Unconsumed column names: doc_type`——改类型的
请求一律 500。这两个字段本来就属于 `knowledge_doc`,下面几行已有赋值,重复写
进 items 的 values 是错的。

现在 `values` 只放 items 的列(title/body_md),`doc_changed`(doc_type/summary)
单独参与"要不要推进 version"的判断,语义不变:只要这条知识的样子变了,
version 就得 +1,否则下一次保存会拿旧 version 撞 4009。

补一个用例:PATCH 同时改 title/body_md/doc_type/tag_names 应该 200,
doc_type 生效、version 前进一步。这个 bug 之前没暴露,是因为 Web 抽屉只改
标签和分类,没人用 PATCH 改过类型。
2026-10-30 16:18:05 +08:00
Steven 9a382e4e8c fix(knowledge): 新建知识的正文换成富文本编辑器,不再是纯文本框
CI / backend (push) Canceled after 0s
「记一条」面板里正文是个 textarea,排版、表格、贴图都没有——创建时反而比
详情页更寒酸。现在新建面板直接用详情同款 Vditor 编辑器,创建与修改一个模样。

- `RichTextEditor` 的 `itemId` 改为可选:新建时还没有条目 id,正文照写,
  只是上传按钮不摆出来;真去贴图会提示「先保存这条内容,再回来贴图」。
- `KnowledgeComposer` 的正文换用 `RichTextEditor`(minHeight 320),页脚提示
  按有没有条目 id 给不同文案。
- 安卓:新建对话框不再放纯文本正文框,保存后直接进 `RichTextEditorScreen`
  写正文(排版、表格、贴图都在那儿),写完回到列表并展开详情。
- e2e 增加一条断言:新建面板里的正文就是富文本编辑器(aria-label=正文 的
  contenteditable),正文按真实键盘输入后 Markdown 正常落库。

验证:`tsc --noEmit` 通过、`vite build` 通过、`knowledge-notebook.mjs` 11 项
全绿(新增「新建面板的正文是富文本编辑器」)。安卓端本机仍是 JDK 25 +
Gradle 8.7/AGP 8.7.3 不兼容,本次未编译验证。
2026-10-30 15:52:09 +08:00
Steven ed31af3f46 feat(knowledge): 知识改成备忘录形态,一次写全 + 三级分类,抽屉加宽
CI / backend (push) Canceled after 0s
知识以前只能"填个标题就完成",正文、标签、归类都要点进去再补。现在新建
就是一张完整的备忘录:标题、类型、分类、标签、正文一次写完,存完直接打开
详情继续编辑;知识页左侧多了工作区三级分类树,可按分类子树 / 未分类 / 标签
筛选。凡是填信息的抽屉都加宽了,还能一键展开整屏。

后端
- 新增 `knowledge_category`(迁移 0007):`workspace_id NOT NULL` + 自引用
  `parent_id`、`depth` 1..3 检查约束、同层重名唯一索引(lower(name))、
  RLS 策略与 `dadao_app` 授权;`knowledge_doc.category_id` 外键 ON DELETE
  SET NULL。
- 新增 `/knowledge/categories` 增删改查:超三级 `7007`、同层重名 `7008`、
  跨租户或不存在 `7006`;`move=true` 才改父级,成环与移动后超过三级的子树
  一律拒绝,删除有知识的分类要 `force=true`(知识变未分类)。
- 知识列表支持 `category_id`(含子树)、`uncategorized`、`tag`;输出带
  `category`(含 `path` / `full_path`)。
- PATCH 只改分类或标签也会推进 `version`,避免"只动标签"之后下次保存撞
  乐观锁 `4009`。

Web
- `Sheet` 增 `size="md/lg/xl"` 与展开整屏按钮;任务、工单、知识抽屉统一
  加宽(`TaskDetail` / `Tickets` / `Knowledge`)。
- 知识页:左侧三级分类树(新建、按子树筛选、未分类)、`KnowledgeComposer`
  一次写全新的备忘录、列表卡片露出分类路径与标签、详情抽屉里可直接改分类
  与标签。
- 抽屉详情只拉一次:重复的 effect 或迟到的响应不再覆盖用户正在敲的内容。

安卓
- `Api.kt`:分类模型与接口、新建/更新带 `tag_names`/`category_id`、
  列表带分类与标签筛选。
- 知识页重做:新建改为近全屏大对话框(标题/类型/分类/标签/正文),列表
  顶部按分类筛选,详情里能改分类与标签后单独保存。

文档与测试
- `2.Design/API.md`:5.1/5.3/5.4 补分类与筛选参数,新增 5.5 分类接口章节,
  错误码补 `7006`/`7007`/`7008`。
- `2.Design/ROADMAP-M0-M1.md`:新增 3.11(三级分类)、3.12(备忘录形态)。
- 新增 `backend/tests/test_knowledge_categories.py`(8 例):备忘形态落库、
  三级到底、移动越界与成环、重名、按子树/未分类/标签/关键词筛选、删分类需
  `force`、跨租户隔离。
- 新增 `web/e2e/knowledge-notebook.mjs`(10 项,全绿):建两级分类 → 一次
  写全 → 卡片与筛选 → 抽屉改标签/分类 → 正文落库 → 重名 7008。

验证:`pytest` 93 passed、`ruff check` / `ruff format --check` 全过、
`tsc --noEmit` 通过、`vite build` 通过、`knowledge-notebook.mjs` 10 项全绿。
安卓端本机只有 JDK 25,Gradle 8.7 / AGP 8.7.3 不支持,本次未做编译验证。
2026-10-30 03:51:43 +08:00
Steven 91b3dde670 feat(notify): 站内通知与手工开单,指派/改状态/评论/验收都有人收到
CI / backend (push) Canceled after 0s
M2 最后一块:让"该我办了"能出现在系统里,并且允许不经过任务直接开单。

通知(backend/app/services/notify.py 统一出口)
- 七类触发:task_assigned、task_status_changed、item_commented、
  ticket_assigned、ticket_submitted、ticket_approved/rejected、task_unblocked
- 不通知自己:notify() 按 actor_id 跳过,调用方不用各自判断
- 已读:GET /notifications、/unread-count、POST /read、/read-all

手工开单
- POST /tickets:直接指定人、初始 open,与任务升级单共用同一条 kind='ticket' item,
  ticket_ext.parent_item_id 为空,验收通过不回流任务
- 指派前 _ensure_member 校验在册成员,越界指派返回 1001(避免"挂在看不到的名字上")

前端
- Web:通知页 /w/:wsId/notifications(未读/全部,点开标已读并跳目标);
  工单页"新建"面板;AppShell 每 30 秒对未读数,标已读后 dispatch notifications:changed 立刻刷新;
  任务与工单支持 ?open= 深链落地即展开
- 安卓:五个 tab 加"通知",未读数 30 秒刷新;工单动作按需求人/办理人/管理员显隐;手工开单对话框

验证
- pytest 85 passed(新增 backend/tests/test_notifications.py 6 个用例)
- ruff check / format --check 全 0
- tsc --noEmit 通过;vite build 成功
- web/e2e/notifications.mjs 12 项断言全绿(手工开单 → B 收到 → 点开跳转 → 未读归零)
- 安卓:本机 JDK 25 与 Gradle 8.7 / AGP 8.7.3 不兼容,本次未做编译验证,
  装回 JDK 17 后第一件事是 assembleDebug
2026-10-30 02:19:42 +08:00
dadao d079564d6c feat(knowledge): 语义检索打通——切片、混合打分、只搜已发布
CI / backend (push) Canceled after 0s
之前知识只能"存",不能"找"。这一版把链路补上:切片 → 向量 → 打分 → 带来源返回。

- chunk 表 + 迁移 0006:正文按标题归段切、代码块整块保留、超长按句切、
  相邻片重叠 80 字;RLS 与 GRANT 与其它租户表一致
- 向量提供方做成协议:内置 hash(字符二元组,零依赖可复现,作为基线)
  与 OpenAI 兼容端点两种,换实现不动业务代码
- 检索是混合打分:向量 0.6 + pg_trgm 0.4。专有编号、单号这类必须原样命中的词
  靠词法兜,换个说法表达的同一件事靠向量兜
- 三条纪律:只搜 published(过滤写在 SQL 里)、低于阈值宁可不返回、
  每条命中都带来源标题与 item_id
- 发布时自动建索引,改完正文用 POST /knowledge/{id}/reindex 重建
- Web 知识页加检索框与命中卡片;新增 e2e/retrieval.mjs 6 项

为什么不是 pgvector:本机 Docker 起不来、fnsvr-01 没有该扩展且明确不动。
先用 real[] + SQL 余弦把链路跑通,将来换列类型与索引即可,接口不变。

后端 79 passed,e2e smoke/knowledge/richtext/retrieval 全绿。
2026-10-29 04:28:09 +08:00
dadao d509f0c335 fix(android): 编辑器改成整页显示,保存后回到刚才那条详情
原来编辑器是叠在工作区上的 Box,而详情抽屉/对话框是独立窗口,永远盖在它上面——用户只能看到编辑器的一条顶栏。改成编辑器替换整页;保存成功后父层记下 itemId,回到工作区由对应页面刷新并重新打开那条详情。取消则什么都不变。
2026-10-29 04:01:28 +08:00
dadao 17be9d1d42 feat(rich): 安卓能编辑富文本了,图片直链换成短期签名,表格不再撑破抽屉
CI / backend (push) Canceled after 0s
三件欠账一次清:

1. 图片直链签名化
   - 新增 `backend/app/services/signing.py`:HMAC-SHA256,密钥由 `JWT_SECRET` 域分离派生
     (`attachment-raw-v1`),载荷 `workspace_id:attachment_id:exp`,默认 30 分钟
   - `/raw` 支持 `?exp=&sig=`,不需要登录态;签名里绑了工作区,跨租户链接直接拒
   - 附件对象多一个 `raw_url`,前端每次渲染现签,正文里只存不带凭据的 `/raw`
   - 保留旧 `?token=` 通道,给未升级的客户端过渡

2. 安卓富文本编辑器
   - `ui/RichTextEditorScreen.kt`:Markdown 工具栏(粗体/斜体/标题/列表/引用/代码/链接/图片),
     选区包裹与行首前缀都按光标位置来;贴图走附件接口,插入签名直链
   - 预览用 Markwon,图片用 Coil;正文落在一张白纸卡片里,不再贴屏幕边
   - 从任务详情与知识点都能进,保存按 `body_md` 回写(PATCH 带 version)

3. 表格
   - 只读渲染走 markdown-it 的 GFM 表,`display:block + overflow-x:auto`:
     宽表横向滚动、窄表铺满,都不撑破抽屉

验证:pytest 74 passed;richtext 15 / smoke 7 / tickets 8 / knowledge 7 全绿;
tsc -b 0 错;vite build 通过(主包 299.42 kB / gzip 74.96 kB);
安卓 assembleDebug 成功并在 API 35 模拟器走通编辑与预览。

仍欠:保存后回列表而非回详情抽屉、图片压缩与缩略图、HTML 片段按文本显示。
2026-10-29 03:32:53 +08:00
dadao fbdf54cb4c feat(web): 富文本编辑器换成 Vditor,不再自研轮子
CI / backend (push) Canceled after 0s
上一版我自己写了 textarea + 正则工具栏,看着能用,一到粘贴图片、
中文输入法、撤销栈就露馅——这几样正是成熟组件积累的部分。按要求改成品。

- 编辑器开关 def: RichTextEditor = Vditor 4.0.1(MIT,ir 模式)
  * 运行时资源自托管:web/scripts/vendor-vditor.mjs 把 lute/i18n/icons
    同步到 public/vditor,predev/prebuild 自动执行,不走 unpkg CDN
    (内网会一直卡在加载)
- 只读渲染换 markdown-it + dompurify,删掉自写的正则渲染器
- 附件直链的令牌只在内存里存在:落库前 stripAttachmentTokens,
  渲染/装载前补回(<img> 带不了 Authorization 头,raw?token= 是必要折中)
- Vditor 不会替我们插入 upload.handler 的返回值(只当提示语 tip.show),
  所以上传完自己 insertMD

验证:pytest 70 passed;e2e richtext 13 项、smoke 7、tickets 8、
knowledge 7 全绿;tsc 0 错;主包 299 kB/gzip 75 kB,Vditor 独立
chunk 385 kB 按需加载。
2026-10-29 02:30:10 +08:00
dadao 7a7ea932a4 fix(web): 表单标签对比度提到 ink-600,附件编辑器截图入册
CI / backend (push) Canceled after 0s
2026-10-29 01:55:51 +08:00
dadao 800c2726e2 docs(api): 补附件契约、Markdown 存储理由与富文本路由
CI / backend (push) Canceled after 0s
2026-10-29 01:53:47 +08:00
dadao a24a493403 feat(rich): 备注/工单说明/知识正文支持 Markdown 与图片附件
写一件事的时候,光有纯文本不够:要贴截图、要分段、要标重点。
这次把「正文」这条路补全,思路是**存 Markdown 而不是 HTML**——
能导出、能全文检索、能直接喂给 LLM,也不必每次渲染都重新清洗一遍 HTML。

后端
- attachment 表 + RLS + 迁移 0005;本地磁盘存储,key 由内容 sha256 决定,
  换对象存储只改 services/storage.py 一个文件
- 上传在**读流时**卡 20MB,不先落盘再判断;磁盘名由服务端生成,用户文件名只进库和响应头
- 列表带 attachment_count、详情带 attachments,避免一屏 N 次请求
- raw 直链给 <img src> 用(图片带不了 Authorization 头),只允许本工作区成员,
  非成员一律 8001 不区分「不存在」与「没权限」
- 5 个用例:上传下载、超限、跨租户、删除确认与权限、raw 鉴权

前端
- 自研零依赖 Markdown 渲染器:先转义再上标签,链接协议白名单
- RichTextEditor:工具栏 + Ctrl+B/I + 预览,粘贴或拖入图片自动上传并插入引用
- AttachmentPanel:上传/下载/删除,下载走带 Authorization 的 fetch 转 blob
- 任务备注、工单说明与验收结论、知识正文、评论全部接入

顺手修的
- TaskDetail.hydrate 漏了 setAttachments,导致附件面板永远是空的(e2e 抓到的)

71 passed;web tsc 0 错、构建 255.07 kB │ gzip 80.85 kB;
e2e:richtext 9 项 + smoke 7 + tickets 8 + knowledge 7,全绿。
2026-10-29 01:53:46 +08:00
216 changed files with 31918 additions and 744 deletions
+19
View File
@@ -27,6 +27,9 @@ android/.gradle/
android/build/
android/app/build/
android/local.properties
# 发布签名证书与口令(只在发布者本机;仓库里没有它就用调试签名)
android/keystore.properties
*.jks
# 编辑器 / 系统
.DS_Store
@@ -43,3 +46,19 @@ tmprfx*
# Kotlin 编译器诊断日志(构建产物)
android/.kotlin/
# 本地上传文件(附件落盘目录,绝不入库)
backend/var/
# 发布的安卓安装包由 publish_apk.py 放在这里,APK 不进版本库
backend/static/apk/
# pytest 的临时目录(在 backend/ 下跑测试时生成)
backend/pytest-of-*/
# Vditor 运行时资源(lute/i18n/icons,约 3.8 MB)
# 由 web/scripts/vendor-vditor.mjs 从 node_modules 生成,不进版本库
web/public/vditor/
# 本机部署/调试的临时脚本与缓存
.tmp/
+492 -13
View File
@@ -29,6 +29,16 @@
| 2010 | 429 | 验证码请求太频繁 | 倒计时 60 秒 |
| 2011 | 400 | 验证码错误/已过期 | 重发 |
| 2012 | 403 | 账号被禁用 | 联系管理员 |
| 2013 | 400 | 密码错误,或这个号还没设过密码 | 提示核对账号与密码 |
| 2014 | 400 | 该账号没有密码(保留码) | 先设密码 |
| 2015 | 422 | 密码太弱(少于 8 位 / 缺字母或数字 / 与旧密码相同) | 就地提示规则 |
| 2016 | 429 | 密码连错太多次(15 分钟内 10 次) | 提示稍后再试 |
| 2017 | 403 | 这条登录方式没开(默认只开密码) | 换密码登录 |
| 2018 | 422 | 邮箱格式不对 | 就地提示 |
| 2019 | 409 | 邮箱已被别的账号占用 | 换一个 |
| 2020 | 502 | 发信失败(SMTP 认证/收件人/网络) | 去「系统设置」核对授权码 |
| 2021 | 400 | 邮箱验证码不对 / 已过期 / 试错次数用尽 | 重新获取验证码 |
| 2022 | 429 | 验证码发送过于频繁 | 等 60 秒再发 |
| 3001 | 404 | 工作区不存在 | — |
| 3003 | 403 | 不是该工作区成员 | **不要**提示"工作区不存在" |
| 3004 | 403 | 角色权限不足 | 隐藏对应按钮 |
@@ -46,6 +56,10 @@
`Authorization: Bearer <access_token>`。`access` 30 分钟,`refresh` 14 天。
`refresh_token` 只能用于 `/auth/refresh`(服务端校验 `typ`)。
**客户端要自己续期,别等用户被踢出去**:access 剩不到 90 秒就先换一对,
撞上 `401/2002` 也先用 refresh 换一对、把原请求重放一次,换不动才清令牌回登录页。
每次 `/auth/refresh` 都会返回新的 refresh,所以只要 14 天内用过一次,登录态就一直往后顺延。
### 0.4 幂等
所有 `POST/PATCH/DELETE` 都可带 `Idempotency-Key: <uuid>`。
@@ -61,6 +75,53 @@
## 1. 认证 `/auth`
登录方式有四种,**默认只开「密码」和「邮箱注册」**。服务端把"现在开着哪些"告诉前端,
前端据此渲染,免得用户点进一条关着的路:
### `GET /auth/methods`
```json
{ "password": true, "email_password": true, "sms_code": false, "email_register": true }
```
`SMS_LOGIN_ENABLED=false`(默认)时,`POST /auth/sms/send` 与 `POST /auth/login` 都回 `2017`
「短信验证码登录暂未开放」。理由:dev 环境的固定码等于"知道手机号就能进任何账号",
这条路必须显式打开,生产打开时还会强制要求真短信通道(见 `deploy` 文档的启动校验)。
### `POST /auth/email/code`
```json
{ "email": "someone@example.com" }
```
发一封注册验证码(6 位数字,`EMAIL_CODE_TTL_SECONDS` 默认 600 秒有效)。
邮箱统一按小写处理;格式不对 → `2018`;**这个邮箱已经注册过 → `2019`**(让用户在填完密码前就知道);
60 秒内重复请求 → `2022`;发信配置没填或发不出去 → `2020`(不会假装发出去);
`EMAIL_REGISTER_ENABLED=false` → `2017`。
响应 `data: { sent: true, expires_in: 600 }`。**真发信时不回显验证码**;
只有本机联调(`EMAIL_CODE_DEV_ECHO=true`)时才带 `dev_code` 且不发信——这个开关在生产会直接拒绝启动。
### `POST /auth/register`
```json
{ "email": "someone@example.com", "code": "123456", "password": "dadao2026", "nickname": "大道" }
```
```json
{ "user": { "id": "…", "phone": "", "email": "someone@example.com", "email_verified": true, … },
"tokens": { "access_token": "…", "refresh_token": "…", "token_type": "bearer", "expires_in": 1800 } }
```
注册成功即登录态(直接返回 `tokens`)。规则:
- 验证码不对 / 过期 / 试错超过 `EMAIL_CODE_MAX_ATTEMPTS`(默认 5)次 → `2021`;用完即作废,不能重放
- 密码强度不够 → `2015`;邮箱已被占用 → `2019`;`EMAIL_REGISTER_ENABLED=false` → `2017`
- 昵称不填就取邮箱 `@` 前面那段
- 建出来的账号**没有手机号**(`app_user.phone` 可空),所以 `user.phone` 回空串,不是星号串
邮箱是这类账号的唯一账号名与联系方式,所以注册必须真验一次码——"随手填一个"会让账号变成没人能找回的孤岛。
### `POST /auth/sms/send`
```json
@@ -68,7 +129,7 @@
```
响应 `data: { dev_code?: "123456" }`。`ENV=dev` 时才回显验证码,生产没有这个字段。
60 秒内重复请求 → `2010`。
60 秒内重复请求 → `2010`。短信登录关着时 → `2017`。
### `POST /auth/login`
@@ -89,11 +150,87 @@
{ "refresh_token": "…" }
```
返回同形状的 `tokens`。无状态 JWT,**服务端不维护黑名单**——token 泄露只能等过期,这是 M0 已知取舍。
返回同形状的 `tokens`(**扁平**,没有 `tokens` 信封)。无状态 JWT,**服务端不维护黑名单**——
token 泄露只能等过期,这是 M0 已知取舍;换来的是刷新不需要查库。
换的时候会看一眼账号状态:账号被停用 → `2012`(403),停用的人不能靠 refresh 一直续下去。
### `POST /auth/login/password`
手机号和邮箱都走这一个入口,`phone` 与 `email` **二选一**(都给或都不给 → 422):
```json
{ "phone": "13800000000", "password": "dadao2026" }
{ "email": "shufen@126.com", "password": "dadao2026" }
```
返回与验证码登录同形状的 `{ user, tokens }`。**没设过密码的账号一律回 `2013`**,与"密码错"同一个码
(不区分账号是否存在,少一个探测面;手机号与邮箱两种账号也不会互相暴露)。15 分钟内连错 10 次 → `2016`,
期间正确密码也挡住,计数键是"手机号或邮箱"本身。
邮箱按 `lower(email)` 比较,绑定与登录都做 `strip` + 小写归一,所以 `Shufen@126.com` 与
`shufen@126.com` 是同一个账号。
密码校验是 scrypt(`n=2^14, r=8, p=1`,标准库实现,不引 passlib),库里存
`scrypt$n$r$p$salt$hash`。`password_hash` 这一列**永不出现在任何响应里**。
### `POST /auth/password`
```json
{ "password": "dadao2026", "old_password": "old-one-1" }
```
设密码 / 改密码共用。已经有密码时 `old_password` 必填且必须正确;新旧密码相同 → `2015`。
强度:8–64 位,至少一个字母 + 一个数字。返回 `{ "has_password": true }`。
### `PATCH /auth/me`
```json
{ "nickname": "大道", "email": "shufen@126.com" }
```
字段都可选,只改传上来的。邮箱留空串表示**解绑**(之后不能再用邮箱登录)。
格式不对 → `2018`,已被别的账号占用 → `2019`。返回 `{ "user": { … } }`。
空昵称(strip 后为空)→ `1002`。手机号暂不支持自助换号。
### `GET /auth/me`
返回 `{ user, workspaces[] }`,`workspaces[].role` 是当前用户在该工作区的角色。
`user` 里带 `has_password`(前端据此显示"设置密码"还是"修改密码")、`email`、
`email_verified`(这个邮箱是不是真验过)、`platform_admin`(能不能进「系统设置」)。
---
## 1.5 系统设置 `/settings`
平台级配置,**全站一份、不挂租户**,只有 `PLATFORM_ADMIN_PHONES` 里的人能读写(否则 `3004`)。
目前只有发信邮箱一项——它承载找回密码与以后的邮件通知,属于"改一次全站受影响"的东西,
放后台可改而不用登服务器改 env。
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | `/settings/smtp` | 当前配置(**不含授权码**) |
| PUT | `/settings/smtp` | 保存,立即生效,不重启 |
| POST | `/settings/smtp/test` | 发一封测试邮件,确认通道通不通 |
```json
// PUT /settings/smtp
{ "host": "smtp.126.com", "port": 465, "username": "shufenhf@126.com",
"password": "客户端授权码", "from_name": "达道Task", "use_ssl": true }
```
`password` 传 `null` 或空串表示**沿用旧值**(改别的字段时不用重填授权码)。
```json
// GET /settings/smtp
{ "host": "smtp.126.com", "port": 465, "username": "shufenhf@126.com",
"password_set": true, "from_name": "达道Task", "use_ssl": true,
"configured": true, "source": "db", "updated_at": "2026-11-02T12:00:00Z" }
```
`source` 说明当前值来自数据库(`db`)、服务器环境变量兜底(`env`),还是根本没配(`none`)。
发信失败统一 `2020`(`502`),消息里写清是认证失败还是收件人被拒——授权码要用邮箱服务商的
**客户端授权码**,不是登录密码。
---
@@ -227,6 +364,20 @@ canceled→ todo (恢复)
工单**不是**另一张表里的另一种东西,它是一条 `kind='ticket'` 的 item 加一行 `ticket_ext`。
所以详情仍然走 `GET /items/{item_id}`,响应里多一个 `ticket` 字段。
### 4.0 `POST /tickets`(手工开单)
```json
{ "title": "核对 9 月发票", "body_md": "可选说明",
"assignee_id": "uuid", "priority": 2, "due_at": "2026-10-30T10:00:00+08:00" }
```
返回与详情同构的工单对象(item + `ticket`)。与 4.1 的区别只有一处:**没有来源任务**,
`ticket.parent_item_id` 为 `null`,因此验收通过后不会回流任何任务(但同样会沉淀一份知识草稿)。
- `title` 必填、`assignee_id` 必须是工作区内 `active` 成员,否则 `1001`。
- 初始状态 `open`,被指派人立刻收到 `ticket_assigned` 通知。
- 手工单与升级单共用同一条 `item`,所以看板、评论、验收、知识沉淀全部复用,不需要另一套接口。
### 4.1 `POST /items/{item_id}/promote`(任务升级成工单)
```json
@@ -286,8 +437,26 @@ canceled→ todo (恢复)
| POST | `/notifications/{id}/read` | 标一条 |
| POST | `/notifications/read-all` | 全标已读 |
`kind`:`ticket_assigned` / `ticket_submitted` / `ticket_approved` / `ticket_rejected` / `task_unblocked`。
通知只发给当事人,同工作区里其他人看不到——这是"协同"与"广播"的区别。
`kind` 一共七种,都是"某人在某件事上做了什么":
| kind | 什么时候发 | 发给谁 |
|---|---|---|
| `task_assigned` | 建任务/改任务时指派了人 | 被指派人 |
| `task_status_changed` | 任务状态变化 | 创建人 + 办理人(除操作者本人) |
| `item_commented` | 有人评论 | 创建人 + 办理人(除评论者本人) |
| `ticket_assigned` | 升级成工单 / 手工开单 / 改派 | 办理人 |
| `ticket_submitted` | 办理人提交验收 | 需求人 |
| `ticket_approved` / `ticket_rejected` | 验收通过 / 退回 | 办理人 |
| `task_unblocked` | 工单验收通过、原任务回流待办 | 原任务创建人 |
三条硬规则:
1. **不通知自己**:谁操作就跳过谁,否则每个人都会收到自己刚做的事。
2. 通知里必须写清"谁 + 做了什么 + 在哪件事上",否则点开等于重新看一遍列表。
3. 通知只是提示,不是凭证——点开之后目标接口会重新校验权限、重新取数据。
Web 端未读数由 `AppShell` 每 30 秒轮询一次;在通知页标已读后立刻派发 `notifications:changed` 事件刷新角标,不必等下一轮。
安卓端同样按 30 秒/切页刷新。
### 4.6 工单的错误码
@@ -310,22 +479,30 @@ canceled→ todo (恢复)
| `status` | 可重复;`draft`/`in_review`/`published`/`archived`,不传为全部 |
| `doc_type` | `note`/`howto`/`faq`/`postmortem`/`decision` |
| `source_item_id` | 按来源事项筛 |
| `category_id` | 按分类筛,**包含子分类** |
| `uncategorized` | `true` 只看未分类 |
| `tag` | 按标签名筛(不区分大小写) |
| `q` | 标题 / 正文 / 摘要子串 |
| `limit` | 1–100,默认 30 |
| `cursor` | 上一页的 `next_cursor` |
返回 `{items, next_cursor, has_more}`。每条是 `item` 字段 + `knowledge` + `source`:
返回 `{items, next_cursor, has_more}`。每条是 `item` 字段 + `knowledge` + `category` + `source`:
```json
{
"id": "…", "kind": "knowledge", "title": "发票原件必须一起寄",
"body_md": "…", "status": "published", "tags": [],
"body_md": "…", "status": "published", "tags": [{"id": "…", "name": "财务"}],
"knowledge": {
"doc_type": "howto", "summary": "…", "status_hint": "published",
"source_kind": "task", "source_item_id": "…", "generated_by": "human",
"published_at": "2026-10-28T05:54:44Z", "archived_at": null,
"chunk_count": 0, "author_id": "…"
},
"category": {
"id": "…", "parent_id": "…", "name": "回滚", "depth": 3,
"sort_order": 0, "version": 1,
"path": ["研发", "部署", "回滚"], "full_path": "研发 / 部署 / 回滚"
},
"source": {"id": "…", "kind": "task", "title": "发票原件必须一起寄", "status": "done"}
}
```
@@ -336,25 +513,76 @@ canceled→ todo (恢复)
### 5.2 `GET /knowledge/{item_id}`
详情,附最近 50 条 `activities`。不存在或跨租户统一 `7001`。
详情,附最近 50 条 `activities`。不存在、跨租户、**已经删掉**的统一 `7001`
(删除是软删,但对读的人来说就是"没了",否则收藏的链接还会打开一篇已删除的知识)。
### 5.2.1 `GET /knowledge/tags` — 侧栏那串标签
```
GET /knowledge/tags?status=published&status=in_review
```
```json
{"items": [{"name": "发布", "count": 12}, {"name": "回滚", "count": 3}]}
```
- `count` 是**有多少条知识用了它**(`count(distinct item_id)`),不是引用次数。
- 只统计未删除的知识;带了 `status` 就只数这些状态,和列表的 tab 对得上。
- 按条数从多到少排,同数按名字。标签是分类之外的第二根轴:分类回答"属于哪一块",标签回答"跟什么有关"。
### 5.2.2 `DELETE /knowledge/{item_id}?confirm=true` — 删除
- 必须显式带 `confirm=true`,否则 `1002`「删除不能撤销,带 confirm=true 再来一次」(防手滑)。
- 只有**作者本人**或 `owner`/`admin` 能删,否则 `3004`。
- 软删:`item.deleted_at` 落时间戳,列表、详情、检索、标签统计里都不再出现;
**检索切片真删**(`chunk` 表),否则"删掉的知识还能被搜出来"比删不掉更吓人。
- 重复删不回错误,回 `message: "已经删过了"`,让重试的客户端不必区分。
### 5.3 `POST /knowledge` — 手记或指定来源
```json
{"title": "报销单要贴发票原件", "body_md": "…", "doc_type": "note",
"tag_names": ["财务", "报销"], "category_id": "可选的分类 id",
"source_item_id": "可选的来源事项 id"}
```
- 不带 `source_item_id` → `source_kind=manual`(手记),允许没有来源。
- 带 → 来源必须是同工作区的 `task`/`ticket`,否则 `7005`。
- `category_id` 可省(未分类);跨工作区的分类一律按不存在处理(`7006`)。
- `tag_names` 按名字去重创建,空格/逗号分隔由前端拆好再传。
- 新知识一律 `draft`,不会直接对外可见。
### 5.4 `PATCH /knowledge/{item_id}` — 修改内容
必须带 `version`(乐观锁,冲突 `4009`)。可改 `title`/`body_md`/`summary`/`doc_type`。
**已发布的不许直接改**(`7003`,先撤回审议或归档),避免"发布后被悄悄改写"。
必须带 `version`(乐观锁,冲突 `4009`)。可改 `title`/`body_md`/`summary`/`doc_type`/`tag_names`/`category_id`;
传 `clear_category: true` 表示移到未分类。
### 5.5 `POST /knowledge/{item_id}/status` — 状态动作
**已发布的可以直接改**:改的是"团队现在看到的这一版",改完立刻重建检索切片(`chunk_count` 当场更新),
避免"正文改了、搜出来还是旧句子"。版本号照旧 `+1`,历史仍能靠审计追。
**已归档的不许直接改**(`7003`,`allowed_actions: ["restore"]`):先恢复成草稿再改,
免得改完还挂着"现役"的样子。
### 5.5 知识分类 `/knowledge/categories`
分类是工作区级的三级小树,用来给知识安一个位置;它**不是权限边界**,只是归类。
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | `/knowledge/categories` | 扁平列表,但按**树的先序**排好(父分类后紧跟它的子分类,兄弟按 `sort_order`、`name`),前端照 `depth` 缩进即可;每条带 `doc_count`、`path`、`full_path`;另有 `max_depth=3` |
| POST | `/knowledge/categories` | `{name, parent_id?, sort_order?}` |
| PATCH | `/knowledge/categories/{id}` | `{name?, parent_id?, move?, sort_order?, version}` |
| DELETE | `/knowledge/categories/{id}?force=false` | 有子分类不让删;有知识要 `force=true`,删后知识变未分类 |
三条规则:
1. **最多三级**。第四级不是"更细",是"找不到",创建/移动越界一律 `7007`。
2. **同一层不能重名**(不区分大小写),否则 `7008`;不同层可以同名。
3. **移动带着子树走**:把一棵三层树挪到第二层下面会让最深节点到第五层,这种移动直接拒绝(`7007`),
不静默改层级。
`PATCH` 里 `move=true` 才处理 `parent_id`,避免"只改名字"被误当成移动。改名的 `version` 冲突是 `4009`。
### 5.6 `POST /knowledge/{item_id}/status` — 状态动作
| action | 允许的前置状态 | 结果 | 谁能做 |
|---|---|---|---|
@@ -369,7 +597,7 @@ canceled→ todo (恢复)
**发布要 owner/admin 的理由**:发布等于团队为这条结论背书,不能谁都能推。
### 5.6 `POST /items/{item_id}/knowledge` — 把一件事沉淀成知识
### 5.7 `POST /items/{item_id}/knowledge` — 把一件事沉淀成知识
Query:`doc_type`(默认 `postmortem`)。来源可以是任务或工单;工单会带出验收结论。
返回与 5.2 相同的知识对象;同一来源重复沉淀 → `7002`。
@@ -377,7 +605,45 @@ Query:`doc_type`(默认 `postmortem`)。来源可以是任务或工单;
**工单验收通过时,后端会自动调用这条逻辑**(`generated_by=rule`,`doc_type=postmortem`,
正文开头固定一句"这份草稿由系统根据来源自动整理,请先核对再发布"),所以用户不需要记得点。
### 5.7 知识的错误码
### 5.8 检索 `GET /knowledge/search`
Query:
| 参数 | 说明 |
|---|---|
| `q` | 必填,1–200 字 |
| `limit` | 1–30,默认 10 |
| `mode` | `hybrid`(默认)/ `vector` / `keyword`,后两个用于对比效果 |
返回 `{query, mode, items[]}`,每项:
| 字段 | 说明 |
|---|---|
| `item_id` / `title` | 来源知识,前端"打开原文"用 |
| `doc_type` / `heading` | 类型与所在小节 |
| `idx` / `content` | 第几片、切片正文 |
| `score` | 加权总分,低于 `SEARCH_MIN_SCORE`(0.06)的结果**不返回** |
| `vector_score` / `lexical_score` | 向量分与 trigram 词法分,便于排查"为什么没搜到" |
三条约定:
1. **只检索 `published`**。草稿是还没被人认可的话,进检索等于把猜测当结论引用出去——过滤写在 SQL 里,调用方改不了。
2. **结果必须带来源**,能点回原文。这是 D3(知识必须有来源)的延伸:不给"出处不存在"的答案。
3. **宁可返回空,也不凑数**。检索失败比误导便宜。
发布(`5.6` 的 `publish`)时会自动重建索引,用户发布完立刻能搜到;
但改完正文不会自动重建,需要调用下面这条。
### `POST /knowledge/{item_id}/reindex` — 重建切片
返回 `{item_id, chunk_count, searchable}`。`searchable=false` 表示内容已建好切片但还不是 `published`,
搜不到——这不是错误。
实现:切片存 `chunk` 表(`content` + `embedding real[]`),相似度用 SQL 函数 `app_cosine()` 计算。
本机没有可用的 pgvector 实例(Docker 起不来,fnsvr-01 明确不动),所以先用 `real[]` 把链路跑通;
将来换 pgvector 只改列类型与索引,接口不变。换 embedding 模型(`EMBEDDING_PROVIDER`)后
**必须整体重建**,否则新旧向量混在一张表里,分数没法比较。
### 5.9 知识的错误码
| 码 | HTTP | 含义 |
|---|---|---|
@@ -386,10 +652,223 @@ Query:`doc_type`(默认 `postmortem`)。来源可以是任务或工单;
| 7003 | 409 | 当前状态不允许这个动作 |
| 7004 | 403 | 权限不足(如非管理员发布) |
| 7005 | 409 | 来源事项无效或缺失 |
| 7006 | 404 | 分类不存在,或不是本工作区的 |
| 7007 | 422 | 分类层级超过三级,或移动后会让子树超过三级 |
| 7008 | 409 | 分类重名 / 还有子分类 / 分类下还有知识(需 `force`) |
---
## 6. 健康与版本
## 6. 附件与富文本
### 6.1 存储格式:Markdown + 附件表
正文类字段(任务 `body_md`、工单说明、知识正文、评论、验收结论)**一律存 Markdown**,不存 HTML。
理由:能导出、能全文检索、能直接喂给 LLM;HTML 每次渲染都要重新清洗一遍,是隐患。
图片不是 base64 塞进正文,而是走附件接口拿地址。**库里存的是不带凭据的相对路径**:
```markdown
![票据截图.png](/api/v1/workspaces/{ws_id}/attachments/{attachment_id}/raw)
```
| 方法 | 路径 | 说明 |
|---|---|---|
| POST | `/workspaces/{ws_id}/items/{item_id}/attachments` | multipart,字段名 `file`;返回 201 + 附件对象 |
| GET | `/workspaces/{ws_id}/items/{item_id}/attachments` | `{items: [...]}`,每条含 `raw_url` |
| GET | `/workspaces/{ws_id}/attachments/{id}/download` | 下载(`Content-Disposition: attachment`) |
| GET | `/workspaces/{ws_id}/attachments/{id}/raw?exp=&sig=` | 直链,给 `<img src>` 用(签名见 6.2) |
| DELETE | `/workspaces/{ws_id}/attachments/{id}?confirm=true` | 上传者或管理员可删;不传 confirm 报 `8004` |
附件对象的字段:`id / item_id / filename / content_type / size / sha256 / uploader_id / created_at / raw_url / can_delete`。
`raw_url` 是**后端签发的短期直链**(默认 30 分钟),客户端直接用它渲染,不需要自己拼令牌。
附件可挂在 `kind` 为 `task / ticket / knowledge` 的事项上(`ALLOWED_KINDS`),知识与任务、工单共用同一套接口;
事项与知识的列表接口会附带 `attachment_count`,详情接口附带 `attachments` 全量。
新建知识时条目还不存在,前端用「懒建草稿」:点上传(或往正文里贴图)时先 `POST /knowledge` 存一条草稿拿到 `item_id`,
再上传到 `/items/{item_id}/attachments`,之后的保存一律走 `PATCH /knowledge/{item_id}`。没传过附件就取消的人,库里不会留下空条目。
### 6.2 图片直链:短期签名,而不是把令牌写进正文
`<img>` 发不出 `Authorization` 头。两种解法:把图片读成 base64 内联(正文膨胀几十倍,破坏检索),
或者给一个短期签名地址。取后者:
- `raw_url` 形如 `…/raw?exp=1730000000&sig=<hmac>`,签名载荷是 `workspace_id:attachment_id:exp`,
密钥从 `JWT_SECRET` 域分离派生(`attachment-raw-v1`),不与登录令牌共用。
- 校验只看签名与过期时间,**不需要用户已登录**;签名里绑了 `workspace_id`,A 工作区签的链接
打到 B 工作区会被拒(`8003`)。
- 过期不宽限,刷新页面即可拿到新链接。
- 兼容旧路径:不带 `sig` 时仍可用 `?token=<access>` 走成员校验,供老客户端过渡。
为什么非要这样:令牌写进正文会落库,几天后过期就是满篇红叉;用户换设备后旧令牌失效,图片全挂。
把凭据放在"每次渲染时现签"的 URL 上,正文才能长期存活。
### 6.3 附件错误码
| 码 | HTTP | 含义 |
|---|---|---|
| 8001 | 404 | 附件不存在,或不属于这个工作区 |
| 8002 | 413 | 超过 `MAX_UPLOAD_MB`(默认 20) |
| 8003 | 403 | 不是上传者也不是管理员 |
| 8004 | 409 | 需要 `?confirm=true`(删除是破坏性动作) |
### 6.4 前端约定
- 编辑器写 Markdown,工具栏/快捷键 `Ctrl+B`、`Ctrl+I` 只管插入语法;预览用 `renderMarkdown()`
渲染(先转义再上标签,链接协议白名单 `http(s)/mailto//`)。
- 粘贴或拖入图片时:先上传 → 拿到 `id` → 在光标处插入 `![文件名](attachmentUrl(...))`。
- 上传接口自己带 `Idempotency-Key`,**不要**手动设 `Content-Type`(浏览器要自己带 boundary)。
---
## 6.5 外部接入(微信 clawbot,页面叫「机器人」)
微信里那个机器人连上本服务之后,发一句话就变成任务或知识草稿。
走的是腾讯 iLink Bot 协议:**扫码登录 → 后端长轮询收消息 → 用 context_token 回复**。
所以这一组里只有「扫码」是免登录的(机器人自己不带我们的令牌),其余都要工作区 admin 以上。
### 6.5.1 扫码与通道(工作区 admin 以上)
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | `/workspaces/{ws}/integrations` | 列出通道(含 `status`、`connected`、`polling`、`last_seen_at`、`last_error`) |
| POST | `/workspaces/{ws}/integrations/wechat/qr` | 取一张二维码,返回 `{channel_id, qrcode_id, payload, image, expires_in}` |
| GET | `/workspaces/{ws}/integrations/wechat/qr/{qrcode_id}` | 问扫码结果:`waiting`/`scaned`/`confirmed`/`expired` |
| PATCH | `/workspaces/{ws}/integrations/{id}` | `{"status":"active"|"disabled"}`,停用就停轮询 |
| DELETE | `/workspaces/{ws}/integrations/{id}?confirm=true` | 断开:清掉登录态与游标,回到 `pending` |
| GET | `/workspaces/{ws}/integrations/{id}/messages?limit=5` | 最近收到的消息与它变成了什么 |
取二维码的响应(`image` 是可以直接塞进 `<img>` 的 data URL,本地渲染,不是图片地址):
```json
{
"channel_id": "4f0a…",
"qrcode_id": "30c97db83291e779217cbe20c5373e91",
"payload": "https://liteapp.weixin.qq.com/q/7GiQu1?qrcode=30c97…&bot_type=3",
"image": "data:image/svg+xml;base64,PD94bWw…",
"expires_in": 300
}
```
扫码成功那一次(`status == "confirmed"`)除了回 `status`,还带 `channel`:登录态已加密存进
`bot_channel.bot_token_enc`(Fernet,密钥由 `JWT_SECRET` 派生),**不会回给任何接口**,轮询任务同时被拉起。
`status` 的四个值:`pending`(还没连)、`active`(连上了、正在收)、`disabled`(管理员停用)、
`expired`(登录态失效,需要重新扫码)。`polling` 说明后台任务此刻是否在跑。
### 6.5.2 消息怎么变成事项(不分接口,轮询里做)
给机器人发的话这样理解:
| 开头 | 落成什么 |
|---|---|
| `任务:`/`待办:`/`todo:` 或不带前缀 | 任务(`status = "inbox"`) |
| `知识:`/`笔记:`/`note:`/`kb:` | 知识草稿(`status = "draft"`,`doc_type = "note"`) |
| `帮助`/`?`/`help` | 只回一段用法,不建任何东西 |
正文第一行当标题(超过 180 字截断),其余当正文;`#标签` 会被摘出来加到事项上(纯数字的不算标签)。
建完之后会用 `context_token` 在微信里回一句「已保存任务:标题」/「已保存知识草稿:标题」。
**回话里不带链接**:站点地址对手机不一定打得开(内网 IP、端口、还没上 HTTPS 的域名),
发过去只会让人以为崩了。
同一条消息(按 `message_id`)重复推送只会落一次,重推直接跳过。
图片/文件/视频目前只在 `bot_message.raw` 里留痕,正文不搬;语音如果服务端给了转写就用转写文本。
### 6.5.2.1 发一条链接:抓正文存成知识
整个消息就是一条 `http://` / `https://` 链接时(前后带标点不算),不当任务,而是去把文章抓下来:
| 环节 | 行为 |
|---|---|
| 判断 | `extract_url()`:去掉首尾标点后只剩一个链接;带了别的话("看下 https://… 这个")仍按任务处理 |
| 抓取 | `httpx` 取页面(25 秒超时、8 MB 上限),`trafilatura` 抽正文;标题取 `og:title`/`<h1>`/`<title>` |
| 公众号(旧页) | `mp.weixin.qq.com` 且页面有 `#js_content`:标题 `#activity-name`、作者 `#js_name`、正文 `#js_content`,图片取 `data-src`(通用抽取器会把公众号的图丢光) |
| 公众号(新版页) | 没有 `#js_content` 时(`appmsg_type = '9'` / `'10002'`)整篇在 `window.cgiDataNew` 里:从 `content_noencode` / `desc` 的 JS 字符串字面量读正文,按 `\xNN`、`\uNNNN` 反转义,话题标签先 `html.unescape` 再走 DOM;配图字段(`cdn_url`/`img_url`/`pic_url`)排在文字之后,前后各找 2500 字符合图;作者兜底取 `nick_name` |
| 清洗 | 导航/页脚按行边界裁掉(`_trim_chrome`);`trafilatura` 结果太短(<120 字)就退到容器打分兜底 |
| 配图 | 正文里的外链图下载进我们自己的存储,最多 12 张、单张 8 MB,正文改写成 `/attachments/{id}/raw?签名`——公众号一换图,外链就是一排红叉 |
| 落库 | 知识草稿(`status="draft"`,`doc_type="note"`),正文首行是 `> 原文:<url>` |
| 回话 | `已存成知识草稿:标题`(存了图再加「,连图 N 张」) |
| 失败 | `这个链接没抓下来(原因)。想直接记成任务的话,加「任务:」前缀再发一次。` |
安全:抓取入口对任何能给机器人发消息的人开放,所以**只允许公网地址**——`127.0.0.1`、内网段、
链路本地、保留地址、解析不出来的域名一律拒绝(`ArticleError`),否则就是个 SSRF 跳板。
反爬/需登录的页面(微信"环境异常"验证页、Cloudflare 302、SPA 空壳)会回上面那句失败提示,不静默吞掉。
### 6.5.3 错误码
| 场景 | code | HTTP |
|---|---|---|
| 取二维码 / 问状态时微信返回错误 | `9002` | 404 |
| 通道不存在、或不属于这个工作区 | `9002` | 404 |
| 登录态失效(微信 errcode `-14`) | —(通道转 `expired`,接口正常返回) | 200 |
### 6.5.4 表与隔离
`bot_channel`(通道、登录态、游标)与 `bot_message`(收到的消息留痕)都挂 `workspace_id`,
走标准 RLS。轮询任务从 `channel_id` 起步、此刻还没有工作区上下文,所以迁移 `0014`
额外给了一条 `app.bot_channel` 的按主键口子(permissive 策略,与原隔离策略 OR)。
## 7. 安卓发布与升级 `/app`
安装包不进版本库:`backend/static/apk/` 由 `publish_apk.py` 写入,里面有 APK 与一份
`latest.json` 清单。接口只读这份清单,**没有清单就等于没发布过**(9001)。
### 7.1 `GET /app/release/latest`
| 参数 | 说明 |
|---|---|
| `version_code` | 可选,客户端当前版本号;给了才会算 `has_update` |
免登录:老版本客户端可能连"登录"这一步都还没走到,检查更新不该需要令牌。
```json
{
"code": 0,
"data": {
"platform": "android",
"version_code": 2,
"version_name": "0.2.0",
"notes": "新增应用内升级;图标换成和 Web 一样",
"file": "dadaotask-0.2.0.apk",
"size": 8123456,
"sha256": "…",
"min_sdk": 26,
"published_at": "2026-10-31T03:20:00Z",
"download_url": "/api/v1/app/release/download/dadaotask-0.2.0.apk",
"has_update": true
}
}
```
`download_url` 是**相对路径**:同一份清单要被内网、公网、模拟器三个入口共用,
写死域名只会让其中一个坏掉;客户端用自己配置的 API 地址拼。
`has_update` 的判据只有 `version_code`,不看版本名字符串——字符串比较会把
`0.10.0` 判成比 `0.9.0` 旧。
### 7.2 `GET /app/release/download/{file}`
只认清单里登记过的文件名,其它一律 9001(含 `..%2F` 这类路径穿越尝试)。
返回 `application/vnd.android.package-archive`。同样免登录:下载地址本身就是
清单里给出的,能拿到清单就说明已经过了一道。
### 7.3 发布包的约定
- APK 必须用**同一把证书**签名,否则新包装不上去(系统只会报"应用未安装")。
证书放在发布者本机(`android/keystore.properties` 指向它),不进仓库。
- 上传清单带 `sha256`,客户端下完先校验再交给系统安装器:内网代理缓存旧包时,
这是唯一能挡住"装了个旧版本"的地方。
- 「允许安装未知应用」是系统开关,App 只能把用户送到设置页,不能代点。
### 7.4 错误码
| 码 | HTTP | 含义 |
|---|---|---|
| 9001 | 404 | 还没发布过安装包,或下载的文件不在清单里 |
---
## 8. 健康与版本
| 方法 | 路径 | 说明 |
|---|---|---|
+203
View File
@@ -0,0 +1,203 @@
# 部署(生产 · openclaw-01)
> 适用于当前唯一的线上环境:`openclaw-01`(192.168.8.188),访问入口 `http://192.168.8.188:8200`。
> 本地开发见 [`DEVELOPMENT.md`](DEVELOPMENT.md)。部署铁律见仓库根 [`AGENTS.md`](../AGENTS.md) 第 2 节。
## 1. 生产拓扑
```
浏览器 ──HTTP──▶ openclaw-01:8200
└ gunicorn(2 × UvicornWorker) ── app.main:app
├ /api/v1/** 业务 API
└ / 前端产物 web/dist(单页应用,404 回退 index.html)
│
┌────────────────────────┼────────────────────────┐
▼ ▼ ▼
fnsvr-01:8432 PG 17.6 fnsvr-01:6379 Redis 本机 backend/var/uploads
库 dadaotask_prod db 6(独立库号) 附件文件
角色 dadao_prod_app(RLS)
角色 dadao_prod_owner(迁移)
```
为什么是这个形状:
- **生产库和开发库在同一个 PG 实例**(fnsvr-01),所以生产用**独立的库 + 独立的角色**。复用 `dadao_app` 等于拿到开发口令就能连生产。迁移里的 `GRANT`/`DEFAULT PRIVILEGES` 按 `APP_DB_ROLE` / `OWNER_DB_ROLE` 生成,靠 `backend/app/roles.py` 注入。
- **Redis 用 6 号库**隔离:共享实例没有密码,靠库号避免和别的项目撞键(验证码、幂等键)。
- **没有 nginx**:openclaw 账号无 sudo 密码,改不了系统 nginx,所以后端单端口同时托管 API 与前端产物(同源,免 CORS)。root 愿意配合时按 `deploy/prod/nginx.dadaotask.conf.example` 加反代,不用改应用。
- **用户级 systemd**:openclaw-01 上所有服务(easymemo、portal-site…)都是 `systemctl --user` 起的,本方案沿用,全程不需要 root。
## 2. 首次部署(一次性)
前置:能 SSH 到 openclaw-01;fnsvr-01 的 root 权限(只在建库这一步需要)。
### 2.1 建生产库与角色(在 fnsvr-01 上,root)
```bash
# 口令自己生成,别用示例里的
OWNER_PW='<dadao_prod_owner 的口令>' APP_PW='<dadao_prod_app 的口令>' \
bash deploy/prod/provision-db.sh
```
会创建:库 `dadaotask_prod`(owner `dadao_prod_owner`)、角色 `dadao_prod_owner` / `dadao_prod_app`(`NOSUPERUSER NOBYPASSRLS`,否则 RLS 会被跳过)、`public` schema 归 owner、`REVOKE CREATE ... FROM PUBLIC`。建表与 RLS 策略由 alembic 负责(2.4)。
### 2.2 代码落地(在 openclaw-01 上,openclaw 身份)
```bash
git clone --branch main --single-branch http://gitea.tatta.cn/Tatta/DadaoTask.git ~/app/dadaotask
```
### 2.3 配置(不进仓库)
```bash
mkdir -p ~/.config/dadaotask && chmod 700 ~/.config/dadaotask
cp ~/app/dadaotask/deploy/prod/env.production.example ~/.config/dadaotask/env.production
chmod 600 ~/.config/dadaotask/env.production
vim ~/.config/dadaotask/env.production # 替换全部 __CHANGE_ME__
```
必填项:`DATABASE_URL` / `MIGRATION_DATABASE_URL`(2.1 的口令)、`JWT_SECRET`(≥32 位随机串)、`REDIS_URL`、`WEB_DIST_DIR`、`UPLOAD_DIR`。
两个踩过的坑:
- **`CORS_ORIGINS` 必须写成 JSON 数组**(`'["http://...","http://..."]'`)。它是 list 字段,逗号分隔的字符串会让 pydantic-settings 当 JSON 解析并抛 `SettingsError`,alembic 这一步就挂。
- **Redis 有口令**:fnsvr-01 的 redis 开了 `requirepass`(口令看 `docker inspect redis`),写进 URL 时要百分号编码(`#` → `%23`),否则连不上、发码/登录直接 500。
**当前状态是内测模式**:短信通道还没接(非 dev provider 直接 501),所以 `SMS_PROVIDER=dev` + `INTERNAL_TEST_LOGIN=true`,固定验证码 `123456`。启动日志会打 WARNING。**只允许内网访问**;短信通道接好后删掉 `INTERNAL_TEST_LOGIN` 并把 `SMS_PROVIDER` 换成真实供应商。
### 2.4 装服务单元
```bash
mkdir -p ~/.config/systemd/user
cp ~/app/dadaotask/deploy/prod/dadaotask-api.service ~/.config/systemd/user/
systemctl --user daemon-reload
```
### 2.5 首次上线
```bash
bash ~/app/dadaotask/deploy/prod/deploy.sh
```
## 3. 日常发布(每次上线)
```bash
# 本地:提交并 push 到 main(生产只跑仓库 HEAD)
# 服务器:
bash ~/app/dadaotask/deploy/prod/deploy.sh
```
脚本做六件事,任何一步失败即中止:代码对齐 `origin/main`(`git reset --hard`,禁止服务器手工改)→ 装 Python 依赖 → `alembic upgrade head`(用迁移角色)→ **在服务器上** `npm ci && npm run build` → 写 `env.build` 并重启 → 校验 `/health` 与 `/version` 的 commit 等于 HEAD。
前端产物只在服务器上构建,**不允许**本地上传 dist(AGENTS.md 铁律 3)。
## 4. 部署后校验
```bash
curl -s http://192.168.8.188:8200/health # {"status":"ok","db":true,...}
curl -s http://192.168.8.188:8200/version # commit 必须等于 git rev-parse --short HEAD
curl -sI http://192.168.8.188:8200/ | head -1 # 200,text/html(前端产物)
```
浏览器验收:打开 `http://192.168.8.188:8200` → 手机号任意 + 验证码 `123456` 登录 → 任务/工单/知识各点一遍。
## 5. 安卓安装包发布(OTA)
APK 不进版本库,也不走 `deploy.sh`:代码发布和"发一个安装包"是两件事,
前者每次上线都做,后者只在安卓端有改动时做。
### 5.1 出包(本机)
```bash
cd android
export JAVA_HOME=<JDK 17> # AGP 8.7 不认 JDK 25
export GRADLE_USER_HOME=<可写的 gradle 目录>
gradle -Dorg.gradle.jvmargs="-Djava.io.tmpdir=<可写 tmp>" assembleRelease
# 产物:android/app/build/outputs/apk/release/app-release.apk
```
- 版本号在 `android/app/build.gradle.kts`:`versionCode` 是 OTA 的唯一判据,**每次发版必须 +1**,
否则客户端查不到更新;`versionName` 只是给人看的。
- 签名证书在 `android/keystore.properties`(本机文件,`.gitignore` 已排除)指向的 `.jks`。
**换证书等于换应用**:装了旧包的手机再也装不上新包,只能卸载重装。
- `dadao.apiBase`(`android/local.properties`)决定包里写死的后端地址。正式包指向
`http://192.168.8.188:8200`,模拟器调试包指向 `http://10.0.2.2:8100`。
### 5.2 发布清单
```bash
python backend/scripts/publish_apk.py android/app/build/outputs/apk/release/app-release.apk \
--version-code 2 --version-name 0.2.0 --notes "新增应用内升级;图标与 Web 一致"
```
脚本把 APK 复制成 `backend/static/apk/dadaotask-<版本>.apk`,并写 `latest.json`
(含 `sha256`、`size`、`published_at`)。接口读的就是这份清单。
### 5.3 传到服务器
```bash
scp backend/static/apk/* openclaw-01:~/app/dadaotask/backend/static/apk/
```
`deploy.sh` 的 `git reset --hard` 不会删未跟踪文件,所以已发布的 APK 在服务器上能留住;
但换了机器或清了目录就要重新传一次。**不需要重启服务**:接口每次请求都现读清单与文件。
### 5.4 验证
```bash
curl -s "http://192.168.8.188:8200/api/v1/app/release/latest?version_code=1"
# data.version_code 为新版号、has_update=true、download_url 可下载
curl -sI http://192.168.8.188:8200/api/v1/app/release/download/dadaotask-0.2.0.apk | head -1 # 200
```
手机端:装上新包 → 下次启动自动查一次;也可以在首页底部点「检查更新」。
装完再启动,`version_code` 已是最新,接口返回 `has_update=false`,不会再弹。
## 6. Web 装到桌面(PWA)
前端已经打成 PWA:`/manifest.webmanifest` 与 `/sw.js` 由后端静态层直接返回(`_SPAStaticFiles` 挂在 `/`,
不需要额外配置),`node_modules/.bin` 之外没有额外依赖。发布流程不变。
**装的条件是安全上下文**——浏览器只允许 `https` 或 `localhost` 注册 Service Worker,也就是说:
| 入口 | 能不能装 |
|---|---|
| `https://<域名>/` | 能,手机和电脑都会出现安装入口 |
| `http://127.0.0.1:8200/`(本机打开) | 能,仅限这台机器 |
| `http://192.168.8.188:8200/` | **不能**,非安全上下文:不弹安装、SW 也不注册 |
内网 IP 目前就是这种情况,所以真要在手机上装到桌面,得给入口套一层 HTTPS:
在能装 nginx 的机器上(或 fnsvr-01 的 1Panel OpenResty)加一个 `443 → 127.0.0.1:8200` 的反代就好,
应用本身不用改;示例见 `deploy/prod/nginx.dadaotask.conf.example`。也可以用 Cloudflare Tunnel 之类的
隧道拿一个 https 域名,同样不用碰应用代码。
装好之后的两个行为:
- **图标**:来自 `web/public/icons/`(any + maskable 两套,iOS 吃 `apple-touch-icon.png`),
与 Web 页头、安卓桌面图标是同一枚。
- **发版提示**:SW 采用 `registerType: "prompt"`,检测到新版本只在页面底部提示「有新版了」,
用户点「刷新」才切换。**故意不自动刷新**:Vditor、图表是懒加载分片,后台把 chunk 换掉之后,
用户下一次点开面板就是 404(编辑器白屏)。
- **离线**:只预缓存页面骨架(构建产物 + 图标,约 0.8MB),`/api/` 一律 NetworkOnly——
宁可报错,也不拿旧数据糊弄人。
## 7. 运维
```bash
systemctl --user status dadaotask-api # 状态
systemctl --user restart dadaotask-api # 重启
journalctl --user -u dadaotask-api -f # 日志(gunicorn 输出到 stdout)
```
- 附件落盘:`backend/var/uploads`(按工作区分子目录),删库不删文件,备份时一起打包
- 回滚:`cd ~/app/dadaotask && git reset --hard <旧 commit> && bash deploy/prod/deploy.sh`;**数据库迁移不回滚**,回滚代码前确认旧代码兼容新表结构
- 端口:`8200`(systemd 单元与 `deploy.sh` 的 `PORT` 默认值,两处要一致)
## 8. 已知限制(当前阶段)
| 限制 | 影响 | 解除方式 |
|---|---|---|
| 没有 HTTPS / 域名 | 只能内网 IP 访问,令牌明文传输;**PWA 装不上**(非安全上下文) | 域名 + nginx + 证书(需 root) |
| 内测固定验证码 | 谁都能用任意手机号登录 | 接短信通道,关 `INTERNAL_TEST_LOGIN` |
| 无 pgvector | 语义检索走 `real[]` 全表扫描,数据量大了会慢 | 上独立 pgvector 实例(`deploy/docker-compose.vector.yml`) |
| 上传目录在本机磁盘 | 机器坏了附件就没了 | 备份 `backend/var/uploads`,或将来到对象存储 |
| 共享 PG / Redis | 别人占满连接或内存会波及本站 | 独立实例 |
+2
View File
@@ -182,6 +182,8 @@ release 仍走白名单,线上域名必须 HTTPS。
| 迁移报 `cannot insert multiple commands into a prepared statement` | asyncpg 走预备语句,一次只能一条 SQL | 多语句 DDL 拆成每语句一次 `op.execute()` |
| 源文件里的中文变成 `宸ュ崟鍩?` | 用 PowerShell `Get-Content`/`Set-Content` 批量改文件:读时按 GBK 解码,写回 UTF-8,不可逆 | **含中文的源文件只能用编辑器/`edit` 工具改**;真要写脚本,必须显式 `[IO.File]::ReadAllText($p, [Text.Encoding]::UTF8)` + `WriteAllText(..., UTF8Encoding($false))` |
| `npm` 报 `无法加载文件 npm.ps1,因为在此系统上禁止运行脚本` | PowerShell 执行策略 | 用 `npm.cmd`,或直接 `node node_modules/vite/bin/vite.js build` |
| 富文本编辑器一直转圈、没有工具栏 | `web/public/vditor` 缺失(它被 gitignore,由脚本生成) | `cd web && npm run vendor`,或走一遍 `predev`/`prebuild` |
| 编辑器能开但排版是源码、不是所见即所得 | `lute.min.js` 没自托管成功,Vditor 退回纯文本模式 | 打开控制台看 `/vditor/dist/js/lute/lute.min.js` 是不是 404 |
### 团队共享开发库(fnsvr-01:8432)
+569 -14
View File
@@ -58,7 +58,8 @@
- [x] 手机号 + 验证码注册/登录(dev 固定码 `123456` 并回显;`ENV=prod` 禁止 `SMS_PROVIDER=dev`)
- [x] JWT:**access 30min / refresh 14d**(计划里写的 15min/30d,实际取宽了一点,理由是移动端弱网刷新成本高)
- [x] 发码节流(`SMS_THROTTLE_SECONDS`,默认 60s,测试可置 0)
- [ ] 邮箱 + 密码(argon2)(**未做**,M1 有空再说;手机号登录先行)
- [x] 手机号 + **密码**登录(可选,第二把钥匙:设过密码就能用密码进,没设过的一律回 `2013`;scrypt 哈希,15 分钟连错 10 次 → `2016`)
- [ ] 邮箱 + 密码(**未做**,M1 有空再说;手机号登录先行)
- [ ] refresh 令牌落库可撤销(**未做**,当前是无状态 JWT;被移出团队的成员靠"每次请求查 member 表"兜底,refresh 本身不可撤销)
- **验收**:✅ 注册 → 登录 → 刷新 → `/auth/me` 全链路通;错误码 2010/2011/2012 语义清晰
@@ -189,8 +190,8 @@
- [ ] 下拉刷新、只读缓存(Room)未做
### 1.6 通知与提醒(0.8d)
- [ ] 站内通知表与未读角标 —— **主动推迟到 M2**:通知的绝大多数触发源是工单(被指派、被催、验收),
M1 只有自己的任务,做出来也没人收到东西。表随 M2 的工单一起建,避免建两次。
- [x] 站内通知表与未读角标 —— 随 M2 一起落地,七类触发(指派、改状态、评论、工单全流程);
未读角标 30 秒轮询,标已读立刻刷新。原计划"推迟到 M2",M2 已兑现。
- [ ] 每日摘要邮件(同上推迟)
### 1.7 测试与验收(0.7d)
@@ -230,6 +231,8 @@
- [x] `POST /items/{id}/promote`、`/ticket/{accept,start,submit,verify,reassign,cancel}`
- [x] `GET /tickets?role=assigned|requested|all&status=`
- [x] `GET|POST /items/{id}/links`、`DELETE /items/{id}/links/{link_id}`
- [x] `POST /tickets` 手工开单:不经过任务直接指定人,初始 `open`,与升级单共用同一条 item
- [x] 指派前校验"在册成员"(`_ensure_member`),否则任务会挂在一个没人看得到的名字上
- [x] `GET /notifications`、`/unread-count`、`/read`、`/read-all`
- [x] 抽出 `app/serializers.py`:任务与工单共用一份序列化,**根除"工单响应缺 tags 导致前端白屏"**
@@ -238,17 +241,27 @@
- [x] 详情抽屉:按角色显示受理、开工、提交、验收、驳回、改派、取消;工单态顶部显示需求人/办理人/退回次数
- [x] 任务详情底部"升级成工单"面板(从成员里选人,成功后提示"这条任务现在处于被阻塞")
- [x] 导航加未读角标(30 秒对齐一次,操作后立刻刷新)
- [x] 通知页 `/w/:wsId/notifications`:未读 / 全部两档,点开即标已读并跳到目标;深链 `?open=` 落地即展开那一条
- [x] 工单页"新建"面板:手工开单(选人、优先级、说明),成功切到"我发起的"并展开
- [x] 登录支持 `?next=` 回跳,邀请链接不再"登录完掉回首页"
### 2.5 安卓(1.5d)
- [ ] 工单列表与动作 —— 只做到任务列表;本机无 JDK/Gradle,仍是**未编译**状态
- [x] 工单列表与动作 —— 仅按"我是需求人 / 办理人 / 管理员"显隐动作按钮,不再人人可见
- [x] 通知页与手工开单:五个 tab、未读数 30 秒刷新、通知点开跳列表并清标记
- [ ] 本次未编译验证:本机 JDK 已升级到 25,Gradle 8.7 / AGP 8.7.3 不兼容,需装回 JDK 17 再 `assembleDebug`
### 2.6 测试与验收(0.7d)
- [x] `backend/tests/test_tickets.py` 11 个用例:升级建单+原任务阻塞+通知、全流程验收回流、驳回、
自验 403、非法流转、重复升级、非成员指派、角色作用域、跨租户、关系挂载、通知已读、**响应必带 tags**
- [x] 全量 `pytest -q` → **57 passed**;`ruff check` / `format --check` 全 0
- [x] `backend/tests/test_notifications.py` 6 个用例:改状态只通知对方(自己不动自己)、指派必须是成员、
手工单全流程(开出→被指派人收到→受理/提交→验收,且没有来源任务)、越界指派 400/1001、
评论通知对方、未读数与全部已读
- [x] 全量 `pytest -q` → **85 passed**;`ruff check` / `format --check` 全 0
- [x] `web/e2e/tickets.mjs` 双浏览器上下文双用户 e2e,8 项断言全绿:
升级 → B 受理/开工/提交 → A 验收通过 → 原任务回流"待办" → 两端控制台零报错
- [x] `web/e2e/notifications.mjs` 12 项断言全绿:A 建工作区邀 B → B 自动加入 → A 手工开单 →
这张单没有来源任务 → B 收到"派给你的工单" → 通知列表有条目 → 点通知跳到工单并展开 →
未读数归零 → 两端控制台零报错
- [x] `web/e2e/smoke.mjs` 回归复跑(抽屉多了"选人"下拉后修正选择器)7 项全绿
### 2.7 通知真实投递
@@ -272,21 +285,39 @@
| 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 项断言绿 |
| 3.8 | 切片与向量索引(`chunk` 表、pgvector HNSW) | 2.0 | [x] **用 `real[]` + SQL 余弦先跑通**(见下方说明) |
| 3.8.1 | 切片:按标题归段、代码块整块保留、超长按句切、相邻片重叠 80 字 | 0.5 | [x] `app/domain/chunking.py` |
| 3.8.2 | 向量:`hash`(字符二元组)与 `openai` 两种提供方,换实现不动业务 | 0.5 | [x] `app/services/embedding.py` |
| 3.8.3 | 混合检索:向量 0.6 + trigram 0.4,低于阈值不返回,只认 `published` | 1.0 | [x] 5 个用例 + `e2e/retrieval.mjs` 6 项 |
| 3.8.4 | 索引生命周期:发布自动建、改正文需 `reindex`、换模型整体重建 | 0.5 | [x] `POST /knowledge/{id}/reindex` |
| 3.9 | 安卓知识列表 | 1.0 | [x] 只读列表 + 详情动作(编辑正文走安卓编辑器) |
| 3.10 | 文档与 e2e | 1.0 | [x] `knowledge.mjs` 7 项 + `retrieval.mjs` 6 项 |
| 3.11 | 知识分类:工作区三级小树(`knowledge_category` + 迁移 0007 + RLS),知识可归一类,列表按分类子树 / 未分类 / 标签筛选 | 1.0 | [x] 增删改移 + 重名 `7008`、超三级 `7007`、删分类要 `force`;列表按树的**先序**返回(父分类后紧跟子分类),侧栏照 `depth` 缩进就是一棵树 |
| 3.12 | 备忘录形态:新建知识一次写全(标题/类型/分类/标签/正文),不再是"填个标题就完成";抽屉加宽 + 可整屏 | 1.0 | [x] Web `KnowledgeComposer` + 安卓新建后直进编辑器;正文就是详情同款富文本编辑器(Vditor / Compose 编辑器);`Sheet` 增 `size="md/lg/xl"` 与展开按钮;截图 `2.Design/screens/knowledge-composer.png`、`knowledge-categories.png` |
| 3.13 | 知识能挂附件:新建面板里直接上传(懒建草稿拿到条目 id 再传),详情里能看、能下载/打开 | 1.0 | [x] Web `AttachmentPanel` + `RichTextEditor.ensureItemId`(贴图/传附件前自动存草稿);安卓详情页附件列表 + `OpenDocument` 上传 + 浏览器打开;附件仍落在 `item` 上(`ALLOWED_KINDS` 含 knowledge) |
| 3.14 | 点开一条知识默认是「看」:Markdown 渲染成正文样式;要改再点「编辑」进编辑器,取消即丢弃未保存改动 | 1.0 | [x] `KnowledgeDrawer` 分 `editing` 两态(只读用 `Markdown` + `prose-dadao`,编辑才挂 `RichTextEditor`);**已发布也能编辑**(PATCH 后自动重建检索切片),归档的要先「恢复成草稿」;e2e 断言默认 `.vditor-ir` 为 0 且已发布也有「编辑」;截图 `2.Design/screens/knowledge-read-view.png` |
| 3.15 | 知识列表 tab 顺序与编辑区高度:「全部」排第一并默认选中,正文编辑器占满剩余屏高 | 1.0 | [x] `TABS` 改 `all/draft/published/archived`,`tab` 默认 `"all"`;`useEditorHeight(reserve)` 按窗口高度算正文区(新建 420、编辑 340 留白,最低 360、最高 960),新建与编辑两处共用;截图 `2.Design/screens/knowledge-tab-all.png`、`knowledge-edit-tall.png` |
### M3 完成定义(DOD)
- [x] 一件事办完 → 自动出现一份草稿 → 核对 → 发布,全程不用手动复制粘贴
- [x] 每条知识都能追到来源(除手记),点开能看到来源事项
- [x] 非管理员发布被拒;跨租户看不到别人的知识
- [ ] 向量切片与语义检索(需要独立 pgvector 实例真正跑起来)
- [x] 知识是备忘录:标题、正文、标签、三级分类、搜索;新建时就能把这些一次填完(Web 与安卓一致)
- [x] 点开知识默认看到的是排好版的正文,不是编辑器的原文;要改才点「编辑」
- [x] 切片与语义检索跑通(`real[]` + SQL 余弦;pgvector 仍待独立实例)
- [ ] 真实用两周:新同事靠知识库自己解决过一次问题(需要真实团队)
**关于 3.8 的说明**:本机 Docker 起不来、fnsvr-01 上也没有 pgvector 扩展,
所以 `deploy/docker-compose.vector.yml` 与 `VECTOR_DATABASE_URL` 只有配置,**没有跑过**。
按"写完不算完成"的规矩,它在真跑起来之前保持未勾。切片表设计要点已写进
`2.Design/PRODUCT-PLAN.md` 的 D8,落地时不要改 `EMBEDDING_DIM`(改了要重算全量)。
**关于 3.8 的说明(2026-10-29 已落地,但与计划不同)**:计划里写的是 pgvector + HNSW,
实际本机 Docker 起不来、fnsvr-01 上也没有 pgvector 扩展且用户明确要求别动那台机器,
所以先用 `chunk` 表(`embedding real[]`)+ SQL 函数 `app_cosine()` 把整条链路跑通:
切片 → 向量(内置 hash 提供方)→ 混合打分(向量 0.6 + trigram 0.4)→ 阈值过滤 → 带来源返回。
`deploy/docker-compose.vector.yml` 与 `VECTOR_DATABASE_URL` 保留,等向量实例真起来之后再切过去,
换 pgvector 只改列类型与索引,应用层接口不变。
两条必须记住的约束:
1. **内置 hash 向量不是语义模型**,只是"零依赖、可复现"的基线;上线前换 `EMBEDDING_PROVIDER=openai`
并整体重建(`reindex_workspace`),否则新旧向量混表、分数不可比。
2. **检索只认 `published`**,草稿永远搜不到——这条写在 SQL 的 WHERE 里,不靠调用方自觉。
---
@@ -328,7 +359,7 @@
重做不是换配色,而是**先立设计系统、再让页面去用它**,否则下次还是东拼西凑。
### 4.1 设计系统(两端共用一套 token)
- [x] 色板:纸白 `paper`、墨绿 `primary`、陶土橙 `clay`,语义色 sage/wheat/rust/sky(Web `tailwind.config.js`,安卓 `ui/Theme.kt` 同名同值)
- [x] 色板:冷白 `paper`、靛蓝 `primary`(`#4F46E5`)、陶土橙 `clay`,语义色 sage/wheat/rust/sky(Web `tailwind.config.js`,安卓 `ui/Theme.kt` 同名同值)(2026-11-01 主色由墨绿改蓝系;2026-11-02 再由沉靛蓝改为高饱和靛蓝 `#4F46E5`,中性色转 Slate 轴,语义色不变)
- [x] 字号阶:11/12/13/14/15/18/22/26/36,正文 14、辅助 11/12;细描边 + 极轻投影 + 14dp 圆角
- [x] 组件:卡片 / 按钮(primary、ghost、subtle、clay、danger)/ 状态 pill / 输入 / 空态 / 骨架屏 / 抽屉
- [x] 图标:Web 用自绘线性图标集(`components/icons.tsx`),安卓用 Material Icons + 少量自绘,线宽一致
@@ -349,3 +380,527 @@
- [x] 模拟器(API 35)实跑:登录 → 工作区 → 任务勾选 → 工单 → 知识 → 成员,截图存 `2.Design/screens/android-*.png`
- [~] 看板不做拖拽:手机屏幕上一列已占 76% 宽,拖拽命中率太差;改为点卡片选目标列,功能等价
- [ ] 离线缓存(Room)、图片附件、消息推送:均未做,M5 再议
---
## 5. 富文本与附件(M2.5)
### 5.1 决策:正文存 Markdown,编辑用成熟组件 Vditor
- [x] 任务备注、工单说明与验收结论、知识正文、评论,统一 `*_md` 字段存 Markdown
- [x] 渲染用 `markdown-it` + `dompurify`(先渲染再消毒),链接协议白名单 `http(s)/mailto//`
- [x] **编辑器不自研**(最初那版 textarea + 正则工具栏已删除):改用 [Vditor](https://github.com/Vanessa219/vditor) 4.0.1(MIT,中文优先,`ir` 模式所见即所得),落盘仍是 Markdown
- [x] 选型依据:Vditor 11.4k★ / 依赖仅 `diff-match-patch` / 中文界面与文档齐全;TipTap 是 headless 且存 JSON 要另做序列化;`@toast-ui/react-editor` 停更在 2023 且 peer 依赖锁 React 17
- [x] 运行时资源自托管(`web/scripts/vendor-vditor.mjs` → `public/vditor`,由 `predev`/`prebuild` 自动生成),**不走 unpkg CDN**——内网/离线环境下编辑器会一直卡在加载
- [x] 表格:Vditor 里能插能编;只读渲染(评论、动态、预览)走 `markdown-it` 的 GFM 表,`display:block + overflow-x:auto` 让宽表在抽屉里横向滚动、不撑破布局(e2e 断言表格宽度 ≤ 900px,实测 561px)
- [ ] HTML 片段:仍按文本显示(不开放 raw HTML 是有意的,避免把消毒责任交给调用方)
### 5.2 附件
- [x] 后端:`attachment` 表 + RLS + 迁移 0005;`POST/GET/DELETE` 与 `download`/`raw` 两个读取口
- [x] 本地磁盘存储(`backend/app/services/storage.py`),key 由内容 sha256 决定;换对象存储只改这一个文件
- [x] 上限 `MAX_UPLOAD_MB=20`,声明大小与流式读取双重拦截,超限 `8002`
- [x] 列表带 `attachment_count`,详情带 `attachments`,避免一屏 N 次请求
- [x] 下载走带 `Authorization` 的 fetch 转 blob;`<img>` 直链走**短期签名** `raw?exp=&sig=`(HMAC 密钥由 `JWT_SECRET` 域分离派生,默认 30 分钟,跨工作区拒绝)
- [x] 图片直链改成签名 URL 后:正文里只存不带凭据的相对路径 `/raw`,凭据每次渲染现签,不再把会过期的令牌写进库
- [ ] 缩略图、图片压缩、病毒扫描:未做
### 5.3 编辑器
- [x] `RichTextEditor` = Vditor 封装:`mode: "ir"`、工具栏(标题/粗体/斜体/删除线/链接/列表/任务列表/引用/代码/行内代码/图片/表格/撤销/重做/全屏)、关掉 `cache`(草稿由我们自己的保存按钮负责)
- [x] 粘贴与拖拽上传图片:`upload.handler` 里逐张落到附件接口,再 `insertMD` 插入 `![](attachmentUrl(...))`。**Vditor 不会替我们插入 handler 的返回值**(它只把字符串当提示语 `tip.show`),这点踩过坑
- [x] 令牌只在浏览器内存里存在:落库前 `stripAttachmentTokens`,渲染/装载前补回;`<img>` 带不了 Authorization 头,所以 `raw?token=` 是必要折中
- [x] `AttachmentPanel`:上传/下载/删除(删除二次确认,对应后端 `?confirm=true`)
- [x] 安卓:`RichTextEditorScreen`(Kotlin 手写 Markdown 工具栏:B/I/标题/列表/引用/代码/链接/图片,选区包裹与行首前缀;预览用 **Markwon** 渲染,图片用 **Coil** 加载签名直链),从任务详情与知识点打开,保存按 `body_md` 回写
- [ ] 安卓编辑器保存后回到任务列表而不是原详情抽屉(体验缺口:用户要重新点开那条任务)
### 5.4 验证
- [x] 后端 `pytest` → **74 passed**(附件 8 个用例:上传下载、超限、跨租户、删除确认与权限、raw 签名校验、签名篡改与过期、跨工作区签名)
- [x] `web/e2e/richtext.mjs` → **15 项全绿**(Vditor 与 lute 就绪、`**粗体**`/`` `代码` ``/`- 列表` 就地渲染、落库正文不含凭据、贴图后 `/raw` 带签名且返回 200、附件重开仍在、表格渲染且不撑破抽屉、控制台无报错)
- [x] 既有三套 e2e 回归通过(smoke 7 / tickets 8 / knowledge 7)
- [x] `tsc -b` 0 错;`vite build` 主包 299.42 kB │ gzip 74.96 kB,Vditor 独立 chunk 385.24 kB │ gzip 136.05 kB(按需加载,只进详情抽屉)
- [x] 安卓 `assembleDebug` 成功(APK 10.1 MB),模拟器上走通「详情 → 编辑正文 → 预览 → 保存」,截图 `2.Design/screens/android-editor{,-preview}.png`
## 6. 安卓分发:应用内升级(OTA,2026-10-31)
要解决的是"改完安卓端怎么送到手机上":不发商店,也不能每次让人手动传 APK。
### 6.1 后端
- [x] `backend/app/api/app_release.py`:`GET /app/release/latest?version_code=` 读 `latest.json` 清单,`GET /app/release/download/{file}` 只认清单里登记的文件名(含 `..%2F` 一律 9001)
- [x] 两个接口都免登录:还没装上新包的客户端未必带着有效令牌,检查更新不该被登录挡住
- [x] `download_url` 是相对路径,客户端用自己配置的 API 地址拼接——同一份清单要在内网/公网/模拟器之间共用
- [x] `has_update` 只比对 `version_code`,不看版本名字符串(`0.10.0` 与 `0.9.0` 的字典序是坑)
- [x] `backend/scripts/publish_apk.py`:复制成 `dadaotask-<版本>.apk` 并写 `latest.json`(sha256/size/published_at);`backend/static/apk/` 已在 `.gitignore`
- [x] `backend/tests/test_app_release.py` 5 个用例:未发布 9001、清单字段与 download_url、`has_update` 判据、下载字节与 content-type、路径穿越
### 6.2 安卓
- [x] `data/AppUpdater.kt`:查清单 → 下到 `Android/data/<pkg>/files/updates/`(先写 `.part` 再改名)→ 校验 sha256 → 经 `FileProvider` 交给系统安装器
- [x] `ui/UpdateDialog.kt`:当前/新版本号、更新说明、下载进度、失败重试;「允许安装未知应用」只跳系统设置,不代点
- [x] 权限回来那一跳要接住:用户在系统设置里开完开关回到 App,再点同一个按钮时先复查权限,开了就直接装——否则按钮点了没反应
- [x] 启动时自动查一次;首页底部有「检查更新」手动入口,结果用一句话回给用户
- [x] 图标换成与 Web 的 `<Logo/>` 同一套图形(靛蓝圆角底 + 三条白色圆头线 + 淡紫点),生成 `mipmap-*/ic_launcher{,_round}.png` 与自适应图标 `mipmap-anydpi-v26/ic_launcher{,_round}.xml`,512px 预览存 `2.Design/screens/android-icon-512.png`
- [x] 版本 `versionCode = 2` / `versionName = "0.2.0"`;发布签名从 `android/keystore.properties` 读,无该文件时回落调试签名(保证别人 clone 下来也能 `assembleRelease`)
### 6.3 构建环境(本机踩过的坑)
- Gradle 8.7 被 AGP 8.7.3 拒绝(`Minimum supported Gradle version is 8.9`),用 **Gradle 8.9**(腾讯镜像下载)
- 系统 `~/.gradle` 的 native 库坏了(`Failed to load native library 'native-platform.dll'`),必须 `GRADLE_USER_HOME` 指到一个干净目录
- JDK 25 编译不过,用 **Temurin JDK 17**;Kotlin 编译还要把 `java.io.tmpdir` 指到工程内可写目录,否则报 `AccessDeniedException: kotlin-compiler-*.alive`
- 完整命令见 `2.Design/DEPLOY.md` 第 5.1 节
### 6.4 验证
- [x] 后端 `pytest` → **103 passed**;`ruff check` / `ruff format --check` 全绿
- [x] `assembleRelease` 成功:`app-release.apk` 7.4 MB,`apksigner verify --print-certs` 显示证书 `CN=Dadao Task`
- [x] 本地 `GET /api/v1/app/release/latest?version_code=1` → `has_update=true`、`download_url` 可下(200,7 762 912 字节),`version_code=2` → `false`
- [x] 生产(`http://192.168.8.188:8200`)同上,安装包已随部署上传
- [x] 模拟器上走通全流程:装 debug 包 → 登录 → 启动即弹「有新版」→ 下载 7 762 912 字节(与清单 `size` 一致,`.part` 已改名)→ 无安装权限时提示去开 → 拉系统安装器「是否更新此应用」→ 装完 `versionCode=2 / versionName=0.2.0`;截图 `2.Design/screens/android-update-{dialog,permission,installer}.png` 与 `android-home-check-update.png`
### 6.5 发版记录
| 版本 | 日期 | 这一版带上了什么 |
|---|---|---|
| 0.3.0(`versionCode=3`) | 2026-11-06 | 知识是备忘录(分类 / 标签 / 筛选 / 搜索)、知识能删、公众号链接抓成知识后手机上读得到、登录改成手机号或邮箱 + 密码(生产已关短信,旧包登不进去)。模拟器上从 0.2.0 走应用内升级到 0.3.0 全程通过:弹窗「有新版 0.3.0」→ 下载 7.5 MB → 系统安装器 → `versionCode=3` |
| 0.2.0(`versionCode=2`) | 2026-10-31 | 第一个带应用内升级的包,图标换成与 Web 同一套 |
升级签名用同一把(`CN=Dadao Task`,SHA-256 `2555c7908f7d…`),所以老版本能直接覆盖安装;
换签名的话只能卸了重装,OTA 也会失败。
---
## 7. 账号与外壳统一(Web,2026-10-31)
### 7.1 两个问题(用户提的)
1. 首页是"居中窄栏",点进工作区却变成全屏——同一套产品两种壳,进出像换了个网站。
2. 用户相关功能缺失:没有基本信息、没有改密码、没有一个统一的"退出登录"入口。
### 7.2 决定
- **外壳统一到全宽 + 左侧栏**(不是把工作区收回窄栏):工作区里本来就要放下看板四列、知识分类树,
收窄是倒退。首页跟着做同样的壳,宽度就不再跳。
- **加密码登录,作为验证码之外的第二条路**。理由:验证码依赖短信通道,通道挂了人就进不来;
密码是自助的第二把钥匙。但**不强制**:没设密码的人照旧只用验证码,少一道门槛。
- 外壳抽成 `web/src/components/SideRail.tsx`,首页与账号页共用;工作区的 `AppShell` 保持自己的侧栏(有工作区切换与页签)。
两处宽度一致(`w-[248px]` 级别的同一份样式),所以进出不跳。
### 7.3 后端
- `app_user.password_hash`(迁移 `0008_user_password`,加列不加表)
- `app/security.py`:scrypt(`n=2^14, r=8, p=1`,标准库 `hashlib`),存 `scrypt$n$r$p$salt$hash`;
`password_problem()` 一处定义强度(8–64 位、字母 + 数字)
- `POST /auth/login/password`、`POST /auth/password`(设/改共用,改要先验旧)、`PATCH /auth/me`(改昵称)
- 用户输出统一走 `_user_out()`:**白名单字段**,`password_hash` 一个字节都不出去;对外只给 `has_password`
- 错误码:`2013` 密码错/没设过、`2014` 未设密码(保留)、`2015` 太弱、`2016` 连错太多
### 7.4 Web
- 首页:左侧栏 + 全宽内容区,工作区从列表改成卡片网格(`sm:2 / xl:3 / 2xl:4` 列),
"新建工作区"是网格里的一张虚线卡片,点开就地展开表单
- 新增 `/account`:基本信息(头像首字母、昵称、只读手机号、加入时间)、登录密码(设置/修改 + 确认输入)、退出登录
- 登录页:顶部「验证码 / 密码」两段切换,同一个表单两种提交
- 工作区 `AppShell` 底部用户块可点进 `/account`,旁边一个齿轮图标
### 7.5 验证
- 后端 `pytest` → **113 passed**(新增 `tests/test_account.py` 10 项);`ruff check` / `ruff format --check` 全绿
- Web `tsc --noEmit` 通过、`vite build` 通过
- 新增 e2e `web/e2e/account.mjs`(**13 项全绿**):登录进首页 → 首页有侧栏 → 进工作区**侧栏宽度不变(差值 < 1px)**
→ 账号页复用同一条侧栏 → 改昵称 → 设/改密码 → 刷新后仍显示"已设置密码" → 退出 →
错密码被拒(`2013`)→ 密码登录成功 → `/auth/me` 里 `has_password=true` 且昵称是刚改的 → 验证码登录仍可用 → 控制台零报错
- 截图:`2.Design/screens/web-home-rail.png`、`web-account-settings.png`、`web-home-mobile.png`
- 脚本里两个坑写进了注释:① 短信 60 秒节流会让"发码→登录"整段失败(先看 429,别怪登录);
② 改密码要先给旧密码,脚本把上一轮的密码记在 `.local/e2e-account-password.txt` 里自己维护
- **上生产时抓到一个真 bug**(`web/src/lib/client.ts`):`crypto.randomUUID()` 与 `navigator.clipboard`
只在**安全上下文**(https / localhost)里存在,内网入口是纯 http,于是所有写请求在拼
`Idempotency-Key` 时抛 TypeError——登录页只显示"登录失败",GET 一切正常,所以一直没暴露;
复制邀请链接也永远失败。改成自拼 v4(`getRandomValues` 不受限,退回 `Math.random`)+
`execCommand("copy")` 兜底。生产核对:首页 / 工作区 / 账号页侧栏都是 **264px**,差值 0。
### 7.6 没做的
- 安卓端登录页仍只有验证码,账号页也没有——下一步对齐(密码登录 + 改昵称 + 退出)
- 手机号换绑、头像上传未做(头像现在只有首字母)
## 8. Web 装到桌面(PWA,2026-10-31)
### 8.1 要解决的问题
"web版本帮我封装成PWA,这样方便安装到手机桌面和电脑桌面。"(用户原话)
现在每次用都要敲地址、开浏览器、找标签页;手机上还给不了"点了图标就进来"的感觉。
### 8.2 决定(写进注释了,别以后翻案)
- 用 `vite-plugin-pwa`(本就在 devDependencies 里,2.0.0)生成 SW 与 manifest,不自己写
- `registerType: "prompt"` + `injectRegister: null`:**有新版只提示,点「刷新」才换**。
理由:Vditor、图表是懒加载分片,`autoUpdate` 会在用户开着页面时把 chunk 换掉,
下一次点开面板就是 404(编辑器白屏)
- 预缓存只放构建产物与图标(约 0.8MB);`/api/` 一律 NetworkOnly——离线宁可报错,也不端旧数据
- 图标复用 Web Logo 的 32 单位坐标生成,和页头、安卓桌面图标同一枚
### 8.3 实现
| 位置 | 内容 |
|---|---|
| `web/vite.config.ts` | manifest(standalone、主题色 `#4f46e5`、三个图标)+ workbox(`navigateFallback` 回退 `index.html`,`/vditor/` 走 CacheFirst,`/api/` NetworkOnly) |
| `web/index.html` | favicon 改成同款 Logo(内联 SVG)、`theme-color`、`apple-touch-icon`、`viewport-fit=cover` |
| `web/src/lib/pwa.ts` | 生产环境注册 SW、`dadao:pwa-update-ready` 事件、`applyServiceWorkerUpdate()`、`isStandalone()` |
| `web/src/components/UpdatePrompt.tsx` | 底部卡片「有新版了」+ 稍后 / 刷新(**居中用 flex 包一层**:`animate-slide-up` 的 keyframes 自带 transform,会和 `-translate-x-1/2` 抢属性,手机窄屏上卡片会被推出屏幕右侧) |
| `web/src/components/InstallHint.tsx` | 首页底部安装引导:能装就给「安装」按钮(拦 `beforeinstallprompt`),iOS 给"分享 → 添加到主屏幕",关掉记在 localStorage |
| `web/public/icons/` | `icon-192` / `icon-512`(any)、`maskable-512`、`apple-touch-icon`(180,iOS 不吃透明) |
### 8.4 验证
- `web/e2e/pwa.mjs`(**19 项全绿**):manifest 名字/standalone/三个图标 → 图标都能下载 200 →
SW 已 `activated` 且作用域是整站 → 页面骨架已进预缓存 → 主题色 → 桌面尺寸控制台零报错 →
窄屏有安装引导 → iOS 文案是「分享 → 添加到主屏幕」→ 关掉后刷新不再出现 →
有新版会提示 → 点「稍后」收起 → **断网后页面骨架仍能打开**
- 跑法:后端用 `WEB_DIST_DIR=<repo>/web/dist` 起一个同构站点(生产同形),
然后 `node web/e2e/pwa.mjs`;`BASE` 可换环境
- 截图:`2.Design/screens/web-pwa-install.png`、`web-pwa-update.png`;断网那张留在 `web/e2e/artifacts/pwa-offline.png`
### 8.5 还没解开的
- **生产入口还是 http**:Service Worker 只在 https / localhost 注册,所以内网 IP 上装不了、
也看不到安装提示(桌面用 `127.0.0.1` 打开可以)。要么加域名 + 证书反代(`443 → 127.0.0.1:8200`),
要么用 Cloudflare Tunnel 之类拿个 https 域名;应用代码不用改。已写进 `DEPLOY.md` 第 6 节
- 安卓端还在走自己的 OTA(原生包),和 PWA 是两条路;要不要让安卓也改成加载 Web,等产品定
---
## 9. 登录方式与系统设置(2026-11-02)
### 9.1 需求
用户原话:"手机验证码我暂时不启动,所以把12345这种测试验证码取消了吧,我用账号密码来登陆……
再增加一种登陆方式就是邮箱……并且这个邮箱是可以后台配置的啊;"(同时要求三个列表的标题样式统一)
### 9.2 决定
- **短信登录默认关**(`SMS_LOGIN_ENABLED=false`):dev 环境的固定码等于"知道手机号就能进任何账号"。
关掉后 `/auth/sms/send` 与 `/auth/login` 统一回 `2017`,不是静默失败;登录页按 `GET /auth/methods`
渲染,关着的路根本不显示(少一次"点进去才发现不能用")。
- **邮箱是账号名**,不是通知地址:绑了就能用「邮箱 + 密码」登录,`lower(email)` 唯一。
- **SMTP 存数据库、后台可改**:发信账号是平台级运维参数,改它不该要人登服务器改 env 再重启。
只有平台管理员(`PLATFORM_ADMIN_PHONES` 白名单)能读写,因为系统设置是跨租户的。
- 密码哈希继续用标准库 scrypt,不引 passlib/bcrypt(多一个升级债,换不来安全)。
### 9.3 后端
| 位置 | 内容 |
|---|---|
| `app/config.py` | `sms_login_enabled=False`、`platform_admin_phones`(`NoDecode` + CSV 拆分,别让 pydantic 按 JSON 解析)、`smtp_*` 兜底字段;生产校验改为"开着短信登录就必须是真通道" |
| `app/security.py` | `hash_password` / `verify_password` / `password_problem`(scrypt,8–64 位且字母+数字) |
| `app/models.py` | `AppUser.email`;新表 `system_setting(key, value JSONB, updated_by)`,**不挂租户、无 RLS**,写权限靠 `deps.platform_admin` |
| `alembic/versions/0010_email_and_settings.py` | `system_setting` 建表 + `app_user.email` + `uq_app_user_email`(`lower(email)` 部分索引)+ GRANT |
| `app/services/mail.py` | 读库优先、环境变量兜底;465 走 SSL、587 走 STARTTLS;认证失败/收件人被拒/网络失败都归 `2020` 但消息不同 |
| `app/api/auth.py` | `GET /auth/methods`、`POST /auth/login/password`(`phone`/`email` 二选一)、`PATCH /auth/me`(改昵称/绑解绑邮箱)、`sms` 两条加开关闸门 |
| `app/api/settings.py` | `GET/PUT /settings/smtp`、`POST /settings/smtp/test`,全部 `PlatformAdminDep` |
| `app/deps.py` | `platform_admin` / `PlatformAdminDep` |
### 9.4 Web
- `pages/Login.tsx`:先问 `/auth/methods` 再渲染;账号档「手机号 / 邮箱」,密码框;短信关着时不出现「验证码」档
- `pages/Settings.tsx`(新):SMTP 表单(服务器/端口/账号/授权码/显示名/SSL)+ 当前状态卡(能不能发信、授权码设没设、上次修改、来源 db/env)+ 测试发信
- `pages/Account.tsx`:基本信息加「邮箱」(说明"留空保存就是解绑")、按钮改「保存资料」、平台管理员多一个「系统设置」入口卡
- `components/SideRail.tsx`:平台管理员才多一条「系统设置」(移动端顶条同理),图标 `Sliders`
- `pages/Tasks.tsx:262` / `pages/Tickets.tsx:248`:列表标题统一成 `text-[15px] font-semibold leading-6`(与知识一致)
### 9.5 验证
- `backend/tests/test_login_email.py`(7 例)+ `test_settings_smtp.py`(6 例)+ 改写的 `test_config_prod.py`;
全量 `pytest` 130 passed、`ruff check` / `format --check` 全绿
- `web/e2e/login-email-smtp.mjs`(**15 项全绿**):methods 说短信关着 → 发码回 2017 → 登录页没有验证码档 →
手机号密码进首页 → 绑邮箱 → 邮箱登录(大小写不敏感)→ 解绑后回 2013 → 侧栏出现「系统设置」 →
存授权码(接口不回显)→ **真发一封测试邮件成功** → 账号页回填邮箱 → 控制台零报错
- 截图:`2.Design/screens/web-login-password.png`、`web-account-email.png`、`web-settings-smtp.png`
### 9.6 踩过的坑
- `platform_admin_phones` 是 `list[str]`,pydantic-settings 会把它当 JSON 解析,`A,B` 直接启动失败:
用 `Annotated[list[str], NoDecode]` 交给 before-validator 拆逗号
- 迁移号写成 `0009` 会撞 `0009_knowledge_category_color.py`,pytest 报 `Multiple head revisions`
- e2e 里 `getByLabel("账号")` 会同时命中侧栏 `aria-label="账号设置"`,要用 `input[aria-label="账号"]`;
侧栏的「系统设置」要等 `/auth/me` 落地才渲染,断言得 `waitForFunction` 而不是立刻 `count()`
### 9.7 上线情况(2026-11-02)
- 生产 `~/.config/dadaotask/env.production` 追加 `SMS_LOGIN_ENABLED=false`、
`PLATFORM_ADMIN_PHONES=13605513683`;重启后 `/auth/methods` 回 `sms_code:false`,发码接口回 `2017`。
- 生产账号 13605513683 设了密码、绑了 `shufenhf@126.com`:手机号登录、全大写邮箱登录都通。
- 生产 SMTP 从「系统设置」页存进库(`configured:true, source:"db"`),当场发测试信成功。
- **短信登录只在本机开着**(`backend/.env` 的 `SMS_LOGIN_ENABLED=true`):新账号目前只能靠"验证码登录
自动注册"造出来,关掉连 e2e 都造不出测试用户。生产是关的,所以生产**暂时没有注册入口**——
要开放注册得先做「邮箱验证码注册 / 邀请注册」,这是下一步的事。
- 生产实拍:`2.Design/screens/prod-account-email.png`、`prod-settings-smtp.png`。
## 10. 邮箱验证码注册(2026-11-03)
### 10.1 需求
手机上短信登录关掉后,生产没有注册入口。用户原话:"默认用邮件验证码注册吧;"
### 10.2 决定
- **先发码再注册**:`POST /auth/email/code` → `POST /auth/register`,两步而不是"注册时顺带发信"。
理由:用户填完密码才被告知"这个邮箱注册过了"最气人,所以发码前就查重(`2019`)。
- **邮箱既是账号名也是唯一联系方式**:注册必须真验一次码,"随手填一个"会让账号变成没人能找回的孤岛。
验证通过时间落 `app_user.email_verified_at`,`/auth/me` 回 `email_verified`。
- **`app_user.phone` 改为可空**(迁移 0011):邮箱注册的账号本来就没有手机号。
老账号不受影响;`user.phone` 对这类账号回空串而不是星号串(成员列表同理)。
- **防刷**:同邮箱 60 秒内只能发一次(`2022`);验证码 10 分钟有效;错 5 次即作废(`2021`)——
6 位数可以被刮,不设上限等于把注册口子交给脚本。
- **本机联调开关** `EMAIL_CODE_DEV_ECHO`:为 true 时把码直接回给调用方且**不发真信**,
否则每跑一次 e2e 就往真实收件箱塞一封。生产打开会让 `validate_runtime()` 拒绝启动。
### 10.3 后端
| 位置 | 内容 |
|---|---|
| `app/config.py` | `email_register_enabled`、`email_code_ttl_seconds=600`、`email_code_throttle_seconds=60`、`email_code_max_attempts=5`、`email_code_dev_echo`(生产校验拒绝) |
| `app/errors.py` | `EMAIL_CODE_INVALID=2021`(400)、`EMAIL_CODE_TOO_FREQUENT=2022`(429) |
| `app/models.py` | `AppUser.phone` 可空、`email_verified_at`;`AuthUser.phone: str \| None` |
| `alembic/versions/0011_email_signup.py` | `phone DROP NOT NULL` + `email_verified_at` + GRANT(downgrade 先给空手机号补占位值) |
| `app/stores.py` | `email_code_key` / `email_tries_key` / `email_throttle_key` |
| `app/services/mail.py` | `send_verify_code(session, to=…, code=…, minutes=…)`,纯文本 + HTML 两版 |
| `app/api/auth.py` | `POST /auth/email/code`、`POST /auth/register`、`/auth/methods` 加 `email_register` |
### 10.4 Web
- `pages/Register.tsx`(新):邮箱 / 验证码(60 秒倒计时)/ 密码(前端先按同一套规则提示)/ 确认密码 / 昵称(可不填,留空用邮箱前缀);
注册成功直接写令牌进首页。服务端关了注册时整页只显示"联系管理员开通"。
- `pages/Login.tsx`:`/auth/methods` 里 `email_register` 为真才显示「还没有账号?用邮箱注册」。
- `App.tsx`:新增 `/register` 路由。
### 10.5 验证
- `backend/tests/test_email_signup.py`(9 例):真走 SMTP(monkeypatch 记账)、dev echo 不发信、
注册即登录、码用完不能重放、错码/过期、试错上限、邮箱占用、发码节流、弱密码、注册关闭时两条接口都回 2017。
- e2e `web/e2e/register.mjs`:登录页进注册页 → 拿码(dev echo)→ 注册 → 落进首页 → 退出用「邮箱 + 密码」登回来 →
同一个邮箱再发码回 `2019`。7 项全绿。
- `pytest` 139 passed、`ruff check` / `format --check` 全绿、`tsc --noEmit` 0、`vite build` 通过;
回归 `account.mjs`(14 项)、`knowledge.mjs`、`login-email-smtp.mjs`(15 项,含真发一封测试信)全绿。
### 10.6 上线情况(2026-11-03,commit b5170ad)
- 生产 `/auth/methods` → `{"password":true,"email_password":true,"sms_code":false,"email_register":true}`。
- 已注册的邮箱再发码回 `2019`;未注册的地址 `POST /auth/email/code` 返回 `sent:true`(真走了 126 的 SMTP)。
- 生产登录页出现「用邮箱注册」,注册页渲染正常、控制台零报错(`web/e2e/prod-register-check.mjs`,
截图 `web/e2e/artifacts/prod-register.png`)。
- 生产 env **不带** `EMAIL_CODE_DEV_ECHO`:码只发到邮箱,页面不回显。
- 踩到的坑:dev 库要先 `alembic upgrade head` 到 0011,否则注册接口报
`column "email_verified_at" of relation "app_user" does not exist` 直接 500——迁移写好不等于跑过。
## 11. 知识的阅读与编辑分开(2026-11-03)
用户原话:"知识页,浏览内容时是打开抽屉页然后展示,这不符合人类的阅读习惯,因为这样的内容完全偏右,
长时间浏览很难受。抽屉效果只能用于编辑,因为这是短时间的,也不会经常编辑同一篇文章。"
### 11.1 决定
- **阅读走整页**:`/w/:wsId/knowledge/:itemId`。抽屉在屏幕右侧,正文永远偏在右边,
长文读十几屏眼睛一直在右半边;整页可以把正文列限宽居中,行宽回到五十来个汉字。
- **抽屉只留给编辑**:编辑是短时动作,改完就走,抽屉"压在当前列表上"反而合适,
也省掉从编辑页退回列表时的滚动位置丢失。
- 阅读页复用列表页那套文案常量,避免两处各写一份以后对不上(`lib/knowledge.ts`)。
### 11.2 Web
| 位置 | 内容 |
|---|---|
| `lib/knowledge.ts`(新) | `DOC_TYPE_LABEL` / `STATUS_LABEL` / `SOURCE_LABEL` / `ACTION_LABEL` / `CATEGORY_PALETTE` / `categoryColor()`,从列表页搬出来 |
| `pages/KnowledgeDoc.tsx`(新) | 阅读页:面包屑(返回知识库 / 分类路径)、类型+状态+分类 chip、28px 标题、`更新于 / 发布于 / 检索切片 N 段`、标签、动作行(编辑/发布/归档/恢复)、`Markdown` 渲染、来源卡片、附件(只读) |
| `pages/Knowledge.tsx` | 卡片与「打开原文」改成 `navigate("/w/<ws>/knowledge/<id>")`;删掉 `openId` 状态与抽屉渲染;`KnowledgeDrawer` 加 `initialEditing`(进来就是编辑器,保存或取消都直接关) |
| `App.tsx` | 新增 `/w/:wsId/knowledge/:itemId` 路由 |
卡片 `max-w-[78rem]`、正文列 `max-w-[56rem]`(约 55 个汉字一行)居中;换一篇自动回顶。
第一版按 40rem / 46rem 做的,1546 宽的屏幕上一量,留白比正文还宽——用户直接发了截图。
留给以后的界线:正文再宽眼睛就要来回扫行,56rem 是"宽屏不空、又不至于扫行"中间那条线。
编辑仍然是抽屉:`initialEditing` 时没有「看」这一态,点「取消」直接关掉,不留半截改动。
### 11.3 验证
- e2e `web/e2e/knowledge-read.mjs`(新,8 项):点卡片进独立页、`h1` 是标题、正文是渲染后的 `.prose-dadao`
且 `.vditor-ir` 计数为 0、正文列宽度落在 720–920px(宽屏不留大片空白)、正文列在 `main` 里左右留白差 < 40px、点「编辑」才出现编辑器、
取消后编辑器 detach、控制台零报错。
- 回归:`knowledge-notebook.mjs`(17 项,含"侧栏分类按树排"与"抽屉默认渲染样式")、`knowledge.mjs`(9 项)、
`knowledge-paging.mjs`(5 项)全绿。
- `tsc --noEmit` 0、`vite build` 通过。
- 截图:`2.Design/screens/knowledge-read-page.png`(长文整页)、`knowledge-edit-drawer.png`(抽屉编辑态)。
### 11.4 顺带修掉的脚本坑
- `knowledge-notebook.mjs` 里"关掉抽屉"那一步在新版里没有按钮可点(保存后直接进阅读页),
改成等 URL 落到 `/knowledge/<id>` 再点「返回知识库」。
- 侧栏分类行里的色点是按钮但没文字,`allInnerTexts()` 会多出一个空串,比较树顺序前先 `.filter(Boolean)`。
- `knowledge-paging.mjs` 原来靠短信发码登录,生产短信已关;改成优先用 `PASSWORD`
(或 `.local/e2e-account-password.txt`)走密码登录,没密码时才回落到短信。
### 11.5 上线情况(2026-11-03,commit d34a6ac)
- 生产 `http://192.168.8.188:8200` 已切到整页阅读:正文列居中限宽、`编辑` 才开抽屉。
- `web/e2e/knowledge-read.mjs` 直接打生产(106 的手机号 + 密码登录)8 项全绿,
截图 `2.Design/screens/prod-knowledge-read-page.png`。
- 脚本都换成了密码登录(`PASSWORD` env 优先),生产短信登录关着,别再指望发验证码。
## 12. 登录态续期(2026-11-03)
用户反馈:"web端登陆后好像没多久就退出登陆了,现在系统对登陆时限是怎么设计的?"
### 12.1 原来的设计(也就是问题所在)
- access 30 分钟、refresh 14 天,服务端两个都签好、都发给了客户端。
- 但**没有任何客户端用过 refresh**:web 与安卓拿到 `401/2002` 都是直接清令牌、回登录页。
- 结果"14 天有效"只是纸面参数:真实登录寿命就是 30 分钟,到点下一次请求就被扔出去,
正在写的工单、正在编辑的知识都不会留下。
### 12.2 改法
- `web/src/lib/client.ts`:新增 `refreshSession()`(并发只发一次),请求前若 access 剩不到 90 秒先换;
收到 401 先换再重放一次原请求,换不动才清令牌发 `authExpired`。`uploadFile` 走同一条路。
- `android/app/src/main/java/com/dadao/task/data/Api.kt`:`call()` 拆成 `call` + `callOnce`,
2002 时 `refreshTokens(stale)` 用 `Mutex` 串行刷新(等锁期间别人换好了就直接用新的),重放一次。
- 后端 `/auth/refresh` 顺手补一条:账号被停用 → `2012`,别让停用账号靠 refresh 一直续。
### 12.3 验证
- `backend/tests/test_auth.py::test_disabled_account_cannot_refresh`(停用账号换令牌 → 403/2012),
全量 **140 passed**、`ruff check` / `format --check` 全绿。
- 新增 `web/e2e/session-refresh.mjs`(6 项):access 换成假串但 refresh 是真的 → 不被踢、首页正常、
拿到新令牌、新令牌能调接口;access 与 refresh 都假 → 才回登录页;控制台零异常。
- 安卓 `assembleDebug` 通过(JDK 17 + Gradle 8.9)。
## 13. 微信接入(clawbot,2026-11-05)
### 13.1 要解决的问题
记事的动作发生在手机上,而任务和知识都在浏览器里。人正在外面、手上只有微信的时候,
"回去再记"通常等于"再也想不起来"。所以要有一条从微信直接落到工作区里的路。
### 13.2 决定
1. **走腾讯官方 iLink Bot 协议**:`get_bot_qrcode` 拿二维码 → 手机扫码确认 →
拿到 `bot_token` → 后端**长轮询** `getupdates` 收消息 → 用 `context_token` 回话。
这是 clawbot / openclaw / dsh-im 走的同一条路,扫码是配对的前提。
2. **不依赖 DSH**:协议自己实现(`backend/app/services/wechat_ilink.py`),
因为达道Task 是独立项目,不该为了收消息去依赖另一个产品的运行环境。
3. **登录态加密存库**:`bot_channel.bot_token_enc` 用 Fernet(密钥由 `JWT_SECRET` 派生),
`bot_token` 不落明文、不回调给任何接口、日志里也没有。
4. **轮询者是我们自己**:每个已连接的通道一个 asyncio 任务,进程启动时按库里的
`status='active'` 恢复;token 失效(微信 errcode `-14`)就把通道转 `expired`,让用户重新扫。
5. **一条消息只落一次**:按 `message_id` 去重,微信重推不会变成第二条任务。
6. **每条消息都留痕**(`bot_message`,含文本、动作、item_id、回复、raw),
出问题时能直接回答"那条任务到底进没进"。
7. **一个工作区一条微信通道**:同一时间只允许一条 `kind='wechat_clawbot'` 的记录承载登录态;
断开就是清登录态回到 `pending`,再连就重新扫。
### 13.3 协议要点(实现对照)
| 用途 | 请求 |
|---|---|
| 取二维码 | `POST https://ilinkai.weixin.qq.com/ilink/bot/get_bot_qrcode?bot_type=3` → `{qrcode, qrcode_img_content}` |
| 问扫码结果 | `POST /ilink/bot/get_qrcode_status?qrcode=<id>` → `waiting`/`scaned`/`confirmed`/`expired` |
| 收消息 | `POST /ilink/bot/getupdates` body `{get_updates_buf, base_info:{channel_version}}`,长轮询 70s |
| 回消息 | `POST /ilink/bot/sendmessage` body `{msg:{to_user_id, context_token, item_list:[{type:1,text_item:{text}}]}}` |
请求头固定带 `iLink-App-ClientVersion: 1` 与 `X-WECHAT-UIN`(随机数 base64);
登录后加 `Authorization: Bearer <bot_token>` + `AuthorizationType: ilink_bot_token`。
二维码不是图片地址,`qrcode_img_content` 是待渲染的 payload,服务端用 `qrcode` 库画成 SVG data URL。
### 13.4 后端
| 位置 | 内容 |
|---|---|
| `backend/app/services/wechat_ilink.py` | 只封协议:取码、问状态、长轮询、回话、消息解析、token 加解密 |
| `backend/app/services/wechat_intake.py` | 文本 → 事项:前缀识别、`#标签`、标题/正文切分、建任务/知识、回复文案 |
| `backend/app/services/wechat_bot.py` | `BotManager`:起停轮询任务、去重、留痕、游标、`-14` 转 `expired` |
| `backend/app/api/integrations.py` | `POST /workspaces/{ws}/integrations/wechat/qr`、`GET …/qr/{id}`、`PATCH`、`DELETE`、`GET …/messages` |
| `backend/app/models_integrations.py` | `BotChannel`(`bot_token_enc`、`ilink_user_id`、`sync_buf`、`context_token`、`last_seen_at`、`last_error`)、`BotMessage` |
| `backend/alembic/versions/0013_wechat_bot_login.py` | 加登录态相关列,`status` 默认 `pending` |
| `backend/alembic/versions/0014_bot_channel_worker_policy.py` | 轮询入口没有工作区上下文,按 `app.bot_channel` 主键开一条 permissive 策略 |
| `backend/app/main.py` | lifespan 里 `manager.start_all()` / `manager.shutdown()` |
正文解析:首行是标题(180 字封顶),其余是正文;`#标签` 摘出来挂事项上(纯数字不算);
前缀 `知识:`/`笔记:` → 知识草稿,`任务:`/`todo:` → 任务,没有前缀就是任务,`帮助`/`?` 只回用法。
### 13.5 Web
新增 `/w/:wsId/integrations`「机器人」页(左侧栏最后一项;栏目名从「接入」改过来,因为用户要的是「机器人」这件事,而不是「接入」这个后台动作):
- 「扫码连接微信」→ 页面上直接画出二维码,每 2 秒问一次状态(待扫 → 已扫待确认 → 成功/过期);
- 连上之后显示通道卡片:接收中/登录已失效/已停用、最后收信时间、"微信用户 xxx"、
`最近消息`(最近 5 条,标"建了任务/建了知识")、重新扫码、断开;
- 只有 owner/admin 能扫码与断开,其他成员只读。
![机器人页](screens/web-integrations.png)
### 13.6 验证
- `backend/tests/test_wechat_bot.py` 9 例:取二维码(SVG data URL、通道 `pending`)、
扫码确认后登录态加密入库且能解回、过期二维码回 `expired`、断开清登录态、
纯文本成任务、`知识:`前缀成草稿带标签、`帮助`不建事项、同一 `message_id` 只落一条并只回一次、
登录态失效后通道转 `expired` 且带原因。
- `web/e2e/integrations.mjs` 7 项(真跑,拿的是微信真二维码):接入页打开 → 点扫码 →
二维码是页面里的 SVG → 提示用微信扫 → 后端通道 `pending` → 没登录态不轮询 → 控制台零报错。
- 全量 `pytest` 151 passed;`ruff check` / `ruff format --check` 干净。
### 13.6.1 上线后修的问题
1. **扫码确认那一刻页面报「服务器内部错误」**:`bot_channel` 的轮询口子(迁移 `0014`)直接
`current_setting('app.bot_channel', true)::uuid`,而事务级 `set_config` 提交后会把连接上的
变量还原成**空串**而不是 NULL,空串一 cast 就
`InvalidTextRepresentationError: invalid input syntax for type uuid: ""`。
迁移 `0015` 改成 `nullif(current_setting(...), '')::uuid`,与 `app_ws_id()` 同一写法。
2. **回话别再带站点链接**:`PUBLIC_BASE_URL` 是内网地址,手机上打不开;
现在只回「已保存任务:…」/「已保存知识草稿:…」。
3. **栏目名从「接入」改成「机器人」**:用户要的是「我和这个机器人说话」,不是「配置接入」。
4. **重启之后机器人静默不收消息**:生产是 `gunicorn -w 2`,两个进程各跑一遍 lifespan,
同一个通道会被起两条长轮询(消息被一个消费、回复从另一个发出,游标互相覆盖);
而且 `start_all()` 是"没有工作区上下文"的查询,被 RLS 全藏起来,一行都查不到。
- 租约:会话级 `pg_try_advisory_lock`,抢到的进程收,另一个每 10 秒重试;进程挂了连接断、
锁自动释放,备用进程接上。页面「接收中」只认抢到租约的那个。
- 迁移 `0016` 补一条只读策略:`current_setting('app.bot_list') = 'on'` 时允许 SELECT,
`start_all()` 先设这个 GUC 再列通道。
### 13.7 教训:第一版是猜的
第一版没找到资料,按"webhook + 令牌在 URL 里"自己编了一套(用户看到的回调地址那版)。
用户当场指出"微信要先扫码配对",一查才发现协议在
`~/.dsh/profiles/desktop/node_modules/@michengai/dsh-im-connect/src/channels/weixin.ts` 里写着。
**教训:接入第三方协议,先去已有实现里找协议,别从直觉倒推。** 那一版的迁移 `0012` 保留
(`bot_channel` / `bot_message` 两张表还在用),`token_hash` 列留着但不再使用。
### 13.8 发一条链接,抓成知识
需求原话:「如果发送给机器人的是一个链接,那么就需要把链接内的文章内容给抓取下来,然后保存为知识;
就算是微信公众号的链接也需要抓取下来」。
- 选型 `trafilatura==2.0.0`(中文正文抽取最稳)+ `lxml`;公众号单独走 DOM(`#js_content`、图片在
`data-src`),因为通用抽取器会把公众号的图全丢掉。
- 抓下来的图下载进自己的存储(最多 12 张、单张 8 MB),正文改写成本地 `raw?签名` 地址——
外链图在公众号侧一换就全红叉。
- 只允许公网地址:谁都能给机器人发消息,不拦住内网就是 SSRF 跳板(`127.0.0.1`、`192.168.x`、链路本地、
保留段、解析不出的域名一律拒绝)。
- 失败要说话:反爬验证页、Cloudflare 302、SPA 空壳统一回「这个链接没抓下来(原因)。想直接记成任务
的话,加「任务:」前缀再发一次。」
- **装依赖的坑**:`pip install trafilatura` 在本机会卡死(依赖解析完就挂住),要逐个装
`lxml / courlan / htmldate / jusText / lxml_html_clean / …` 再 `pip install --no-deps trafilatura`;
`lxml` 6 起 `lxml.html.clean` 拆成 `lxml_html_clean`,不装就 `ImportError`。
#### 13.8.1 用户发来真链接,结果只抓到一个「。」
上线后用户发了 `https://mp.weixin.qq.com/s/ZEOsi4R4JcmZ_mhS6qpcww`,库里只有 67 个字符的正文。
复盘:**公众号换了渲染方式**——新版页面(`window.appmsg_type = '9'` / `'10002'`)整篇塞进
`window.cgiDataNew` 这个 JS 对象、由前端渲染,页面里根本没有 `id="js_content"`;
三种 UA(桌面 / iPhone Safari / MicroMessenger)拿到的字节数完全一样,所以也不是 UA 的问题。
修法(`backend/app/services/article.py`):
- 从 JS 字符串字面量里读正文:`content_noencode` / `desc`,按 `\xNN`、`\uNNNN` 反转义;
话题标签是双重转义的 HTML(`\x26lt;a class=\x26quot;wx_topic_link\x26quot;\x26gt;#调料…`),
先 `html.unescape` 再走 DOM。
- 配图字段(`cdn_url` / `img_url` / `pic_url`)**排在文字字段之后**,所以配图要前后各找 2500 字符;
剩下没用过的 `mmbiz.qpic.cn` 图按 12 张上限补在末尾(图文型「小绿书」的图不在文字附近)。
- 解析顺序:旧 DOM(`#js_content`)→ 新版 JS 数据 → 通用抽取,一层层退。
- 作者兜底从 `nick_name` 取,标题取 `og:title`。
结果:那篇 2373 字符 + 12 张图,蒜蓉酱那篇 2772 字符 + 13 张图,作者都取到了。
**教训:抓取器写死在一种页面结构上,等于给自己埋一个定时炸弹;线上先看真实 HTML 再改。**
### 13.9 知识库:标签清单与删除
用户提两件:"文章都缺少删除功能"、"分类有列表显示,但是分类的下面应该还有标签的列表"。
- `GET /knowledge/tags`:按标签统计"有多少条知识用了它",带 `status` 就跟着 tab 数;
侧栏在分类树下面渲染成一排 `#标签 + 条数`,点一下筛选、再点取消。
- `DELETE /knowledge/{item_id}?confirm=true`:软删 + 真删检索切片;只有作者或 `owner`/`admin` 能删;
列表卡片(悬停出现)和阅读页动作行各有一个入口。删完详情也回 `7001`,
否则收藏的链接还能打开一篇"已经删掉的知识"。
![知识页的标签清单](screens/web-knowledge-tags.png)
### 13.10 还没做
- 只做了"进"(收消息建事项),没做"出"(任务有进展回一条微信通知)。
- 消息不认人:按通道创建者记账;要认人得先做微信身份绑定。
- 语音只在服务端给了转写时使用,图片/文件/视频只留痕不搬。
Binary file not shown.

After

Width:  |  Height:  |  Size: 223 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

+5 -3
View File
@@ -6,6 +6,7 @@
- M0–M1 实施清单(含真实进度):[`2.Design/ROADMAP-M0-M1.md`](2.Design/ROADMAP-M0-M1.md)
- 接口约定(已实现部分):[`2.Design/API.md`](2.Design/API.md)
- 本地开发:[`2.Design/DEVELOPMENT.md`](2.Design/DEVELOPMENT.md)
- 生产部署(openclaw-01):[`2.Design/DEPLOY.md`](2.Design/DEPLOY.md)
- 中文命名方案:[`2.Design/NAMING.md`](2.Design/NAMING.md)
## 技术栈
@@ -27,9 +28,10 @@
| 安卓端任务 / 看板 / 工单 / 知识 / 成员五个页面 | ✅ 编译成 APK(10.1 MB),模拟器上逐页跑通,截图见 [`2.Design/screens/`](2.Design/screens) |
| 工单:升级不搬数据、受理→办理→提交→验收、驳回、改派、站内通知 | ✅ 后端 + Web + 安卓已通,双人 e2e 8 项全绿 |
| 知识:工单验收自动生成草稿、核对后发布、来源可追、手记 | ✅ 后端 + Web + 安卓已通,e2e 7 项全绿 |
| 界面:两端统一设计系统(纸白 / 墨绿 / 陶土橙,语义色、字号阶、组件库) | ✅ 已重做,见 [`2.Design/ROADMAP-M0-M1.md`](2.Design/ROADMAP-M0-M1.md) 第 4 节 |
| 后端测试 | ✅ 66 passed,ruff 干净 |
| 知识切片与语义检索(pgvector) | ⬜ 配置已就绪(`deploy/docker-compose.vector.yml`),本机 Docker 起不来,未跑过 |
| 界面:两端统一设计系统(冷白 / 靛蓝 #4F46E5 / 陶土橙,语义色、字号阶、组件库) | ✅ 已重做,见 [`2.Design/ROADMAP-M0-M1.md`](2.Design/ROADMAP-M0-M1.md) 第 4 节 |
| 富文本:备注 / 工单说明 / 知识正文支持图文混排与附件 | ✅ Web 用 Vditor 编辑、安卓用 Markwon 预览编辑(e2e 15 项全绿);接口见 [`2.Design/API.md`](2.Design/API.md) 第 6 节 |
| 知识切片与语义检索(混合打分、只搜已发布、带来源) | ✅ 已跑通:`real[]` + SQL 余弦(pgvector 待独立实例),`pytest` 5 个用例 + `e2e/retrieval.mjs` 6 项 |
| 后端测试 | ✅ 79 passed,ruff 干净 |
| LLM 能力(结构化录入、问答带引用、人物画像) | ⬜ M4 |
## 快速开始
+6 -4
View File
@@ -79,7 +79,9 @@ release 仍然只允许 `network_security_config.xml` 里列出的域,别把
adb install -r app/build/outputs/apk/debug/app-debug.apk
```
首次打开 → 手机号登录(开发环境验证码在接口响应里回显,形如 `开发环境验证码:123456`)。
首次打开 → **手机号或邮箱 + 密码**登录(短信验证码登录默认关闭;开发环境需要验证码登录时,
后端设 `INTERNAL_TEST_LOGIN=true`,固定验证码 `123456`,登录页会自动出现「验证码登录」切换)。
没有账号就点「用邮箱注册」:收邮箱验证码 → 设密码 → 直接进工作区。
## 结构
@@ -90,11 +92,11 @@ app/src/main/java/com/dadao/task/
│ ├── Api.kt OkHttp + kotlinx.serialization;信封解包、业务码抛 ApiException
│ └── TokenStore.kt 令牌本地存储(TODO:换 EncryptedSharedPreferences)
└── ui/
├── Theme.kt 与 Web 一致的墨青 + 陶土色板
├── LoginScreen.kt 手机号 + 验证码
├── Theme.kt 与 Web 一致的靛蓝 #4F46E5 + 陶土色板
├── LoginScreen.kt 手机号/邮箱 + 密码(短信登录只在后端开启时出现)、邮箱验证码注册
├── HomeScreen.kt 工作区列表 / 新建 / 生成邀请码
├── TaskListScreen.kt 任务视图切换、快速新建、勾选完成、详情与评论
└── KnowledgeScreen.kt 知识四态 tab、手记、按状态给动作(发布需 owner/admin)
└── KnowledgeScreen.kt 知识四态 tab、手记、按状态给动作(发布需 owner/admin)、分类管理与配色
```
## 模拟器(本机搭建记录,别重复踩)
+42 -2
View File
@@ -26,8 +26,9 @@ android {
applicationId = "com.dadao.task"
minSdk = 26
targetSdk = 35
versionCode = 1
versionName = "0.1.0"
// 版本号是 OTA 的唯一判据:后端只比对 version_code,改这里才算"有新版本"
versionCode = 3
versionName = "0.3.0"
buildConfigField("String", "API_BASE", "\"$apiBase\"")
}
@@ -51,6 +52,37 @@ android {
keyAlias = "androiddebugkey"
keyPassword = "android"
}
/**
* 发布签名从 android/keystore.properties 读,证书本身不进仓库。
*
* 没有这个文件(别人 clone 下来、CI 没注入)时回落到调试签名:
* assembleRelease 依然能跑,只是装不上已经装了正式版的机器——
* 而这正是应该发生的:发布包的签名只能由发布者本人持有。
*/
create("release") {
val file = rootProject.file("keystore.properties")
if (file.exists()) {
val props = Properties()
file.inputStream().use { props.load(it) }
storeFile = rootProject.file(props.getProperty("storeFile"))
storePassword = props.getProperty("storePassword")
keyAlias = props.getProperty("keyAlias")
keyPassword = props.getProperty("keyPassword")
}
}
}
buildTypes {
release {
// 没有正式证书时回落调试签名,保证任何机器都能 assembleRelease
signingConfig = if (rootProject.file("keystore.properties").exists()) {
signingConfigs.getByName("release")
} else {
signingConfigs.getByName("debug")
}
isMinifyEnabled = false
isShrinkResources = false
}
}
compileOptions {
@@ -69,9 +101,17 @@ dependencies {
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material3:material3")
implementation("androidx.activity:activity-compose:1.9.3")
// FileProvider(应用内升级把安装包交给系统安装器)就在 core 里,显式声明不靠传递依赖
implementation("androidx.core:core-ktx:1.13.1")
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
// Markdown 预览用成熟库渲染,不自己写解析器
implementation("io.noties.markwon:core:4.6.2")
// 表格与删除线:Web 端正文支持,安卓不能把 | 管道符原样吐给读者
implementation("io.noties.markwon:ext-tables:4.6.2")
implementation("io.noties.markwon:ext-strikethrough:4.6.2")
implementation("io.coil-kt:coil-compose:2.7.0")
debugImplementation("androidx.compose.ui:ui-tooling")
}
+15
View File
@@ -2,11 +2,15 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<!-- 应用内升级:下好的 APK 交给系统安装器 -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<application
android:allowBackup="false"
android:icon="@mipmap/ic_launcher"
android:label="达道Task"
android:networkSecurityConfig="@xml/network_security_config"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.DadaoTask">
@@ -26,5 +30,16 @@
<data android:scheme="https" android:host="dadaotask.com" android:pathPrefix="/join/" />
</intent-filter>
</activity>
<!-- 安装包只从应用自己的 updates 目录出,不外扩到其它路径 -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>
@@ -17,16 +17,23 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import com.dadao.task.data.Api
import com.dadao.task.data.AppRelease
import com.dadao.task.data.AppUpdater
import com.dadao.task.data.TokenStore
import com.dadao.task.data.Workspace
import androidx.compose.foundation.layout.Box
import com.dadao.task.ui.BoardScreen
import com.dadao.task.ui.DadaoTheme
import com.dadao.task.ui.EditRequest
import com.dadao.task.ui.HomeScreen
import com.dadao.task.ui.KnowledgeScreen
import com.dadao.task.ui.LoginScreen
import com.dadao.task.ui.MembersScreen
import com.dadao.task.ui.NotificationsScreen
import com.dadao.task.ui.RichTextEditorScreen
import com.dadao.task.ui.TaskListScreen
import com.dadao.task.ui.TicketsScreen
import com.dadao.task.ui.UpdateDialog
import com.dadao.task.ui.WorkspaceScaffold
import com.dadao.task.ui.WorkspaceTab
@@ -68,6 +75,17 @@ private fun AppRoot(joinUri: String?) {
var current by remember { mutableStateOf<Workspace?>(null) }
var tab by remember { mutableStateOf(WorkspaceTab.Tasks) }
var unread by remember { mutableStateOf(0) }
// 通知页标了已读之后要立刻刷新角标:改这个数会触发下面的 LaunchedEffect
var unreadRefresh by remember { mutableStateOf(0) }
// 打开富文本编辑器时非空;关掉就恢复原来的界面
var editing by remember { mutableStateOf<EditRequest?>(null) }
// 编辑器保存成功的那条:回到工作区后重新打开它的详情,然后清掉
var reopenItem by remember { mutableStateOf<String?>(null) }
// 上面那条是任务还是知识:决定回哪个页面去重开
var reopenKnowledge by remember { mutableStateOf(false) }
// 应用内升级:启动后查一次,查到了就把对话框摆出来
val updater = remember { AppUpdater(context, api) }
var update by remember { mutableStateOf<AppRelease?>(null) }
// 有本地令牌就先静默校验一次:令牌过期/被吊销要退回登录页,而不是进了主页再崩。
LaunchedEffect(screen) {
@@ -96,17 +114,39 @@ private fun AppRoot(joinUri: String?) {
.onFailure { pendingJoin = null }
}
// 工单未读数:进工作区就拉一次,切页面时再刷。
LaunchedEffect(current?.id, tab) {
// 未读数:进工作区、切页面、或通知页标了已读(unreadRefresh)时刷新
LaunchedEffect(current?.id, tab, unreadRefresh) {
val ws = current ?: return@LaunchedEffect
unread = api.unreadCount(ws.id)
}
// 版本检查:登录成功后查一次就够,查不到(网络、没发布过包)就当没有新版
LaunchedEffect(screen) {
if (screen != Screen.Home && screen != Screen.Workspace) return@LaunchedEffect
if (update != null) return@LaunchedEffect
update = runCatching { updater.check() }.getOrNull()
}
/** 手动检查:结果用一句话回给首页显示,查到新版顺便把对话框摆出来。 */
suspend fun checkUpdate(): String = runCatching { updater.checkStrict() }.fold(
onSuccess = { release ->
if (release == null) {
"已经是最新版 ${updater.currentVersionName}"
} else {
update = release
"发现新版本 ${release.versionName}"
}
},
onFailure = { "检查更新失败:${it.message ?: "网络不通"}" },
)
when (screen) {
Screen.Splash -> Unit
Screen.Login -> LoginScreen(api = api, onLoggedIn = { screen = Screen.Home })
Screen.Home -> HomeScreen(
api = api,
versionName = updater.currentVersionName,
onCheckUpdate = { checkUpdate() },
onLogout = {
tokens.clear()
screen = Screen.Login
@@ -119,8 +159,45 @@ private fun AppRoot(joinUri: String?) {
)
Screen.Workspace -> {
val ws = current
val request = editing
if (ws == null) {
screen = Screen.Home
} else if (request != null) {
// 编辑器**替换**整页,而不是叠在上面:抽屉/对话框都是独立窗口,
// 叠一层会被它们盖住,用户只能看到编辑器的一条边。
RichTextEditorScreen(
api = api,
workspaceId = ws.id,
itemId = request.itemId,
initialTitle = request.title,
initialBody = request.body,
titleEnabled = request.titleEnabled,
save = { title, body ->
if (request.knowledge) {
api.updateKnowledge(
workspaceId = ws.id,
itemId = request.itemId,
version = request.version,
title = title,
body = body,
)
} else {
api.updateItem(
workspaceId = ws.id,
itemId = request.itemId,
version = request.version,
title = title,
body = body,
)
}
// 保存成功才标记"回到哪条":取消退出不该改变任何界面状态。
reopenItem = request.itemId
reopenKnowledge = request.knowledge
},
// saved 参数由编辑器给:true 时上面已经设过 reopenItem,
// 这里只负责把编辑器收起来,回到工作区后由对应页面重开详情。
onClose = { _ -> editing = null },
)
} else {
WorkspaceScaffold(
workspace = ws,
@@ -130,13 +207,54 @@ private fun AppRoot(joinUri: String?) {
onBackHome = { screen = Screen.Home },
) {
when (tab) {
WorkspaceTab.Tasks -> TaskListScreen(api = api, workspace = ws)
WorkspaceTab.Tickets -> TicketsScreen(api = api, workspace = ws)
WorkspaceTab.Knowledge -> KnowledgeScreen(api = api, workspace = ws)
WorkspaceTab.Tasks -> TaskListScreen(
api = api,
workspace = ws,
onEdit = { editing = it },
reopenItem = if (reopenItem != null && !reopenKnowledge) reopenItem else null,
onReopened = { reopenItem = null },
)
WorkspaceTab.Tickets -> TicketsScreen(
api = api,
workspace = ws,
focusId = reopenItem,
onFocusHandled = { reopenItem = null },
)
WorkspaceTab.Knowledge -> KnowledgeScreen(
api = api,
workspace = ws,
onEdit = { editing = it },
reopenItem = if (reopenItem != null && reopenKnowledge) reopenItem else null,
onReopened = { reopenItem = null },
)
WorkspaceTab.Members -> MembersScreen(api = api, workspace = ws)
WorkspaceTab.Notifications -> NotificationsScreen(
api = api,
workspaceId = ws.id,
onOpenItem = { itemId, isTicket ->
// 通知点开后跳到对应列表并展开那一条;通知本身不带数据,页面自己回源
reopenItem = itemId
reopenKnowledge = false
tab = if (isTicket) WorkspaceTab.Tickets else WorkspaceTab.Tasks
},
onUnreadChanged = { unreadRefresh += 1 },
)
}
}
}
}
}
// 更新对话框是独立窗口,摆在哪一层都能盖住当前界面
update?.let { release ->
UpdateDialog(
release = release,
currentVersionName = updater.currentVersionName,
updater = updater,
onLater = { update = null },
)
}
}
@@ -2,6 +2,8 @@ package com.dadao.task.data
import com.dadao.task.BuildConfig
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.SerialName
@@ -10,6 +12,8 @@ import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
@@ -40,10 +44,34 @@ private data class Envelope<T>(
@Serializable
data class User(
val id: String,
val phone: String,
/**
* 邮箱注册的账号没有手机号;有手机号的也只给脱敏值(138****8000),
* 与后端 `_user_out` 一致。所以这里必须可空——否则邮箱账号一登录就解析失败。
*/
val phone: String? = null,
val email: String? = null,
@SerialName("email_verified") val emailVerified: Boolean = false,
@SerialName("has_password") val hasPassword: Boolean = false,
val nickname: String = "",
)
/**
* 这台服务现在开放哪些登录入口(后端 `/auth/methods`)。
*
* 生产早就把短信登录关了,客户端不能"照着界面上有就点"——
* 点进去才发现这条路关着最气人,所以登录页先问一次再渲染。
*/
@Serializable
data class AuthMethods(
val password: Boolean = true,
@SerialName("email_password") val emailPassword: Boolean = true,
@SerialName("sms_code") val smsCode: Boolean = false,
@SerialName("email_register") val emailRegister: Boolean = false,
) {
/** 一条路都没有时至少让密码登录留在界面上,免得白屏。 */
val usable: Boolean get() = password || emailPassword || smsCode
}
@Serializable
data class Tokens(
@SerialName("access_token") val accessToken: String,
@@ -136,8 +164,29 @@ data class ItemDetail(
val tags: List<Tag> = emptyList(),
val version: Int = 1,
val activities: List<Activity> = emptyList(),
val attachments: List<Attachment> = emptyList(),
)
/**
* 附件。[rawUrl] 是后端签发的短时直链(带 exp/sig),
* 正文里的图片就用它,**不要**把长期凭据写进正文。
*/
@Serializable
data class Attachment(
val id: String,
@SerialName("item_id") val itemId: String = "",
val filename: String = "",
@SerialName("content_type") val contentType: String = "application/octet-stream",
val size: Long = 0,
@SerialName("uploader_id") val uploaderId: String = "",
@SerialName("created_at") val createdAt: String = "",
@SerialName("raw_url") val rawUrl: String? = null,
@SerialName("can_delete") val canDelete: Boolean = false,
)
@Serializable
data class AttachmentPage(val items: List<Attachment> = emptyList())
@Serializable
data class Invitation(
val code: String,
@@ -213,6 +262,44 @@ data class SourceRef(
val status: String = "",
)
/** 服务端发布的安卓安装包(OTA)。`hasUpdate` 由服务端按 version_code 比较后给。 */
@Serializable
data class AppRelease(
val platform: String = "android",
@SerialName("version_code") val versionCode: Int = 0,
@SerialName("version_name") val versionName: String = "",
val notes: String = "",
val file: String = "",
val size: Long = 0,
val sha256: String = "",
@SerialName("min_sdk") val minSdk: Int = 26,
@SerialName("published_at") val publishedAt: String? = null,
@SerialName("download_url") val downloadUrl: String = "",
@SerialName("has_update") val hasUpdate: Boolean = false,
)
/** 知识分类:工作区级的三级小树,fullPath 形如「研发 / 部署 / 回滚」。 */
@Serializable
data class KnowledgeCategory(
val id: String,
val name: String = "",
@SerialName("parent_id") val parentId: String? = null,
/** 分类色(#RRGGBB),卡片上边线用它;没设时前端按 id 稳定挑一个默认色。 */
val color: String? = null,
val depth: Int = 1,
@SerialName("sort_order") val sortOrder: Int = 0,
val version: Int = 1,
val path: List<String> = emptyList(),
@SerialName("full_path") val fullPath: String = "",
@SerialName("doc_count") val docCount: Int = 0,
)
@Serializable
data class KnowledgeCategoryPage(
val items: List<KnowledgeCategory> = emptyList(),
@SerialName("max_depth") val maxDepth: Int = 3,
)
@Serializable
data class KnowledgeDoc(
val id: String,
@@ -222,7 +309,9 @@ data class KnowledgeDoc(
val version: Int = 1,
val tags: List<Tag> = emptyList(),
val knowledge: KnowledgeInfo = KnowledgeInfo(),
val category: KnowledgeCategory? = null,
val source: SourceRef? = null,
val attachments: List<Attachment> = emptyList(),
)
@Serializable
@@ -234,6 +323,25 @@ data class KnowledgePage(
// ---- 请求体。每个都要 @Serializable,否则 encodeToString 找不到序列化器 ----
@Serializable
data class AppNotification(
val id: String,
val kind: String = "",
@SerialName("item_id") val itemId: String? = null,
@SerialName("item_title") val itemTitle: String? = null,
val title: String = "",
val body: String = "",
@SerialName("read_at") val readAt: String? = null,
@SerialName("created_at") val createdAt: String = "",
)
@Serializable
data class NotificationPage(
val notifications: List<AppNotification> = emptyList(),
@SerialName("next_cursor") val nextCursor: String? = null,
@SerialName("has_more") val hasMore: Boolean = false,
)
@Serializable
private data class UnreadCount(@SerialName("unread") val unread: Int = 0)
@@ -243,6 +351,25 @@ private data class SmsRequest(val phone: String)
@Serializable
private data class LoginRequest(val phone: String, val code: String)
/** 手机号或邮箱二选一:只发一个字段,另一个为 null 时不会被序列化出去。 */
@Serializable
private data class PasswordLoginRequest(
val phone: String? = null,
val email: String? = null,
val password: String,
)
@Serializable
private data class EmailCodeRequest(val email: String)
@Serializable
private data class RegisterRequest(
val email: String,
val code: String,
val password: String,
val nickname: String? = null,
)
@Serializable
private data class RefreshRequest(@SerialName("refresh_token") val refreshToken: String)
@@ -282,16 +409,59 @@ private data class KnowledgeCreateRequest(
val title: String,
@SerialName("body_md") val bodyMd: String,
@SerialName("doc_type") val docType: String,
@SerialName("tag_names") val tagNames: List<String> = emptyList(),
@SerialName("category_id") val categoryId: String? = null,
)
@Serializable
private data class KnowledgeActionRequest(val action: String)
@Serializable
private data class CategoryCreateRequest(
val name: String,
@SerialName("parent_id") val parentId: String? = null,
@SerialName("sort_order") val sortOrder: Int = 0,
val color: String? = null,
)
/**
* 分类改名 / 改色。`version` 是乐观锁,必填。
*
* color 传空串表示"取消上色",不传(null)表示"别动颜色"——
* 靠 `explicitNulls = false` 把 null 字段整个省掉来区分这两件事。
*/
@Serializable
private data class CategoryPatchRequest(
val version: Int,
val name: String? = null,
val color: String? = null,
)
@Serializable
private data class TicketCreateRequest(
val title: String,
@SerialName("body_md") val bodyMd: String? = null,
@SerialName("assignee_id") val assigneeId: String,
val priority: Int = 1,
@SerialName("due_at") val dueAt: String? = null,
)
@Serializable
private data class ItemUpdateRequest(
val version: Int,
val title: String? = null,
@SerialName("body_md") val bodyMd: String? = null,
)
@Serializable
private data class KnowledgeUpdateRequest(
val version: Int,
val title: String? = null,
@SerialName("body_md") val bodyMd: String? = null,
@SerialName("tag_names") val tagNames: List<String>? = null,
@SerialName("category_id") val categoryId: String? = null,
// 想把分类清成"未分类"就传 clear_category=true:只传 null 会被服务端当成"不改"
@SerialName("clear_category") val clearCategory: Boolean = false,
)
/**
@@ -372,6 +542,58 @@ class Api(private val tokens: TokenStore) {
suspend fun me(): MeResult = call(path = "/auth/me", auth = true)
/** 这台服务开放哪些登录入口;登录页拿到之后才决定渲染哪几个 tab。 */
suspend fun authMethods(): AuthMethods = call(path = "/auth/methods")
/**
* 手机号 / 邮箱 + 密码登录。
*
* 账号里有没有 `@` 决定发哪个字段:后端两个都收,但带着一个空字符串 phone
* 会被 pattern 校验挡掉,所以这里只发一个。
*/
suspend fun loginPassword(account: String, password: String): LoginResult {
val who = account.trim()
val body = if (who.contains('@')) {
PasswordLoginRequest(email = who, password = password)
} else {
PasswordLoginRequest(phone = who, password = password)
}
val result: LoginResult = call(
path = "/auth/login/password",
body = json.encodeToString(body),
)
tokens.save(result.tokens.accessToken, result.tokens.refreshToken)
return result
}
/** 邮箱注册验证码。生产真发信;`dev_code` 只在开了 EMAIL_CODE_DEV_ECHO 的本机才回显。 */
suspend fun sendEmailCode(email: String): JsonElement? = call(
path = "/auth/email/code",
body = json.encodeToString(EmailCodeRequest(email.trim())),
)
/** 邮箱 + 验证码 + 密码一步建号,注册完就是登录态(服务端直接给令牌)。 */
suspend fun register(
email: String,
code: String,
password: String,
nickname: String? = null,
): LoginResult {
val result: LoginResult = call(
path = "/auth/register",
body = json.encodeToString(
RegisterRequest(
email = email.trim(),
code = code.trim(),
password = password,
nickname = nickname?.trim()?.ifBlank { null },
),
),
)
tokens.save(result.tokens.accessToken, result.tokens.refreshToken)
return result
}
/**
* 列表类接口服务端返回的是 `{"items": [...]}` 而不是裸数组
* (`backend/app/api/workspaces.py` 的 list_workspaces),
@@ -386,6 +608,13 @@ class Api(private val tokens: TokenStore) {
auth = true,
)
/**
* 查最新安装包。不带 auth:还没登录就可能在检查更新,
* 传当前 versionCode 让服务端直接算好 `hasUpdate`。
*/
suspend fun latestAppRelease(versionCode: Int): AppRelease =
call(path = "/app/release/latest?version_code=$versionCode")
suspend fun members(workspaceId: String): List<Member> =
call<MemberPage>(path = "/workspaces/$workspaceId/members", auth = true).items
@@ -396,6 +625,48 @@ class Api(private val tokens: TokenStore) {
auth = true,
)
// ---- M4 通知 ----
/** 通知默认只看未读;"全部"是查历史用的。 */
suspend fun notifications(
workspaceId: String,
unreadOnly: Boolean = true,
): NotificationPage = call(
path = "/workspaces/$workspaceId/notifications?unread_only=$unreadOnly&limit=50",
auth = true,
)
// OkHttp 的 POST 必须带 body,这里发个空对象,服务端不需要入参
suspend fun markNotificationRead(workspaceId: String, id: String): JsonElement = call(
path = "/workspaces/$workspaceId/notifications/$id/read",
body = "{}",
auth = true,
)
suspend fun markAllNotificationsRead(workspaceId: String): JsonElement = call(
path = "/workspaces/$workspaceId/notifications/read-all",
body = "{}",
auth = true,
)
// ---- M2 工单(手工开单)----
/** 不经过任务,直接开一张工单交给别人。 */
suspend fun createTicket(
workspaceId: String,
title: String,
assigneeId: String,
bodyMd: String? = null,
priority: Int = 1,
dueAt: String? = null,
): Ticket = call(
path = "/workspaces/$workspaceId/tickets",
body = json.encodeToString(
TicketCreateRequest(title, bodyMd?.ifBlank { null }, assigneeId, priority, dueAt),
),
auth = true,
)
/** 未读工单通知数,导航角标用。失败就当 0,不要因为角标让整页报错。 */
suspend fun unreadCount(workspaceId: String): Int = runCatching {
call<UnreadCount>(path = "/workspaces/$workspaceId/notifications/unread-count", auth = true).unread
@@ -451,6 +722,67 @@ class Api(private val tokens: TokenStore) {
auth = true,
)
// ---- 附件与富文本 ----
suspend fun attachments(workspaceId: String, itemId: String): List<Attachment> =
call<AttachmentPage>(
path = "/workspaces/$workspaceId/items/$itemId/attachments",
auth = true,
).items
/**
* 上传附件。返回的 [Attachment.rawUrl] 是短时签名直链,
* 直接写进 Markdown 的 `![](...)` 即可,不需要把登录令牌塞进正文。
*/
suspend fun uploadAttachment(
workspaceId: String,
itemId: String,
filename: String,
mimeType: String,
bytes: ByteArray,
): Attachment = withContext(Dispatchers.IO) {
val form = MultipartBody.Builder()
.setType(MultipartBody.FORM)
.addFormDataPart(
"file",
filename,
bytes.toRequestBody(mimeType.toMediaTypeOrNull()),
)
.build()
val builder = Request.Builder()
.url("$base/workspaces/$workspaceId/items/$itemId/attachments")
.post(form)
.header("Accept", "application/json")
tokens.accessToken?.let { builder.header("Authorization", "Bearer $it") }
try {
http.newCall(builder.build()).execute().use { response ->
val text = response.body?.string().orEmpty()
val envelope = runCatching { json.decodeFromString<Envelope<Attachment>>(text) }
.getOrElse { throw ApiException(1500, "上传失败(HTTP ${response.code})") }
if (envelope.code != 0) throw ApiException(envelope.code, envelope.message, envelope.requestId)
envelope.data ?: throw ApiException(1500, "上传响应缺少 data")
}
} catch (e: IOException) {
throw unreachable(e)
}
}
/** 保存正文/标题。PATCH 必须带 version,冲突由服务端返回 4009。 */
suspend fun updateItem(
workspaceId: String,
itemId: String,
version: Int,
title: String? = null,
body: String? = null,
): ItemDetail = call(
path = "/workspaces/$workspaceId/items/$itemId",
body = json.encodeToString(ItemUpdateRequest(version, title, body)),
auth = true,
method = "PATCH",
)
// ---- M2 工单 ----
/** role: assigned(派给我的)/ requested(我发起的)/ all。 */
@@ -504,20 +836,83 @@ class Api(private val tokens: TokenStore) {
workspaceId: String,
statuses: List<String> = listOf("draft", "in_review"),
query: String? = null,
categoryId: String? = null,
uncategorized: Boolean = false,
): KnowledgePage {
val params = buildString {
append("?limit=30")
statuses.forEach { append("&status=").append(it) }
if (!query.isNullOrBlank()) append("&q=").append(java.net.URLEncoder.encode(query, "UTF-8"))
if (!query.isNullOrBlank()) {
append("&q=").append(java.net.URLEncoder.encode(query, "UTF-8"))
}
if (categoryId != null) append("&category_id=").append(categoryId)
if (uncategorized) append("&uncategorized=true")
}
return call(path = "/workspaces/$workspaceId/knowledge$params", auth = true)
}
/** 工作区三级分类小树(扁平返回,depth 表示层级)。 */
suspend fun knowledgeCategories(workspaceId: String): KnowledgeCategoryPage =
call(path = "/workspaces/$workspaceId/knowledge/categories", auth = true)
/** 建分类;同层重名会被服务端拒(4006)。color 不传就是不上色。 */
suspend fun createKnowledgeCategory(
workspaceId: String,
name: String,
parentId: String? = null,
color: String? = null,
): KnowledgeCategory = call(
path = "/workspaces/$workspaceId/knowledge/categories",
body = json.encodeToString(
CategoryCreateRequest(name = name.trim(), parentId = parentId, color = color),
),
auth = true,
)
/**
* 改名 / 改色,必须带 version。
*
* color 传 `""` 表示取消上色,传 null 表示别动颜色——这个区别由
* `explicitNulls = false`(null 字段直接不序列化)兜住。
*/
suspend fun patchKnowledgeCategory(
workspaceId: String,
categoryId: String,
version: Int,
name: String? = null,
color: String? = null,
): KnowledgeCategory = call(
path = "/workspaces/$workspaceId/knowledge/categories/$categoryId",
body = json.encodeToString(CategoryPatchRequest(version, name?.trim(), color)),
auth = true,
method = "PATCH",
)
/** 删分类。force=true 才能删有内容的分类,里面的知识会退成未分类。 */
suspend fun deleteKnowledgeCategory(
workspaceId: String,
categoryId: String,
force: Boolean = false,
): JsonElement = call(
path = "/workspaces/$workspaceId/knowledge/categories/$categoryId?force=$force",
auth = true,
method = "DELETE",
)
/** 手记:没有来源的知识,得自己写清楚。 */
suspend fun createKnowledge(workspaceId: String, title: String, body: String = ""): KnowledgeDoc =
suspend fun createKnowledge(
workspaceId: String,
title: String,
body: String = "",
docType: String = "note",
tags: List<String> = emptyList(),
categoryId: String? = null,
): KnowledgeDoc =
call(
path = "/workspaces/$workspaceId/knowledge",
body = json.encodeToString(KnowledgeCreateRequest(title, body, "note")),
body = json.encodeToString(
KnowledgeCreateRequest(title, body, docType, tags, categoryId),
),
auth = true,
)
@@ -547,18 +942,67 @@ class Api(private val tokens: TokenStore) {
version: Int,
title: String? = null,
body: String? = null,
tags: List<String>? = null,
categoryId: String? = null,
clearCategory: Boolean = false,
): KnowledgeDoc = call(
path = "/workspaces/$workspaceId/knowledge/$itemId",
body = json.encodeToString(KnowledgeUpdateRequest(version, title, body)),
body = json.encodeToString(
KnowledgeUpdateRequest(version, title, body, tags, categoryId, clearCategory),
),
auth = true,
method = "PATCH",
)
/**
* 访问令牌只有 30 分钟,refresh 有 14 天。
*
* 撞上 2002(登录已过期)时先用 refresh 换一对新令牌,把这次请求原样重放一次;
* 换不动才清令牌让界面回登录页。以前是直接清,用户用一会儿就被踢出去。
* 并发请求共用一把锁:刷新只发一次,其余等它换完直接用新令牌。
*/
private suspend inline fun <reified T> call(
path: String,
body: String? = null,
auth: Boolean = false,
method: String? = null,
): T {
val stale = tokens.accessToken
return try {
callOnce<T>(path, body, auth, method)
} catch (e: ApiException) {
if (!auth || e.code != 2002 || !refreshTokens(stale)) throw e
callOnce<T>(path, body, auth, method)
}
}
private val refreshLock = Mutex()
private suspend fun refreshTokens(stale: String?): Boolean {
val refresh = tokens.refreshToken ?: return false
return refreshLock.withLock {
// 等锁期间别人可能已经换完了:本地令牌已经不是失败的那一个,直接用新的
val current = tokens.accessToken
if (current != null && current != stale) return@withLock true
try {
val fresh: Tokens = call(
path = "/auth/refresh",
body = json.encodeToString(RefreshRequest(refresh)),
)
tokens.save(fresh.accessToken, fresh.refreshToken)
true
} catch (e: ApiException) {
tokens.clear()
false
}
}
}
private suspend inline fun <reified T> callOnce(
path: String,
body: String? = null,
auth: Boolean = false,
method: String? = null,
): T = withContext(Dispatchers.IO) {
val builder = Request.Builder().url(base + path)
val verb = method ?: if (body == null) "GET" else "POST"
@@ -582,7 +1026,6 @@ class Api(private val tokens: TokenStore) {
throw ApiException(1500, "响应格式异常(HTTP ${response.code})")
}
if (envelope.code != 0) {
if (envelope.code == 2002 && auth) tokens.clear()
throw ApiException(envelope.code, envelope.message, envelope.requestId)
}
envelope.data ?: throw ApiException(1500, "响应缺少 data")
@@ -0,0 +1,140 @@
package com.dadao.task.data
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.provider.Settings
import androidx.core.content.FileProvider
import com.dadao.task.BuildConfig
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.File
import java.io.IOException
import java.security.MessageDigest
import java.util.concurrent.TimeUnit
/**
* 应用内升级(OTA)。
*
* 流程刻意做得很短:查清单 → 下到应用自己的目录 → 交给系统安装器。
* 不申请存储权限、不自己实现安装界面:APK 走 [FileProvider] 授权给系统,
* 「允许安装未知应用」这类系统开关也必须由用户在系统设置里点,不能被代劳。
*/
class AppUpdater(private val context: Context, private val api: Api) {
private val http = OkHttpClient.Builder()
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(60, TimeUnit.SECONDS)
.build()
val currentVersionName: String = BuildConfig.VERSION_NAME
/**
* 有新版就返回它,否则 null。
*
* 网络失败、后端还没发布过安装包都当成"没有更新":启动时不打扰用户,
* 手动检查那次再把异常抛给界面去提示。
*/
suspend fun check(): AppRelease? = runCatching {
api.latestAppRelease(BuildConfig.VERSION_CODE)
}.getOrNull()?.takeIf { it.hasUpdate && it.downloadUrl.isNotBlank() }
/** 同 [check],但失败会抛异常——给「检查更新」按钮用。 */
suspend fun checkStrict(): AppRelease? {
val release = api.latestAppRelease(BuildConfig.VERSION_CODE)
return release.takeIf { it.hasUpdate && it.downloadUrl.isNotBlank() }
}
/**
* 把安装包下到 `Android/data/<pkg>/files/updates/` 下。
*
* 先写 .part 再改名:下到一半断网不会留下一个"看起来能用"的坏 APK。
* 清单带了 sha256 就校验,防止内网代理/缓存给出旧包。
*/
suspend fun download(
release: AppRelease,
onProgress: (downloaded: Long, total: Long) -> Unit = { _, _ -> },
): File = withContext(Dispatchers.IO) {
val dir = File(context.getExternalFilesDir(null) ?: context.filesDir, "updates")
dir.mkdirs()
val safeName = release.file.ifBlank { "dadaotask-${release.versionName}.apk" }
val target = File(dir, safeName)
val partial = File(dir, "$safeName.part")
val url = api.currentOrigin() + release.downloadUrl
val response = try {
http.newCall(Request.Builder().url(url).build()).execute()
} catch (e: IOException) {
throw ApiException(1, "下载失败:连不上 $url")
}
response.use { res ->
if (!res.isSuccessful) throw ApiException(1, "下载失败:HTTP ${res.code}")
val body = res.body ?: throw ApiException(1, "下载失败:响应为空")
val total = if (release.size > 0) release.size else body.contentLength()
partial.outputStream().use { out ->
body.byteStream().use { input ->
val buffer = ByteArray(64 * 1024)
var downloaded = 0L
while (true) {
val read = input.read(buffer)
if (read <= 0) break
out.write(buffer, 0, read)
downloaded += read
onProgress(downloaded, total)
}
}
}
}
if (release.sha256.isNotBlank() && sha256Of(partial) != release.sha256.lowercase()) {
partial.delete()
throw ApiException(1, "安装包校验不通过(sha256 不一致),请稍后重试")
}
if (target.exists()) target.delete()
if (!partial.renameTo(target)) throw ApiException(1, "安装包保存失败,检查存储空间")
target
}
/** 应用没有「安装未知应用」权限时,先把用户送到系统设置页。 */
fun canInstall(): Boolean =
Build.VERSION.SDK_INT < Build.VERSION_CODES.O || context.packageManager.canRequestPackageInstalls()
fun openInstallPermission(): Boolean {
if (canInstall()) return false
val intent = Intent(
Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
Uri.parse("package:${context.packageName}"),
).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
runCatching { context.startActivity(intent) }
return true
}
/** 交给系统安装器。返回 false 表示还没给"安装未知应用"权限。 */
fun install(file: File): Boolean {
if (!canInstall()) return false
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, "application/vnd.android.package-archive")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
return true
}
private fun sha256Of(file: File): String {
val digest = MessageDigest.getInstance("SHA-256")
file.inputStream().use { input ->
val buffer = ByteArray(64 * 1024)
while (true) {
val read = input.read(buffer)
if (read <= 0) break
digest.update(buffer, 0, read)
}
}
return digest.digest().joinToString("") { "%02x".format(it) }
}
}
@@ -29,11 +29,12 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.dadao.task.data.Workspace
/** 手机端的五个去处。看板不是独立入口,而是任务页顶部的一个切换(屏幕太窄,塞不下六个 tab)。 */
/** 手机端的去处。看板不是独立入口,而是任务页顶部的一个切换(屏幕太窄,塞不下更多 tab)。 */
enum class WorkspaceTab(val label: String) {
Tasks("任务"),
Tickets("工单"),
Knowledge("知识"),
Notifications("通知"),
Members("成员"),
}
@@ -63,7 +63,13 @@ private fun roleLabel(role: String?): String = when (role) {
}
@Composable
fun HomeScreen(api: Api, onLogout: () -> Unit, onOpenWorkspace: (Workspace) -> Unit) {
fun HomeScreen(
api: Api,
versionName: String,
onCheckUpdate: suspend () -> String,
onLogout: () -> Unit,
onOpenWorkspace: (Workspace) -> Unit,
) {
val scope = rememberCoroutineScope()
var user by remember { mutableStateOf<User?>(null) }
var workspaces by remember { mutableStateOf<List<Workspace>>(emptyList()) }
@@ -71,6 +77,9 @@ fun HomeScreen(api: Api, onLogout: () -> Unit, onOpenWorkspace: (Workspace) -> U
var name by remember { mutableStateOf("") }
var busy by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) }
// 手动检查更新的回话:查到/没查到/失败都用一句话说清楚
var updateNotice by remember { mutableStateOf<String?>(null) }
var checking by remember { mutableStateOf(false) }
suspend fun reload() {
runCatching { api.me() }
@@ -234,6 +243,38 @@ fun HomeScreen(api: Api, onLogout: () -> Unit, onOpenWorkspace: (Workspace) -> U
item { SoftPill(notice, Rust, RustSoft, Modifier.fillMaxWidth()) }
}
item {
Column(Modifier.padding(top = 6.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
"达道Task v$versionName",
style = MaterialTheme.typography.labelSmall,
color = InkMuted,
)
Spacer(Modifier.width(12.dp))
Text(
if (checking) "检查中…" else "检查更新",
style = MaterialTheme.typography.labelSmall,
color = Primary,
modifier = Modifier
.clickable(enabled = !checking) {
checking = true
scope.launch {
updateNotice = onCheckUpdate()
checking = false
}
}
.padding(vertical = 4.dp),
)
}
val note = updateNotice
if (note != null) {
Spacer(Modifier.height(6.dp))
Text(note, style = MaterialTheme.typography.labelSmall, color = InkMuted)
}
}
}
item { Spacer(Modifier.height(40.dp)) }
}
File diff suppressed because it is too large Load Diff
@@ -6,11 +6,12 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
@@ -19,6 +20,7 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -32,23 +34,45 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import com.dadao.task.data.Api
import com.dadao.task.data.ApiException
import com.dadao.task.data.AuthMethods
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
/** 手机号 11 位、邮箱只要有 @ 就放行——最终格式由服务端判,前端别提前拦。 */
private fun looksLikeEmail(input: String): Boolean = input.contains('@')
private fun accountReady(account: String): Boolean {
val text = account.trim()
return if (looksLikeEmail(text)) text.length >= 6 else text.length == 11
}
@Composable
fun LoginScreen(api: Api, onLoggedIn: () -> Unit) {
val scope = rememberCoroutineScope()
// 登录入口以服务端为准:生产早就关了短信登录,界面不能还摆着一条走不通的路。
var methods by remember { mutableStateOf<AuthMethods?>(null) }
var mode by remember { mutableStateOf("password") }
var account by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var phone by remember { mutableStateOf("") }
var code by remember { mutableStateOf("") }
var seconds by remember { mutableStateOf(0) }
var devCode by remember { mutableStateOf<String?>(null) }
var busy by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) }
var registerOpen by remember { mutableStateOf(false) }
// 后端地址可改:包里默认是模拟器地址 10.0.2.2,真机必须换成电脑的局域网地址。
var serverOpen by remember { mutableStateOf(false) }
@@ -86,6 +110,15 @@ fun LoginScreen(api: Api, onLoggedIn: () -> Unit) {
}
}
LaunchedEffect(Unit) {
runCatching { api.authMethods() }
.onSuccess {
methods = it
// 只有短信这一条路时,默认切到它,别让人对着密码框发呆
if (!it.password && !it.emailPassword && it.smsCode) mode = "code"
}
}
LaunchedEffect(seconds) {
if (seconds > 0) {
delay(1000)
@@ -93,6 +126,25 @@ fun LoginScreen(api: Api, onLoggedIn: () -> Unit) {
}
}
fun report(e: Throwable, fallback: String) {
val c = (e as? ApiException)?.code ?: 0
if (c == 1) serverOpen = true
error = verifyStatusCode(c).ifBlank { e.message ?: fallback }
}
fun submitPassword() {
scope.launch {
busy = true
runCatching { api.loginPassword(account, password) }
.onSuccess {
error = null
onLoggedIn()
}
.onFailure { report(it, "登录没成功") }
busy = false
}
}
fun send() {
scope.launch {
busy = true
@@ -102,16 +154,12 @@ fun LoginScreen(api: Api, onLoggedIn: () -> Unit) {
devCode = data?.jsonObject?.get("dev_code")?.jsonPrimitive?.content
error = null
}
.onFailure {
val c = (it as? ApiException)?.code ?: 0
if (c == 1) serverOpen = true
error = verifyStatusCode(c).ifBlank { it.message ?: "验证码没发出去" }
}
.onFailure { report(it, "验证码没发出去") }
busy = false
}
}
fun submit() {
fun submitCode() {
scope.launch {
busy = true
runCatching { api.login(phone.trim(), code.trim()) }
@@ -119,15 +167,14 @@ fun LoginScreen(api: Api, onLoggedIn: () -> Unit) {
error = null
onLoggedIn()
}
.onFailure {
val c = (it as? ApiException)?.code ?: 0
if (c == 1) serverOpen = true
error = verifyStatusCode(c).ifBlank { it.message ?: "登录没成功" }
}
.onFailure { report(it, "登录没成功") }
busy = false
}
}
val smsAvailable = methods?.smsCode == true
val registerAvailable = methods?.emailRegister == true
Column(
Modifier
.fillMaxSize()
@@ -160,39 +207,45 @@ fun LoginScreen(api: Api, onLoggedIn: () -> Unit) {
Text("登录", style = MaterialTheme.typography.headlineMedium)
Spacer(Modifier.height(6.dp))
Text(
"手机号 + 验证码,不用记密码。第一次登录会自动建好账号。",
if (smsAvailable) {
"手机号或邮箱 + 密码,也可以收验证码登录。"
} else {
"手机号或邮箱 + 密码。第一次来先注册一个账号。"
},
style = MaterialTheme.typography.bodySmall,
color = InkMuted,
)
Spacer(Modifier.height(22.dp))
OutlinedTextField(
value = phone,
onValueChange = { phone = it.filter { c -> c.isDigit() }.take(11) },
modifier = Modifier.fillMaxWidth(),
label = { Text("手机号") },
placeholder = { Text("11 位手机号", color = InkMuted) },
singleLine = true,
shape = RoundedCornerShape(12.dp),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone),
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.White,
unfocusedContainerColor = Color.White,
focusedIndicatorColor = Primary,
unfocusedIndicatorColor = Line,
),
)
// 两条路都可以走时才给切换 tab,只有一条就别摆出来当摆设
if (smsAvailable) {
Spacer(Modifier.height(14.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
SurfaceChip(
text = "密码登录",
selected = mode == "password",
onClick = { mode = "password"; error = null },
)
SurfaceChip(
text = "验证码登录",
selected = mode == "code",
onClick = { mode = "code"; error = null },
)
}
}
Spacer(Modifier.height(12.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Spacer(Modifier.height(18.dp))
if (mode == "password") {
OutlinedTextField(
value = code,
onValueChange = { code = it.filter { c -> c.isDigit() }.take(6) },
modifier = Modifier.weight(1f),
label = { Text("验证码") },
value = account,
onValueChange = { account = it.take(80) },
modifier = Modifier.fillMaxWidth(),
label = { Text("手机号或邮箱") },
placeholder = { Text("13800138000 或 you@example.com", color = InkMuted) },
singleLine = true,
shape = RoundedCornerShape(12.dp),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword),
keyboardOptions = KeyboardOptions(
keyboardType = if (looksLikeEmail(account)) KeyboardType.Email else KeyboardType.Text,
),
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.White,
unfocusedContainerColor = Color.White,
@@ -200,23 +253,76 @@ fun LoginScreen(api: Api, onLoggedIn: () -> Unit) {
unfocusedIndicatorColor = Line,
),
)
Spacer(Modifier.width(10.dp))
GhostButton(
text = if (seconds > 0) "${seconds}s" else "获取验证码",
enabled = phone.length == 11 && seconds == 0 && !busy,
onClick = { send() },
Spacer(Modifier.height(12.dp))
OutlinedTextField(
value = password,
onValueChange = { password = it.take(64) },
modifier = Modifier.fillMaxWidth(),
label = { Text("密码") },
singleLine = true,
shape = RoundedCornerShape(12.dp),
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.White,
unfocusedContainerColor = Color.White,
focusedIndicatorColor = Primary,
unfocusedIndicatorColor = Line,
),
)
} else {
OutlinedTextField(
value = phone,
onValueChange = { phone = it.filter { c -> c.isDigit() }.take(11) },
modifier = Modifier.fillMaxWidth(),
label = { Text("手机号") },
placeholder = { Text("11 位手机号", color = InkMuted) },
singleLine = true,
shape = RoundedCornerShape(12.dp),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone),
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.White,
unfocusedContainerColor = Color.White,
focusedIndicatorColor = Primary,
unfocusedIndicatorColor = Line,
),
)
}
val hint = devCode
if (hint != null) {
Spacer(Modifier.height(10.dp))
SoftPill(
"开发环境验证码:$hint",
Primary,
PrimarySoft,
Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(12.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
OutlinedTextField(
value = code,
onValueChange = { code = it.filter { c -> c.isDigit() }.take(6) },
modifier = Modifier.weight(1f),
label = { Text("验证码") },
singleLine = true,
shape = RoundedCornerShape(12.dp),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword),
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.White,
unfocusedContainerColor = Color.White,
focusedIndicatorColor = Primary,
unfocusedIndicatorColor = Line,
),
)
Spacer(Modifier.width(10.dp))
GhostButton(
text = if (seconds > 0) "${seconds}s" else "获取验证码",
enabled = phone.length == 11 && seconds == 0 && !busy,
onClick = { send() },
)
}
val hint = devCode
if (hint != null) {
Spacer(Modifier.height(10.dp))
SoftPill(
"开发环境验证码:$hint",
Primary,
PrimarySoft,
Modifier.fillMaxWidth(),
)
}
}
val notice = error
@@ -226,12 +332,36 @@ fun LoginScreen(api: Api, onLoggedIn: () -> Unit) {
}
Spacer(Modifier.height(22.dp))
PrimaryButton(
text = if (busy) "请稍候…" else "登录",
enabled = phone.length == 11 && code.length >= 4 && !busy,
modifier = Modifier.fillMaxWidth(),
onClick = { submit() },
)
if (mode == "password") {
PrimaryButton(
text = if (busy) "请稍候…" else "登录",
enabled = accountReady(account) && password.isNotEmpty() && !busy,
modifier = Modifier.fillMaxWidth(),
onClick = { submitPassword() },
)
} else {
PrimaryButton(
text = if (busy) "请稍候…" else "登录",
enabled = phone.length == 11 && code.length >= 4 && !busy,
modifier = Modifier.fillMaxWidth(),
onClick = { submitCode() },
)
}
if (registerAvailable) {
Spacer(Modifier.height(10.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
"还没有账号?",
style = MaterialTheme.typography.labelSmall,
color = InkMuted,
)
TextButton(
enabled = !busy,
onClick = { error = null; registerOpen = true },
) { Text("用邮箱注册") }
}
}
Spacer(Modifier.height(14.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
@@ -338,4 +468,190 @@ fun LoginScreen(api: Api, onLoggedIn: () -> Unit) {
}
Spacer(Modifier.height(40.dp))
}
if (registerOpen) {
RegisterDialog(
api = api,
onDismiss = { registerOpen = false },
onRegistered = {
registerOpen = false
onLoggedIn()
},
)
}
}
/**
* 邮箱注册:验证码 + 密码,一步建号。
*
* 注册完服务端直接回令牌,所以这里不再跳回登录页——落地即登录,少一次输入。
*/
@Composable
private fun RegisterDialog(api: Api, onDismiss: () -> Unit, onRegistered: () -> Unit) {
val scope = rememberCoroutineScope()
var email by remember { mutableStateOf("") }
var code by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var nickname by remember { mutableStateOf("") }
var seconds by remember { mutableStateOf(0) }
var devCode by remember { mutableStateOf<String?>(null) }
var busy by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) }
LaunchedEffect(seconds) {
if (seconds > 0) {
delay(1000)
seconds -= 1
}
}
fun fail(e: Throwable, fallback: String) {
val c = (e as? ApiException)?.code ?: 0
error = verifyStatusCode(c).ifBlank { e.message ?: fallback }
}
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
Column(
Modifier
.fillMaxWidth(0.94f)
.fillMaxHeight(0.9f)
.background(Paper, RoundedCornerShape(16.dp))
.padding(16.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
"注册",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.weight(1f),
)
GhostButton("取消", onClick = onDismiss)
}
Spacer(Modifier.height(10.dp))
Column(Modifier.weight(1f).verticalScroll(rememberScrollState())) {
Text(
"用邮箱收一个验证码,设好密码就是账号。邮箱也是以后找回密码的唯一途径,别填错。",
style = MaterialTheme.typography.labelSmall,
color = InkMuted,
)
Spacer(Modifier.height(14.dp))
OutlinedTextField(
value = email,
onValueChange = { email = it.take(80) },
modifier = Modifier.fillMaxWidth(),
label = { Text("邮箱") },
placeholder = { Text("you@example.com", color = InkMuted) },
singleLine = true,
shape = RoundedCornerShape(10.dp),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.White,
unfocusedContainerColor = Color.White,
focusedIndicatorColor = Primary,
unfocusedIndicatorColor = Line,
),
)
Spacer(Modifier.height(10.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
OutlinedTextField(
value = code,
onValueChange = { code = it.filter { c -> c.isDigit() }.take(6) },
modifier = Modifier.weight(1f),
label = { Text("验证码") },
singleLine = true,
shape = RoundedCornerShape(10.dp),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword),
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.White,
unfocusedContainerColor = Color.White,
focusedIndicatorColor = Primary,
unfocusedIndicatorColor = Line,
),
)
Spacer(Modifier.width(10.dp))
GhostButton(
text = if (seconds > 0) "${seconds}s" else "发验证码",
enabled = email.contains('@') && seconds == 0 && !busy,
onClick = {
scope.launch {
busy = true
runCatching { api.sendEmailCode(email) }
.onSuccess {
seconds = 60
devCode = it?.jsonObject
?.get("dev_code")
?.jsonPrimitive
?.content
error = null
}
.onFailure { fail(it, "验证码没发出去") }
busy = false
}
},
)
}
val hint = devCode
if (hint != null) {
Spacer(Modifier.height(10.dp))
SoftPill("开发环境验证码:$hint", Primary, PrimarySoft, Modifier.fillMaxWidth())
}
Spacer(Modifier.height(10.dp))
OutlinedTextField(
value = password,
onValueChange = { password = it.take(64) },
modifier = Modifier.fillMaxWidth(),
label = { Text("密码") },
placeholder = { Text("至少 8 位,别用生日", color = InkMuted) },
singleLine = true,
shape = RoundedCornerShape(10.dp),
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.White,
unfocusedContainerColor = Color.White,
focusedIndicatorColor = Primary,
unfocusedIndicatorColor = Line,
),
)
Spacer(Modifier.height(10.dp))
OutlinedTextField(
value = nickname,
onValueChange = { nickname = it.take(32) },
modifier = Modifier.fillMaxWidth(),
label = { Text("称呼(可不填)") },
placeholder = { Text("别人在成员列表里看到的名字", color = InkMuted) },
singleLine = true,
shape = RoundedCornerShape(10.dp),
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.White,
unfocusedContainerColor = Color.White,
focusedIndicatorColor = Primary,
unfocusedIndicatorColor = Line,
),
)
val notice = error
if (notice != null) {
Spacer(Modifier.height(10.dp))
SoftPill(notice, Rust, RustSoft, Modifier.fillMaxWidth())
}
}
Spacer(Modifier.height(12.dp))
PrimaryButton(
text = if (busy) "请稍候…" else "注册并登录",
enabled = email.contains('@') && code.length >= 4 && password.isNotEmpty() && !busy,
modifier = Modifier.fillMaxWidth(),
onClick = {
scope.launch {
busy = true
runCatching { api.register(email, code, password, nickname) }
.onSuccess { onRegistered() }
.onFailure { fail(it, "注册没成功") }
busy = false
}
},
)
}
}
}
@@ -0,0 +1,205 @@
package com.dadao.task.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.dadao.task.data.Api
import com.dadao.task.data.ApiException
import com.dadao.task.data.AppNotification
import kotlinx.coroutines.launch
/**
* 站内通知。
*
* 只做三件事:看有没有新动静、点开跳过去、标为已读。
* 通知本身不是凭证——点开之后目标页面会重新拉数据,因为通知生成后事情可能又被改过。
*/
@Composable
fun NotificationsScreen(
api: Api,
workspaceId: String,
onOpenItem: (itemId: String, isTicket: Boolean) -> Unit,
onUnreadChanged: () -> Unit,
refreshKey: Int = 0,
) {
var unreadOnly by remember { mutableStateOf(true) }
var rows by remember { mutableStateOf<List<AppNotification>>(emptyList()) }
var loading by remember { mutableStateOf(true) }
var error by remember { mutableStateOf<String?>(null) }
val scope = rememberCoroutineScope()
suspend fun reload() {
loading = true
error = null
runCatching { api.notifications(workspaceId, unreadOnly) }
.onSuccess { rows = it.notifications }
.onFailure { error = verifyStatusCode((it as? ApiException)?.code ?: 0) }
loading = false
}
LaunchedEffect(workspaceId, unreadOnly, refreshKey) { reload() }
Column(Modifier.fillMaxSize().padding(horizontal = 16.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Text("通知", fontSize = 22.sp, fontWeight = FontWeight.SemiBold, color = Ink)
Box(Modifier.width(2.dp))
SoftPill(
text = "未读",
fg = if (unreadOnly) Primary else InkMuted,
bg = if (unreadOnly) PrimarySoft else PaperSunken,
modifier = Modifier.clickable { unreadOnly = true },
)
SoftPill(
text = "全部",
fg = if (unreadOnly) InkMuted else Primary,
bg = if (unreadOnly) PaperSunken else PrimarySoft,
modifier = Modifier.clickable { unreadOnly = false },
)
}
Text(
text = if (unreadOnly) "有人派活、或你关注的事有进展时,会出现在这里" else "历史通知,方便回头找",
fontSize = 12.sp,
color = InkMuted,
modifier = Modifier.padding(top = 4.dp, bottom = 10.dp),
)
error?.let {
Text(it, fontSize = 12.sp, color = Rust, modifier = Modifier.padding(bottom = 8.dp))
}
if (loading) {
Box(Modifier.fillMaxWidth().padding(top = 40.dp), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = Primary, strokeWidth = 2.dp, modifier = Modifier.size(22.dp))
}
return@Column
}
if (rows.isEmpty()) {
SoftCard(Modifier.fillMaxWidth()) {
EmptyState(
title = if (unreadOnly) "没有未读通知" else "还没有通知",
desc = if (unreadOnly) "有动静时这里会提醒你。" else "所有历史通知都会留在这里。",
)
}
return@Column
}
LazyColumn(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = androidx.compose.foundation.layout.PaddingValues(bottom = 20.dp),
) {
items(rows, key = { it.id }) { n ->
val isTicket = n.kind.startsWith("ticket_")
SoftCard(
Modifier.fillMaxWidth(),
onClick = {
scope.launch {
if (n.readAt == null) {
runCatching { api.markNotificationRead(workspaceId, n.id) }
rows = rows.map { if (it.id == n.id) it.copy(readAt = "read") else it }
onUnreadChanged()
}
n.itemId?.let { onOpenItem(it, isTicket) }
}
},
) {
Row(verticalAlignment = Alignment.Top) {
Box(
modifier = Modifier
.size(26.dp)
.background(if (n.readAt == null) PrimarySoft else PaperSunken, CircleShape),
contentAlignment = Alignment.Center,
) {
Text(
text = if (isTicket) "工" else "记",
fontSize = 11.sp,
fontWeight = FontWeight.SemiBold,
color = if (n.readAt == null) Primary else InkMuted,
)
}
Box(Modifier.width(10.dp))
Column(Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = n.title,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
color = Ink,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
if (n.readAt == null) {
Box(
Modifier
.padding(start = 6.dp)
.size(6.dp)
.background(Clay, CircleShape),
)
}
}
if (n.body.isNotBlank()) {
Text(
text = n.body,
fontSize = 12.sp,
color = InkSoft,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 2.dp),
)
}
Row(
modifier = Modifier.padding(top = 4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
n.itemTitle?.let {
Text(
text = it,
fontSize = 11.sp,
color = InkMuted,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false),
)
}
Text(formatTime(n.createdAt), fontSize = 11.sp, color = InkMuted)
}
}
}
}
}
}
}
}
@@ -0,0 +1,428 @@
package com.dadao.task.ui
import android.content.Context
import android.net.Uri
import android.provider.OpenableColumns
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import coil.compose.AsyncImage
import com.dadao.task.data.Api
import com.dadao.task.data.ApiException
import com.dadao.task.data.Attachment
import io.noties.markwon.Markwon
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/** 打开编辑器需要的最小上下文:改的是哪条、改前是什么。 */
data class EditRequest(
val itemId: String,
val title: String,
val body: String,
val version: Int,
val titleEnabled: Boolean = true,
val knowledge: Boolean = false,
)
/**
* 富文本编辑器(Markdown 存储)。
*
* 为什么存 Markdown 而不是 HTML:任务备注、工单说明、知识正文都要能被检索、
* 被 LLM 读、被导出;HTML 里混着行内样式,清洗成本远高于收益。
* 因此编辑区写 Markdown,预览用 Markwon 渲染,图片传附件后用签名直链插入。
*/
@Composable
fun RichTextEditorScreen(
api: Api,
workspaceId: String,
itemId: String,
initialTitle: String,
initialBody: String,
/** 保存动作由调用方决定:事项走 PATCH /items,知识走 PATCH /knowledge。 */
save: suspend (title: String?, body: String) -> Unit,
titleEnabled: Boolean = true,
onClose: (saved: Boolean) -> Unit,
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
var title by remember { mutableStateOf(initialTitle) }
var field by remember { mutableStateOf(TextFieldValue(initialBody)) }
var preview by remember { mutableStateOf(false) }
var saving by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) }
var images by remember { mutableStateOf(emptyList<Attachment>()) }
// 传过的图片:进来先拉一份,预览区与「图片」栏都用它
LaunchedEffect(itemId) {
runCatching { api.attachments(workspaceId, itemId) }
.onSuccess { images = it.filter { row -> row.contentType.startsWith("image/") } }
}
val picker = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
if (uri == null) return@rememberLauncherForActivityResult
scope.launch {
saving = true
try {
val bytes = withContext(Dispatchers.IO) {
context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
}
if (bytes == null) {
error = "读不到这张图片,换个来源试试"
return@launch
}
val name = displayName(context, uri) ?: "image-${System.currentTimeMillis()}.png"
val mime = context.contentResolver.getType(uri) ?: "image/png"
val created = api.uploadAttachment(workspaceId, itemId, name, mime, bytes)
images = images + created
// 插入用签名直链:它会过期,所以库里存的是干净的相对路径
field = insert(field, "![${created.filename}](${created.rawUrl.orEmpty()})\n")
} catch (e: ApiException) {
error = e.message
} catch (e: Exception) {
error = "上传失败:${e.message ?: "未知错误"}"
} finally {
saving = false
}
}
}
Column(
Modifier
.fillMaxSize()
.background(Paper)
.statusBarsPadding(),
) {
// 顶部:取消 / 状态 / 预览 / 保存
Row(
Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
GhostButton("取消", onClick = { onClose(false) })
Spacer(Modifier.width(10.dp))
Column(Modifier.weight(1f)) {
Text(
if (preview) "预览" else "编辑",
fontSize = 15.sp,
fontWeight = FontWeight.SemiBold,
color = Ink,
)
Text(
"${field.text.length} 字 · Markdown",
fontSize = 11.sp,
color = InkMuted,
)
}
if (saving) {
CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp, color = Primary)
Spacer(Modifier.width(8.dp))
}
PrimaryButton(if (preview) "继续写" else "预览", onClick = { preview = !preview })
Spacer(Modifier.width(8.dp))
PrimaryButton(
"保存",
enabled = !saving,
onClick = {
scope.launch {
saving = true
error = null
try {
save(if (titleEnabled) title else null, field.text)
onClose(true)
} catch (e: ApiException) {
error = if (e.code == 4009) "刚被别人改过,请退出重进再改一次" else e.message
} catch (e: Exception) {
error = e.message ?: "保存失败"
} finally {
saving = false
}
}
},
)
}
HorizontalDivider(color = Line)
error?.let {
Text(
it,
color = Rust,
fontSize = 13.sp,
modifier = Modifier
.fillMaxWidth()
.background(RustSoft)
.padding(horizontal = 14.dp, vertical = 8.dp),
)
}
// 编辑区做成一张纸:标题、工具栏、正文在同一张纸上,边界清楚
Box(
Modifier
.fillMaxSize()
.padding(horizontal = 12.dp, vertical = 10.dp),
) {
Column(
Modifier
.fillMaxSize()
.clip(RoundedCornerShape(14.dp))
.background(Color.White)
.border(1.dp, Line, RoundedCornerShape(14.dp)),
) {
if (titleEnabled) {
BasicTextField(
value = title,
onValueChange = { title = it },
singleLine = true,
textStyle = TextStyle(
fontSize = 19.sp,
fontWeight = FontWeight.Bold,
color = Ink,
),
cursorBrush = SolidColor(Primary),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 12.dp),
decorationBox = { inner ->
if (title.isBlank()) Text("标题", fontSize = 19.sp, color = InkMuted)
inner()
},
)
HorizontalDivider(color = Line)
} else if (preview) {
Text(
title.ifBlank { initialTitle },
fontSize = 19.sp,
fontWeight = FontWeight.Bold,
color = Ink,
modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp),
)
HorizontalDivider(color = Line)
}
if (preview) {
PreviewBody(field.text, images, Modifier.weight(1f))
} else {
Row(
Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState())
.padding(horizontal = 10.dp, vertical = 6.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
ToolButton("B", bold = true) { field = wrap(field, "**") }
ToolButton("I", italic = true) { field = wrap(field, "*") }
ToolButton("标题") { field = prefixLines(field, "## ") }
ToolButton("列表") { field = prefixLines(field, "- ") }
ToolButton("引用") { field = prefixLines(field, "> ") }
ToolButton("代码") { field = wrap(field, "`") }
ToolButton("链接") { field = insert(field, "[文字](https://)") }
ToolButton("图片") { picker.launch("image/*") }
}
HorizontalDivider(color = Line)
Box(
Modifier
.fillMaxSize()
.padding(horizontal = 14.dp, vertical = 12.dp),
) {
BasicTextField(
value = field,
onValueChange = { field = it },
textStyle = TextStyle(fontSize = 15.sp, color = Ink, lineHeight = 24.sp),
cursorBrush = SolidColor(Primary),
modifier = Modifier
.fillMaxSize()
.navigationBarsPadding(),
decorationBox = { inner ->
if (field.text.isBlank()) {
Text(
"写点什么。「B」「列表」在光标处插语法,图片直接选会自动上传。",
fontSize = 15.sp,
color = InkMuted,
lineHeight = 24.sp,
)
}
inner()
},
)
}
}
}
}
}
}
@Composable
private fun PreviewBody(
source: String,
images: List<Attachment>,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val markwon = remember { Markwon.create(context) }
val text = remember(source) {
// 预览里图片单独排一栏(Markwon 核心版不联网取图),正文里的图片语法换成占位说明
source.replace(Regex("!\\[[^\\]]*\\]\\([^)]*\\)"), "〔图片〕")
}
Column(
modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(horizontal = 14.dp, vertical = 12.dp)
.navigationBarsPadding(),
) {
if (images.isNotEmpty()) {
Row(
Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
images.forEach { image ->
AsyncImage(
model = image.rawUrl,
contentDescription = image.filename,
modifier = Modifier
.size(96.dp)
.clip(RoundedCornerShape(10.dp)),
)
}
}
Spacer(Modifier.height(12.dp))
}
if (text.isBlank()) {
Text("还没有内容", fontSize = 14.sp, color = InkMuted)
} else {
AndroidView(
factory = { ctx ->
android.widget.TextView(ctx).apply {
setTextIsSelectable(true)
textSize = 15f
setPadding(0, 0, 0, 0)
setLineSpacing(0f, 1.5f)
setTextColor(android.graphics.Color.parseColor("#475569"))
markwon.setMarkdown(this, text)
}
},
update = { view -> markwon.setMarkdown(view, text) },
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 120.dp),
)
}
}
}
/** 工具条按钮:小、方、无多余留白,横滑容得下。 */
@Composable
private fun ToolButton(
label: String,
bold: Boolean = false,
italic: Boolean = false,
onClick: () -> Unit,
) {
Box(
Modifier
.clip(RoundedCornerShape(8.dp))
.background(PaperSunken)
.clickable { onClick() }
.padding(horizontal = 10.dp, vertical = 6.dp),
) {
Text(
label,
fontSize = 13.sp,
color = InkSoft,
fontWeight = if (bold) FontWeight.Bold else FontWeight.Medium,
fontStyle = if (italic) FontStyle.Italic else null,
)
}
}
/** 把选区包起来:没选就插一对并落光标在中间,选了就把选中的包进去。 */
private fun wrap(field: TextFieldValue, token: String): TextFieldValue {
val start = field.selection.start.coerceIn(0, field.text.length)
val end = field.selection.end.coerceIn(start, field.text.length)
val picked = field.text.substring(start, end)
val next = field.text.substring(0, start) + token + picked + token + field.text.substring(end)
val caret = if (picked.isEmpty()) start + token.length else start + picked.length + token.length * 2
return field.copy(text = next, selection = TextRange(caret))
}
/** 行首插前缀(标题/列表/引用),一次处理选区覆盖的每一行。 */
private fun prefixLines(field: TextFieldValue, prefix: String): TextFieldValue {
val start = field.selection.start.coerceIn(0, field.text.length)
val end = field.selection.end.coerceIn(start, field.text.length)
val lineStart = field.text.lastIndexOf('\n', (start - 1).coerceAtLeast(0)).let {
if (it < 0 || start == 0) 0 else it + 1
}
val before = field.text.substring(0, lineStart)
val block = field.text.substring(lineStart, end)
val after = field.text.substring(end)
val nextBlock = block.split('\n').joinToString("\n") { line ->
if (line.startsWith(prefix)) line else prefix + line
}
val next = before + nextBlock + after
return field.copy(text = next, selection = TextRange(lineStart + nextBlock.length))
}
private fun insert(field: TextFieldValue, snippet: String): TextFieldValue {
val start = field.selection.start.coerceIn(0, field.text.length)
val end = field.selection.end.coerceIn(start, field.text.length)
val next = field.text.substring(0, start) + snippet + field.text.substring(end)
return field.copy(text = next, selection = TextRange(start + snippet.length))
}
private fun displayName(context: Context, uri: Uri): String? = runCatching {
context.contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)
?.use { cursor -> if (cursor.moveToFirst()) cursor.getString(0) else null }
}.getOrNull()
@@ -66,6 +66,10 @@ fun TaskDetailSheet(
itemId: String,
onClose: () -> Unit,
onChanged: () -> Unit,
/** 进富文本编辑器改正文。标题与状态留在抽屉里,只有正文要更大地方。 */
onEdit: (ItemDetail) -> Unit = {},
/** 编辑器保存回来后变一次,抽屉据此重新拉数据——否则还显示保存前的旧正文。 */
refreshKey: Int = 0,
) {
val sheet = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val scope = rememberCoroutineScope()
@@ -91,7 +95,7 @@ fun TaskDetailSheet(
runCatching { api.me() }.onSuccess { meId = it.user.id }
}
LaunchedEffect(itemId) { reload() }
LaunchedEffect(itemId, refreshKey) { reload() }
fun fail(e: Throwable) {
val code = (e as? ApiException)?.code ?: 0
@@ -125,6 +129,8 @@ fun TaskDetailSheet(
)
Spacer(Modifier.width(6.dp))
if (d.kind == "ticket") SoftPill("工单", Clay, ClaySoft)
Spacer(Modifier.weight(1f))
GhostButton("编辑正文", onClick = { onEdit(d) })
}
Spacer(Modifier.height(8.dp))
OutlinedTextField(
@@ -42,16 +42,24 @@ import com.dadao.task.data.Item
import com.dadao.task.data.Workspace
import kotlinx.coroutines.launch
// 「全部」放第一个,与 Web 任务页保持一致;默认选中的视图在下面单独定,不靠顺序。
private val views = listOf(
"all" to "全部",
"today" to "今天",
"active" to "进行中",
"inbox" to "收件箱",
"done" to "已完成",
"all" to "全部",
)
@Composable
fun TaskListScreen(api: Api, workspace: Workspace) {
fun TaskListScreen(
api: Api,
workspace: Workspace,
onEdit: (EditRequest) -> Unit = {},
/** 编辑器保存后要重新打开的那条。父层持这个状态,因为编辑器打开时本页会被整体卸载。 */
reopenItem: String? = null,
onReopened: () -> Unit = {},
) {
val scope = rememberCoroutineScope()
var view by remember { mutableStateOf("active") }
var mode by remember { mutableStateOf("list") }
@@ -60,6 +68,7 @@ fun TaskListScreen(api: Api, workspace: Workspace) {
var draft by remember { mutableStateOf("") }
var query by remember { mutableStateOf("") }
var openId by remember { mutableStateOf<String?>(null) }
var detailRefresh by remember { mutableStateOf(0) }
var error by remember { mutableStateOf<String?>(null) }
suspend fun reload() {
@@ -75,6 +84,15 @@ fun TaskListScreen(api: Api, workspace: Workspace) {
LaunchedEffect(view, query) { reload() }
// 从编辑器保存回来:本页是新建出来的,先刷数据再把那一条详情重新打开。
LaunchedEffect(reopenItem) {
val target = reopenItem ?: return@LaunchedEffect
reload()
detailRefresh += 1
openId = target
onReopened()
}
fun fail(e: Throwable) {
val code = (e as? ApiException)?.code ?: 0
error = verifyStatusCode(code).ifBlank { e.message ?: "操作没成功" }
@@ -193,6 +211,12 @@ fun TaskListScreen(api: Api, workspace: Workspace) {
itemId = id,
onClose = { openId = null },
onChanged = { scope.launch { reload() } },
// 抽屉是独立窗口,关掉才能让编辑器显示在上层;保存回来后由父层重新打开。
onEdit = {
openId = null
onEdit(EditRequest(it.id, it.title, it.bodyMd, it.version))
},
refreshKey = detailRefresh,
)
}
}
@@ -276,7 +300,7 @@ private fun TaskRow(item: Item, onOpen: () -> Unit, onToggle: () -> Unit) {
Text(
item.title,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
fontWeight = FontWeight.SemiBold,
color = if (item.isDone) InkMuted else Ink,
textDecoration = if (item.isDone) TextDecoration.LineThrough else TextDecoration.None,
)
@@ -13,22 +13,22 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
/**
* 与 Web 端 tailwind.config.js 同一套色板:暖白纸面 + 墨松绿主色 + 陶土橙强调。
* 与 Web 端 tailwind.config.js 同一套色板:冷白纸面 + 靛蓝主色(#4F46E5)+ 陶土橙强调。
* 两端看同一张截图时,颜色应该能对上,所以这里的值不要单独调。
*/
val Paper = Color(0xFFFBF9F6)
val PaperSunken = Color(0xFFF3F0EA)
val Line = Color(0xFFE4E0D8)
val LineStrong = Color(0xFFD3CEC2)
val Paper = Color(0xFFFAFAFC)
val PaperSunken = Color(0xFFF4F5F9)
val Line = Color(0xFFE3E7EF)
val LineStrong = Color(0xFFD5DAE4)
val Ink = Color(0xFF1F2A28)
val InkSoft = Color(0xFF4A5854)
val InkMuted = Color(0xFF7C8985)
val Ink = Color(0xFF1E293B)
val InkSoft = Color(0xFF475569)
val InkMuted = Color(0xFF64748B)
val Primary = Color(0xFF2A5A50)
val PrimaryDeep = Color(0xFF1E443C)
val PrimarySoft = Color(0xFFE3EDE8)
val PrimaryTint = Color(0xFFD2E3DB)
val Primary = Color(0xFF4F46E5)
val PrimaryDeep = Color(0xFF4338CA)
val PrimarySoft = Color(0xFFE0E7FF)
val PrimaryTint = Color(0xFFC7D2FE)
val Clay = Color(0xFFC2703D)
val ClaySoft = Color(0xFFFBEADF)
@@ -39,8 +39,8 @@ val Wheat = Color(0xFFA97C25)
val WheatSoft = Color(0xFFF8EED7)
val Rust = Color(0xFFB04A32)
val RustSoft = Color(0xFFFBE5DF)
val Sky = Color(0xFF3E6F91)
val SkySoft = Color(0xFFE4EEF5)
val Sky = Color(0xFF16697C)
val SkySoft = Color(0xFFE6F4F6)
private val colors = lightColorScheme(
primary = Primary,
@@ -1,5 +1,6 @@
package com.dadao.task.ui
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -9,6 +10,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AlertDialog
@@ -31,6 +33,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.dadao.task.data.Api
import com.dadao.task.data.ApiException
import com.dadao.task.data.Member
import com.dadao.task.data.Ticket
import com.dadao.task.data.Workspace
import kotlinx.coroutines.launch
@@ -42,7 +45,12 @@ private val ticketRoles = listOf(
)
@Composable
fun TicketsScreen(api: Api, workspace: Workspace) {
fun TicketsScreen(
api: Api,
workspace: Workspace,
focusId: String? = null,
onFocusHandled: () -> Unit = {},
) {
val scope = rememberCoroutineScope()
var role by remember { mutableStateOf("assigned") }
var tickets by remember { mutableStateOf<List<Ticket>>(emptyList()) }
@@ -52,6 +60,16 @@ fun TicketsScreen(api: Api, workspace: Workspace) {
var note by remember { mutableStateOf("") }
var resolution by remember { mutableStateOf("") }
// 手工开单:不经过任务,人得自己选
var members by remember { mutableStateOf<List<Member>>(emptyList()) }
var creating by remember { mutableStateOf(false) }
var newTitle by remember { mutableStateOf("") }
var newAssignee by remember { mutableStateOf("") }
var newPriority by remember { mutableStateOf(1) }
// 动作按钮按"我是谁"决定显隐:需求人不去接收自己的单,办理人也不该自验
var meId by remember { mutableStateOf("") }
suspend fun reload() {
loading = true
runCatching { api.tickets(workspace.id, role) }
@@ -65,6 +83,18 @@ fun TicketsScreen(api: Api, workspace: Workspace) {
LaunchedEffect(role) { reload() }
// 从通知点进来:切到"全部"才看得见那张单,然后清掉待处理标记
LaunchedEffect(focusId) {
if (focusId == null) return@LaunchedEffect
role = "all"
onFocusHandled()
}
LaunchedEffect(Unit) {
runCatching { api.members(workspace.id) }.onSuccess { members = it }
runCatching { api.me() }.onSuccess { meId = it.user.id }
}
fun fail(e: Throwable) {
val code = (e as? ApiException)?.code ?: 0
message = verifyStatusCode(code).ifBlank { e.message ?: "操作没成功" }
@@ -95,10 +125,20 @@ fun TicketsScreen(api: Api, workspace: Workspace) {
modifier = Modifier.padding(top = 4.dp),
)
Spacer(Modifier.height(10.dp))
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
ticketRoles.forEach { (key, label) ->
SurfaceChip(text = label, selected = role == key, onClick = { role = key })
}
Spacer(Modifier.weight(1f))
GhostButton("新建", onClick = {
newTitle = ""
newAssignee = ""
newPriority = 1
creating = true
})
}
}
}
@@ -124,7 +164,7 @@ fun TicketsScreen(api: Api, workspace: Workspace) {
Text(
ticket.title,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
fontWeight = FontWeight.SemiBold,
)
Spacer(Modifier.height(6.dp))
Row(
@@ -140,21 +180,23 @@ fun TicketsScreen(api: Api, workspace: Workspace) {
}
}
Spacer(Modifier.height(10.dp))
val isAssignee = ticket.ticket.assigneeId == meId
val isRequester = ticket.ticket.requesterId == meId
val isAdmin = workspace.role == "owner" || workspace.role == "admin"
val canWork = isAssignee || isAdmin
val canVerify = isRequester || isAdmin
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
when (ticket.status) {
"open" -> PrimaryButton("接收", onClick = { act(ticket, "accept") })
"accepted" -> PrimaryButton("开始办理", onClick = { act(ticket, "start") })
"in_progress" -> PrimaryButton("提交验收", onClick = {
scope.launch {
runCatching { api.ticketAction(workspace.id, ticket.id, "submit") }
.onSuccess {
message = null
reload()
}
.onFailure { fail(it) }
}
})
"pending_verify" -> {
"open" -> if (canWork) {
PrimaryButton("接收", onClick = { act(ticket, "accept") })
}
"accepted" -> if (canWork) {
PrimaryButton("开始办理", onClick = { act(ticket, "start") })
}
"in_progress" -> if (canWork) {
PrimaryButton("提交验收", onClick = { act(ticket, "submit") })
}
"pending_verify" -> if (canVerify) {
PrimaryButton("通过验收", onClick = {
verifyTarget = ticket
note = ""
@@ -174,6 +216,17 @@ fun TicketsScreen(api: Api, workspace: Workspace) {
})
}
}
// 谁都不能替别人取消,但需求人/办理人/管理员可以中止
if (ticket.status != "done" && ticket.status != "canceled" && (isRequester || isAssignee || isAdmin)) {
GhostButton("取消", onClick = { act(ticket, "cancel") })
}
if (!canWork && !canVerify && ticket.status != "done" && ticket.status != "canceled") {
Text(
if (isRequester) "等对方处理" else "这条不是派给你的",
style = MaterialTheme.typography.bodySmall,
color = InkMuted,
)
}
}
}
}
@@ -181,6 +234,89 @@ fun TicketsScreen(api: Api, workspace: Workspace) {
item { Spacer(Modifier.height(20.dp)) }
}
if (creating) {
AlertDialog(
onDismissRequest = { creating = false },
title = { Text("新建工单", style = MaterialTheme.typography.titleMedium) },
text = {
Column {
Text(
"不经过任务也能开单。写清楚要办什么,指定一个人,对方会收到通知。",
style = MaterialTheme.typography.bodySmall,
color = InkMuted,
)
Spacer(Modifier.height(10.dp))
OutlinedTextField(
value = newTitle,
onValueChange = { newTitle = it },
modifier = Modifier.fillMaxWidth(),
placeholder = { Text("要办什么,例如:核对 9 月发票", color = InkMuted) },
singleLine = true,
shape = RoundedCornerShape(10.dp),
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.White,
unfocusedContainerColor = Color.White,
focusedIndicatorColor = Primary,
unfocusedIndicatorColor = Line,
),
)
Spacer(Modifier.height(10.dp))
Text("交给谁", style = MaterialTheme.typography.labelMedium, color = InkMuted)
Spacer(Modifier.height(6.dp))
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.horizontalScroll(rememberScrollState()),
) {
members.forEach { m ->
SurfaceChip(
text = m.nickname.ifBlank { m.phone.takeLast(4) },
selected = newAssignee == m.userId,
onClick = { newAssignee = m.userId },
)
}
}
Spacer(Modifier.height(10.dp))
Text("优先级", style = MaterialTheme.typography.labelMedium, color = InkMuted)
Spacer(Modifier.height(6.dp))
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
listOf(0 to "无", 1 to "低", 2 to "中", 3 to "高").forEach { (v, label) ->
SurfaceChip(
text = label,
selected = newPriority == v,
onClick = { newPriority = v },
)
}
}
}
},
confirmButton = {
TextButton(
enabled = newTitle.isNotBlank() && newAssignee.isNotBlank(),
onClick = {
scope.launch {
runCatching {
api.createTicket(
workspace.id,
newTitle.trim(),
newAssignee,
priority = newPriority,
)
}
.onSuccess {
creating = false
message = null
role = "requested"
}
.onFailure { fail(it) }
}
},
) { Text("开单并通知对方") }
},
dismissButton = { TextButton(onClick = { creating = false }) { Text("取消") } },
containerColor = Paper,
)
}
val target = verifyTarget
if (target != null) {
AlertDialog(
@@ -0,0 +1,153 @@
package com.dadao.task.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.dadao.task.data.AppRelease
import com.dadao.task.data.AppUpdater
import kotlinx.coroutines.launch
import java.io.File
import java.util.Locale
/** 更新对话框当前停在哪一步。 */
private enum class UpdatePhase { Offer, Downloading, Downloaded, Denied, Failed }
/**
* 应用内升级的对话窗口。
*
* 只有一条主按钮,因为这一步没得选:点了就下载、下完就交给系统安装器。
* 「允许安装未知应用」是系统开关,界面只能把用户送到设置页,不能替他点。
*/
@Composable
fun UpdateDialog(
release: AppRelease,
currentVersionName: String,
updater: AppUpdater,
onLater: () -> Unit,
) {
val scope = rememberCoroutineScope()
var phase by remember { mutableStateOf(UpdatePhase.Offer) }
var progress by remember { mutableStateOf(0f) }
var detail by remember { mutableStateOf("") }
var error by remember { mutableStateOf<String?>(null) }
var file by remember { mutableStateOf<File?>(null) }
fun handToInstaller(target: File) {
if (updater.install(target)) onLater() else phase = UpdatePhase.Denied
}
AlertDialog(
// 下载过程中不给关:半截的包留在磁盘上比重新下一个更麻烦
onDismissRequest = { if (phase != UpdatePhase.Downloading) onLater() },
title = { Text("有新版 ${release.versionName}", style = MaterialTheme.typography.titleMedium) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text(
"当前 ${currentVersionName} · 新包 ${formatSize(release.size)}",
style = MaterialTheme.typography.labelMedium,
color = InkMuted,
)
if (release.notes.isNotBlank()) {
Text(release.notes, style = MaterialTheme.typography.bodySmall, color = InkSoft)
}
when (phase) {
UpdatePhase.Downloading -> {
LinearProgressIndicator(
progress = { progress },
modifier = Modifier.fillMaxWidth(),
)
Text(
"下载中 ${(progress * 100).toInt()}% $detail",
style = MaterialTheme.typography.labelSmall,
color = InkMuted,
)
}
UpdatePhase.Denied -> Text(
"安装包已经下好了,但系统还关着「允许安装未知应用」。去打开这个开关再回来点安装。",
style = MaterialTheme.typography.bodySmall,
color = Clay,
)
UpdatePhase.Failed -> Text(
error ?: "更新失败了,稍后再试",
style = MaterialTheme.typography.bodySmall,
color = Clay,
)
else -> Unit
}
}
},
confirmButton = {
when (phase) {
UpdatePhase.Denied -> PrimaryButton(
text = "去开启安装权限",
onClick = {
// 用户可能是刚从系统设置回来:权限开了就直接装,没开才把人送过去
val downloaded = file
if (downloaded != null && updater.canInstall()) {
handToInstaller(downloaded)
} else {
updater.openInstallPermission()
}
},
)
UpdatePhase.Downloaded -> PrimaryButton(
text = "安装",
onClick = { file?.let { handToInstaller(it) } },
)
UpdatePhase.Downloading -> PrimaryButton("下载中…", onClick = {}, enabled = false)
else -> PrimaryButton(
text = if (phase == UpdatePhase.Failed) "重新下载" else "下载并安装",
onClick = {
scope.launch {
phase = UpdatePhase.Downloading
progress = 0f
runCatching {
val downloaded = updater.download(release) { done, total ->
progress = if (total > 0) (done.toFloat() / total).coerceIn(0f, 1f) else 0f
detail = "${formatSize(done)} / ${formatSize(total)}"
}
file = downloaded
phase = UpdatePhase.Downloaded
handToInstaller(downloaded)
}.onFailure {
error = it.message ?: "更新失败了,稍后再试"
phase = UpdatePhase.Failed
}
}
},
)
}
},
dismissButton = {
TextButton(onClick = { if (phase != UpdatePhase.Downloading) onLater() }) {
Text("以后再说", color = InkMuted)
}
},
)
}
/** 把字节数说成人话:文件小就 KB,大就 MB。 */
private fun formatSize(bytes: Long): String {
if (bytes <= 0) return "未知大小"
if (bytes < 1024 * 1024) return "${(bytes / 1024)} KB"
return String.format(Locale.US, "%.1f MB", bytes / 1024.0 / 1024.0)
}
@@ -27,6 +27,7 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
@@ -96,27 +97,46 @@ fun SoftPill(text: String, fg: Color = InkSoft, bg: Color = PaperSunken, modifie
}
}
/** 卡片:白底、细描边、几乎不可见的投影。整个 App 用它承载所有内容。 */
/**
* 卡片:白底、细描边、几乎不可见的投影。整个 App 用它承载所有内容。
*
* [accent] 是卡片顶部的一条 3dp 色带——知识卡片用它显示所属分类的颜色,
* 扫一眼列表就能按颜色找回"这堆是哪个分类的"。用 clip 保证色带两端跟着圆角走。
*/
@Composable
fun SoftCard(
modifier: Modifier = Modifier,
padding: Dp = 14.dp,
accent: Color? = null,
onClick: (() -> Unit)? = null,
content: @Composable ColumnScope.() -> Unit,
) {
val shape = RoundedCornerShape(14.dp)
val base = modifier
.fillMaxWidth()
.border(1.dp, Line, RoundedCornerShape(14.dp))
.background(Color.White, RoundedCornerShape(14.dp))
.clip(shape)
.border(1.dp, Line, shape)
.background(Color.White, shape)
Surface(
modifier = if (onClick != null) base.clickable { onClick() } else base,
color = Color.White,
shape = RoundedCornerShape(14.dp),
shape = shape,
) {
Column(modifier = Modifier.padding(padding), content = content)
Column {
if (accent != null) {
Box(Modifier.fillMaxWidth().height(3.dp).background(accent))
}
Column(modifier = Modifier.padding(padding), content = content)
}
}
}
/** 分类色的小圆点:侧栏、筛选 chip、卡片 meta 里到处都用它。 */
@Composable
fun CategoryDot(color: Color, size: Dp = 8.dp, modifier: Modifier = Modifier) {
Box(modifier.size(size).background(color, CircleShape))
}
@Composable
fun SectionLabel(text: String, modifier: Modifier = Modifier) {
Text(
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 自适应图标前景:与 Web Logo 同一套比例(108 画布,安全区 66 居中;靛蓝底上的白线与淡紫点) -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:pathData="M36.3,42.6 L71.7,42.6"
android:strokeColor="#FFFFFF"
android:strokeWidth="5.06"
android:strokeLineCap="round" />
<path
android:pathData="M36.3,54 L59.6,54"
android:strokeColor="#FFFFFF"
android:strokeWidth="5.06"
android:strokeLineCap="round" />
<path
android:pathData="M36.3,65.4 L50.8,65.4"
android:strokeColor="#FFFFFF"
android:strokeWidth="5.06"
android:strokeLineCap="round" />
<path
android:fillColor="#C7D2FE"
android:pathData="M71.7,65.4 m-7.6,0 a7.6,7.6 0 1,0 15.2,0 a7.6,7.6 0 1,0 -15.2,0" />
</vector>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- 与 Web 端 Logo(web/src/components/icons.tsx 的 <Logo/>)同一个靛蓝 -->
<color name="ic_launcher_background">#4F46E5</color>
</resources>
+2 -2
View File
@@ -2,7 +2,7 @@
<resources>
<!-- Compose 自己管配色,这里只提供无 ActionBar 的容器主题 -->
<style name="Theme.DadaoTask" parent="android:Theme.Material.Light.NoActionBar">
<item name="android:statusBarColor">#3F5A68</item>
<item name="android:windowBackground">#F7F8F8</item>
<item name="android:statusBarColor">#4F46E5</item>
<item name="android:windowBackground">#FAFAFC</item>
</style>
</resources>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- FileProvider 的可见范围:只暴露应用自己的 files/updates,安装包就在那儿 -->
<paths>
<external-files-path name="updates" path="updates/" />
<files-path name="internal_updates" path="updates/" />
</paths>
@@ -1,11 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
只对本机开发地址放行明文 HTTP。生产域名必须走 HTTPS,不放行任何 cleartext。
只给内网/本机地址放行明文 HTTP:10.0.2.2 是模拟器里的宿主机,
192.168.8.188 是当前的内网生产入口(还没上 HTTPS)。
正式域名必须走 HTTPS——上线时把 192.168.8.188 这条删掉。
-->
<network-security-config>
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="false">10.0.2.2</domain>
<domain includeSubdomains="false">127.0.0.1</domain>
<domain includeSubdomains="false">localhost</domain>
<domain includeSubdomains="false">192.168.8.188</domain>
</domain-config>
</network-security-config>
+7 -1
View File
@@ -24,14 +24,20 @@ JWT_SECRET=dev-only-change-me
ACCESS_TOKEN_MINUTES=30
REFRESH_TOKEN_DAYS=14
# 生产环境禁止 SMS_PROVIDER=dev(否则任意验证码可登录),启动即失败
# 生产环境禁止 SMS_PROVIDER=dev(否则任意验证码可登录),启动即失败;
# 内网试用确需固定验证码时显式设置 INTERNAL_TEST_LOGIN=true(启动会打 WARNING)
SMS_PROVIDER=dev
SMS_DEV_CODE=123456
INTERNAL_TEST_LOGIN=false
# 同一手机号两次发码的最小间隔(秒),测试里置 0 可关闭
SMS_THROTTLE_SECONDS=60
CORS_ORIGINS=http://localhost:5180,http://127.0.0.1:5180
# 生产单端口部署:后端直接托管前端构建产物(同源,可留空 CORS)
# 留空则只提供 API;部署方式见 2.Design/DEPLOY.md
# WEB_DIST_DIR=/home/openclaw/app/dadaotask/web/dist
# ── 知识库向量库(独立的 PG + pgvector,M3 起用)──────────────────────
# 单独实例,不动 fnsvr-01:那台没有 pgvector,且向量检索吃内存,不该和业务库挤。
# 起法见 deploy/docker-compose.vector.yml。不配则 M3 的语义检索能力直接不可用。
+3 -3
View File
@@ -12,13 +12,13 @@ import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
from app.roles import APP_ROLE, OWNER_ROLE
revision: str = "0001"
down_revision: str | None = None
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
APP_ROLE = "dadao_app"
# 说明:asyncpg 走"预备语句"执行,**一条 execute 里只能有一条 SQL**,
# 多条语句会报 `cannot insert multiple commands into a prepared statement`。
# 所以这里按语句拆成元组,逐条执行。
@@ -76,7 +76,7 @@ POLICY_STATEMENTS: tuple[str, ...] = (
f"workspace, app_user, member, invitation, audit_log TO {APP_ROLE};"
),
(
"ALTER DEFAULT PRIVILEGES FOR ROLE dadao_owner IN SCHEMA public "
f"ALTER DEFAULT PRIVILEGES FOR ROLE {OWNER_ROLE} IN SCHEMA public "
f"GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO {APP_ROLE};"
),
)
+2 -2
View File
@@ -15,13 +15,13 @@ import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
from app.roles import APP_ROLE
revision: str = "0002"
down_revision: str | None = "0001"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
APP_ROLE = "dadao_app"
TENANT_TABLES_0002: tuple[str, ...] = ("item", "tag", "item_tag", "comment", "activity")
_TIMESTAMP_COLUMNS = (
+2 -2
View File
@@ -19,13 +19,13 @@ import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
from app.roles import APP_ROLE
revision: str = "0003"
down_revision: str | None = "0002"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
APP_ROLE = "dadao_app"
TENANT_TABLES_0003: tuple[str, ...] = ("ticket_ext", "item_link", "notification")
TASK_STATUSES = ("inbox", "todo", "doing", "done", "blocked", "canceled")
+2 -2
View File
@@ -21,13 +21,13 @@ import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
from app.roles import APP_ROLE
revision: str = "0004"
down_revision: str | None = "0003"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
APP_ROLE = "dadao_app"
TENANT_TABLES_0004: tuple[str, ...] = ("knowledge_doc",)
TASK_STATUSES = ("inbox", "todo", "doing", "done", "blocked", "canceled")
@@ -0,0 +1,68 @@
"""附件表(任务 / 工单 / 知识共用)
Revision ID: 0005
Revises: 0004
"""
from __future__ import annotations
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import UUID as PgUUID
from alembic import op
from app.roles import APP_ROLE
revision = "0005"
down_revision = "0004"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"attachment",
sa.Column("id", PgUUID(as_uuid=True), primary_key=True),
sa.Column(
"workspace_id",
PgUUID(as_uuid=True),
sa.ForeignKey("workspace.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"item_id",
PgUUID(as_uuid=True),
sa.ForeignKey("item.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"uploader_id",
PgUUID(as_uuid=True),
sa.ForeignKey("app_user.id", ondelete="RESTRICT"),
nullable=False,
),
sa.Column("filename", sa.String(255), nullable=False),
sa.Column("content_type", sa.String(120), nullable=False),
sa.Column("size_bytes", sa.BigInteger(), nullable=False),
sa.Column("sha256", sa.String(64), nullable=False),
sa.Column("storage_key", sa.String(255), nullable=False, unique=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.CheckConstraint("size_bytes >= 0", name="ck_attachment_size"),
)
op.create_index(
"ix_attachment_ws_item", "attachment", ["workspace_id", "item_id", "created_at"]
)
for statement in (
"ALTER TABLE attachment ENABLE ROW LEVEL SECURITY",
"CREATE POLICY attachment_isolation ON attachment "
"USING (workspace_id = app_ws_id()) WITH CHECK (workspace_id = app_ws_id())",
f"GRANT SELECT, INSERT, UPDATE, DELETE ON attachment TO {APP_ROLE}",
):
op.execute(statement)
def downgrade() -> None:
op.execute("DROP POLICY IF EXISTS attachment_isolation ON attachment")
op.drop_index("ix_attachment_ws_item", table_name="attachment")
op.drop_table("attachment")
+104
View File
@@ -0,0 +1,104 @@
"""知识切片与检索(M3)
Revision ID: 0006
Revises: 0005
Create Date: 2026-10-29
建 `chunk` 表:知识正文按段切开,每片一行,带向量数组。
两个刻意的取舍:
1. **向量存 `real[]`,不依赖 pgvector**。本机拿不到带 pgvector 的实例(Docker 起不来,
fnsvr-01 明确不动),但检索链路不能一直停在纸面上。`real[]` + 一条 SQL 余弦函数
在小数据量下够用;将来换上 pgvector 只需改列类型、加 HNSW 索引,应用层接口不变。
2. **检索范围只能是 `published`**。草稿是"还没被人认可的话",进检索等于让 AI 把
猜测当结论引用出去——这条过滤写在查询里,不靠调用方自觉。
"""
from __future__ import annotations
from collections.abc import Sequence
from alembic import op
from app.roles import APP_ROLE
revision: str = "0006"
down_revision: str = "0005"
branch_labels: Sequence[str] | None = None
depends_on: Sequence[str] | None = None
# 余弦相似度。写成 SQL 函数而不是在 Python 里算,是为了让过滤与排序留在数据库里做——
# 把整表向量捞回应用算相似度,数据一多就会拖垮 API。
COSINE_SQL = """
CREATE OR REPLACE FUNCTION app_cosine(a real[], b real[]) RETURNS double precision
LANGUAGE plpgsql IMMUTABLE PARALLEL SAFE AS $fn$
DECLARE
n int;
dot double precision := 0;
na double precision := 0;
nb double precision := 0;
BEGIN
n := LEAST(array_length(a, 1), array_length(b, 1));
IF n IS NULL THEN
RETURN NULL;
END IF;
FOR i IN 1..n LOOP
dot := dot + a[i] * b[i];
na := na + a[i] * a[i];
nb := nb + b[i] * b[i];
END LOOP;
IF na = 0 OR nb = 0 THEN
RETURN 0;
END IF;
RETURN dot / sqrt(na * nb);
END
$fn$;
"""
CREATE_SQL = """
CREATE TABLE chunk (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id uuid NOT NULL,
item_id uuid NOT NULL REFERENCES item(id) ON DELETE CASCADE,
idx integer NOT NULL,
heading varchar(200),
content text NOT NULL,
char_count integer NOT NULL DEFAULT 0,
embedding real[],
embed_model varchar(64),
version integer NOT NULL DEFAULT 1,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT uq_chunk_item_idx UNIQUE (item_id, idx)
)
"""
# asyncpg 的执行器不接受多语句,一条 `execute` 只能带一句 SQL(0001 踩过同样的坑)
INDEX_STATEMENTS = (
"CREATE INDEX ix_chunk_ws_item ON chunk (workspace_id, item_id)",
"COMMENT ON TABLE chunk IS '知识正文切片,检索的最小单位'",
)
POLICY_SQL = (
"ALTER TABLE chunk ENABLE ROW LEVEL SECURITY",
"CREATE POLICY chunk_isolation ON chunk "
"USING (workspace_id = app_ws_id()) WITH CHECK (workspace_id = app_ws_id())",
f"GRANT SELECT, INSERT, UPDATE, DELETE ON chunk TO {APP_ROLE}",
)
def upgrade() -> None:
op.execute(COSINE_SQL)
op.execute(CREATE_SQL)
for statement in INDEX_STATEMENTS:
op.execute(statement)
for statement in POLICY_SQL:
op.execute(statement)
def downgrade() -> None:
op.execute("DROP POLICY IF EXISTS chunk_isolation ON chunk")
op.execute("DROP TABLE IF EXISTS chunk")
# 函数被别的东西引用时先别删;本轮只有切片在用,留一条注释说明为什么还留着。
# op.execute("DROP FUNCTION IF EXISTS app_cosine(real[], real[])")
@@ -0,0 +1,81 @@
"""知识分类(M3 补充)
Revision ID: 0007
Revises: 0006
Create Date: 2026-10-30
知识库的基本形态是"备忘录",不是"任务":一条知识要有标题、正文、标签,
还要能归类。这里补上分类树(一级大类 / 二级 / 三级,深不过三)。
两个取舍:
1. **分类独立成表,不塞进 item**。分类是工作区级的字典,一条知识挂一个分类;
塞进 item 会让"分类"变成每条知识自己的一份副本,改个名字要改几百行。
2. **删分类不删知识**。外键是 `ON DELETE SET NULL`:知识退回"未分类",
分类只是入口,不是知识的容器。真要连内容一起删,那是删知识,得单独确认。
"""
from __future__ import annotations
from collections.abc import Sequence
from alembic import op
from app.roles import APP_ROLE
revision: str = "0007"
down_revision: str = "0006"
branch_labels: Sequence[str] | None = None
depends_on: Sequence[str] | None = None
CREATE_SQL = """
CREATE TABLE knowledge_category (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id uuid NOT NULL REFERENCES workspace(id) ON DELETE CASCADE,
parent_id uuid REFERENCES knowledge_category(id) ON DELETE CASCADE,
name varchar(40) NOT NULL,
depth integer NOT NULL DEFAULT 1,
sort_order integer NOT NULL DEFAULT 0,
version integer NOT NULL DEFAULT 1,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT ck_kcat_depth CHECK (depth BETWEEN 1 AND 3),
CONSTRAINT ck_kcat_name CHECK (length(btrim(name)) > 0)
)
"""
INDEX_STATEMENTS = (
"CREATE INDEX ix_kcat_ws_parent ON knowledge_category (workspace_id, parent_id)",
# 同一父级下不许重名。根分类的 parent_id 是 NULL,而 PG 里 NULL 互不相等,
# 所以根要单独一条 `WHERE parent_id IS NULL` 的部分索引,否则可以建出两个同名大类。
"CREATE UNIQUE INDEX uq_kcat_ws_parent_name ON knowledge_category "
"(workspace_id, parent_id, lower(name)) WHERE parent_id IS NOT NULL",
"CREATE UNIQUE INDEX uq_kcat_ws_root_name ON knowledge_category "
"(workspace_id, lower(name)) WHERE parent_id IS NULL",
"COMMENT ON TABLE knowledge_category IS '知识分类树,深不过三'",
"ALTER TABLE knowledge_doc ADD COLUMN category_id uuid "
"REFERENCES knowledge_category(id) ON DELETE SET NULL",
"CREATE INDEX ix_knowledge_ws_category ON knowledge_doc (workspace_id, category_id)",
)
POLICY_SQL = (
"ALTER TABLE knowledge_category ENABLE ROW LEVEL SECURITY",
"CREATE POLICY knowledge_category_isolation ON knowledge_category "
"USING (workspace_id = app_ws_id()) WITH CHECK (workspace_id = app_ws_id())",
f"GRANT SELECT, INSERT, UPDATE, DELETE ON knowledge_category TO {APP_ROLE}",
)
def upgrade() -> None:
op.execute(CREATE_SQL)
for statement in INDEX_STATEMENTS:
op.execute(statement)
for statement in POLICY_SQL:
op.execute(statement)
def downgrade() -> None:
op.execute("DROP INDEX IF EXISTS ix_knowledge_ws_category")
op.execute("ALTER TABLE knowledge_doc DROP COLUMN IF EXISTS category_id")
op.execute("DROP POLICY IF EXISTS knowledge_category_isolation ON knowledge_category")
op.execute("DROP TABLE IF EXISTS knowledge_category")
@@ -0,0 +1,40 @@
"""登录密码(M1 账号功能)
Revision ID: 0008
Revises: 0007
Create Date: 2026-10-31
M0 只有手机号 + 验证码登录。验证码登录在换手机、短信通道欠费、
或者只想快一点的时候都不够用,所以给账号加一条**可选**的密码登录路径。
两个取舍:
1. **密码可空**。老用户不去设置就一直走验证码,不会被强制改流程;
登录页两种方式并存,谁设过谁自己知道。
2. **只存 scrypt 串**(见 `app/security.py`),不存明文也不存 md5。
密码本身永远不回给客户端,接口只返回 `has_password` 这个布尔。
"""
from __future__ import annotations
from collections.abc import Sequence
from alembic import op
revision: str = "0008"
down_revision: str = "0007"
branch_labels: Sequence[str] | None = None
depends_on: Sequence[str] | None = None
def upgrade() -> None:
op.execute(
"""
ALTER TABLE app_user
ADD COLUMN IF NOT EXISTS password_hash VARCHAR(255)
"""
)
def downgrade() -> None:
op.execute("ALTER TABLE app_user DROP COLUMN IF EXISTS password_hash")
@@ -0,0 +1,48 @@
"""知识分类配色(M3 补充)
Revision ID: 0009
Revises: 0008
Create Date: 2026-11-02
分类从"只能分级"变成"还能上色"。一列而已,但解决了卡片墙上的一个真问题:
三十条知识平铺在一起,光看标题要逐条读才知道哪条属于部署、哪条属于业务。
分类色让"归属"在扫视时就成立,不用读字。
三个取舍:
1. **颜色存分类上,不存知识上**。同一类的颜色必须一致,否则卡片墙会花成
一盘散沙;存在分类上,改一次全类生效。
2. **允许为空**。老数据(0007 建的分类)没有颜色,强行补一个默认值等于替用户
做决定;为空时前端按分类 id 稳定地挑一个调色板色,看起来仍是"每类一色"。
3. **CHECK 约束卡格式**。只接受 `#RRGGBB`,大写。前端传 `rgb(...)` 或带 alpha
的值会被挡在数据库外——渲染层不会因为一个脏值悄悄丢样式。
"""
from __future__ import annotations
from collections.abc import Sequence
from alembic import op
revision: str = "0009"
down_revision: str = "0008"
branch_labels: Sequence[str] | None = None
depends_on: Sequence[str] | None = None
UPGRADE_SQL = (
"ALTER TABLE knowledge_category ADD COLUMN IF NOT EXISTS color varchar(7)",
"ALTER TABLE knowledge_category DROP CONSTRAINT IF EXISTS ck_kcat_color",
"ALTER TABLE knowledge_category ADD CONSTRAINT ck_kcat_color "
"CHECK (color IS NULL OR color ~ '^#[0-9A-F]{6}$')",
"COMMENT ON COLUMN knowledge_category.color IS '分类色 #RRGGBB,卡片上边线用它'",
)
def upgrade() -> None:
for statement in UPGRADE_SQL:
op.execute(statement)
def downgrade() -> None:
op.execute("ALTER TABLE knowledge_category DROP CONSTRAINT IF EXISTS ck_kcat_color")
op.execute("ALTER TABLE knowledge_category DROP COLUMN IF EXISTS color")
@@ -0,0 +1,67 @@
"""邮箱登录 + 平台设置(M1 账号功能)
Revision ID: 0010
Revises: 0009
Create Date: 2026-11-02
两件事:
1. `app_user.email`:邮箱当账号名,配合密码登录。唯一性用 `lower(email)` 的部分
唯一索引——邮箱大小写不敏感,`Steven@x.com` 和 `steven@x.com` 是同一个人;
允许为空(老用户先不绑)。
2. `system_setting`:平台级键值表,第一项就是 SMTP。**这张表刻意不挂 workspace_id、
也不开 RLS**:发信账号是整台服务共用的,属于平台而不是某个租户,开 RLS 反而要
编一个"当前租户"出来。写权限在 API 层用平台管理员白名单把关(deps.platform_admin),
应用角色只有表的读写权,没有建表/改结构的权。
"""
from __future__ import annotations
from collections.abc import Sequence
from alembic import op
from app.roles import APP_ROLE
revision: str = "0010"
down_revision: str = "0009"
branch_labels: Sequence[str] | None = None
depends_on: Sequence[str] | None = None
CREATE_SQL = """
CREATE TABLE IF NOT EXISTS system_setting (
key VARCHAR(64) PRIMARY KEY,
value JSONB NOT NULL DEFAULT '{}'::jsonb,
updated_by UUID REFERENCES app_user(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
version INTEGER NOT NULL DEFAULT 1
)
"""
INDEX_STATEMENTS = (
"ALTER TABLE app_user ADD COLUMN IF NOT EXISTS email VARCHAR(255)",
"CREATE UNIQUE INDEX uq_app_user_email ON app_user (lower(email)) WHERE email IS NOT NULL",
"COMMENT ON TABLE system_setting IS '平台级设置(SMTP 等),不挂租户、无 RLS'",
)
GRANT_SQL = (
f"GRANT SELECT, INSERT, UPDATE, DELETE ON system_setting TO {APP_ROLE}",
f"GRANT SELECT, UPDATE ON app_user TO {APP_ROLE}",
)
def upgrade() -> None:
op.execute(CREATE_SQL)
for statement in INDEX_STATEMENTS:
op.execute(statement)
for statement in GRANT_SQL:
op.execute(statement)
def downgrade() -> None:
op.execute("DROP INDEX IF EXISTS uq_app_user_email")
op.execute("ALTER TABLE app_user DROP COLUMN IF EXISTS email")
op.execute("DROP TABLE IF EXISTS system_setting")
@@ -0,0 +1,41 @@
"""app_user 支持邮箱注册:放开 phone 非空、记录邮箱已验证时间
手机验证码登录关掉以后,新用户是「邮箱 + 验证码」注册进来的,此时还没有手机号;
`app_user.phone` 原来 NOT NULL,不放开就没法插这类账号。
`email_verified_at` 用来区分"填了个邮箱字符串"和"这个地址确实验证过":
注册流程发码成功后写入;账号页手工绑邮箱时同一套流程。
Revision ID: 0011
Revises: 0010
"""
from __future__ import annotations
from alembic import op
from app.roles import APP_ROLE
revision = "0011"
down_revision = "0010"
branch_labels = None
depends_on = None
def upgrade() -> None:
# 邮箱注册的账号没有手机号:老账号都有值,放开非空不影响它们
op.execute("ALTER TABLE app_user ALTER COLUMN phone DROP NOT NULL")
op.execute("ALTER TABLE app_user ADD COLUMN IF NOT EXISTS email_verified_at TIMESTAMPTZ")
op.execute(
"COMMENT ON COLUMN app_user.email_verified_at IS "
"'邮箱验证通过的时间;为空表示还没验证过这个地址'"
)
# 本表全局(不挂租户),只补列不新增表,权限照旧:应用角色要能读写这些列
op.execute(f"GRANT SELECT, UPDATE ON app_user TO {APP_ROLE}")
def downgrade() -> None:
# 回滚前先把没有手机号的账号补上占位值,否则加回 NOT NULL 会失败
op.execute("UPDATE app_user SET phone = 'redeem-' || replace(id::text, '-', '') WHERE phone IS NULL")
op.execute("ALTER TABLE app_user DROP COLUMN IF EXISTS email_verified_at")
op.execute("ALTER TABLE app_user ALTER COLUMN phone SET NOT NULL")
@@ -0,0 +1,108 @@
"""微信(clawbot)接入通道
Revision ID: 0012
Revises: 0011
Create Date: 2026-11-05
两张表:
- `bot_channel`:一个工作区可以挂多个机器人通道,令牌只存 sha256。
- `bot_message`:进来的每条消息留痕,附带它变成了哪条任务/知识。
`bot_channel` 的 RLS 多放了一条口子:`token_hash = app_bot_token_hash()`。
原因很实在——**收到 webhook 时我们还不知道它是哪个工作区的**,而候选令牌
必须读过表才知道。没有这条口子,就只能要么关掉 RLS(不行),要么把令牌
放一张不挂租户的表里(等于把钥匙串放在门外)。现在的做法是:
请求先把 `app.bot_token_hash` 设成本次令牌的哈希,策略只允许命中那一行,
其余行照旧看不见。
"""
from __future__ import annotations
from collections.abc import Sequence
from alembic import op
from app.roles import APP_ROLE
revision: str = "0012"
down_revision: str = "0011"
branch_labels: Sequence[str] | None = None
depends_on: Sequence[str] | None = None
CREATE_STATEMENTS = (
"""
CREATE TABLE bot_channel (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id uuid NOT NULL REFERENCES workspace(id) ON DELETE CASCADE,
kind varchar(32) NOT NULL DEFAULT 'wechat_clawbot',
name varchar(64) NOT NULL DEFAULT '',
token_hash varchar(64) NOT NULL,
status varchar(16) NOT NULL DEFAULT 'active',
created_by uuid REFERENCES app_user(id) ON DELETE SET NULL,
last_seen_at timestamptz,
last_error text,
version integer NOT NULL DEFAULT 1,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
)
""",
"""
CREATE TABLE bot_message (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id uuid NOT NULL REFERENCES workspace(id) ON DELETE CASCADE,
channel_id uuid NOT NULL REFERENCES bot_channel(id) ON DELETE CASCADE,
external_id varchar(128),
sender varchar(128),
text text NOT NULL DEFAULT '',
action varchar(24) NOT NULL DEFAULT 'ignored',
item_id uuid REFERENCES item(id) ON DELETE SET NULL,
reply text NOT NULL DEFAULT '',
raw jsonb NOT NULL DEFAULT '{}'::jsonb,
version integer NOT NULL DEFAULT 1,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
)
""",
)
INDEX_STATEMENTS = (
"CREATE INDEX ix_bot_channel_ws ON bot_channel (workspace_id)",
"CREATE UNIQUE INDEX uq_bot_channel_token ON bot_channel (token_hash)",
"CREATE INDEX ix_bot_message_channel ON bot_message (channel_id, created_at)",
"CREATE UNIQUE INDEX uq_bot_message_external ON bot_message (channel_id, external_id) "
"WHERE external_id IS NOT NULL",
)
POLICY_SQL = (
"ALTER TABLE bot_channel ENABLE ROW LEVEL SECURITY",
"CREATE POLICY bot_channel_isolation ON bot_channel "
"USING (workspace_id = app_ws_id() OR token_hash = current_setting('app.bot_token_hash', true)) "
"WITH CHECK (workspace_id = app_ws_id())",
"ALTER TABLE bot_message ENABLE ROW LEVEL SECURITY",
"CREATE POLICY bot_message_isolation ON bot_message "
"USING (workspace_id = app_ws_id()) WITH CHECK (workspace_id = app_ws_id())",
f"GRANT SELECT, INSERT, UPDATE, DELETE ON bot_channel TO {APP_ROLE}",
f"GRANT SELECT, INSERT, UPDATE, DELETE ON bot_message TO {APP_ROLE}",
)
COMMENT_SQL = (
"COMMENT ON TABLE bot_channel IS '外部接入通道(微信 clawbot 等),令牌只存 sha256'",
"COMMENT ON TABLE bot_message IS '外部接入收到并处理过的消息,用于支线排查与去重'",
)
def upgrade() -> None:
for statement in CREATE_STATEMENTS:
op.execute(statement)
for statement in INDEX_STATEMENTS:
op.execute(statement)
for statement in POLICY_SQL:
op.execute(statement)
for statement in COMMENT_SQL:
op.execute(statement)
def downgrade() -> None:
op.execute("DROP TABLE IF EXISTS bot_message")
op.execute("DROP TABLE IF EXISTS bot_channel")
@@ -0,0 +1,63 @@
"""微信 clawbot 改成"扫码登录 + 长轮询"
Revision ID: 0013
Revises: 0012
Create Date: 2026-11-05
上一版(0012)做成了"clawbot 往我们这 POST webhook"。真实协议是腾讯 iLink Bot:
我们先扫码登录拿 `bot_token`,再由我们长轮询 `getupdates` 拉消息(见
`app/services/wechat_ilink.py` 文件头的出处说明)。所以这张表要补登录态与游标:
- `bot_token_enc`:加密后的登录态(密钥由 JWT_SECRET 派生),换 secret 就要重新扫码;
- `ilink_user_id`:扫码的微信号,界面上显示"谁扫的";
- `base_url`:服务端可能下发新基址;
- `sync_buf`:长轮询游标,丢了会重复收消息;
- `context_token`:回复必须带对方上一条消息的 context_token。
`bot_channel.status` 增加 `pending`(等扫码)与 `expired`(登录态失效)两个取值;
`token_hash` 是上一版遗留列,改成可空、不再使用。
"""
from __future__ import annotations
from collections.abc import Sequence
from alembic import op
from app.roles import APP_ROLE
revision: str = "0013"
down_revision: str = "0012"
branch_labels: Sequence[str] | None = None
depends_on: Sequence[str] | None = None
STATEMENTS = (
"ALTER TABLE bot_channel ADD COLUMN IF NOT EXISTS bot_token_enc text",
"ALTER TABLE bot_channel ADD COLUMN IF NOT EXISTS ilink_user_id varchar(64)",
"ALTER TABLE bot_channel ADD COLUMN IF NOT EXISTS base_url text",
"ALTER TABLE bot_channel ADD COLUMN IF NOT EXISTS sync_buf text",
"ALTER TABLE bot_channel ADD COLUMN IF NOT EXISTS context_token text",
"ALTER TABLE bot_channel ALTER COLUMN token_hash DROP NOT NULL",
"ALTER TABLE bot_channel ALTER COLUMN status SET DEFAULT 'pending'",
"COMMENT ON COLUMN bot_channel.bot_token_enc IS 'iLink 登录态,Fernet 加密(密钥由 JWT_SECRET 派生)'",
"COMMENT ON COLUMN bot_channel.sync_buf IS 'getupdates 游标,丢了会重复收消息'",
f"GRANT SELECT, INSERT, UPDATE, DELETE ON bot_channel TO {APP_ROLE}",
)
DOWN_STATEMENTS = (
"ALTER TABLE bot_channel DROP COLUMN IF EXISTS context_token",
"ALTER TABLE bot_channel DROP COLUMN IF EXISTS sync_buf",
"ALTER TABLE bot_channel DROP COLUMN IF EXISTS base_url",
"ALTER TABLE bot_channel DROP COLUMN IF EXISTS ilink_user_id",
"ALTER TABLE bot_channel DROP COLUMN IF EXISTS bot_token_enc",
)
def upgrade() -> None:
for statement in STATEMENTS:
op.execute(statement)
def downgrade() -> None:
for statement in DOWN_STATEMENTS:
op.execute(statement)
@@ -0,0 +1,46 @@
"""轮询任务读通道需要一条 RLS 口子
Revision ID: 0014
Revises: 0013
Create Date: 2026-11-05
后台轮询是"从 channel_id 开始"的:它还没有工作区上下文,而 `bot_channel` 的策略要求
`workspace_id = app_ws_id()`,直接 SELECT 会一行都看不到(先有鸡还是先有蛋)。
和 0012 那条 `token_hash = app_bot_token_hash()` 同理,这里再放一条**只按主键命中自己**
的口子:任务在读任何东西之前先把 `app.bot_channel` 设成本次要处理的通道 id,
策略允许它看见那一行,其余行照旧不可见。读到 workspace_id 之后立刻 `bind_tenant`,
后面所有查询都回到正常的租户上下文里。
两条策略是 permissive 关系(PG 会 OR 起来),所以原来的隔离策略不用改。
"""
from __future__ import annotations
from collections.abc import Sequence
from alembic import op
from app.roles import APP_ROLE
revision: str = "0014"
down_revision: str = "0013"
branch_labels: Sequence[str] | None = None
depends_on: Sequence[str] | None = None
CREATE_POLICY = """
CREATE POLICY bot_channel_worker ON bot_channel
FOR ALL
USING (id = current_setting('app.bot_channel', true)::uuid)
WITH CHECK (id = current_setting('app.bot_channel', true)::uuid)
"""
def upgrade() -> None:
op.execute("DROP POLICY IF EXISTS bot_channel_worker ON bot_channel")
op.execute(CREATE_POLICY)
op.execute(f"GRANT SELECT, INSERT, UPDATE, DELETE ON bot_channel TO {APP_ROLE}")
def downgrade() -> None:
op.execute("DROP POLICY IF EXISTS bot_channel_worker ON bot_channel")
@@ -0,0 +1,56 @@
"""bot_channel 的轮询口子要容忍"空字符串"的 GUC
`set_config(..., true)` 提交后会把变量还原成上一次的值;连接池里这条连接如果本来没有
会话级值,还原出来的是空串 `''` 而不是 NULL。`0014` 的策略直接
`current_setting('app.bot_channel', true)::uuid`,空串一 cast 就
`InvalidTextRepresentationError: invalid input syntax for type uuid: ""`,
于是同一连接上下一个请求(比如扫码确认)500。
这里把策略重建成和 `app_ws_id()` 一样的写法:先 `nullif(..., '')` 再 cast。
Revision ID: 0015
Revises: 0014
"""
from __future__ import annotations
from alembic import op
from app.roles import APP_ROLE
revision = "0015"
down_revision = "0014"
branch_labels = None
depends_on = None
STATEMENTS: tuple[str, ...] = (
"DROP POLICY IF EXISTS bot_channel_worker ON bot_channel",
"""
CREATE POLICY bot_channel_worker ON bot_channel
FOR ALL
USING (
id = nullif(current_setting('app.bot_channel', true), '')::uuid
)
WITH CHECK (
id = nullif(current_setting('app.bot_channel', true), '')::uuid
)
""",
f"GRANT SELECT, INSERT, UPDATE, DELETE ON bot_channel TO {APP_ROLE}",
)
def upgrade() -> None:
for statement in STATEMENTS:
op.execute(statement)
def downgrade() -> None:
op.execute("DROP POLICY IF EXISTS bot_channel_worker ON bot_channel")
op.execute(
"""
CREATE POLICY bot_channel_worker ON bot_channel
FOR ALL
USING (id = current_setting('app.bot_channel', true)::uuid)
WITH CHECK (id = current_setting('app.bot_channel', true)::uuid)
"""
)
@@ -0,0 +1,43 @@
"""启动时列出要接管的通道,也需要一条 RLS 口子
Revision ID: 0016
Revises: 0015
Create Date: 2026-11-05
`BotManager.start_all()` 要在进程启动时问一句"现在有哪些通道已登录、该接着收消息",
但 `bot_channel` 的隔离策略要求 `workspace_id = app_ws_id()`,启动那一刻**没有工作区**,
结果一行都查不到——表现是"重启之后机器人静默不收消息,页面上还显示已连接"。
0014 那条口子是"知道 id、只看自己那一行",这里补的是"允许列出还没开始干的活":
只有当次事务显式设了 `app.bot_list = 'on'` 时才放行,而且只开 **SELECT**,
防止有人顺手拿它改别人的通道。
"""
from __future__ import annotations
from collections.abc import Sequence
from alembic import op
from app.roles import APP_ROLE
revision: str = "0016"
down_revision: str = "0015"
branch_labels: Sequence[str] | None = None
depends_on: Sequence[str] | None = None
CREATE_POLICY = """
CREATE POLICY bot_channel_startup ON bot_channel
FOR SELECT
USING (current_setting('app.bot_list', true) = 'on')
"""
def upgrade() -> None:
op.execute("DROP POLICY IF EXISTS bot_channel_startup ON bot_channel")
op.execute(CREATE_POLICY)
op.execute(f"GRANT SELECT ON bot_channel TO {APP_ROLE}")
def downgrade() -> None:
op.execute("DROP POLICY IF EXISTS bot_channel_startup ON bot_channel")
+93
View File
@@ -0,0 +1,93 @@
"""安卓 OTA:最新版本清单 + APK 下载。
为什么不用一张表:一个安装包就是一条"发布记录",而 APK 是构建产物、不能进仓库。
所以发布目录里放「latest.json + 同名 apk」,换版本只换文件,运维不需要动数据库。
清单由 `backend/scripts/publish_apk.py` 生成(算好大小与 sha256),本模块只读。
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
from fastapi import APIRouter
from fastapi.responses import FileResponse
from ..config import get_settings
from ..errors import AppError, ErrorCode
from ..logging_setup import get_logger
from ..schemas import ok
router = APIRouter(prefix="/app", tags=["版本升级"])
settings = get_settings()
logger = get_logger("app.release")
MANIFEST_NAME = "latest.json"
def apk_dir() -> Path:
"""发布目录:优先用配置里的 APK_DIR,默认在 backend/static/apk(部署时保留、不入库)。"""
if settings.apk_dir:
return Path(settings.apk_dir)
return Path(__file__).resolve().parents[2] / "static" / "apk"
def _manifest() -> dict[str, Any]:
path = apk_dir() / MANIFEST_NAME
if not path.is_file():
raise AppError(ErrorCode.APP_RELEASE_NOT_FOUND, "还没有发布过安装包")
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError) as exc: # noqa: BLE001 - 清单坏了也要说人话
logger.exception("读取 OTA 清单失败")
raise AppError(ErrorCode.APP_RELEASE_NOT_FOUND, "安装包清单损坏,请重新发布") from exc
if not isinstance(data, dict) or not data.get("file"):
raise AppError(ErrorCode.APP_RELEASE_NOT_FOUND, "安装包清单缺少文件名")
return data
def _payload(data: dict[str, Any], base: str, current: int | None) -> dict[str, Any]:
version_code = int(data.get("version_code") or 0)
return {
"platform": data.get("platform", "android"),
"version_code": version_code,
"version_name": data.get("version_name", ""),
"notes": data.get("notes", ""),
"file": data["file"],
"size": int(data.get("size") or 0),
"sha256": data.get("sha256", ""),
"min_sdk": int(data.get("min_sdk") or 26),
"published_at": data.get("published_at"),
# 客户端拿相对路径拼自己的 API 前缀,换域名不用改包
"download_url": f"{base}/app/release/download/{data['file']}",
# 带了当前版本号就顺手算好要不要升级,客户端不用再比一次
"has_update": current is not None and version_code > current,
}
@router.get("/release/latest", summary="安卓最新版本")
async def latest_release(version_code: int | None = None) -> dict[str, Any]:
"""公开接口:装完 App 之前就要能查,不该要登录。
`version_code` 传客户端当前版本(`BuildConfig.VERSION_CODE`),返回里的
`has_update` 直接告诉它要不要提示升级。
"""
data = _manifest()
return ok(_payload(data, settings.api_prefix, version_code))
@router.api_route("/release/download/{name}", methods=["GET", "HEAD"], summary="下载安装包")
async def download_release(name: str) -> FileResponse:
# 只认清单里那一个文件:不做目录列举,也不接受 ../ 之类
data = _manifest()
if name != data["file"]:
raise AppError(ErrorCode.APP_RELEASE_NOT_FOUND, "没有这个安装包")
path = apk_dir() / name
if not path.is_file():
raise AppError(ErrorCode.APP_RELEASE_NOT_FOUND, "安装包文件不见了,请重新发布")
return FileResponse(
path,
media_type="application/vnd.android.package-archive",
filename=name,
)
+307
View File
@@ -0,0 +1,307 @@
"""附件接口:上传 / 列表 / 下载 / 删除。
三条约束(都会有用例守):
1. 只认「本工作区的条目」。拿着别的租户的 item_id 上传,返回 5001 而不是 403——
对外不该暴露那条记录是否存在。
2. 大小上限在**读流时**卡住,不能先落盘再判断(否则等于给了别人一个写满磁盘的入口)。
3. 磁盘上的名字由服务端生成,用户文件名只进库和 Content-Disposition。
"""
from __future__ import annotations
import urllib.parse
import uuid
from typing import Annotated
from fastapi import APIRouter, File, Query, UploadFile
from fastapi.responses import FileResponse
from sqlalchemy import select
from starlette.concurrency import run_in_threadpool
from app.audit import record as audit_record
from app.config import get_settings
from app.db import bind_tenant
from app.deps import MemberDep, SessionDep
from app.errors import AppError, ErrorCode
from app.models import Member
from app.models_attachment import Attachment
from app.models_task import Item
from app.schemas import ok
from app.security import decode_token
from app.services import signing, storage
router = APIRouter(prefix="/workspaces/{ws_id}", tags=["attachments"])
ALLOWED_KINDS = ("task", "ticket", "knowledge")
def _attachment_out(row: Attachment, viewer_id=None, role=None) -> dict:
settings = get_settings()
return {
"id": str(row.id),
"item_id": str(row.item_id),
"filename": row.filename,
"content_type": row.content_type,
"size": row.size_bytes,
"sha256": row.sha256,
"uploader_id": str(row.uploader_id),
"created_at": row.created_at.isoformat(),
# 图片直链:自带短期签名,浏览器 <img> 直接能用,不需要把登录令牌塞进 URL
"raw_url": (
f"{settings.api_prefix}/workspaces/{row.workspace_id}"
f"/attachments/{row.id}/raw?{signing.sign(row.workspace_id, row.id)}"
),
"can_delete": row.uploader_id == viewer_id or role in ("owner", "admin"),
}
async def _load_item(session, ws_id, item_id) -> Item:
item = await session.scalar(
select(Item).where(
Item.id == item_id,
Item.workspace_id == ws_id,
Item.deleted_at.is_(None),
Item.kind.in_(ALLOWED_KINDS),
)
)
if item is None:
raise AppError(ErrorCode.ITEM_NOT_FOUND, "事项不存在")
return item
def _safe_filename(name: str | None, fallback: str = "未命名文件") -> str:
"""只用于展示与下载响应头:去掉路径分隔符与控制字符,长度截断。"""
raw = (name or "").strip().replace("\\", "/").split("/")[-1]
cleaned = "".join(ch for ch in raw if ch.isprintable() and ch not in '\r\n"')
cleaned = cleaned.strip() or fallback
if len(cleaned) > 200:
stem, dot, ext = cleaned.rpartition(".")
cleaned = (stem[:180] + dot + ext[-16:]) if dot and ext else cleaned[:200]
return cleaned
@router.post("/items/{item_id}/attachments", status_code=201)
async def upload_attachment(
ctx: MemberDep,
session: SessionDep,
item_id: str,
file: Annotated[UploadFile, File()],
):
item = await _load_item(session, ctx.ws_id, item_id)
limit = get_settings().max_upload_mb * 1024 * 1024
# 先看声明的大小(可选,客户端不一定给),真正的限额在读流时兜底
if file.size is not None and file.size > limit:
raise AppError(
ErrorCode.ATTACHMENT_TOO_LARGE,
f"文件超过 {get_settings().max_upload_mb}MB 上限",
)
try:
stored = await run_in_threadpool(
storage.save, file.file, workspace_id=ctx.ws_id, item_id=item.id
)
if stored.size > limit:
await run_in_threadpool(storage.remove, stored.key)
raise AppError(
ErrorCode.ATTACHMENT_TOO_LARGE,
f"文件超过 {get_settings().max_upload_mb}MB 上限",
)
row = Attachment(
workspace_id=ctx.ws_id,
item_id=item.id,
uploader_id=ctx.user.id,
filename=_safe_filename(file.filename),
content_type=(file.content_type or "application/octet-stream")[:120],
size_bytes=stored.size,
sha256=stored.sha256,
storage_key=stored.key,
)
session.add(row)
await session.flush()
await audit_record(
session,
workspace_id=ctx.ws_id,
actor_user_id=ctx.user.id,
action="attachment_uploaded",
target_type="item",
target_id=str(item.id),
payload={"attachment_id": str(row.id), "filename": row.filename},
)
await session.commit()
finally:
await file.close()
return ok(_attachment_out(row, ctx.user.id, ctx.role))
@router.get("/items/{item_id}/attachments")
async def list_attachments(ctx: MemberDep, session: SessionDep, item_id: str):
item = await _load_item(session, ctx.ws_id, item_id)
rows = (
await session.scalars(
select(Attachment)
.where(Attachment.workspace_id == ctx.ws_id, Attachment.item_id == item.id)
.order_by(Attachment.created_at.asc())
)
).all()
return ok({"items": [_attachment_out(row, ctx.user.id, ctx.role) for row in rows]})
@router.get("/attachments/{attachment_id}/download")
async def download_attachment(ctx: MemberDep, session: SessionDep, attachment_id: str):
row = await session.scalar(
select(Attachment).where(
Attachment.id == attachment_id, Attachment.workspace_id == ctx.ws_id
)
)
if row is None:
raise AppError(ErrorCode.ATTACHMENT_NOT_FOUND, "附件不存在")
path = storage.path_of(row.storage_key)
if not path.exists():
# 库里有记录、盘上没文件:说明备份漏了 blob,明确报错而不是返回空文件
raise AppError(ErrorCode.ATTACHMENT_NOT_FOUND, "附件文件已丢失,请联系管理员")
return FileResponse(
path,
media_type=row.content_type,
headers={
# 中文文件名必须按 RFC 5987 编码,否则浏览器拿到的是乱码
"Content-Disposition": "attachment; filename*=UTF-8''"
+ urllib.parse.quote(row.filename),
# 附件是不可信内容:禁止浏览器嗅探类型,否则 .txt 里的 HTML 会被执行
"X-Content-Type-Options": "nosniff",
},
)
@router.get("/attachments/{attachment_id}/raw")
async def raw_attachment(
ws_id: uuid.UUID,
session: SessionDep,
attachment_id: uuid.UUID,
exp: Annotated[int | None, Query(description="签名过期时间(unix 秒)")] = None,
sig: Annotated[str | None, Query(description="短期签名,见 services/signing.py")] = None,
token: Annotated[str | None, Query(description="兼容旧链接用的 access token")] = None,
):
"""给 Markdown 里的图片用:浏览器 `<img>` 无法附加 Authorization 头。
优先认 `?exp=&sig=` 的短期签名(由附件列表接口签发,只为这一张图、只活 30 分钟);
老链接里的 `?token=` 仍能用,但那是过渡期兼容,前端已不再生成。
"""
if exp is not None or sig is not None:
if not signing.verify(ws_id, attachment_id, exp, sig):
raise AppError(ErrorCode.ATTACHMENT_FORBIDDEN, "图片链接已失效,请刷新页面")
# 这个接口不走 MemberDep,RLS 不会自动绑租户,得手动绑一次
await bind_tenant(session, ws_id=ws_id, user_id=None)
else:
if not token:
raise AppError(ErrorCode.UNAUTHORIZED, "缺少访问凭据")
try:
user_id = decode_token(token, "access")
except Exception: # noqa: BLE001 —— 任何解码失败都按未认证处理
raise AppError(ErrorCode.UNAUTHORIZED, "登录已过期,请重新登录") from None
await bind_tenant(session, ws_id=ws_id, user_id=user_id)
member = await session.scalar(
select(Member.id).where(
Member.workspace_id == ws_id,
Member.user_id == user_id,
Member.status == "active",
)
)
if member is None:
# 不是成员就当作不存在:不暴露"这里有个附件但你看不到"
raise AppError(ErrorCode.ATTACHMENT_NOT_FOUND, "附件不存在")
row = await session.scalar(
select(Attachment).where(Attachment.id == attachment_id, Attachment.workspace_id == ws_id)
)
if row is None:
raise AppError(ErrorCode.ATTACHMENT_NOT_FOUND, "附件不存在")
path = storage.path_of(row.storage_key)
if not path.exists():
raise AppError(ErrorCode.ATTACHMENT_NOT_FOUND, "附件文件已丢失,请联系管理员")
return FileResponse(
path,
media_type=row.content_type,
headers={
"Content-Disposition": "inline",
"X-Content-Type-Options": "nosniff",
# 附件内容不变(key 由内容哈希决定),可以放心长缓存;换了文件就是换了 id
"Cache-Control": "private, max-age=86400",
},
)
@router.delete("/attachments/{attachment_id}")
async def delete_attachment(
ctx: MemberDep, session: SessionDep, attachment_id: str, confirm: bool = Query(False)
):
row = await session.scalar(
select(Attachment).where(
Attachment.id == attachment_id, Attachment.workspace_id == ctx.ws_id
)
)
if row is None:
raise AppError(ErrorCode.ATTACHMENT_NOT_FOUND, "附件不存在")
is_uploader = row.uploader_id == ctx.user.id
if not is_uploader and ctx.role not in ("owner", "admin"):
raise AppError(ErrorCode.ATTACHMENT_FORBIDDEN, "只有上传者或管理员能删除附件")
if not confirm:
# 删除不可逆,要求显式确认,避免误点
raise AppError(
ErrorCode.CONFIRM_REQUIRED, "删除附件需要二次确认", details={"confirm": True}
)
await run_in_threadpool(storage.remove, row.storage_key)
await session.delete(row)
await audit_record(
session,
workspace_id=ctx.ws_id,
actor_user_id=ctx.user.id,
action="attachment_deleted",
target_type="item",
target_id=str(row.item_id),
payload={"attachment_id": str(row.id), "filename": row.filename},
)
await session.commit()
return ok({"deleted": True})
async def list_for_item(session, ws_id, item_id) -> list[dict]:
"""给其他接口内联用(列表/详情里带上附件摘要)。"""
rows = (
await session.scalars(
select(Attachment)
.where(Attachment.workspace_id == ws_id, Attachment.item_id == item_id)
.order_by(Attachment.created_at.asc())
)
).all()
return [_attachment_out(row) for row in rows]
async def count_for_items(session, ws_id, item_ids: list) -> dict[str, int]:
"""列表页只显示"几个附件",避免每条都拉全量。"""
if not item_ids:
return {}
rows = (
await session.execute(
select(Attachment.item_id).where(
Attachment.workspace_id == ws_id, Attachment.item_id.in_(item_ids)
)
)
).all()
counts: dict[str, int] = {}
for (item_id,) in rows:
counts[str(item_id)] = counts.get(str(item_id), 0) + 1
return counts
__all__ = ["router", "list_for_item", "count_for_items"]
+359 -36
View File
@@ -1,32 +1,98 @@
"""认证:手机号 + 验证码。M0 不做密码、不做第三方登录。"""
"""认证:手机号 / 邮箱 + 密码,邮箱验证码注册,手机验证码可选。
当前**默认关掉短信验证码**(`SMS_LOGIN_ENABLED=false`):内测阶段的固定验证码
等于"知道手机号就能进任意账号"。关掉后 `/auth/sms/send` 与 `/auth/login` 直接
回 2017,登录走「手机号 或 邮箱 + 密码」。登录页先问 `/auth/methods`,只显示
服务端真开着的登录方式,免得点进去才报错。
新用户走「邮箱 + 验证码」注册(`POST /auth/email/code` → `POST /auth/register`):
短信一关,就没有别的注册入口了。注册出来的账号没有手机号,`app_user.phone` 因此可空。
"""
from __future__ import annotations
import hmac
import re
import secrets
from datetime import datetime, timezone
from fastapi import APIRouter
from pydantic import BaseModel, Field
from sqlalchemy import select
from pydantic import BaseModel, Field, model_validator
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from ..config import get_settings
from ..deps import CurrentUser, SessionDep
from ..errors import AppError, ErrorCode
from ..models import AppUser, Member, Workspace
from ..schemas import ok
from ..security import create_token_pair, decode_token
from ..stores import get_store, sms_code_key, sms_throttle_key
from ..security import (
create_token_pair,
decode_token,
hash_password,
password_problem,
verify_password,
)
from ..services import mail
from ..stores import (
email_code_key,
email_throttle_key,
email_tries_key,
get_store,
sms_code_key,
sms_throttle_key,
)
router = APIRouter(prefix="/auth", tags=["认证"])
settings = get_settings()
PHONE_PATTERN = r"^1[3-9]\d{9}$"
# 邮箱只做形状校验:真正的可达性靠发信验证,正则再严也证明不了地址存在
EMAIL_RE = re.compile(r"^[^@\s]{1,64}@[^@\s]{1,190}\.[A-Za-z]{2,}$")
# 密码登录连错这么多次就先锁一会儿:暴力猜密码的成本必须比猜验证码高
PASSWORD_FAIL_LIMIT = 10
PASSWORD_FAIL_TTL = 900
def mask_phone(phone: str) -> str:
def mask_phone(phone: str | None) -> str:
if not phone:
return ""
return f"{phone[:3]}****{phone[-4:]}" if len(phone) == 11 else "***"
def normalize_email(email: str) -> str:
"""邮箱大小写不敏感:统一小写存,登录时也按小写比。"""
return email.strip().lower()
def _user_out(user: AppUser) -> dict:
"""对外只给脱敏手机号;密码本身一个字节都不出去,只告诉客户端「设没设」。
邮箱是用户自己的账号名,给全值(账号页要显示、要能改)。
"""
return {
"id": str(user.id),
"phone": mask_phone(user.phone),
"email": user.email,
"email_verified": user.email_verified_at is not None,
"nickname": user.nickname,
"avatar_url": user.avatar_url,
"has_password": bool(user.password_hash),
"platform_admin": bool(user.phone) and user.phone in settings.platform_admin_phones,
"created_at": user.created_at,
}
def _tokens_out(tokens) -> dict:
return {
"access_token": tokens.access_token,
"refresh_token": tokens.refresh_token,
"token_type": "bearer",
"expires_in": tokens.expires_in,
}
class SendCodeIn(BaseModel):
phone: str = Field(pattern=PHONE_PATTERN, examples=["13800138000"])
@@ -36,12 +102,57 @@ class LoginIn(BaseModel):
code: str = Field(min_length=4, max_length=8)
class PasswordLoginIn(BaseModel):
"""手机号或邮箱 + 密码。两个都给也行,手机号优先(老客户端只发 phone)。"""
phone: str | None = Field(default=None, pattern=PHONE_PATTERN)
email: str | None = Field(default=None, max_length=255)
password: str = Field(min_length=1, max_length=64)
@model_validator(mode="after")
def _one_account(self) -> PasswordLoginIn:
if not self.phone and not (self.email and self.email.strip()):
raise ValueError("手机号或邮箱填一个")
return self
class ProfilePatch(BaseModel):
nickname: str | None = Field(default=None, min_length=1, max_length=32)
# 传空串表示解绑;不传表示不动
email: str | None = Field(default=None, max_length=255)
class PasswordIn(BaseModel):
"""old_password 只在「已经设过密码」时必填;第一次设密码不需要。"""
password: str = Field(min_length=1, max_length=64)
old_password: str | None = Field(default=None, max_length=64)
class RefreshIn(BaseModel):
refresh_token: str = Field(min_length=10)
@router.get("/methods", summary="这台服务现在开放哪些登录方式")
async def methods() -> dict:
"""登录页据此渲染,别让用户点进去才发现这条路关着。"""
return ok(
{
"password": True,
"email_password": True,
"sms_code": settings.sms_login_enabled,
"email_register": settings.email_register_enabled,
}
)
@router.post("/sms/send", summary="发送登录验证码")
async def send_code(body: SendCodeIn) -> dict:
if not settings.sms_login_enabled:
raise AppError(
ErrorCode.LOGIN_METHOD_DISABLED,
"短信验证码登录暂未开放,请用手机号或邮箱 + 密码登录",
)
store = get_store()
if settings.sms_throttle_seconds > 0:
hits = await store.incr(sms_throttle_key(body.phone), settings.sms_throttle_seconds)
@@ -64,6 +175,11 @@ async def send_code(body: SendCodeIn) -> dict:
@router.post("/login", summary="验证码登录(不存在则自动注册)")
async def login(body: LoginIn, session: SessionDep) -> dict:
if not settings.sms_login_enabled:
raise AppError(
ErrorCode.LOGIN_METHOD_DISABLED,
"短信验证码登录暂未开放,请用手机号或邮箱 + 密码登录",
)
store = get_store()
stored = await store.get(sms_code_key(body.phone))
if stored is None:
@@ -81,40 +197,68 @@ async def login(body: LoginIn, session: SessionDep) -> dict:
await session.flush()
tokens = create_token_pair(user.id)
return ok(
{
"user": {
"id": str(user.id),
"phone": mask_phone(user.phone),
"nickname": user.nickname,
"created_at": user.created_at,
},
"tokens": {
"access_token": tokens.access_token,
"refresh_token": tokens.refresh_token,
"token_type": "bearer",
"expires_in": tokens.expires_in,
},
}
)
return ok({"user": _user_out(user), "tokens": _tokens_out(tokens)})
@router.post("/login/password", summary="手机号 / 邮箱 + 密码登录")
async def login_password(body: PasswordLoginIn, session: SessionDep) -> dict:
"""手机号或邮箱 + 密码登录。
三个细节:
- **不区分"没这个人"和"密码错"**,同一个错误码,别让登录接口变成账号探测工具。
- 连错 PASSWORD_FAIL_LIMIT 次锁 PASSWORD_FAIL_TTL 秒;成功即清零,正常用户不会感知。
失败计数按"填的那个账号"记,换手机号/邮箱轮着试也照样累计。
- 邮箱大小写不敏感(存的时候已经统一小写,这里再 lower 一次兜住历史数据)。
"""
store = get_store()
account_key = body.phone or normalize_email(body.email or "")
fail_key = f"pwd_fail:{account_key}"
if int(await store.get(fail_key) or 0) >= PASSWORD_FAIL_LIMIT:
raise AppError(
ErrorCode.LOGIN_TOO_MANY,
f"密码错误次数过多,请 {PASSWORD_FAIL_TTL // 60} 分钟后再试",
)
if body.phone:
stmt = select(AppUser).where(AppUser.phone == body.phone)
hint = "手机号或密码不对"
else:
stmt = select(AppUser).where(func.lower(AppUser.email) == account_key)
hint = "邮箱或密码不对"
user = (await session.execute(stmt)).scalar_one_or_none()
if user is None or not verify_password(body.password, user.password_hash):
await store.incr(fail_key, PASSWORD_FAIL_TTL)
raise AppError(ErrorCode.PASSWORD_INVALID, hint)
if user.status != "active":
raise AppError(ErrorCode.ACCOUNT_DISABLED, "账号已停用")
await store.delete(fail_key)
tokens = create_token_pair(user.id)
return ok({"user": _user_out(user), "tokens": _tokens_out(tokens)})
@router.post("/refresh", summary="刷新访问令牌")
async def refresh(body: RefreshIn) -> dict:
async def refresh(body: RefreshIn, session: SessionDep) -> dict:
user_id = decode_token(body.refresh_token, "refresh")
# 换令牌时也看一眼账号状态:停用的账号不该靠 refresh 一直续下去
user = await session.get(AppUser, user_id)
if user is None:
raise AppError(ErrorCode.UNAUTHORIZED, "账号不存在")
if user.status != "active":
raise AppError(ErrorCode.ACCOUNT_DISABLED, "账号已停用,请联系管理员")
tokens = create_token_pair(user_id)
return ok(
{
"access_token": tokens.access_token,
"refresh_token": tokens.refresh_token,
"token_type": "bearer",
"expires_in": tokens.expires_in,
}
)
# 这里的形状保持扁平(不带 tokens 信封),老客户端与安卓端都按这个取
return ok(_tokens_out(tokens))
@router.get("/me", summary="当前用户与所属工作区")
async def me(user: CurrentUser, session: SessionDep) -> dict:
row = await session.get(AppUser, user.id)
if row is None:
raise AppError(ErrorCode.UNAUTHORIZED, "账号不存在")
rows = (
await session.execute(
select(Workspace, Member.role)
@@ -126,11 +270,7 @@ async def me(user: CurrentUser, session: SessionDep) -> dict:
return ok(
{
"user": {
"id": str(user.id),
"phone": mask_phone(user.phone),
"nickname": user.nickname,
},
"user": _user_out(row),
"workspaces": [
{
"id": str(ws.id),
@@ -144,3 +284,186 @@ async def me(user: CurrentUser, session: SessionDep) -> dict:
],
}
)
@router.patch("/me", summary="改自己的昵称与邮箱")
async def update_me(body: ProfilePatch, user: CurrentUser, session: SessionDep) -> dict:
row = await session.get(AppUser, user.id)
if row is None:
raise AppError(ErrorCode.UNAUTHORIZED, "账号不存在")
if body.nickname is not None:
nickname = body.nickname.strip()
if not nickname:
raise AppError(ErrorCode.VALIDATION_FAILED, "昵称不能为空")
row.nickname = nickname
if body.email is not None:
raw = body.email.strip()
if not raw:
# 解绑:邮箱只是"多一条登录方式",不是必填项
row.email = None
else:
email = normalize_email(raw)
if not EMAIL_RE.match(email):
raise AppError(ErrorCode.EMAIL_INVALID, "邮箱格式不对")
taken = (
await session.execute(
select(AppUser.id).where(
func.lower(AppUser.email) == email, AppUser.id != user.id
)
)
).scalar_one_or_none()
if taken is not None:
raise AppError(ErrorCode.EMAIL_TAKEN, "这个邮箱已经绑在别的账号上了")
row.email = email
await session.flush()
return ok({"user": _user_out(row)})
@router.post("/password", summary="设置或修改登录密码")
async def set_password(body: PasswordIn, user: CurrentUser, session: SessionDep) -> dict:
"""已经设过密码的,必须先给旧密码——否则拿到一个没锁屏的浏览器就能把密码换掉。"""
row = await session.get(AppUser, user.id)
if row is None:
raise AppError(ErrorCode.UNAUTHORIZED, "账号不存在")
if row.password_hash:
if not body.old_password:
raise AppError(ErrorCode.PASSWORD_INVALID, "请输入当前密码")
if not verify_password(body.old_password, row.password_hash):
raise AppError(ErrorCode.PASSWORD_INVALID, "当前密码不对")
if hmac.compare_digest(body.old_password, body.password):
raise AppError(ErrorCode.PASSWORD_WEAK, "新密码不能和当前密码一样")
problem = password_problem(body.password)
if problem:
raise AppError(ErrorCode.PASSWORD_WEAK, problem)
row.password_hash = hash_password(body.password)
await session.flush()
return ok({"has_password": True})
# ── 邮箱注册 ────────────────────────────────────────────
class EmailCodeIn(BaseModel):
email: str = Field(max_length=255, examples=["someone@example.com"])
class RegisterIn(BaseModel):
"""邮箱 + 验证码 + 密码,一步把账号建出来。
昵称不填就取邮箱 @ 前面那段,别让新用户第一眼看到一个空名字。
"""
email: str = Field(max_length=255)
code: str = Field(min_length=4, max_length=8)
password: str = Field(min_length=1, max_length=64)
nickname: str | None = Field(default=None, max_length=32)
async def _email_taken(session: AsyncSession, email: str) -> bool:
stmt = select(AppUser.id).where(func.lower(AppUser.email) == email)
return (await session.execute(stmt)).first() is not None
@router.post("/email/code", summary="发送邮箱注册验证码")
async def send_email_code(body: EmailCodeIn, session: SessionDep) -> dict:
"""发码前先查邮箱是否已被占用:让用户填完密码才发现"这个邮箱注册过了"最气人。"""
if not settings.email_register_enabled:
raise AppError(
ErrorCode.LOGIN_METHOD_DISABLED,
"邮箱注册暂未开放,请联系管理员开通账号",
)
email = normalize_email(body.email)
if not EMAIL_RE.match(email):
raise AppError(ErrorCode.EMAIL_INVALID, "邮箱格式不对")
if await _email_taken(session, email):
raise AppError(ErrorCode.EMAIL_TAKEN, "这个邮箱已经注册过了,直接登录即可")
store = get_store()
if settings.email_code_throttle_seconds > 0:
hits = await store.incr(email_throttle_key(email), settings.email_code_throttle_seconds)
if hits > 1:
raise AppError(
ErrorCode.EMAIL_CODE_TOO_FREQUENT,
f"发送过于频繁,请 {settings.email_code_throttle_seconds} 秒后重试",
)
code = f"{secrets.randbelow(1_000_000):06d}"
await store.set(email_code_key(email), code, settings.email_code_ttl_seconds)
await store.delete(email_tries_key(email))
minutes = max(1, settings.email_code_ttl_seconds // 60)
if settings.email_code_dev_echo:
# 本机联调:不发真信,直接把码回给调用方(生产打开会被 validate_runtime 拒掉)
return ok(
{
"sent": True,
"expires_in": settings.email_code_ttl_seconds,
"dev_code": code,
}
)
await mail.send_verify_code(session, to=email, code=code, minutes=minutes)
return ok({"sent": True, "expires_in": settings.email_code_ttl_seconds})
@router.post("/register", summary="邮箱注册(验证码 + 密码)")
async def register(body: RegisterIn, session: SessionDep) -> dict:
"""邮箱注册:验码 → 建账号 → 直接发令牌,注册完就是登录状态。
账号没有手机号(`phone` 为空),所以邮件是唯一联系方式——这也是为什么注册必须
真验一次码,而不是"填个邮箱就算"。
"""
if not settings.email_register_enabled:
raise AppError(
ErrorCode.LOGIN_METHOD_DISABLED,
"邮箱注册暂未开放,请联系管理员开通账号",
)
email = normalize_email(body.email)
if not EMAIL_RE.match(email):
raise AppError(ErrorCode.EMAIL_INVALID, "邮箱格式不对")
store = get_store()
stored = await store.get(email_code_key(email))
if not stored:
raise AppError(ErrorCode.EMAIL_CODE_INVALID, "验证码已过期,请重新获取")
tries = await store.incr(email_tries_key(email), settings.email_code_ttl_seconds)
if tries > settings.email_code_max_attempts:
await store.delete(email_code_key(email))
raise AppError(ErrorCode.EMAIL_CODE_INVALID, "验证码错太多次,请重新获取")
if not hmac.compare_digest(stored, body.code.strip()):
raise AppError(ErrorCode.EMAIL_CODE_INVALID, "验证码不对")
problem = password_problem(body.password)
if problem:
raise AppError(ErrorCode.PASSWORD_WEAK, problem)
# 拿到验证码之后这段时间里,别人可能已经用同一个邮箱注册完了
if await _email_taken(session, email):
raise AppError(ErrorCode.EMAIL_TAKEN, "这个邮箱已经注册过了,直接登录即可")
nickname = (body.nickname or "").strip() or email.split("@", 1)[0][:32]
user = AppUser(
email=email,
phone=None,
nickname=nickname,
password_hash=hash_password(body.password),
email_verified_at=datetime.now(timezone.utc),
status="active",
)
session.add(user)
await session.flush()
await store.delete(email_code_key(email))
await store.delete(email_tries_key(email))
await store.delete(email_throttle_key(email))
tokens = create_token_pair(user.id)
return ok({"user": _user_out(user), "tokens": _tokens_out(tokens)})
+246
View File
@@ -0,0 +1,246 @@
"""外部接入:微信 clawbot 的扫码登录与状态。
真实协议是腾讯 iLink Bot(出处见 `app/services/wechat_ilink.py` 文件头):
**我们**去取二维码、等人扫、拿到登录态后长轮询拉消息。所以这里的接口是三件事:
1. `POST /workspaces/{ws}/integrations/wechat/qr` —— 取一张二维码,页面显示给人扫;
2. `GET /workspaces/{ws}/integrations/wechat/qr/{id}` —— 问"扫了吗",扫成功就存登录态并开始轮询;
3. `DELETE /workspaces/{ws}/integrations/{channel_id}` —— 断开:登录态作废、轮询停止。
二维码 id 放 Redis 而不是内存:问状态的那一下可能落到另一个进程,
存内存就会"扫完了但没人知道"。key 带工作区,两个工作区互不踩。
上一版这里做的是 webhook 接收端(`POST /integrations/clawbot/{token}`),
对 clawbot 的理解是错的,接口与令牌一并删掉了(迁移 0013 的注释记了原因)。
"""
from __future__ import annotations
import base64
import io
import uuid
from typing import Any
import qrcode
import qrcode.image.svg
from fastapi import APIRouter, Query
from pydantic import BaseModel
from sqlalchemy import select
from ..db import bind_tenant
from ..deps import AdminDep, SessionDep
from ..errors import AppError, ErrorCode
from ..models_integrations import BotChannel, BotMessage
from ..schemas import ok
from ..services import wechat_ilink
from ..services.wechat_bot import lease_held, manager
from ..stores import get_store
admin_router = APIRouter(prefix="/workspaces", tags=["外部接入"])
# iLink 的二维码大约 3 分钟过期,我们按 5 分钟缓存 id,过期就重新取。
QR_TTL_SECONDS = 300
class ChannelPatch(BaseModel):
status: str
def _qr_key(ws_id: uuid.UUID) -> str:
return f"wechat:qr:{ws_id}"
def render_qr_svg(payload: str) -> str:
"""把二维码内容画成 data URL:服务端画,前端不用再引一个二维码库。"""
image = qrcode.make(payload, image_factory=qrcode.image.svg.SvgPathImage, box_size=10)
buffer = io.BytesIO()
image.save(buffer)
encoded = base64.b64encode(buffer.getvalue()).decode("ascii")
return f"data:image/svg+xml;base64,{encoded}"
async def _channel_out(channel: BotChannel) -> dict[str, Any]:
return {
"id": str(channel.id),
"kind": channel.kind,
"name": channel.name,
"status": channel.status,
"connected": bool(channel.bot_token_enc) and channel.status == "active",
# 数据库级租约:谁在真收消息所有进程都认,页面不会跟着进程抖动。
"polling": await lease_held(channel.id),
"ilink_user_id": channel.ilink_user_id,
"last_seen_at": channel.last_seen_at,
"last_error": channel.last_error,
"created_at": channel.created_at,
}
async def _wechat_channel(session: SessionDep, ws_id: uuid.UUID) -> BotChannel | None:
rows = await session.execute(
select(BotChannel)
.where(BotChannel.workspace_id == ws_id, BotChannel.kind == "wechat_clawbot")
.order_by(BotChannel.created_at.desc())
)
return rows.scalars().first()
@admin_router.get("/{ws_id}/integrations")
async def list_integrations(ws_id: uuid.UUID, ctx: AdminDep, session: SessionDep):
rows = await session.execute(
select(BotChannel)
.where(BotChannel.workspace_id == ws_id)
.order_by(BotChannel.created_at.desc())
)
return ok({"items": [await _channel_out(c) for c in rows.scalars().all()]})
@admin_router.post("/{ws_id}/integrations/wechat/qr")
async def begin_wechat_login(ws_id: uuid.UUID, ctx: AdminDep, session: SessionDep):
"""取一张二维码。已经登录过的通道会被重置成 pending(换号 = 重新扫)。"""
try:
code = await wechat_ilink.get_qrcode()
except wechat_ilink.WechatError as exc:
raise AppError(ErrorCode.BOT_TOKEN_INVALID, f"取二维码失败:{exc}") from exc
channel = await _wechat_channel(session, ws_id)
if channel is None:
channel = BotChannel(
workspace_id=ws_id,
kind="wechat_clawbot",
name="微信",
status="pending",
created_by=ctx.user.id,
)
session.add(channel)
if channel.bot_token_enc:
await manager.stop(channel.id)
channel.bot_token_enc = None
channel.sync_buf = None
channel.context_token = None
channel.status = "pending"
channel.last_error = None
await session.flush()
await get_store().set(_qr_key(ws_id), code.qrcode_id, QR_TTL_SECONDS)
await session.commit()
return ok(
{
"channel_id": str(channel.id),
"qrcode_id": code.qrcode_id,
"payload": code.payload,
"image": render_qr_svg(code.payload),
"expires_in": QR_TTL_SECONDS,
}
)
@admin_router.get("/{ws_id}/integrations/wechat/qr/{qrcode_id}")
async def poll_wechat_login(ws_id: uuid.UUID, qrcode_id: str, ctx: AdminDep, session: SessionDep):
"""问扫码结果。只有 confirmed 才落登录态并开轮询,其余状态原样回给页面。"""
cached = await get_store().get(_qr_key(ws_id))
if cached != qrcode_id:
# 页面拿着旧二维码在问:直接说"过期",让它重新取一张。
return ok({"status": "expired"})
try:
status = await wechat_ilink.qrcode_status(qrcode_id)
except wechat_ilink.WechatError as exc:
return ok({"status": "error", "message": str(exc)})
if status.status != "confirmed" or not status.bot_token:
return ok({"status": status.status})
channel = await _wechat_channel(session, ws_id)
if channel is None:
raise AppError(ErrorCode.BOT_TOKEN_INVALID, "通道不见了,请重新取二维码")
await bind_tenant(session, ws_id, ctx.user.id)
channel.bot_token_enc = wechat_ilink.encrypt_token(status.bot_token)
channel.ilink_user_id = status.ilink_user_id
channel.base_url = status.base_url
channel.status = "active"
channel.sync_buf = None
channel.context_token = None
channel.last_error = None
channel.created_by = channel.created_by or ctx.user.id
await session.commit()
await get_store().delete(_qr_key(ws_id))
await manager.start(channel.id)
return ok({"status": "confirmed", "channel": await _channel_out(channel)})
@admin_router.patch("/{ws_id}/integrations/{channel_id}")
async def update_integration(
ws_id: uuid.UUID,
channel_id: uuid.UUID,
ctx: AdminDep,
session: SessionDep,
body: ChannelPatch,
):
channel = await session.get(BotChannel, channel_id)
if channel is None or channel.workspace_id != ws_id:
raise AppError(ErrorCode.BOT_TOKEN_INVALID, "接入通道不存在")
wanted = body.status.strip()
if wanted not in ("active", "disabled"):
raise AppError(ErrorCode.VALIDATION_FAILED, "status 只能是 active 或 disabled")
if wanted == "active" and not channel.bot_token_enc:
raise AppError(ErrorCode.BOT_TOKEN_INVALID, "这个通道还没扫码登录")
channel.status = wanted
await session.commit()
if wanted == "active":
await manager.start(channel.id)
else:
await manager.stop(channel.id)
return ok({"channel": await _channel_out(channel)})
@admin_router.delete("/{ws_id}/integrations/{channel_id}")
async def disconnect_integration(
ws_id: uuid.UUID, channel_id: uuid.UUID, ctx: AdminDep, session: SessionDep
):
"""断开:停轮询、清登录态。留痕(bot_message)不删,翻旧账用得上。"""
channel = await session.get(BotChannel, channel_id)
if channel is None or channel.workspace_id != ws_id:
raise AppError(ErrorCode.BOT_TOKEN_INVALID, "接入通道不存在")
await manager.stop(channel.id)
channel.bot_token_enc = None
channel.sync_buf = None
channel.context_token = None
channel.status = "pending"
channel.last_error = None
await session.commit()
return ok({"channel": await _channel_out(channel)})
@admin_router.get("/{ws_id}/integrations/{channel_id}/messages")
async def list_messages(
ws_id: uuid.UUID,
channel_id: uuid.UUID,
ctx: AdminDep,
session: SessionDep,
limit: int = Query(default=5, ge=1, le=100),
):
rows = await session.execute(
select(BotMessage)
.where(BotMessage.channel_id == channel_id, BotMessage.workspace_id == ws_id)
.order_by(BotMessage.created_at.desc())
.limit(limit)
)
return ok(
{
"items": [
{
"id": str(m.id),
"action": m.action,
"sender": m.sender,
"text": m.text,
"reply": m.reply,
"item_id": str(m.item_id) if m.item_id else None,
"created_at": m.created_at,
}
for m in rows.scalars().all()
]
}
)
+85 -2
View File
@@ -27,10 +27,13 @@ from ..domain.task import (
view_statuses,
)
from ..errors import AppError, ErrorCode
from ..models import Member
from ..models_task import Activity, Comment, Item, ItemTag, Tag
from ..models_ticket import TicketExt
from ..schemas import Pagination, encode_cursor, ok
from ..serializers import item_out, tag_out, tags_of
from ..services.notify import TASK_STATUS_CN, notify
from .attachments import count_for_items, list_for_item
router = APIRouter(prefix="/workspaces/{ws_id}/items", tags=["任务"])
tag_router = APIRouter(prefix="/workspaces/{ws_id}/tags", tags=["标签"])
@@ -97,6 +100,25 @@ async def _load_item(
return item
async def _ensure_member(session: AsyncSession, ws_id: uuid.UUID, user_id: uuid.UUID) -> None:
"""被指派的人必须是这个工作区的在册成员。
不校验的话可以指派给任意 uuid:任务从此挂在"不存在的人"名下,
既不会出现在任何人的列表里,也不会有人收到通知。
"""
row = (
await session.execute(
select(Member.id).where(
Member.workspace_id == ws_id,
Member.user_id == user_id,
Member.status == "active",
)
)
).first()
if row is None:
raise AppError(ErrorCode.BAD_REQUEST, "被指派的人不在这个工作区里")
async def _tags_of(
session: AsyncSession, ws_id: uuid.UUID, item_ids: list[uuid.UUID]
) -> dict[uuid.UUID, list[Tag]]:
@@ -294,9 +316,13 @@ async def list_items(
.all()
)
tags = await _tags_of(session, ctx.ws_id, [r.id for r in rows])
counts = await count_for_items(session, ctx.ws_id, [r.id for r in rows])
return ok(
{
"items": [_item_out(r, tags.get(r.id)) for r in rows],
"items": [
dict(_item_out(r, tags.get(r.id)), attachment_count=counts.get(str(r.id), 0))
for r in rows
],
"next_cursor": None,
"has_more": False,
}
@@ -325,9 +351,13 @@ async def list_items(
encode_cursor(getattr(rows[-1], sort_attr), rows[-1].id) if has_more and rows else None
)
tags = await _tags_of(session, ctx.ws_id, [r.id for r in rows])
counts = await count_for_items(session, ctx.ws_id, [r.id for r in rows])
return ok(
{
"items": [_item_out(r, tags.get(r.id)) for r in rows],
"items": [
dict(_item_out(r, tags.get(r.id)), attachment_count=counts.get(str(r.id), 0))
for r in rows
],
"next_cursor": next_cursor,
"has_more": has_more,
}
@@ -405,6 +435,8 @@ async def get_item(item_id: uuid.UUID, ctx: MemberDep, session: SessionDep) -> d
.all()
)
data = _item_out(item, tags.get(item.id))
data["attachments"] = await list_for_item(session, ctx.ws_id, item.id)
data["attachment_count"] = len(data["attachments"])
data["activities"] = [
{
"id": str(a.id),
@@ -441,6 +473,9 @@ async def create_item(body: CreateItemIn, ctx: MemberDep, session: SessionDep) -
fields: dict[str, Any] = {"status": body.status, "completed_at": None}
apply_status(fields, body.status, now=datetime.now(timezone.utc))
if body.assignee_id is not None:
await _ensure_member(session, ctx.ws_id, body.assignee_id)
item = Item(
id=uuid.uuid4(),
workspace_id=ctx.ws_id,
@@ -459,6 +494,17 @@ async def create_item(body: CreateItemIn, ctx: MemberDep, session: SessionDep) -
if body.tag_names:
await _set_tags(session, ctx.ws_id, item, body.tag_names)
await _log(session, item, ctx.user.id, "created", {"status": item.status})
if item.assignee_id is not None:
await notify(
session,
ws_id=ctx.ws_id,
user_ids=(item.assignee_id,),
kind="task_assigned",
item_id=item.id,
title=f"有人把「{item.title}」交给你了",
body=f"{ctx.user.nickname or '同事'} 指派给你,注意截止时间",
actor_id=ctx.user.id,
)
tags = await _tags_of(session, ctx.ws_id, [item.id])
return ok(_item_out(item, tags.get(item.id)), message="已创建")
@@ -565,6 +611,7 @@ async def update_item(
values["assignee_id"] = None
changes["assignee_id"] = None
elif body.assignee_id is not None and body.assignee_id != item.assignee_id:
await _ensure_member(session, ctx.ws_id, body.assignee_id)
values["assignee_id"] = body.assignee_id
changes["assignee_id"] = str(body.assignee_id)
if body.clear_due_at:
@@ -594,6 +641,18 @@ async def update_item(
)
item.version += 1
if values.get("assignee_id") is not None:
await notify(
session,
ws_id=ctx.ws_id,
user_ids=(values["assignee_id"],),
kind="task_assigned",
item_id=item.id,
title=f"「{item.title}」转给你了",
body=f"{ctx.user.nickname or '同事'} 把这件事交给你,原来的办理人已经卸下",
actor_id=ctx.user.id,
)
tags = await _tags_of(session, ctx.ws_id, [item.id])
return ok(_item_out(item, tags.get(item.id)), message="已保存")
@@ -623,6 +682,20 @@ async def change_status(
payload={"from": previous, "to": body.status},
)
tags = await _tags_of(session, ctx.ws_id, [item.id])
await notify(
session,
ws_id=ctx.ws_id,
user_ids=(item.creator_id, item.assignee_id),
kind="task_status_changed",
item_id=item.id,
title=f"「{item.title}」变成{TASK_STATUS_CN.get(body.status, body.status)}了",
body=(
f"{ctx.user.nickname or '同事'} 把它从 "
f"{TASK_STATUS_CN.get(previous, previous)} 改成了 "
f"{TASK_STATUS_CN.get(body.status, body.status)}"
),
actor_id=ctx.user.id,
)
return ok(_item_out(item, tags.get(item.id)), message="状态已更新")
@@ -660,6 +733,16 @@ async def add_comment(
session.add(comment)
await session.flush()
await _log(session, item, ctx.user.id, "commented", {"comment_id": str(comment.id)})
await notify(
session,
ws_id=ctx.ws_id,
user_ids=(item.creator_id, item.assignee_id),
kind="item_commented",
item_id=item.id,
title=f"「{item.title}」有新讨论",
body=f"{ctx.user.nickname or '同事'}:{body.body_md[:120]}",
actor_id=ctx.user.id,
)
return ok(
{
"id": str(comment.id),
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More