Compare commits

...
Author SHA1 Message Date
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
124 changed files with 15303 additions and 440 deletions
+12
View File
@@ -27,6 +27,9 @@ android/.gradle/
android/build/
android/app/build/
android/local.properties
# 发布签名证书与口令(只在发布者本机;仓库里没有它就用调试签名)
android/keystore.properties
*.jks
# 编辑器 / 系统
.DS_Store
@@ -47,6 +50,15 @@ 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/
+162 -6
View File
@@ -29,6 +29,14 @@
| 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 认证/收件人/网络) | 去「系统设置」核对授权码 |
| 3001 | 404 | 工作区不存在 | — |
| 3003 | 403 | 不是该工作区成员 | **不要**提示"工作区不存在" |
| 3004 | 403 | 角色权限不足 | 隐藏对应按钮 |
@@ -61,6 +69,19 @@
## 1. 认证 `/auth`
登录方式有三种,**默认只开密码那一种**。服务端把"现在开着哪些"告诉前端,前端据此渲染,
免得用户点进一条关着的路:
### `GET /auth/methods`
```json
{ "password": true, "email_password": true, "sms_code": false }
```
`SMS_LOGIN_ENABLED=false`(默认)时,`POST /auth/sms/send` 与 `POST /auth/login` 都回 `2017`
「短信验证码登录暂未开放」。理由:dev 环境的固定码等于"知道手机号就能进任何账号",
这条路必须显式打开,生产打开时还会强制要求真短信通道(见 `deploy` 文档的启动校验)。
### `POST /auth/sms/send`
```json
@@ -68,7 +89,7 @@
```
响应 `data: { dev_code?: "123456" }`。`ENV=dev` 时才回显验证码,生产没有这个字段。
60 秒内重复请求 → `2010`。
60 秒内重复请求 → `2010`。短信登录关着时 → `2017`。
### `POST /auth/login`
@@ -91,9 +112,82 @@
返回同形状的 `tokens`。无状态 JWT,**服务端不维护黑名单**——token 泄露只能等过期,这是 M0 已知取舍。
### `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`、`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`),消息里写清是认证失败还是收件人被拒——授权码要用邮箱服务商的
**客户端授权码**,不是登录密码。
---
@@ -396,7 +490,11 @@ Web 端未读数由 `AppShell` 每 30 秒轮询一次;在通知页标已读后
必须带 `version`(乐观锁,冲突 `4009`)。可改 `title`/`body_md`/`summary`/`doc_type`/`tag_names`/`category_id`;
传 `clear_category: true` 表示移到未分类。
**已发布的不许直接改**(`7003`,先撤回审议或归档),避免"发布后被悄悄改写"。
**已发布的可以直接改**:改的是"团队现在看到的这一版",改完立刻重建检索切片(`chunk_count` 当场更新),
避免"正文改了、搜出来还是旧句子"。版本号照旧 `+1`,历史仍能靠审计追。
**已归档的不许直接改**(`7003`,`allowed_actions: ["restore"]`):先恢复成草稿再改,
免得改完还挂着"现役"的样子。
### 5.5 知识分类 `/knowledge/categories`
@@ -404,7 +502,7 @@ Web 端未读数由 `AppShell` 每 30 秒轮询一次;在通知页标已读后
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | `/knowledge/categories` | 扁平列表(按 depth/sort_order 排好),每条带 `doc_count`、`path`、`full_path`;另有 `max_depth=3` |
| 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`,删后知识变未分类 |
@@ -540,8 +638,6 @@ Query:
### 6.3 附件错误码
### 6.3 附件错误码
| 码 | HTTP | 含义 |
|---|---|---|
| 8001 | 404 | 附件不存在,或不属于这个工作区 |
@@ -558,7 +654,67 @@ Query:
---
## 7. 健康与版本
## 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. 健康与版本
| 方法 | 路径 | 说明 |
|---|---|---|
+88 -3
View File
@@ -57,6 +57,11 @@ 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 装服务单元
@@ -95,7 +100,87 @@ curl -sI http://192.168.8.188:8200/ | head -1 # 200,text/html(前端产
浏览器验收:打开 `http://192.168.8.188:8200` → 手机号任意 + 验证码 `123456` 登录 → 任务/工单/知识各点一遍。
## 5. 运维
## 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 # 状态
@@ -107,11 +192,11 @@ journalctl --user -u dadaotask-api -f # 日志(gunicorn 输出到 stdout
- 回滚:`cd ~/app/dadaotask && git reset --hard <旧 commit> && bash deploy/prod/deploy.sh`;**数据库迁移不回滚**,回滚代码前确认旧代码兼容新表结构
- 端口:`8200`(systemd 单元与 `deploy.sh` 的 `PORT` 默认值,两处要一致)
## 6. 已知限制(当前阶段)
## 8. 已知限制(当前阶段)
| 限制 | 影响 | 解除方式 |
|---|---|---|
| 没有 HTTPS / 域名 | 只能内网 IP 访问,令牌明文传输 | 域名 + nginx + 证书(需 root) |
| 没有 HTTPS / 域名 | 只能内网 IP 访问,令牌明文传输;**PWA 装不上**(非安全上下文) | 域名 + nginx + 证书(需 root) |
| 内测固定验证码 | 谁都能用任意手机号登录 | 接短信通道,关 `INTERNAL_TEST_LOGIN` |
| 无 pgvector | 语义检索走 `real[]` 全表扫描,数据量大了会慢 | 上独立 pgvector 实例(`deploy/docker-compose.vector.yml`) |
| 上传目录在本机磁盘 | 机器坏了附件就没了 | 备份 `backend/var/uploads`,或将来到对象存储 |
+192 -3
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 语义清晰
@@ -291,15 +292,18 @@
| 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` |
| 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] 非管理员发布被拒;跨租户看不到别人的知识
- [x] 知识是备忘录:标题、正文、标签、三级分类、搜索;新建时就能把这些一次填完(Web 与安卓一致)
- [x] 点开知识默认看到的是排好版的正文,不是编辑器的原文;要改才点「编辑」
- [x] 切片与语义检索跑通(`real[]` + SQL 余弦;pgvector 仍待独立实例)
- [ ] 真实用两周:新同事靠知识库自己解决过一次问题(需要真实团队)
@@ -355,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 + 少量自绘,线宽一致
@@ -414,3 +418,188 @@
- [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`
---
## 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`。
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: 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.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 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: 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: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 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: 58 KiB

+1 -1
View File
@@ -28,7 +28,7 @@
| 安卓端任务 / 看板 / 工单 / 知识 / 成员五个页面 | ✅ 编译成 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 节 |
| 界面:两端统一设计系统(冷白 / 靛蓝 #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 干净 |
+1 -1
View File
@@ -90,7 +90,7 @@ app/src/main/java/com/dadao/task/
│ ├── Api.kt OkHttp + kotlinx.serialization;信封解包、业务码抛 ApiException
│ └── TokenStore.kt 令牌本地存储(TODO:换 EncryptedSharedPreferences)
└── ui/
├── Theme.kt 与 Web 一致的墨青 + 陶土色板
├── Theme.kt 与 Web 一致的靛蓝 #4F46E5 + 陶土色板
├── LoginScreen.kt 手机号 + 验证码
├── HomeScreen.kt 工作区列表 / 新建 / 生成邀请码
├── TaskListScreen.kt 任务视图切换、快速新建、勾选完成、详情与评论
+36 -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 = 2
versionName = "0.2.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,6 +101,8 @@ 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")
+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,6 +17,8 @@ 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
@@ -31,6 +33,7 @@ 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
@@ -80,6 +83,9 @@ private fun AppRoot(joinUri: String?) {
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) {
@@ -114,11 +120,33 @@ private fun AppRoot(joinUri: String?) {
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
@@ -219,4 +247,14 @@ private fun AppRoot(joinUri: String?) {
}
}
}
// 更新对话框是独立窗口,摆在哪一层都能盖住当前界面
update?.let { release ->
UpdateDialog(
release = release,
currentVersionName = updater.currentVersionName,
updater = updater,
onLater = { update = null },
)
}
}
@@ -236,10 +236,25 @@ 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,
data class KnowledgeCategory( val id: String,
val name: String = "",
@SerialName("parent_id") val parentId: String? = null,
val depth: Int = 1,
@@ -472,6 +487,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
@@ -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) }
}
}
@@ -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)) }
}
@@ -43,6 +43,8 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import com.dadao.task.data.Api
@@ -51,15 +53,17 @@ import com.dadao.task.data.Attachment
import com.dadao.task.data.KnowledgeCategory
import com.dadao.task.data.KnowledgeDoc
import com.dadao.task.data.Workspace
import io.noties.markwon.Markwon
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
// 与 Web 一致:「全部」在第一位并且是默认——先看有什么,再去看待办
private val knowledgeTabs = listOf(
"all" to "全部",
"draft" to "待核对",
"published" to "已发布",
"archived" to "已归档",
"all" to "全部",
)
private val docTypes = listOf(
@@ -93,7 +97,7 @@ fun KnowledgeScreen(
onReopened: () -> Unit = {},
) {
val scope = rememberCoroutineScope()
var tab by remember { mutableStateOf("draft") }
var tab by remember { mutableStateOf("all") }
var docs by remember { mutableStateOf<List<KnowledgeDoc>>(emptyList()) }
var categories by remember { mutableStateOf<List<KnowledgeCategory>>(emptyList()) }
var loading by remember { mutableStateOf(true) }
@@ -204,7 +208,13 @@ fun KnowledgeScreen(
if (!loading && docs.isEmpty()) {
item {
EmptyState(
if (catFilter == null) "还没有待核对的知识" else "这个分类下还没有知识",
if (catFilter != null) {
"这个分类下还没有知识"
} else if (tab == "all") {
"还没有知识"
} else {
"这一类里还没有知识"
},
"把工单验收通过,或在任务详情里点「沉淀成知识」",
)
}
@@ -299,6 +309,37 @@ fun KnowledgeScreen(
}
}
/**
* 正文的「看」形态:Markwon 把 Markdown 排成排版好的正文。
*
* 编辑走 RichTextEditorScreen,所以这里永远是只读——点开一条知识先看到内容本身,
* 而不是一堆 `##`、`-` 原样摆着。
*/
@Composable
private fun KnowledgeBody(text: String) {
if (text.isBlank()) {
Text("(还没有正文,点「编辑正文」补齐)", fontSize = 13.sp, color = InkMuted)
return
}
val context = LocalContext.current
val markwon = remember { Markwon.create(context) }
AndroidView(
factory = { ctx ->
android.widget.TextView(ctx).apply {
setTextIsSelectable(true)
textSize = 14f
setLineSpacing(0f, 1.5f)
setTextColor(android.graphics.Color.parseColor("#1E293B"))
markwon.setMarkdown(this, text)
}
},
update = { view -> markwon.setMarkdown(view, text) },
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 80.dp),
)
}
private fun sourceLine(doc: KnowledgeDoc): String {
val src = doc.source
return if (src != null && src.title.isNotBlank()) {
@@ -537,11 +578,8 @@ private fun KnowledgeDetailDialog(
) {
Text(sourceLine(doc), style = MaterialTheme.typography.labelSmall, color = InkMuted)
Spacer(Modifier.height(10.dp))
Text(
doc.bodyMd.ifBlank { "(还没有正文,点「编辑正文」补齐)" },
style = MaterialTheme.typography.bodySmall,
color = InkSoft,
)
// 点开就是「看」:正文用 Markwon 排好版再显示,要看原文/改内容点「编辑正文」
KnowledgeBody(doc.bodyMd)
// 附件:手册、日志、导出表格都能挂在这条知识下,点一下用浏览器打开
Spacer(Modifier.height(14.dp))
@@ -587,8 +625,8 @@ private fun KnowledgeDetailDialog(
}
}
// 已发布的内容不能直接改,分类/标签也跟着锁住,避免"发布后又悄悄换归属"
if (action != "published") {
// 已发布的也能改(后端改完会重建检索切片);归档的先恢复成草稿再动,免得"收起来的"被悄悄改写
if (action != "archived") {
Spacer(Modifier.height(14.dp))
SectionLabel("分类")
Row(
@@ -644,14 +682,14 @@ private fun KnowledgeDetailDialog(
title = doc.title,
body = doc.bodyMd,
version = version,
titleEnabled = action == "draft",
titleEnabled = action != "archived",
knowledge = true,
),
)
onClose()
},
) { Text("编辑正文") }
if (action != "published") {
if (action != "archived") {
TextButton(
enabled = !savingMeta,
onClick = {
@@ -350,7 +350,7 @@ private fun PreviewBody(
textSize = 15f
setPadding(0, 0, 0, 0)
setLineSpacing(0f, 1.5f)
setTextColor(android.graphics.Color.parseColor("#4A5854"))
setTextColor(android.graphics.Color.parseColor("#475569"))
markwon.setMarkdown(this, text)
}
},
@@ -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,
@@ -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)
}
@@ -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>
@@ -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")
+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,
)
+209 -32
View File
@@ -1,32 +1,82 @@
"""认证:手机号 + 验证码。M0 不做密码、不做第三方登录。"""
"""认证:手机号 / 邮箱 + 密码,手机验证码可选。
当前**默认关掉短信验证码**(`SMS_LOGIN_ENABLED=false`):内测阶段的固定验证码
等于"知道手机号就能进任意账号"。关掉后 `/auth/sms/send` 与 `/auth/login` 直接
回 2017,登录走「手机号 或 邮箱 + 密码」。登录页先问 `/auth/methods`,只显示
服务端真开着的登录方式,免得点进去才报错。
"""
from __future__ import annotations
import hmac
import re
import secrets
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 ..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 ..security import (
create_token_pair,
decode_token,
hash_password,
password_problem,
verify_password,
)
from ..stores import 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:
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,
"nickname": user.nickname,
"avatar_url": user.avatar_url,
"has_password": bool(user.password_hash),
"platform_admin": 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 +86,56 @@ 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,
}
)
@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 +158,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 +180,62 @@ 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:
user_id = decode_token(body.refresh_token, "refresh")
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 +247,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 +261,63 @@ 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})
+69 -5
View File
@@ -15,6 +15,7 @@
from __future__ import annotations
import re
import uuid
from datetime import datetime, timezone
from typing import Annotated, Any
@@ -73,6 +74,8 @@ class CategoryIn(BaseModel):
name: str = Field(min_length=1, max_length=40)
parent_id: uuid.UUID | None = None
sort_order: int = 0
# 分类色,卡片上边线用它。不传就不上色(前端按 id 稳定地挑一个默认色)。
color: str | None = Field(default=None, max_length=7)
class CategoryPatch(BaseModel):
@@ -80,6 +83,8 @@ class CategoryPatch(BaseModel):
parent_id: uuid.UUID | None = None
move: bool = False
sort_order: int | None = None
# 不传 = 不改;传空串 = 清成默认色(分类色是“可选项”,得能取消)。
color: str | None = Field(default=None, max_length=7)
version: int
@@ -147,6 +152,29 @@ def _doc_out(doc: KnowledgeDoc) -> dict[str, Any]:
# ── 分类 ────────────────────────────────────────────────────────────────
_COLOR_RE = re.compile(r"^#[0-9A-Fa-f]{6}$")
def _norm_color(value: str | None) -> str | None:
"""分类色统一成 ``#RRGGBB`` 大写;空串表示“不要颜色”。
不在这里做“看得见就好”的宽松处理:颜色是直接拼进 CSS 的,宁可当场报错,
也不要让一个 ``red`` 或带 alpha 的值进了库、在前端静默变成透明边框。
"""
if value is None:
return None
raw = value.strip()
if raw == "":
return None
if not _COLOR_RE.match(raw):
raise AppError(
ErrorCode.VALIDATION_FAILED,
"分类颜色要用 #RRGGBB 格式,例如 #4F46E5",
details={"value": value},
)
return raw.upper()
async def _categories(
session: AsyncSession, ws_id: uuid.UUID
) -> dict[uuid.UUID, KnowledgeCategory]:
@@ -175,12 +203,38 @@ def _category_payload(
"name": cat.name,
"depth": cat.depth,
"sort_order": cat.sort_order,
"color": cat.color,
"version": cat.version,
"path": names,
"full_path": " / ".join(names),
}
def _ordered_categories(
by_id: dict[uuid.UUID, KnowledgeCategory],
) -> list[KnowledgeCategory]:
"""深度优先:父分类在前,它的子分类紧跟其后。
早先按 `(depth, sort_order, name)` 排,二级分类会全部堆在一级分类后面,
前端照着 depth 缩进就成了一棵"假树"——「技术」和它的上级「我的备忘」隔着十几行。
"""
children: dict[uuid.UUID | None, list[KnowledgeCategory]] = {}
for cat in by_id.values():
children.setdefault(cat.parent_id, []).append(cat)
for group in children.values():
group.sort(key=lambda c: (c.sort_order, c.name))
ordered: list[KnowledgeCategory] = []
def walk(parent_id: uuid.UUID | None) -> None:
for cat in children.get(parent_id, []):
ordered.append(cat)
walk(cat.id)
walk(None)
return ordered
def _subtree_ids(root_id: uuid.UUID, by_id: dict[uuid.UUID, KnowledgeCategory]) -> list[uuid.UUID]:
children: dict[uuid.UUID, list[uuid.UUID]] = {}
for cat in by_id.values():
@@ -303,7 +357,7 @@ async def list_knowledge(
stmt = stmt.where(
or_(
Item.updated_at < cur.created_at,
and_(Item.updated_at == cur.created_at, Item.id < cur.item_id),
and_(Item.updated_at == cur.created_at, Item.id < cur.id),
)
)
@@ -358,7 +412,7 @@ async def list_categories(ctx: MemberDep, session: SessionDep) -> dict:
).all()
)
items = []
for cat in sorted(by_id.values(), key=lambda c: (c.depth, c.sort_order, c.name)):
for cat in _ordered_categories(by_id):
data = _category_payload(cat, by_id)
data["doc_count"] = counts.get(cat.id, 0)
items.append(data)
@@ -387,6 +441,7 @@ async def create_category(body: CategoryIn, ctx: MemberDep, session: SessionDep)
name=name,
depth=depth,
sort_order=body.sort_order,
color=_norm_color(body.color),
)
session.add(cat)
await session.flush()
@@ -447,6 +502,9 @@ async def patch_category(
values.update(parent_id=new_parent_id, depth=new_depth)
if body.sort_order is not None:
values["sort_order"] = body.sort_order
# 只传了 color 才算改色;没传保持原样,传空串等于取消上色。
if body.color is not None:
values["color"] = _norm_color(body.color)
result = await session.execute(
update(KnowledgeCategory)
@@ -636,11 +694,12 @@ async def patch_knowledge(
) -> dict:
ws_id = ctx.ws_id
item, doc = await _load_doc(session, ws_id, item_id)
if item.status == "published":
if item.status == "archived":
# 已归档是先收起来,不是不能改:要改先恢复成草稿,免得改完"发布着"的样子被人当成现役
raise AppError(
ErrorCode.KNOWLEDGE_STATUS_INVALID,
"已发布的知识不能直接改内容,先撤回成草稿",
details={"from": item.status, "allowed_actions": ["archive"]},
"已归档的知识不能直接改内容,先恢复成草稿",
details={"from": item.status, "allowed_actions": ["restore"]},
)
if body.doc_type is not None and body.doc_type not in DOC_TYPES:
raise AppError(ErrorCode.VALIDATION_ERROR, f"未知的知识类型:{body.doc_type}")
@@ -689,6 +748,11 @@ async def patch_knowledge(
if body.tag_names is not None:
await _set_tags_for(session, ws_id, item, body.tag_names)
await session.flush()
if (body.title is not None or body.body_md is not None) and item.status == "published":
# 已发布的知识改完立刻重建切片:检索只认 published,
# 不重建就会出现"正文改了、搜出来还是旧句子"。
await reindex_document(session, ws_id, item.id)
await session.flush()
return ok(await _knowledge_out(session, ws_id, item, doc))
+83
View File
@@ -0,0 +1,83 @@
"""系统设置:平台级的运维参数(目前只有 SMTP)。
为什么单独一个路由而不是塞进 auth:这些设置**不属于任何工作区**,
权限也不是租户角色,而是「平台管理员」白名单(`deps.platform_admin`)。
把它和租户接口分开,读代码的人一眼就知道这里改的是整台服务的配置。
"""
from __future__ import annotations
from fastapi import APIRouter
from pydantic import BaseModel, Field
from ..deps import PlatformAdminDep, SessionDep
from ..errors import AppError, ErrorCode
from ..models import AppUser
from ..schemas import ok
from ..services import mail
router = APIRouter(prefix="/settings", tags=["系统设置"])
class SmtpIn(BaseModel):
"""授权码留空表示"沿用原来的",不传等于没改——避免每次保存都要重新粘贴一遍。"""
host: str = Field(min_length=1, max_length=255)
port: int = Field(default=465, ge=1, le=65535)
username: str = Field(min_length=1, max_length=255)
password: str | None = Field(default=None, max_length=255)
from_name: str = Field(default="达道Task", max_length=64)
use_ssl: bool = True
class TestMailIn(BaseModel):
# 不填就发给自己(账号里绑的邮箱),方便先确认通道通不通
to: str | None = Field(default=None, max_length=255)
@router.get("/smtp", summary="读发信配置(授权码只回是否已设)")
async def get_smtp(admin: PlatformAdminDep, session: SessionDep) -> dict:
return ok(await mail.config_payload(session))
@router.put("/smtp", summary="改发信配置,存库即时生效")
async def put_smtp(body: SmtpIn, admin: PlatformAdminDep, session: SessionDep) -> dict:
await mail.save_config(
session,
values={
"host": body.host.strip(),
"port": body.port,
"username": body.username.strip(),
"password": (body.password or "").strip() or None,
"from_name": body.from_name.strip() or "达道Task",
"use_ssl": body.use_ssl,
},
updated_by=admin.id,
)
return ok(await mail.config_payload(session))
@router.post("/smtp/test", summary="发一封测试邮件")
async def test_smtp(body: TestMailIn, admin: PlatformAdminDep, session: SessionDep) -> dict:
"""真发一封。配错授权码、端口不通这类问题,只有真连一次才知道。"""
to = (body.to or "").strip()
if not to:
row = await session.get(AppUser, admin.id)
to = (row.email if row else None) or ""
if not to:
raise AppError(ErrorCode.VALIDATION_FAILED, "填一个收件邮箱,或先在自己的账号里绑一个")
cfg = await mail.load_config(session)
if cfg is None:
raise AppError(ErrorCode.MAIL_SEND_FAILED, "还没有配置发信邮箱,请先填上面的 SMTP")
await mail.send_mail(
cfg,
to=to,
subject="达道Task 测试邮件",
text=(
"这封信是从达道Task的「系统设置」里发出来的。\n\n"
"收到它说明 SMTP 配置可用:以后的找回密码、任务提醒都会走这条通道。\n"
),
)
return ok({"sent": True, "to": to})
+36 -7
View File
@@ -4,10 +4,10 @@ from __future__ import annotations
import os
from functools import lru_cache
from typing import Literal
from typing import Annotated, Literal
from pydantic import Field, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict
DEV_JWT_SECRET = "dev-only-change-me" # noqa: S105 - 仅开发默认值,生产启动时会被校验拦下
@@ -60,6 +60,30 @@ class Settings(BaseSettings):
# 内测开关:生产默认禁止固定验证码。确实要在内网试用时显式打开,
# 启动会打 WARNING(见 main.py),其余生产校验照旧——绝不能配合公网暴露。
internal_test_login: bool = False
# 登录方式开关:手机验证码这条**默认关掉**。内测期的固定验证码等于"知道手机号就能进任意账号",
# 关掉后 /auth/sms/send 与 /auth/login 统一回 2017,登录走「手机号 / 邮箱 + 密码」。
# 将来接上真短信通道把它打开即可,登录页会按 /auth/methods 自动列出可用的方式。
sms_login_enabled: bool = False
# 平台管理员:能改系统设置(SMTP 等)。按手机号白名单,不挂工作区角色——
# 系统设置是**跨租户**的,某个工作区的 owner 也不该看到平台的发信配置。
# `NoDecode` + 下面的 before-validator:这个值环境变量里是逗号分隔的一串,
# 不让 pydantic-settings 先按 JSON 解析(那样 `1360...,1390...` 会直接启动失败)。
platform_admin_phones: Annotated[list[str], NoDecode] = Field(
default_factory=lambda: ["13605513683"]
)
# ── 邮件(SMTP)────────────────────────────────────────
# 以「系统设置」页里存的值为准(DB),这里只是首次启动前的兜底。
# 改 SMTP 不该要人登服务器改 env 再重启——所以真配置在 DB 里(见 services/mail.py)。
smtp_host: str | None = None
smtp_port: int = 465
smtp_username: str | None = None
smtp_password: str | None = None
smtp_from_name: str = "达道Task"
# 465 走 SSL,587 走 STARTTLS;126/163 这类用 465 + SSL
smtp_use_ssl: bool = True
smtp_timeout_seconds: int = 10
# ── 幂等 ──────────────────────────────────────────────
idempotency_enabled: bool = True
@@ -108,14 +132,19 @@ class Settings(BaseSettings):
# 指向 web/dist 的绝对路径;留空则只提供 API。
web_dist_dir: str | None = None
# ── 安卓安装包(OTA)──────────────────────────────────
# 放 latest.json + 对应 apk 的目录;留空则用 backend/static/apk。
# 发布目录是构建产物,不入库,部署脚本也不清理(git reset 不动未跟踪文件)。
apk_dir: str | None = None
# ── 构建标记(部署校验用)──────────────────────────────
build_commit: str = "unknown"
build_time: str = "unknown"
@field_validator("cors_origins", mode="before")
@field_validator("cors_origins", "platform_admin_phones", mode="before")
@classmethod
def _split_origins(cls, value: object) -> object:
"""支持 CORS_ORIGINS="a,b" 这种写法。"""
"""支持 CORS_ORIGINS="a,b" 这种写法(平台管理员白名单同理)。"""
if isinstance(value, str) and not value.startswith("["):
return [item.strip() for item in value.split(",") if item.strip()]
return value
@@ -129,10 +158,10 @@ class Settings(BaseSettings):
problems.append("JWT_SECRET 仍是开发默认值或长度不足 32")
if "dadao_dev_pw" in self.database_url:
problems.append("DATABASE_URL 仍在用开发默认口令")
if self.sms_provider == "dev" and not self.internal_test_login:
if self.sms_login_enabled and self.sms_provider == "dev":
problems.append(
"生产环境不允许 SMS_PROVIDER=dev(任意验证码可登录);"
"内网试用确需固定验证码时显式设置 INTERNAL_TEST_LOGIN=true"
"短信验证码登录开着(SMS_LOGIN_ENABLED=true),但 SMS_PROVIDER=dev 只会发固定验证码"
"(等于知道手机号就能进);接真短信通道之前请把 SMS_LOGIN_ENABLED 关掉"
)
if problems:
raise RuntimeError("配置校验失败:" + ";".join(problems))
+16
View File
@@ -14,6 +14,7 @@ from fastapi import Depends, Header, Path
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from .config import get_settings
from .db import bind_tenant, bind_user, get_session
from .errors import AppError, ErrorCode
from .logging_setup import user_id_ctx, workspace_id_ctx
@@ -112,3 +113,18 @@ def require_role(minimum: str):
OwnerDep = Annotated[TenantCtx, Depends(require_role("owner"))]
AdminDep = Annotated[TenantCtx, Depends(require_role("admin"))]
async def platform_admin(user: CurrentUser) -> AuthUser:
"""平台管理员:能改 SMTP 这类**跨租户**的系统设置。
刻意不复用工作区角色:某个工作区的 owner 只说明他管得了自己的工作区,
不代表他能看到整台服务的发信账号(那里面存着别人的授权码)。
名单来自配置 `PLATFORM_ADMIN_PHONES`,换人不用发版。
"""
if user.phone not in get_settings().platform_admin_phones:
raise AppError(ErrorCode.INSUFFICIENT_ROLE, "只有平台管理员能改系统设置")
return user
PlatformAdminDep = Annotated[AuthUser, Depends(platform_admin)]
+20
View File
@@ -25,6 +25,14 @@ class ErrorCode(IntEnum):
SMS_CODE_INVALID = 2010
SMS_TOO_FREQUENT = 2011
ACCOUNT_DISABLED = 2012
PASSWORD_INVALID = 2013
PASSWORD_NOT_SET = 2014
PASSWORD_WEAK = 2015
LOGIN_TOO_MANY = 2016
LOGIN_METHOD_DISABLED = 2017
EMAIL_INVALID = 2018
EMAIL_TAKEN = 2019
MAIL_SEND_FAILED = 2020
# 3xxx 租户 / 权限
WORKSPACE_NOT_FOUND = 3001
@@ -66,6 +74,9 @@ class ErrorCode(IntEnum):
ATTACHMENT_FORBIDDEN = 8003
CONFIRM_REQUIRED = 8004
# 9xxx 客户端发布(OTA)
APP_RELEASE_NOT_FOUND = 9001
# 默认 HTTP 状态:业务码自己带语义,HTTP 只是传输层
_HTTP_STATUS: dict[ErrorCode, int] = {
@@ -83,6 +94,14 @@ _HTTP_STATUS: dict[ErrorCode, int] = {
ErrorCode.SMS_CODE_INVALID: 400,
ErrorCode.SMS_TOO_FREQUENT: 429,
ErrorCode.ACCOUNT_DISABLED: 403,
ErrorCode.PASSWORD_INVALID: 400,
ErrorCode.PASSWORD_NOT_SET: 400,
ErrorCode.PASSWORD_WEAK: 422,
ErrorCode.LOGIN_TOO_MANY: 429,
ErrorCode.LOGIN_METHOD_DISABLED: 403,
ErrorCode.EMAIL_INVALID: 422,
ErrorCode.EMAIL_TAKEN: 409,
ErrorCode.MAIL_SEND_FAILED: 502,
ErrorCode.WORKSPACE_NOT_FOUND: 404,
ErrorCode.NOT_A_MEMBER: 403,
ErrorCode.INSUFFICIENT_ROLE: 403,
@@ -111,6 +130,7 @@ _HTTP_STATUS: dict[ErrorCode, int] = {
ErrorCode.ATTACHMENT_TOO_LARGE: 413,
ErrorCode.ATTACHMENT_FORBIDDEN: 403,
ErrorCode.CONFIRM_REQUIRED: 409,
ErrorCode.APP_RELEASE_NOT_FOUND: 404,
}
+6 -3
View File
@@ -16,7 +16,8 @@ from starlette.responses import Response
from starlette.staticfiles import StaticFiles
from starlette.types import Scope
from .api import attachments, auth, items, knowledge, tickets, workspaces
from .api import app_release, attachments, auth, items, knowledge, tickets, workspaces
from .api import settings as settings_api
from .config import get_settings
from .db import SessionFactory, current_app_role, engine
from .errors import AppError, ErrorCode, http_status_for
@@ -31,9 +32,9 @@ logger = get_logger("app")
@asynccontextmanager
async def lifespan(app: FastAPI):
settings.validate_runtime()
if settings.env == "prod" and settings.internal_test_login:
if settings.env == "prod" and settings.sms_login_enabled:
logger.warning(
"内测登录已开启:任意手机号可用固定验证码登录,只能在内网访问,切勿暴露公网",
"短信验证码登录开着,只能配真通道;用 dev 固定码等于知道手机号就能进任何账号",
extra={"extra_fields": {"sms_provider": settings.sms_provider}},
)
logger.info(
@@ -115,6 +116,8 @@ app.include_router(tickets.router, prefix=settings.api_prefix)
app.include_router(tickets.notify_router, prefix=settings.api_prefix)
app.include_router(knowledge.router, prefix=settings.api_prefix)
app.include_router(attachments.router, prefix=settings.api_prefix)
app.include_router(app_release.router, prefix=settings.api_prefix)
app.include_router(settings_api.router, prefix=settings.api_prefix)
@app.get("/health", tags=["运维"], summary="存活探针")
+21
View File
@@ -45,6 +45,27 @@ class AppUser(Base, Timestamps):
nickname: Mapped[str] = mapped_column(String(64), nullable=False, default="")
avatar_url: Mapped[str | None] = mapped_column(String(512))
status: Mapped[str] = mapped_column(String(16), nullable=False, default="active")
# 登录密码(scrypt 串,见 security.hash_password)。为空表示只用验证码登录。
password_hash: Mapped[str | None] = mapped_column(String(255))
# 邮箱:既是「邮箱 + 密码」登录的账号名,也是以后找回密码/发通知的去处。
# 可空(老用户还没有),唯一性由迁移里的 lower(email) 部分唯一索引保证。
email: Mapped[str | None] = mapped_column(String(255))
class SystemSetting(Base, Timestamps):
"""平台级键值设置(SMTP 等),**不挂租户、没有 RLS**。
刻意不做成租户表:发信账号是整台服务共用的,某个工作区的 owner 不该看到
平台的 SMTP 授权码。写权限由 API 层的「平台管理员」依赖把关(见 deps.py)。
"""
__tablename__ = "system_setting"
key: Mapped[str] = mapped_column(String(64), primary_key=True)
value: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
updated_by: Mapped[uuid.UUID | None] = mapped_column(
PgUUID(as_uuid=True), ForeignKey("app_user.id", ondelete="SET NULL")
)
class Workspace(Base, Timestamps):
+6
View File
@@ -48,6 +48,10 @@ class KnowledgeCategory(Base, Timestamps):
`depth` 是冗余列,但值得:每次筛选、移动子树都要判断层级,存下来就不用
递归数祖先。写入时由 API 计算,读到不一致的行属于数据事故。
`color` 是给人看的分类色(`#RRGGBB`)。它不参与任何判断,只负责让
"这条知识属于哪一类"在卡片墙上一眼分得出来——所以允许为空(前端给默认色),
但一旦有值就必须是合法十六进制,否则渲染会静默丢样式。
"""
__tablename__ = "knowledge_category"
@@ -55,6 +59,7 @@ class KnowledgeCategory(Base, Timestamps):
Index("ix_kcat_ws_parent", "workspace_id", "parent_id"),
CheckConstraint("depth BETWEEN 1 AND 3", name="ck_kcat_depth"),
CheckConstraint("length(btrim(name)) > 0", name="ck_kcat_name"),
CheckConstraint("color IS NULL OR color ~ '^#[0-9A-F]{6}$'", name="ck_kcat_color"),
{"comment": "知识分类树,深不过三"},
)
@@ -68,6 +73,7 @@ class KnowledgeCategory(Base, Timestamps):
name: Mapped[str] = mapped_column(String(40), nullable=False)
depth: Mapped[int] = mapped_column(nullable=False, default=1)
sort_order: Mapped[int] = mapped_column(nullable=False, default=0)
color: Mapped[str | None] = mapped_column(String(7))
class KnowledgeDoc(Base, Timestamps):
+63
View File
@@ -8,6 +8,8 @@
from __future__ import annotations
import base64
import hashlib
import hmac
import secrets
import time
@@ -78,3 +80,64 @@ def new_invite_code() -> str:
def invite_code_matches(provided: str, expected: str) -> bool:
return hmac.compare_digest(provided.strip().upper(), expected)
# —— 登录密码 ——
# 用标准库 scrypt,不引入 passlib/bcrypt:少一个依赖,也就少一处装不上的风险。
# 存储格式 `scrypt$n$r$p$salt_b64$hash_b64`,参数写进串里,将来调强度不用改代码。
_SCRYPT_N = 2**14
_SCRYPT_R = 8
_SCRYPT_P = 1
_PASSWORD_MIN = 8
_PASSWORD_MAX = 64
def hash_password(password: str) -> str:
salt = secrets.token_bytes(16)
digest = hashlib.scrypt(
password.encode("utf-8"), salt=salt, n=_SCRYPT_N, r=_SCRYPT_R, p=_SCRYPT_P, dklen=32
)
return "$".join(
[
"scrypt",
str(_SCRYPT_N),
str(_SCRYPT_R),
str(_SCRYPT_P),
base64.b64encode(salt).decode(),
base64.b64encode(digest).decode(),
]
)
def verify_password(password: str, stored: str | None) -> bool:
"""stored 为空(没设过密码)一律返回 False,别把"没密码"当成"密码对"。"""
if not stored:
return False
try:
scheme, n, r, p, salt_b64, hash_b64 = stored.split("$")
if scheme != "scrypt":
return False
digest = hashlib.scrypt(
password.encode("utf-8"),
salt=base64.b64decode(salt_b64),
n=int(n),
r=int(r),
p=int(p),
dklen=len(base64.b64decode(hash_b64)),
)
except (ValueError, TypeError):
return False
return hmac.compare_digest(digest, base64.b64decode(hash_b64))
def password_problem(password: str) -> str | None:
"""返回不合格的原因;合格返回 None。规则写在一处,设置与修改都用它。"""
if len(password) < _PASSWORD_MIN:
return f"密码至少 {_PASSWORD_MIN} 位"
if len(password) > _PASSWORD_MAX:
return f"密码最多 {_PASSWORD_MAX} 位"
has_letter = any(c.isalpha() for c in password)
has_digit = any(c.isdigit() for c in password)
if not (has_letter and has_digit):
return "密码要同时有字母和数字"
return None
+202
View File
@@ -0,0 +1,202 @@
"""发信(SMTP)。
三个刻意的取舍:
1. **配置以数据库里的为准**。发信账号是平台级的运维参数,改它不该要人登服务器
改 env 再重启;所以「系统设置」页写库,这里每次读库、读不到才回落到环境变量。
2. **只用标准库 smtplib**。发信不是性能路径,引一个三方 SDK 换来的只是多一条升级债;
SSL / STARTTLS 两种握手都覆盖了(465 走 SSL,587 走 STARTTLS)。
3. **失败要能说清是哪种失败**。连不上、认证不过、被对方拒收,对使用者是三件不同的
事;统一报"发信失败"会让人只能去翻服务器日志。所以异常分类后带进错误码 2020。
"""
from __future__ import annotations
import asyncio
import contextlib
import smtplib
import ssl
from dataclasses import dataclass
from email.header import Header
from email.message import EmailMessage
from email.utils import formataddr, formatdate
from sqlalchemy.ext.asyncio import AsyncSession
from ..config import get_settings
from ..errors import AppError, ErrorCode
from ..models import SystemSetting
SMTP_KEY = "smtp"
# 存库的字段名与对外字段一致,避免来回翻译出现第三个名字
SMTP_FIELDS = ("host", "port", "username", "password", "from_name", "use_ssl")
@dataclass(frozen=True)
class SmtpConfig:
host: str
port: int
username: str
password: str
from_name: str = "达道Task"
use_ssl: bool = True
@property
def sender(self) -> str:
return formataddr((str(Header(self.from_name, "utf-8")), self.username))
@property
def configured(self) -> bool:
return bool(self.host and self.username and self.password)
def _from_settings() -> SmtpConfig | None:
s = get_settings()
if not (s.smtp_host and s.smtp_username and s.smtp_password):
return None
return SmtpConfig(
host=s.smtp_host,
port=s.smtp_port,
username=s.smtp_username,
password=s.smtp_password,
from_name=s.smtp_from_name,
use_ssl=s.smtp_use_ssl,
)
async def load_config(session: AsyncSession) -> SmtpConfig | None:
"""先看库里有没有人配过,没有再回落到环境变量。"""
row = await session.get(SystemSetting, SMTP_KEY)
data = dict(row.value or {}) if row else {}
if not (data.get("host") and data.get("username") and data.get("password")):
return _from_settings()
return SmtpConfig(
host=str(data["host"]),
port=int(data.get("port") or 465),
username=str(data["username"]),
# 授权码里可能有多余空格,粘贴时最常见的就是把尾巴带上
password=str(data["password"]).strip(),
from_name=str(data.get("from_name") or "达道Task"),
use_ssl=bool(data.get("use_ssl", True)),
)
async def save_config(session: AsyncSession, *, values: dict, updated_by) -> SmtpConfig:
"""写库。password 传空表示"沿用原来的",不传等于没改。"""
row = await session.get(SystemSetting, SMTP_KEY)
current = dict(row.value or {}) if row else {}
merged = {**current, **{k: v for k, v in values.items() if v is not None}}
if not values.get("password"):
merged["password"] = current.get("password", "")
if not (merged.get("host") and merged.get("username") and merged.get("password")):
raise AppError(ErrorCode.VALIDATION_FAILED, "SMTP 服务器、账号、授权码都要填")
if row is None:
row = SystemSetting(key=SMTP_KEY, value=merged, updated_by=updated_by)
session.add(row)
else:
row.value = merged
row.updated_by = updated_by
await session.flush()
cfg = SmtpConfig(
host=str(merged["host"]),
port=int(merged.get("port") or 465),
username=str(merged["username"]),
password=str(merged["password"]).strip(),
from_name=str(merged.get("from_name") or "达道Task"),
use_ssl=bool(merged.get("use_ssl", True)),
)
return cfg
def _send_sync(cfg: SmtpConfig, to: str, subject: str, text: str, html: str | None) -> None:
msg = EmailMessage()
msg["Subject"] = str(Header(subject, "utf-8"))
msg["From"] = cfg.sender
msg["To"] = to
msg["Date"] = formatdate(localtime=True)
msg.set_content(text)
if html:
msg.add_alternative(html, subtype="html")
s = get_settings()
timeout = s.smtp_timeout_seconds
context = ssl.create_default_context()
if cfg.use_ssl:
server: smtplib.SMTP = smtplib.SMTP_SSL(
cfg.host, cfg.port, timeout=timeout, context=context
)
else:
server = smtplib.SMTP(cfg.host, cfg.port, timeout=timeout)
server.starttls(context=context)
try:
server.login(cfg.username, cfg.password)
server.send_message(msg)
finally:
# 信已经发出去了,QUIT 失败不该把成功回报成失败
with contextlib.suppress(Exception):
server.quit()
async def send_mail(
session_or_cfg: AsyncSession | SmtpConfig,
*,
to: str,
subject: str,
text: str,
html: str | None = None,
) -> None:
"""发一封纯文本(可带 HTML 替代体)的信。失败一律抛 2020,并说清是哪一步。"""
cfg = (
await load_config(session_or_cfg)
if isinstance(session_or_cfg, AsyncSession)
else session_or_cfg
)
if cfg is None or not cfg.configured:
raise AppError(ErrorCode.MAIL_SEND_FAILED, "还没有配置发信邮箱,请先在系统设置里填 SMTP")
try:
await asyncio.to_thread(_send_sync, cfg, to, subject, text, html)
except smtplib.SMTPAuthenticationError as exc:
raise AppError(
ErrorCode.MAIL_SEND_FAILED,
"SMTP 认证失败:账号或授权码不对(126/163 要用客户端授权码,不是登录密码)",
) from exc
except smtplib.SMTPRecipientsRefused as exc:
raise AppError(ErrorCode.MAIL_SEND_FAILED, f"收件地址被拒绝:{to}") from exc
except (smtplib.SMTPException, OSError) as exc:
raise AppError(ErrorCode.MAIL_SEND_FAILED, f"发信失败:{exc}") from exc
async def config_payload(session: AsyncSession) -> dict:
"""给设置页看的形状:授权码只回"设没设",不回原值。"""
row = await session.get(SystemSetting, SMTP_KEY)
data = dict(row.value or {}) if row else {}
cfg = await load_config(session)
if cfg is None:
return {
"host": data.get("host") or "",
"port": int(data.get("port") or 465),
"username": data.get("username") or "",
"password_set": bool(data.get("password")),
"from_name": data.get("from_name") or "达道Task",
"use_ssl": bool(data.get("use_ssl", True)),
"configured": False,
"source": "none",
"updated_at": row.updated_at if row else None,
}
return {
"host": cfg.host,
"port": cfg.port,
"username": cfg.username,
"password_set": True,
"from_name": cfg.from_name,
"use_ssl": cfg.use_ssl,
"configured": cfg.configured,
"source": "db" if row else "env",
"updated_at": row.updated_at if row else None,
}
+275
View File
@@ -0,0 +1,275 @@
"""把旧 memo_app(MySQL)里的备忘录导进达道Task的知识库。
一次性数据迁移,但保留成脚本:同一套数据以后可能还要再导一次(换环境、重导),
手点 84 次不现实,也不该让"导进去的东西为什么缺一块"变成口头传说。
设计上的几个决定:
1. **走 HTTP API,不直连数据库**。直接 INSERT 会绕过版本、审计和检索切片,
看起来更快,但导进去的知识搜不到——那等于把数据搬进了一个看不见的柜子。
发布(publish)会顺带建切片索引,所以每条都走一次 publish。
2. **分类按层级先建父后建子**,挪过来仍是最多三层(旧库只有两层)。
已有同名分类就复用,重跑不会长出第二棵一模一样的树。
3. **时间戳最后单独补**。API 不让人指定 created_at(本该如此),但备忘录的时间
就是内容的一部分:一条 5 月写的配方不该显示成"今天写的"。所以建完之后用一条
UPDATE 把 created_at/updated_at 改回原值;脚本只生成 SQL 文件,执行与否由人定。
4. **图片不搬**。旧库的 uploads 在服务器上已经找不到(容器没了),正文里的
`![](...)` 会让页面上一堆碎图图标,所以换成一行说明,把原始路径留在那里。
5. **可以被中断、可以重跑**。按标题去重,导到一半断了再跑一次只会补没进去的。
用法:
backend/.venv/Scripts/python.exe scripts/import_memo_app.py --data .local/memo-export
然后按它打印的路径跑那条 SQL 补时间戳。
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from pathlib import Path
from typing import Any
import httpx
BASE = "http://127.0.0.1:8100/api/v1"
PHONE = "13900000001"
CODE = "123456"
WORKSPACE_NAME = "我的备忘"
DOC_TYPE = "note"
ROOT = Path(__file__).resolve().parents[2]
IMAGE_RE = re.compile(r"!\[([^\]]*)\]\(([^)\s]+)\)")
def _token_file() -> Path:
"""令牌按「环境 + 手机号」分文件存:dev 和生产的令牌互不通用,
混着写会出现"刚登录完下一个请求又 401"这种很难看的事。"""
host = BASE.split("//", 1)[-1].split("/", 1)[0]
return ROOT / ".local" / f"memo-token-{PHONE}-{host.replace(':', '-')}.txt"
def _client() -> httpx.Client:
return httpx.Client(base_url=BASE, timeout=120.0)
def _data(resp: httpx.Response) -> Any:
payload = resp.json()
if payload.get("code") != 0:
raise RuntimeError(f"{resp.request.method} {resp.request.url.path} → {payload}")
return payload.get("data")
def login(client: httpx.Client) -> dict[str, str]:
"""拿令牌。dev 发验证码有 60 秒节流,重跑脚本很容易撞上,
所以令牌缓存到文件里;缓存失效再走一次验证码。"""
cache = _token_file()
cache.parent.mkdir(parents=True, exist_ok=True)
if cache.exists():
auth = {"Authorization": f"Bearer {cache.read_text(encoding='utf-8').strip()}"}
if client.get("/auth/me", headers=auth).status_code == 200:
return auth
client.post("/auth/sms/send", json={"phone": PHONE}).raise_for_status()
payload = client.post("/auth/login", json={"phone": PHONE, "code": CODE}).json()
if payload.get("code") != 0:
raise SystemExit(
f"登录失败:{payload.get('message')}(开发环境发码有 " f"{60} 秒节流,等一会儿再跑)"
)
token = payload["data"]["tokens"]["access_token"]
cache.write_text(token, encoding="utf-8")
return {"Authorization": f"Bearer {token}"}
def workspace(client: httpx.Client, auth: dict[str, str], name: str) -> tuple[str, set[str]]:
"""找到(或建出)工作区,并带出里面已有的知识标题,用来跳过已导入的。"""
rows = _data(client.get("/workspaces", headers=auth))["items"]
ws_id = next((row["id"] for row in rows if row["name"] == name), None)
if ws_id is None:
created = _data(client.post("/workspaces", headers=auth, json={"name": name}))
return created["id"], set()
titles: set[str] = set()
cursor = None
while True:
params = {"limit": 100}
if cursor:
params["cursor"] = cursor
page = _data(client.get(f"/workspaces/{ws_id}/knowledge", headers=auth, params=params))
titles.update(row["title"] for row in page["items"])
cursor = page.get("next_cursor")
if not cursor:
break
return ws_id, titles
def clean_body(body: str) -> str:
"""把正文里的图片引用换成一行说明。
旧库的图片文件已经不在服务器上了(uploads 目录随容器一起没了),留着 markdown
只会渲染成一排碎图。原始路径写进注释,将来真找回来了还能对得上。
"""
def repl(match: re.Match[str]) -> str:
target = match.group(2)
name = target.rsplit("/", 1)[-1]
return f"> 原图未迁移:{name}(旧路径 {target})"
return IMAGE_RE.sub(repl, body or "").strip()
def _depth_of(row: dict[str, Any], by_id: dict[int, dict[str, Any]]) -> int:
depth = 1
parent = row["parent_id"]
seen = set()
while parent and parent in by_id and parent not in seen:
seen.add(parent)
depth += 1
parent = by_id[parent]["parent_id"]
return depth
def create_categories(
client: httpx.Client, auth: dict[str, str], ws_id: str, old: list[dict[str, Any]]
) -> dict[int, str]:
"""按层级从浅到深建;同名同父已存在就复用。返回 旧 id → 新 id。"""
existing = _data(client.get(f"/workspaces/{ws_id}/knowledge/categories", headers=auth))
known: dict[tuple[str | None, str], str] = {
(row["parent_id"], row["name"].lower()): row["id"] for row in existing["items"]
}
by_id = {row["id"]: row for row in old}
mapping: dict[int, str] = {}
for row in sorted(old, key=lambda r: (_depth_of(r, by_id), r["sort_order"], r["id"])):
parent_new = mapping.get(row["parent_id"]) if row["parent_id"] else None
key = (parent_new, row["name"].strip().lower())
if key in known:
mapping[row["id"]] = known[key]
continue
created = _data(
client.post(
f"/workspaces/{ws_id}/knowledge/categories",
headers=auth,
json={
"name": row["name"].strip(),
"parent_id": parent_new,
"sort_order": row["sort_order"],
},
)
)
mapping[row["id"]] = created["id"]
known[key] = created["id"]
return mapping
def main() -> int:
global BASE, PHONE
parser = argparse.ArgumentParser()
parser.add_argument("--data", default=".local/memo-export", help="导出的 JSON 目录")
parser.add_argument("--sql-out", default=".local/memo-timestamps.sql")
parser.add_argument("--limit", type=int, default=0, help="只导前 N 条(试跑用)")
parser.add_argument("--api", default=BASE, help="目标环境的 API 根地址")
parser.add_argument("--phone", default=PHONE, help="知识记到哪个手机号名下")
parser.add_argument("--workspace", default=WORKSPACE_NAME, help="工作区名字,没有就新建")
args = parser.parse_args()
BASE = args.api.rstrip("/")
PHONE = args.phone
workspace_name = args.workspace
root = Path(args.data)
notes = json.loads((root / "notes.json").read_text(encoding="utf-8"))
categories = json.loads((root / "categories.json").read_text(encoding="utf-8"))
tags = json.loads((root / "tags.json").read_text(encoding="utf-8"))
note_tags = json.loads((root / "note_tags.json").read_text(encoding="utf-8"))
tag_name = {row["id"]: row["name"] for row in tags}
tags_of: dict[int, list[str]] = {}
for row in note_tags:
tags_of.setdefault(row["note_id"], []).append(tag_name[row["tag_id"]])
# 旧库有两个用户,只有 steven(1) 有笔记;别人的分类不搬,免得空分类挂在树上
owners = {row["user_id"] for row in notes}
categories = [row for row in categories if row["user_id"] in owners]
timestamps: list[str] = []
failed: list[str] = []
done = 0
with _client() as client:
auth = login(client)
ws_id, existing_titles = workspace(client, auth, workspace_name)
print(f"工作区 {ws_id},已有 {len(existing_titles)} 条知识")
cat_map = create_categories(client, auth, ws_id, categories)
print(f"分类 {len(cat_map)} 个")
ordered = sorted(notes, key=lambda r: r["id"])
queue = [n for n in ordered if n["title"] not in existing_titles]
if args.limit:
queue = queue[: args.limit]
print(f"待导入 {len(queue)} 条")
for index, note in enumerate(queue, start=1):
title = (note["title"] or "").strip() or "无标题备忘"
payload = {
"title": title[:200],
"body_md": clean_body(note["content"] or ""),
"summary": (note["summary"] or "").strip(),
"doc_type": DOC_TYPE,
"tag_names": tags_of.get(note["id"], []),
"category_id": cat_map.get(note["category_id"]),
}
try:
doc = _data(
client.post(f"/workspaces/{ws_id}/knowledge", headers=auth, json=payload)
)
action = "archive" if note["is_archived"] else "publish"
_data(
client.post(
f"/workspaces/{ws_id}/knowledge/{doc['id']}/status",
headers=auth,
json={"action": action, "version": doc["version"]},
)
)
except Exception as error: # 一条失败不该拖垮整批,记下来最后一起看
failed.append(f"#{note['id']} {title}: {error}")
continue
done += 1
timestamps.append(
f"('{doc['id']}', '{note['created_at']}+00', '{note['updated_at']}+00')"
)
if index % 10 == 0:
print(f" … {index}/{len(queue)}")
sql = [
"-- 把导入时的 now() 改回笔记原本的时间(dev 库执行,dbadmin 可绕过 RLS)",
"BEGIN;",
"CREATE TEMP TABLE memo_ts (item_id uuid PRIMARY KEY, created_at timestamptz,"
" updated_at timestamptz) ON COMMIT DROP;",
"INSERT INTO memo_ts (item_id, created_at, updated_at) VALUES",
",\n".join(timestamps) + ";",
"UPDATE item i SET created_at = t.created_at, updated_at = t.updated_at"
" FROM memo_ts t WHERE i.id = t.item_id;",
"UPDATE knowledge_doc k SET published_at = t.updated_at,"
" archived_at = CASE WHEN k.status_hint = 'archived' THEN t.updated_at ELSE NULL END"
" FROM memo_ts t WHERE k.item_id = t.item_id;",
"COMMIT;",
]
Path(args.sql_out).write_text("\n".join(sql) + "\n", encoding="utf-8")
print(f"导入完成 {done} 条;时间戳 SQL → {args.sql_out}")
if failed:
print("这些没进去:")
for line in failed:
print(" -", line)
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
+86
View File
@@ -0,0 +1,86 @@
"""把构建好的 APK 发布成 OTA 版本:算 sha256、写 latest.json。
用法(在仓库根目录):
python backend/scripts/publish_apk.py android/app/build/outputs/apk/release/app-release.apk \
--version-code 2 --version-name 0.2.0 --notes "第一版安装包:知识库支持分类与附件"
发布目录默认 `backend/static/apk/`,可用 `--dir` 或环境变量 `APK_DIR` 改。
这个目录是产物、不入库;部署到服务器时把 `latest.json` 和 apk 一起 scp 过去即可。
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import shutil
import sys
from datetime import datetime, timezone
from pathlib import Path
REPO = Path(__file__).resolve().parents[2]
DEFAULT_DIR = REPO / "backend" / "static" / "apk"
def sha256_of(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="发布安卓安装包(OTA)")
parser.add_argument("apk", type=Path, help="构建产物路径,如 app-release.apk")
parser.add_argument(
"--version-code", type=int, required=True, help="与 build.gradle 的 versionCode 一致"
)
parser.add_argument("--version-name", required=True, help="展示用版本号,如 0.2.0")
parser.add_argument("--notes", default="", help="更新说明,App 里会原样显示")
parser.add_argument("--min-sdk", type=int, default=26)
parser.add_argument(
"--dir",
type=Path,
default=Path(os.environ.get("APK_DIR") or DEFAULT_DIR),
help="发布目录(默认 backend/static/apk)",
)
args = parser.parse_args(argv)
if not args.apk.is_file():
print(f"APK 不存在:{args.apk}", file=sys.stderr)
return 1
target_dir = args.dir
target_dir.mkdir(parents=True, exist_ok=True)
name = f"dadaotask-{args.version_name}.apk"
target = target_dir / name
shutil.copyfile(args.apk, target)
manifest = {
"platform": "android",
"version_code": args.version_code,
"version_name": args.version_name,
"notes": args.notes.strip(),
"file": name,
"size": target.stat().st_size,
"sha256": sha256_of(target),
"min_sdk": args.min_sdk,
"published_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
}
(target_dir / "latest.json").write_text(
json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
)
print(f"已发布 {name}")
print(f" versionCode={manifest['version_code']} versionName={manifest['version_name']}")
print(f" size={manifest['size'] / 1024 / 1024:.1f}MB sha256={manifest['sha256'][:16]}…")
print(f" 清单:{target_dir / 'latest.json'}")
print("提示:服务器上把这两个文件放到 <repo>/backend/static/apk/ 即可,无需重启服务。")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+4 -1
View File
@@ -13,6 +13,9 @@ os.environ["ENV"] = "test"
os.environ.setdefault("SMS_DEV_CODE", "123456")
# 测试里关掉发码节流,否则"同一手机号再登录一次"要等 60 秒
os.environ.setdefault("SMS_THROTTLE_SECONDS", "0")
# 线上默认关掉了短信验证码登录(固定码等于谁都能进),但测试夹具 `login()` 靠它批量造用户。
# 这里显式打开;想验证"关掉之后回 2017"的用例自己 monkeypatch 一下 settings 即可。
os.environ.setdefault("SMS_LOGIN_ENABLED", "true")
# 测试库由 config 的 test_database_url / test_migration_database_url 决定(见 backend/.env),
# 这里不再改 os.environ —— 配置对象自己会读 .env。
@@ -36,7 +39,7 @@ from app.stores import reset_store_for_tests
settings = get_settings()
DEV_CODE = settings.sms_dev_code
_TABLES = "audit_log, invitation, member, workspace, app_user"
_TABLES = "system_setting, audit_log, invitation, member, workspace, app_user"
def _run_migrations() -> None:
+189
View File
@@ -0,0 +1,189 @@
"""账号功能:昵称、密码设置/修改、密码登录。
这些都是"账号被占"的高风险入口,所以断言不只看 200,还要看:
- 密码一字节都不回给客户端(只回 has_password)
- 没设过密码的人不会被密码登录误放进去
- 改完密码,旧密码立刻失效
- 连错太多次要先锁一会儿,别让登录接口变成离线爆破的放大镜
"""
from __future__ import annotations
from httpx import AsyncClient
from .conftest import auth, login, unwrap
async def _me(client: AsyncClient, user: dict) -> dict:
return unwrap(await client.get("/api/v1/auth/me", headers=user["headers"]))
async def test_me_reports_profile_without_leaking_password(client: AsyncClient) -> None:
user = await login(client)
data = await _me(client, user)
assert data["user"]["has_password"] is False
assert data["user"]["phone"].endswith(user["phone"][-4:])
assert "****" in data["user"]["phone"]
# 只回「设没设」,不回密码本身:字段白名单比黑名单可靠
assert set(data["user"]) == {
"id",
"phone",
"email",
"nickname",
"avatar_url",
"has_password",
"platform_admin",
"created_at",
}
async def test_patch_nickname(client: AsyncClient) -> None:
user = await login(client)
data = unwrap(
await client.patch(
"/api/v1/auth/me", json={"nickname": " 老王 "}, headers=user["headers"]
)
)
assert data["user"]["nickname"] == "老王"
# 空昵称要被拦住,不能变成"无名氏"
resp = await client.patch("/api/v1/auth/me", json={"nickname": " "}, headers=user["headers"])
assert resp.json()["code"] == 1002
async def test_set_password_then_login_with_it(client: AsyncClient) -> None:
user = await login(client)
unwrap(
await client.post(
"/api/v1/auth/password",
json={"password": "dadao2026"},
headers=user["headers"],
)
)
assert (await _me(client, user))["user"]["has_password"] is True
# 验证码登录照旧可用,两条路并存
data = unwrap(
await client.post(
"/api/v1/auth/login/password",
json={"phone": user["phone"], "password": "dadao2026"},
)
)
assert data["tokens"]["access_token"]
assert data["user"]["id"] == user["user_id"]
async def test_password_login_rejects_wrong_password(client: AsyncClient) -> None:
user = await login(client)
unwrap(
await client.post(
"/api/v1/auth/password", json={"password": "dadao2026"}, headers=user["headers"]
)
)
resp = await client.post(
"/api/v1/auth/login/password", json={"phone": user["phone"], "password": "wrong-pass1"}
)
assert resp.status_code == 400
assert resp.json()["code"] == 2013
async def test_password_login_on_account_without_password(client: AsyncClient) -> None:
user = await login(client)
resp = await client.post(
"/api/v1/auth/login/password", json={"phone": user["phone"], "password": "dadao2026"}
)
# 没设过密码 = 密码不对,不能因为「没设置」就报 2014 把信息漏出去
assert resp.json()["code"] == 2013
async def test_unknown_phone_looks_the_same_as_wrong_password(client: AsyncClient) -> None:
user = await login(client)
unwrap(
await client.post(
"/api/v1/auth/password", json={"password": "dadao2026"}, headers=user["headers"]
)
)
resp = await client.post(
"/api/v1/auth/login/password", json={"phone": "13511112222", "password": "dadao2026"}
)
assert resp.json()["code"] == 2013
async def test_change_password_requires_old_one_and_invalidates_it(client: AsyncClient) -> None:
user = await login(client)
unwrap(
await client.post(
"/api/v1/auth/password", json={"password": "dadao2026"}, headers=user["headers"]
)
)
# 不给旧密码 → 拒绝
resp = await client.post(
"/api/v1/auth/password", json={"password": "dadao2027"}, headers=user["headers"]
)
assert resp.json()["code"] == 2013
# 旧密码给错 → 拒绝
resp = await client.post(
"/api/v1/auth/password",
json={"password": "dadao2027", "old_password": "nope-1234"},
headers=user["headers"],
)
assert resp.json()["code"] == 2013
unwrap(
await client.post(
"/api/v1/auth/password",
json={"password": "dadao2027", "old_password": "dadao2026"},
headers=user["headers"],
)
)
# 新密码能登,旧密码不能
unwrap(
await client.post(
"/api/v1/auth/login/password", json={"phone": user["phone"], "password": "dadao2027"}
)
)
resp = await client.post(
"/api/v1/auth/login/password", json={"phone": user["phone"], "password": "dadao2026"}
)
assert resp.json()["code"] == 2013
async def test_weak_password_rejected(client: AsyncClient) -> None:
user = await login(client)
for weak in ["short1", "12345678", "abcdefgh"]:
resp = await client.post(
"/api/v1/auth/password", json={"password": weak}, headers=user["headers"]
)
assert resp.json()["code"] == 2015, weak
async def test_password_endpoints_require_login(client: AsyncClient) -> None:
resp = await client.post("/api/v1/auth/password", json={"password": "dadao2026"})
assert resp.status_code == 401
resp = await client.patch("/api/v1/auth/me", json={"nickname": "x"}, headers=auth("nope"))
assert resp.status_code == 401
async def test_repeated_failures_are_throttled(client: AsyncClient) -> None:
user = await login(client)
unwrap(
await client.post(
"/api/v1/auth/password", json={"password": "dadao2026"}, headers=user["headers"]
)
)
for _ in range(10):
await client.post(
"/api/v1/auth/login/password", json={"phone": user["phone"], "password": "bad-pass1"}
)
resp = await client.post(
"/api/v1/auth/login/password", json={"phone": user["phone"], "password": "dadao2026"}
)
# 次数用光之后,连正确密码也要先等一会儿——锁的是这段时间,不是这一次请求
assert resp.json()["code"] == 2016
+101
View File
@@ -0,0 +1,101 @@
"""OTA:最新版本清单与安装包下载。
不依赖数据库,也不需要登录——App 还没登录就可能在检查更新。
发布目录用 tmp_path 顶替,绝不去碰真实产物。
"""
from __future__ import annotations
import hashlib
import json
from pathlib import Path
import pytest
from httpx import AsyncClient
from app.api import app_release
@pytest.fixture()
def release_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
monkeypatch.setattr(app_release.settings, "apk_dir", str(tmp_path))
return tmp_path
def _publish(directory: Path, *, version_code: int = 2, version_name: str = "0.2.0") -> bytes:
payload = b"PK\x03\x04 fake apk bytes"
name = f"dadaotask-{version_name}.apk"
(directory / name).write_bytes(payload)
manifest = {
"platform": "android",
"version_code": version_code,
"version_name": version_name,
"notes": "修了分类侧栏的层级",
"file": name,
"size": len(payload),
"sha256": hashlib.sha256(payload).hexdigest(),
"min_sdk": 26,
"published_at": "2026-10-31T02:00:00+00:00",
}
(directory / "latest.json").write_text(json.dumps(manifest), encoding="utf-8")
return payload
async def test_latest_release_is_public_and_reports_update(
client: AsyncClient, release_dir: Path
) -> None:
# 没发布过:明确告诉客户端没有安装包,而不是 500
resp = await client.get("/api/v1/app/release/latest")
body = resp.json()
assert resp.status_code == 404
assert body["code"] == 9001
async def test_latest_release_payload(client: AsyncClient, release_dir: Path) -> None:
_publish(release_dir)
resp = await client.get("/api/v1/app/release/latest")
assert resp.status_code == 200
data = resp.json()["data"]
assert data["version_code"] == 2
assert data["version_name"] == "0.2.0"
assert data["notes"] == "修了分类侧栏的层级"
assert data["download_url"] == "/api/v1/app/release/download/dadaotask-0.2.0.apk"
assert data["has_update"] is False # 没带当前版本就不猜
async def test_latest_release_tells_client_whether_to_upgrade(
client: AsyncClient, release_dir: Path
) -> None:
_publish(release_dir, version_code=2)
older = await client.get("/api/v1/app/release/latest?version_code=1")
assert older.json()["data"]["has_update"] is True
same = await client.get("/api/v1/app/release/latest?version_code=2")
assert same.json()["data"]["has_update"] is False
newer = await client.get("/api/v1/app/release/latest?version_code=9")
assert newer.json()["data"]["has_update"] is False
async def test_download_returns_the_apk_bytes(client: AsyncClient, release_dir: Path) -> None:
payload = _publish(release_dir)
resp = await client.get("/api/v1/app/release/download/dadaotask-0.2.0.apk")
assert resp.status_code == 200
assert resp.headers["content-type"] == "application/vnd.android.package-archive"
assert resp.content == payload
async def test_download_refuses_files_outside_the_manifest(
client: AsyncClient, release_dir: Path
) -> None:
_publish(release_dir)
resp = await client.get("/api/v1/app/release/download/..%2Fsecret.txt")
assert resp.status_code == 404
missing = await client.get("/api/v1/app/release/download/dadaotask-9.9.9.apk")
assert missing.status_code == 404
assert missing.json()["code"] == 9001
+12 -6
View File
@@ -1,6 +1,8 @@
"""生产配置校验:固定验证码必须显式开关,否则启动即失败。
这两条是「部署纪律」的守门员:生产一旦误用 dev 验证码,等于任意手机号可登录。
现在的规则更简单——**短信验证码登录默认关着**,关着时 SMS_PROVIDER 是 dev 也无所谓,
因为那条路根本不通;只有在 SMS_LOGIN_ENABLED=true 时才要求真通道。
"""
from __future__ import annotations
@@ -24,14 +26,18 @@ def _prod(**overrides: object) -> Settings:
return Settings(**values)
def test_prod_rejects_dev_sms_without_switch() -> None:
with pytest.raises(RuntimeError, match="SMS_PROVIDER=dev"):
_prod(sms_provider="dev").validate_runtime()
def test_prod_rejects_dev_sms_when_login_enabled() -> None:
with pytest.raises(RuntimeError, match="SMS_LOGIN_ENABLED"):
_prod(sms_provider="dev", sms_login_enabled=True).validate_runtime()
def test_prod_allows_dev_sms_with_internal_test_login() -> None:
# 内测模式:显式开关后放行,启动时 main.lifespan 会打 WARNING
_prod(sms_provider="dev", internal_test_login=True).validate_runtime()
def test_prod_is_fine_with_sms_login_closed() -> None:
# 默认就是这条:短信登录关着,provider 配成 dev 也不会有人能用它进来
_prod(sms_provider="dev", sms_login_enabled=False).validate_runtime()
def test_prod_allows_real_sms_provider_when_enabled() -> None:
_prod(sms_provider="aliyun", sms_login_enabled=True).validate_runtime()
def test_prod_still_rejects_weak_jwt_secret() -> None:
+58 -3
View File
@@ -200,13 +200,18 @@ async def test_publish_requires_admin(client: AsyncClient) -> None:
assert published["status"] == "published"
assert published["knowledge"]["published_at"] is not None
# 已发布的内容不许直接改,先撤回
blocked = await client.patch(
# 已发布的能直接改正文:改完后端自动重建切片,状态仍是已发布
edited = unwrap(
await client.patch(
f"{V1}/workspaces/{ws['id']}/knowledge/{doc['id']}",
json={"title": "值班手册 v2", "version": published["version"]},
headers=owner["headers"],
)
assert blocked.json()["code"] == 7003
)
assert edited["title"] == "值班手册 v2"
assert edited["knowledge"]["status_hint"] == "published"
assert edited["version"] == published["version"] + 1
published = edited
archived = unwrap(
await client.post(
@@ -218,6 +223,14 @@ async def test_publish_requires_admin(client: AsyncClient) -> None:
assert archived["status"] == "archived"
assert archived["knowledge"]["archived_at"] is not None
# 归档是先收起来;要改先恢复成草稿,免得改完还挂着"现役"的样子
blocked = await client.patch(
f"{V1}/workspaces/{ws['id']}/knowledge/{doc['id']}",
json={"title": "值班手册 v3", "version": archived["version"]},
headers=owner["headers"],
)
assert blocked.json()["code"] == 7003
@pytest.mark.asyncio
async def test_illegal_action_reports_allowed(client: AsyncClient) -> None:
@@ -368,3 +381,45 @@ async def test_draft_timeline_reads_like_a_story(client: AsyncClient) -> None:
body = doc["body_md"]
assert "待办 → 进行中" in body
assert "进行中 → 已完成" in body
@pytest.mark.asyncio
async def test_list_pages_through_every_document(client: AsyncClient) -> None:
"""翻页要能翻到底,且不重不漏。
这条用例是有来历的:知识列表的游标解出来是 `Cursor(created_at, id)`,
查询里却写成了 `cur.item_id`——属性不存在,第二页必 500。
库小于一页(默认 30 条)时永远走不到这条路径,所以一直没暴露,
直到把旧备忘导进来 84 条,用户点「加载更多」就看到"服务器内部错误"。
"""
owner = await login(client)
ws = await create_workspace(client, owner, "翻页")
titles = [f"备忘 {i:02d}" for i in range(25)]
for title in titles:
unwrap(
await client.post(
f"{V1}/workspaces/{ws['id']}/knowledge",
json={"title": title},
headers=owner["headers"],
)
)
seen: list[str] = []
cursor: str | None = None
for _ in range(10): # 25 条 / 每页 10 条,三轮就该到底,多留几次余地
params: dict[str, Any] = {"limit": 10}
if cursor:
params["cursor"] = cursor
page = unwrap(
await client.get(
f"{V1}/workspaces/{ws['id']}/knowledge", params=params, headers=owner["headers"]
)
)
seen.extend(i["title"] for i in page["items"])
cursor = page["next_cursor"]
if not page["has_more"] or not cursor:
break
assert cursor is None
assert sorted(seen) == titles
assert len(seen) == len(set(seen)), "翻页翻出了重复条目"
+113
View File
@@ -303,3 +303,116 @@ async def test_patch_knowledge_accepts_doc_type_and_bumps_version(client: AsyncC
assert patched["body_md"] == "改过的正文"
assert patched["version"] == doc["version"] + 1
assert {t["name"] for t in patched["tags"]} == {"写法"}
@pytest.mark.asyncio
async def test_categories_come_back_as_a_tree(client: AsyncClient) -> None:
"""列表按「父在前、子紧跟」排:前端照着 depth 缩进才像一棵树。
之前按 (depth, sort_order) 排,二级分类全堆在一级后面,
侧栏看着像"所有一级在一起、所有二级在一起"。
"""
user, ws_id = await _ws(client)
tech = await _category(client, user, ws_id, "技术开发")
biz = await _category(client, user, ws_id, "餐饮业务")
child = await _category(client, user, ws_id, "技术", tech["id"])
await _category(client, user, ws_id, "部署", child["id"])
assert biz["depth"] == 1
items = unwrap(
await client.get(f"{V1}/workspaces/{ws_id}/knowledge/categories", headers=user["headers"])
)["items"]
assert [i["name"] for i in items] == ["技术开发", "技术", "部署", "餐饮业务"]
assert [i["full_path"] for i in items] == [
"技术开发",
"技术开发 / 技术",
"技术开发 / 技术 / 部署",
"餐饮业务",
]
@pytest.mark.asyncio
async def test_category_color_roundtrip(client: AsyncClient) -> None:
"""分类色:建时能给、能改、能取消,非法值当场拒绝。
颜色是直接拼进 CSS 的(卡片上边线),所以格式必须在入口挡住:
一个 `red` 或 `#4f46e5cc` 进了库,前端只会静默退化成灰边框,没人知道为什么。
"""
user, ws_id = await _ws(client)
base = f"{V1}/workspaces/{ws_id}/knowledge/categories"
created = unwrap(
await client.post(
url=base, json={"name": "部署", "color": "#4f46e5"}, headers=user["headers"]
)
)
# 统一成大写,前端不用再 casefold
assert created["color"] == "#4F46E5"
listed = unwrap(await client.get(base, headers=user["headers"]))["items"]
assert [c["color"] for c in listed] == ["#4F46E5"]
recolored = unwrap(
await client.patch(
f"{base}/{created['id']}",
json={"color": "#0EA5E9", "version": created["version"]},
headers=user["headers"],
)
)
assert recolored["color"] == "#0EA5E9"
assert recolored["name"] == "部署" # 只改色不动名字
cleared = unwrap(
await client.patch(
f"{base}/{created['id']}",
json={"color": "", "version": recolored["version"]},
headers=user["headers"],
)
)
assert cleared["color"] is None
# 不传 color 的普通改名不该顺手把颜色清掉
colored = unwrap(
await client.patch(
f"{base}/{created['id']}",
json={"color": "#10B981", "version": cleared["version"]},
headers=user["headers"],
)
)
renamed = unwrap(
await client.patch(
f"{base}/{created['id']}",
json={"name": "上线", "version": colored["version"]},
headers=user["headers"],
)
)
assert renamed["name"] == "上线"
assert renamed["color"] == "#10B981"
bad = await client.post(
url=base, json={"name": "业务", "color": "red"}, headers=user["headers"]
)
assert bad.status_code == 422, bad.text
assert _code(bad) == 1002
too_long = await client.post(
json={"name": "业务2", "color": "#4f46e5cc"}, url=base, headers=user["headers"]
)
assert too_long.status_code == 422, too_long.text
@pytest.mark.asyncio
async def test_knowledge_card_carries_category_color(client: AsyncClient) -> None:
"""知识列表里带上分类(含颜色),前端画卡片上边线不用再查一次。"""
user, ws_id = await _ws(client)
cat = unwrap(
await client.post(
f"{V1}/workspaces/{ws_id}/knowledge/categories",
json={"name": "复盘", "color": "#EF4444"},
headers=user["headers"],
)
)
await _note(client, user, ws_id, "一次事故复盘", category_id=cat["id"])
page = unwrap(await client.get(f"{V1}/workspaces/{ws_id}/knowledge", headers=user["headers"]))
assert page["items"][0]["category"]["color"] == "#EF4444"
+131
View File
@@ -0,0 +1,131 @@
"""登录方式的开关,以及用邮箱登录。
三条是这个功能的重点,也是测试的重点:
1. **短信验证码能整体关掉**。关掉后连"发码"这一步都不该通——否则等于留了个后门:
内置验证码仍然对得上,只是没人去点。
2. **邮箱是账号名,不是联系方式**:绑定之后能用它登录,改绑立刻生效、解绑立刻失效。
3. **邮箱唯一且大小写不敏感**:`Shufen@126.com` 和 `shufen@126.com` 是同一个人,
不能让第二个账号绑走。
"""
from __future__ import annotations
from httpx import AsyncClient
from app.config import get_settings
from .conftest import login, unwrap
settings = get_settings()
async def _set_password(client: AsyncClient, user: dict, password: str) -> None:
unwrap(
await client.post(
"/api/v1/auth/password", json={"password": password}, headers=user["headers"]
)
)
async def _bind_email(client: AsyncClient, user: dict, email: str) -> dict:
return unwrap(
await client.patch("/api/v1/auth/me", json={"email": email}, headers=user["headers"])
)
async def test_methods_lists_what_is_open(client: AsyncClient) -> None:
data = unwrap(await client.get("/api/v1/auth/methods"))
assert data["password"] is True
assert data["email_password"] is True
# 测试环境把短信开着(夹具要靠它造用户);线上默认是 false
assert data["sms_code"] is True
async def test_sms_login_can_be_closed(client: AsyncClient, monkeypatch) -> None:
monkeypatch.setattr(settings, "sms_login_enabled", False)
methods = unwrap(await client.get("/api/v1/auth/methods"))
assert methods["sms_code"] is False
sent = await client.post("/api/v1/auth/sms/send", json={"phone": "13800138000"})
assert sent.json()["code"] == 2017
# 就算手里还捏着旧验证码,登录这一步也必须关死
logged = await client.post(
"/api/v1/auth/login", json={"phone": "13800138000", "code": settings.sms_dev_code}
)
assert logged.json()["code"] == 2017
async def test_bind_email_and_login_with_it(client: AsyncClient) -> None:
user = await login(client)
await _set_password(client, user, "Dadao2026pw")
data = await _bind_email(client, user, " Shufen@126.com ")
# 存的是小写,避免大小写绕出第二个账号
assert data["user"]["email"] == "shufen@126.com"
logged = unwrap(
await client.post(
"/api/v1/auth/login/password",
json={"email": "SHUFEN@126.COM", "password": "Dadao2026pw"},
)
)
assert logged["user"]["id"] == user["user_id"]
async def test_email_login_rejects_wrong_password(client: AsyncClient) -> None:
user = await login(client)
await _set_password(client, user, "Dadao2026pw")
await _bind_email(client, user, "shufen@126.com")
resp = await client.post(
"/api/v1/auth/login/password", json={"email": "shufen@126.com", "password": "nope-nope-1"}
)
assert resp.json()["code"] == 2013
async def test_email_is_validated_and_unique(client: AsyncClient) -> None:
first = await login(client)
bad = await client.patch(
"/api/v1/auth/me", json={"email": "not-an-email"}, headers=first["headers"]
)
assert bad.json()["code"] == 2018
await _bind_email(client, first, "shufen@126.com")
second = await login(client)
taken = await client.patch(
"/api/v1/auth/me", json={"email": "SHUFEN@126.com"}, headers=second["headers"]
)
assert taken.json()["code"] == 2019
async def test_unbind_email_then_login_by_email_fails(client: AsyncClient) -> None:
user = await login(client)
await _set_password(client, user, "Dadao2026pw")
await _bind_email(client, user, "shufen@126.com")
cleared = await _bind_email(client, user, "")
assert cleared["user"]["email"] is None
resp = await client.post(
"/api/v1/auth/login/password",
json={"email": "shufen@126.com", "password": "Dadao2026pw"},
)
assert resp.json()["code"] == 2013
async def test_phone_login_still_works(client: AsyncClient) -> None:
"""老客户端/安卓只发 phone,这条路不能因为加了邮箱就断。"""
user = await login(client)
await _set_password(client, user, "Dadao2026pw")
logged = unwrap(
await client.post(
"/api/v1/auth/login/password",
json={"phone": user["phone"], "password": "Dadao2026pw"},
)
)
assert logged["user"]["id"] == user["user_id"]
+130
View File
@@ -0,0 +1,130 @@
"""系统设置(SMTP):只有平台管理员能改,授权码不回显,改完立刻生效。
为什么值得单独测:这是全站唯一一个**跨租户**的写接口。租户隔离测试管不到它,
一旦漏了权限判断,任何登录用户都能改掉全平台发信账号(等于劫持找回密码邮件)。
"""
from __future__ import annotations
from httpx import AsyncClient
from app.config import get_settings
from app.services import mail
from .conftest import login, unwrap
settings = get_settings()
SMTP = {
"host": "smtp.126.com",
"port": 465,
"username": "shufenhf@126.com",
"password": "CH46ngqQ7Z2XE2eZ",
"from_name": "达道Task",
"use_ssl": True,
}
async def _admin(client: AsyncClient) -> dict:
"""白名单里的手机号登录,就是平台管理员。"""
user = await login(client, phone="13605513683")
return user
async def test_settings_require_platform_admin(client: AsyncClient, monkeypatch) -> None:
monkeypatch.setattr(settings, "platform_admin_phones", ["13605513683"])
outsider = await login(client)
read = await client.get("/api/v1/settings/smtp", headers=outsider["headers"])
assert read.json()["code"] == 3004
write = await client.put("/api/v1/settings/smtp", json=SMTP, headers=outsider["headers"])
assert write.json()["code"] == 3004
async def test_platform_admin_flag_comes_back_in_me(client: AsyncClient, monkeypatch) -> None:
monkeypatch.setattr(settings, "platform_admin_phones", ["13605513683"])
admin = await _admin(client)
outsider = await login(client)
me_admin = unwrap(await client.get("/api/v1/auth/me", headers=admin["headers"]))
me_outsider = unwrap(await client.get("/api/v1/auth/me", headers=outsider["headers"]))
assert me_admin["user"]["platform_admin"] is True
assert me_outsider["user"]["platform_admin"] is False
async def test_save_smtp_hides_password_and_persists(client: AsyncClient, monkeypatch) -> None:
monkeypatch.setattr(settings, "platform_admin_phones", ["13605513683"])
admin = await _admin(client)
empty = unwrap(await client.get("/api/v1/settings/smtp", headers=admin["headers"]))
assert empty["configured"] is False
saved = unwrap(await client.put("/api/v1/settings/smtp", json=SMTP, headers=admin["headers"]))
assert saved["configured"] is True
assert saved["source"] == "db"
assert saved["host"] == "smtp.126.com"
assert saved["password_set"] is True
# 授权码一个字节都不能回给页面
assert "password" not in saved
assert "CH46ngqQ7Z2XE2eZ" not in str(saved)
again = unwrap(await client.get("/api/v1/settings/smtp", headers=admin["headers"]))
assert again["username"] == "shufenhf@126.com"
assert again["port"] == 465
async def test_saving_without_password_keeps_old_one(client: AsyncClient, monkeypatch) -> None:
"""授权码留空 = 沿用原来的。否则每次改端口都得重新粘贴一遍授权码。"""
monkeypatch.setattr(settings, "platform_admin_phones", ["13605513683"])
admin = await _admin(client)
await client.put("/api/v1/settings/smtp", json=SMTP, headers=admin["headers"])
payload = {**SMTP, "port": 587, "use_ssl": False, "password": ""}
resp = await client.put("/api/v1/settings/smtp", json=payload, headers=admin["headers"])
saved = unwrap(resp)
assert saved["port"] == 587
assert saved["password_set"] is True
from app.db import SessionFactory
async with SessionFactory() as session:
cfg = await mail.load_config(session)
assert cfg is not None
assert cfg.password == "CH46ngqQ7Z2XE2eZ"
assert cfg.port == 587
async def test_test_mail_reports_missing_config(client: AsyncClient, monkeypatch) -> None:
monkeypatch.setattr(settings, "platform_admin_phones", ["13605513683"])
admin = await _admin(client)
resp = await client.post(
"/api/v1/settings/smtp/test", json={"to": "steven@example.com"}, headers=admin["headers"]
)
assert resp.json()["code"] == 2020
async def test_test_mail_actually_sends(client: AsyncClient, monkeypatch) -> None:
"""真发信在这里被替换掉——测的是"取到配置、走对收件人",不是 SMTP 本身。"""
monkeypatch.setattr(settings, "platform_admin_phones", ["13605513683"])
admin = await _admin(client)
await client.put("/api/v1/settings/smtp", json=SMTP, headers=admin["headers"])
sent: list[tuple] = []
def fake_send(cfg, to, subject, text, html): # noqa: ANN001 - 与真实签名一致
sent.append((cfg.host, cfg.username, to, subject, text))
monkeypatch.setattr(mail, "_send_sync", fake_send)
data = unwrap(
await client.post(
"/api/v1/settings/smtp/test",
json={"to": "steven@example.com"},
headers=admin["headers"],
)
)
assert data["sent"] is True
assert data["to"] == "steven@example.com"
assert sent and sent[0][0] == "smtp.126.com"
assert sent[0][1] == "shufenhf@126.com"
+14 -3
View File
@@ -11,6 +11,16 @@
# 首次部署前准备好 ~/.config/dadaotask/env.production(见 env.production.example)。
set -euo pipefail
# 这个脚本会 git reset 自己所在的仓库:bash 按字节偏移边读边执行,脚本被改写后
# 可能读到新旧混合的内容(实测踩过)——先把自己复制到临时文件再执行。
if [ -z "${DADAO_DEPLOY_SNAPSHOT:-}" ]; then
_snap=$(mktemp /tmp/dadao-deploy.XXXXXX.sh)
cp "$0" "$_snap"
trap 'rm -f "$_snap"' EXIT
DADAO_DEPLOY_SNAPSHOT=1 bash "$_snap" "$@"
exit $?
fi
APP_DIR=${APP_DIR:-/home/openclaw/app/dadaotask}
CONF_DIR=${CONF_DIR:-$HOME/.config/dadaotask}
ENV_FILE=$CONF_DIR/env.production
@@ -61,10 +71,11 @@ step "4. 前端构建(只在服务器上、只用 HEAD 的源码)"
( cd web && npm ci --no-audit --no-fund --registry="$NPM_REGISTRY" && npm run build )
test -f web/dist/index.html || die "前端构建产物缺失:web/dist/index.html"
step "5. 写入构建标记并重启服务"
mkdir -p "$CONF_DIR" backend/var/uploads
step "5. 写入构建标记、刷新 systemd 单元并重启服务"
mkdir -p "$CONF_DIR" "$HOME/.config/systemd/user" backend/var/uploads
chmod 700 "$CONF_DIR"
printf 'BUILD_COMMIT=%s\nBUILD_TIME=%s\n' "$SHORT" "$(date -Iseconds)" >"$CONF_DIR/env.build"
cp deploy/prod/dadaotask-api.service "$HOME/.config/systemd/user/"
printf 'BUILD_COMMIT=%s\nBUILD_TIME=%s\n' "$SHORT" "$(TZ=Asia/Shanghai date -Iseconds)" >"$CONF_DIR/env.build"
chmod 600 "$CONF_DIR/env.build" "$ENV_FILE"
systemctl --user daemon-reload
systemctl --user enable "$SERVICE" >/dev/null 2>&1 || true
+7 -3
View File
@@ -13,7 +13,10 @@ DATABASE_URL=postgresql+asyncpg://dadao_prod_app:__APP_PW__@192.168.8.160:8432/d
MIGRATION_DATABASE_URL=postgresql+asyncpg://dadao_prod_owner:__OWNER_PW__@192.168.8.160:8432/dadaotask_prod
# ── Redis:fnsvr-01 共享实例,独立 db 号隔离(不使用默认 0 号库)──────
REDIS_URL=redis://192.168.8.160:6379/6
# 该实例开了 requirepass(口令见 fnsvr-01 上 `docker inspect redis`),
# URL 里的口令必须百分号编码(例如 # → %23),否则 asyncpg/redis 解析失败:
# REDIS_URL=redis://:__REDIS_PW_URLENCODED__@192.168.8.160:6379/6
REDIS_URL=redis://:__REDIS_PW_URLENCODED__@192.168.8.160:6379/6
# ── 认证 ─────────────────────────────────────────────────────────────
# 生产校验:长度 ≥ 32 且不是开发默认值
@@ -28,8 +31,9 @@ SMS_PROVIDER=dev
SMS_DEV_CODE=123456
SMS_THROTTLE_SECONDS=60
# 同源部署(后端直接托管 web/dist),CORS 只留本机调试用
CORS_ORIGINS=http://127.0.0.1:8200,http://192.168.8.188:8200
# 同源部署(后端直接托管 web/dist),CORS 只留本机调试用。
# 注意:这是 list 字段,必须写成 JSON 数组(逗号分隔会被 pydantic-settings 当成 JSON 解析而报错)
CORS_ORIGINS='["http://192.168.8.188:8200","http://127.0.0.1:8200"]'
# ── 前端产物与上传目录(绝对路径)─────────────────────────────────────
WEB_DIST_DIR=/home/openclaw/app/dadaotask/web/dist
+76
View File
@@ -0,0 +1,76 @@
"""按 Web Logo(icons.tsx 的 <Logo/>)重绘 PWA 图标:靛蓝底 + 三条白线 + 一点淡紫。
用法:python tools/make-icons.py
输出覆盖 web/public/icons/*.png(与 SVG 同一套比例:64 画布,rx=18,线宽 4,点半径 6)。
"""
from __future__ import annotations
from pathlib import Path
from PIL import Image, ImageDraw
ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT / "web" / "public" / "icons"
BG = "#4F46E5"
LINE = "#FFFFFF"
DOT = "#C7D2FE"
SS = 8 # 超采样倍数,边缘才不毛
def draw(size: int, *, rounded: bool, inset: float = 0.0) -> Image.Image:
"""在 size×size 上画 logo。inset 是安全区留白比例(maskable 图标要留)。"""
big = size * SS
img = Image.new("RGBA", (big, big), (0, 0, 0, 0))
d = ImageDraw.Draw(img)
if rounded:
d.rounded_rectangle([0, 0, big - 1, big - 1], radius=big * 18 / 64, fill=BG)
else:
d.rectangle([0, 0, big, big], fill=BG)
# 64 画布坐标 → 像素;inset 让图形整体缩小居中(给 maskable 留安全区)
scale = big / 64 * (1 - 2 * inset)
off = big * inset
def box(x: float, y: float) -> tuple[float, float]:
return off + x * scale, off + y * scale
stroke = max(1, round(4 * scale))
for y, x2 in ((23, 46), (32, 37), (41, 30)):
x1, yy = box(18, y)
d.line([x1, yy, *box(x2, y)], fill=LINE, width=stroke)
# 圆头:PIL 的 line 没有 cap,用小圆补两端
r = stroke / 2
for cx, cy in ((x1, yy), box(x2, y)):
d.ellipse([cx - r, cy - r, cx + r, cy + r], fill=LINE)
cx, cy = box(46, 41)
r = 6 * scale
d.ellipse([cx - r, cy - r, cx + r, cy + r], fill=DOT)
return img.resize((size, size), Image.LANCZOS)
def main() -> None:
OUT.mkdir(parents=True, exist_ok=True)
# 圆角版:favicon 之外的常规 PWA 图标
draw(192, rounded=True).save(OUT / "icon-192.png")
draw(512, rounded=True).save(OUT / "icon-512.png")
# iOS 会自己裁圆角,底图必须是方的
draw(180, rounded=False).save(OUT / "apple-touch-icon.png")
# maskable:图形缩进 12% 安全区
draw(512, rounded=False, inset=0.12).save(OUT / "maskable-512.png")
# 文档里引用的安卓图标预览
preview = ROOT / "2.Design" / "screens" / "android-icon-512.png"
preview.parent.mkdir(parents=True, exist_ok=True)
draw(512, rounded=True).save(preview)
for p in sorted(OUT.glob("*.png")):
print(p.relative_to(ROOT), Image.open(p).size)
print(preview.relative_to(ROOT), Image.open(preview).size)
if __name__ == "__main__":
main()
+220
View File
@@ -0,0 +1,220 @@
/**
* 账号功能的验收脚本:首页外壳、账号设置、密码登录。
*
* 只跑真浏览器、真接口:断言都落在"页面上看得见什么"和"接口回了什么",
* 这样截图与断言是同一次运行,文档里贴的图不会是另一版界面的。
*
* 两个坑记在这里:
* 1. 短信验证码有 60 秒节流,反复跑脚本会拿到 429,于是"发码"这一步要先看
* 是不是被拦了;登录态优先用密码直接从接口换,别每次都走验证码。
* 2. 改密码要先验证旧密码,而系统不会告诉你旧密码——上一轮的密码记在
* .local/e2e-account-password.txt 里,脚本自己维护。
*/
import { chromium } from "playwright-core";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
const WEB = process.env.WEB ?? "http://127.0.0.1:5180";
const API = process.env.API ?? "http://127.0.0.1:8100/api/v1";
const PHONE = process.env.PHONE ?? "13900000001";
const CODE = process.env.CODE ?? "123456";
const PWD_FILE = new URL("../../.local/e2e-account-password.txt", import.meta.url);
const OUT = "e2e/artifacts";
let pass = 0;
let fail = 0;
function check(name, ok, extra = "") {
if (ok) {
pass += 1;
console.log(`PASS ${name}`);
} else {
fail += 1;
console.log(`FAIL ${name} ${extra}`);
}
}
function savedPassword() {
try {
return readFileSync(PWD_FILE, "utf8").trim() || null;
} catch {
return null;
}
}
function randomPassword() {
return `dadao${Math.floor(Math.random() * 900000 + 100000)}`;
}
async function apiLogin(password) {
if (!password) return null;
const r = await fetch(`${API}/auth/login/password`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ phone: PHONE, password }),
});
const body = await r.json();
return body?.code === 0 ? body.data.tokens : null;
}
async function main() {
mkdirSync(OUT, { recursive: true });
const browser = await chromium.launch({
executablePath:
process.env.CHROME ??
`${process.env.LOCALAPPDATA}\\ms-playwright\\chromium-1223\\chrome-win64\\chrome.exe`,
});
const page = await browser.newPage({ viewport: { width: 1440, height: 960 } });
const errors = [];
page.on("console", (m) => m.type() === "error" && errors.push(m.text()));
page.on("pageerror", (e) => errors.push(String(e)));
// ① 先拿到登录态:有密码就直接换令牌,省掉一次发码
let tokens = await apiLogin(savedPassword());
if (tokens) {
await page.addInitScript(
([access, refresh]) => {
localStorage.setItem("dadao.access", access);
localStorage.setItem("dadao.refresh", refresh);
},
[tokens.access_token, tokens.refresh_token],
);
}
await page.goto(WEB);
if (!tokens) {
// 第一次跑(还没有密码)只能走验证码;被节流就等一会儿再发
await page.goto(`${WEB}/login`);
await page.getByLabel("手机号").fill(PHONE);
for (let i = 0; i < 2; i += 1) {
await page.getByRole("button", { name: "获取验证码" }).click();
await page.waitForTimeout(1500);
const throttled = await page.locator("text=/秒后再试|稍后/").count();
if (!throttled) break;
await page.waitForTimeout(61_000);
}
await page.getByLabel("验证码").fill(CODE);
await page.getByRole("button", { name: "登录" }).click();
await page.waitForURL(`${WEB}/`);
}
await page.waitForSelector("text=我的工作区");
check("登录后进入首页", true);
// ② 首页与工作区页是同一套外壳:侧栏都在,宽度不变
const homeRail = await page.locator("aside").first().boundingBox();
const homeMain = await page.locator("main").first().boundingBox();
check("首页有左侧栏", Boolean(homeRail && homeRail.width > 200), JSON.stringify(homeRail));
check("首页内容区贴着侧栏", Boolean(homeMain && homeMain.x >= (homeRail?.x ?? 0)));
await page.screenshot({ path: `${OUT}/account-home.png` });
await page.locator("main button", { hasText: "我的备忘" }).first().click();
await page.waitForURL(/\/w\//);
await page.waitForSelector("aside");
const wsRail = await page.locator("aside").first().boundingBox();
check(
"进了工作区侧栏宽度不变",
Boolean(wsRail && homeRail && Math.abs(wsRail.width - homeRail.width) < 1),
`home=${homeRail?.width} ws=${wsRail?.width}`,
);
await page.screenshot({ path: `${OUT}/account-workspace.png` });
// ③ 账号设置:侧栏有入口,能改昵称、能设/改密码
await page.goto(`${WEB}/account`);
await page.waitForSelector("text=登录密码");
const accountRail = await page.locator("aside").first().boundingBox();
check(
"账号页复用了同一条侧栏",
Boolean(accountRail && Math.abs(accountRail.width - (homeRail?.width ?? 0)) < 1),
);
const nickname = `用户${Math.floor(Math.random() * 9000 + 1000)}`;
// 等 /auth/me 回填完再改,否则 backfill 会把刚输入的值盖回去
await page.waitForFunction(
() => document.querySelector('input[aria-label="昵称"]')?.value?.length > 0,
);
await page.getByLabel("昵称").fill(nickname);
await page.getByRole("button", { name: "保存资料" }).click();
await page.waitForSelector("text=资料已更新");
check("昵称保存成功", true);
const currentField = page.getByLabel("当前密码");
const hadPassword = (await currentField.count()) > 0;
if (hadPassword) await currentField.fill(savedPassword());
const password = randomPassword();
await page.getByLabel(hadPassword ? "新密码" : "密码", { exact: true }).fill(password);
await page.getByLabel("确认密码").fill(password);
await page.getByRole("button", { name: /设置密码|修改密码/ }).click();
await page.waitForSelector("text=/密码已(设置|修改)/");
writeFileSync(PWD_FILE, password, "utf8");
await page.screenshot({ path: `${OUT}/account-settings.png` });
check(hadPassword ? "密码修改成功" : "密码设置成功", true);
// 刷新后仍是"已设置密码",说明后端真的存住了
await page.reload();
await page.waitForSelector("text=已设置密码", { timeout: 15_000 });
check("刷新后仍显示已设置密码", true);
// ④ 退出,改用密码登录
await page.goto(`${WEB}/account`);
await page.getByRole("button", { name: "退出登录" }).last().click();
await page.waitForURL(/\/login/);
await page.getByRole("button", { name: "密码", exact: true }).click();
await page.getByLabel("手机号").fill(PHONE);
await page.getByLabel("密码").fill("wrong-pass1");
await page.getByRole("button", { name: "登录" }).click();
await page.waitForSelector("text=手机号或密码不对");
check("错误密码被拒绝", true);
await page.getByLabel("密码").fill(password);
await page.getByRole("button", { name: "登录" }).click();
await page.waitForURL(`${WEB}/`, { timeout: 15_000 });
check("密码登录成功", true);
const me = await page.evaluate(async (api) => {
const token = localStorage.getItem("dadao.access");
const r = await fetch(`${api}/auth/me`, { headers: { Authorization: `Bearer ${token}` } });
return r.json();
}, API);
check(
"接口里 has_password=true、昵称是刚改的",
me?.data?.user?.has_password === true && me?.data?.user?.nickname === nickname,
JSON.stringify(me?.data?.user ?? null),
);
// ⑤ 退出后用验证码也能进来(登录页两种模式都得通)
await page.goto(`${WEB}/account`);
await page.getByRole("button", { name: "退出登录" }).last().click();
await page.waitForURL(/\/login/);
await page.getByRole("button", { name: "验证码", exact: true }).click();
await page.getByLabel("手机号").fill(PHONE);
await page.getByRole("button", { name: /获取验证码|重新获取/ }).click();
await page.waitForTimeout(1200);
await page.getByLabel("验证码").fill(CODE);
await page.getByRole("button", { name: "登录" }).click();
const codeLoginOk = await page
.waitForURL(`${WEB}/`, { timeout: 15_000 })
.then(() => true)
.catch(() => false);
if (codeLoginOk) check("验证码登录也照旧能用", true);
else console.log("SKIP 验证码登录(本次发码被 60 秒节流挡住)");
const realErrors = errors.filter((e) => !e.includes("429") && !e.includes("400"));
check("控制台零报错", realErrors.length === 0, realErrors.slice(0, 3).join(" | "));
// ⑥ 窄屏:侧栏收成顶条,别把整页挤成一列
await page.setViewportSize({ width: 390, height: 844 });
await page.goto(WEB);
await page.waitForSelector("main h1");
check("窄屏首页是顶条而不是侧栏", (await page.locator("header").count()) > 0);
await page.screenshot({ path: `${OUT}/account-home-mobile.png`, fullPage: false });
await page.goto(`${WEB}/account`);
await page.waitForSelector("text=登录密码");
await page.screenshot({ path: `${OUT}/account-settings-mobile.png`, fullPage: false });
await browser.close();
console.log(`\n${fail === 0 ? "ALL PASS" : "SOME FAILED"} — ${pass} passed, ${fail} failed`);
if (fail > 0) process.exit(1);
}
main().catch((err) => {
console.log(`FAIL 脚本异常 — ${err?.message ?? err}`);
process.exit(1);
});
+19
View File
@@ -77,6 +77,15 @@ try {
await page.getByRole("button", { name: /^部署/, exact: false }).first().waitFor();
check("能在侧栏建出「研发 / 部署」两级分类", true);
// 树要按「父在前、子紧跟」排:部署必须紧挨在研发后面,而不是堆在别的根分类后面
const navTexts = await page.locator("aside nav button").allInnerTexts();
const navNames = navTexts.map((t) => t.replace(/[·\d]/g, "").trim());
check(
"侧栏分类按树的顺序排(子紧跟在父后面)",
navNames.indexOf("部署") === navNames.indexOf("研发") + 1,
navNames.join(" / "),
);
// ② 记一条:一个面板里填完标题/类型/分类/标签/正文
await page.getByRole("button", { name: "记一条", exact: true }).click();
await page.getByLabel("标题").fill("回滚前先停灰度");
@@ -170,6 +179,16 @@ try {
),
page.getByRole("button", { name: "打开", exact: true }).first().click(),
]);
// 详情默认是「看」:Markdown 渲染后的样子,不是编辑器
await page.locator(".prose-dadao h2").first().waitFor({ timeout: 10_000 });
check(
"详情抽屉默认渲染样式(不是进来就编辑)",
(await page.locator(".vditor-ir").count()) === 0 &&
(await page.locator(".prose-dadao h2").first().textContent())?.includes("步骤") === true,
);
// 要改才点「编辑」
await page.getByRole("button", { name: "编辑", exact: true }).last().click();
const tagInput = page.getByLabel("标签", { exact: true }).last();
await tagInput.waitFor({ timeout: 10_000 });
// 先等抽屉把自己拉回来的旧标签填上——那一刻之后才没有"迟到的响应"来覆盖输入
+124
View File
@@ -0,0 +1,124 @@
/**
* 知识列表翻页的界面验收:点「加载更多」能把 84 条翻到底,不出错。
*
* 这条脚本是有来历的:接口的游标字段名写错(`cur.item_id`),第二页直接 500,
* 但库小于一页时永远碰不到——导入 84 条旧备忘之后用户一点就撞上了。
*
* 用法:
* node web/e2e/knowledge-paging.mjs # 默认打生产
* WEB=http://127.0.0.1:5180 PHONE=13900000001 node web/e2e/knowledge-paging.mjs
*/
import { chromium } from "playwright-core";
const WEB = process.env.WEB ?? "http://192.168.8.188:8200";
const API = process.env.API ?? `${WEB}/api/v1`;
const PHONE = process.env.PHONE ?? "13605513683";
const CODE = process.env.CODE ?? "123456";
const CODE_PIN = process.env.CODE_PIN ?? "123456"; // 生产 pin 就是验证码本身
const CHROME =
process.env.CHROME ??
`${process.env.LOCALAPPDATA}\\ms-playwright\\chromium-1223\\chrome-win64\\chrome.exe`;
let pass = 0;
let fail = 0;
function check(name, ok, extra = "") {
if (ok) {
pass += 1;
console.log(`PASS ${name}`);
} else {
fail += 1;
console.log(`FAIL ${name} ${extra}`);
}
}
async function login() {
const r = await fetch(`${API}/auth/sms/send`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ phone: PHONE }),
});
const sent = await r.json();
if (sent.code !== 0) throw new Error(`发码失败:${sent.message}`);
const login = await fetch(`${API}/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ phone: PHONE, code: CODE_PIN }),
});
const body = await login.json();
if (body.code !== 0) throw new Error(`登录失败:${body.message}`);
return { tokens: body.data.tokens, workspaces: await workspaces(body.data.tokens) };
}
async function workspaces(tokens) {
const r = await fetch(`${API}/workspaces`, {
headers: { Authorization: `Bearer ${tokens.access_token}` },
});
const body = await r.json();
return body.data?.items ?? body.data ?? [];
}
async function main() {
void CODE;
const { tokens, workspaces: spaces } = await login();
if (!spaces.length) throw new Error("这个账号没有工作区");
const ws = spaces[0];
// 先问接口一共有多少条,作为界面上应该数出来的数字
const head = await fetch(`${API}/workspaces/${ws.id}/knowledge?limit=100`, {
headers: { Authorization: `Bearer ${tokens.access_token}` },
});
const headBody = await head.json();
const total = (headBody.data?.items ?? []).length;
const browser = await chromium.launch({ executablePath: CHROME });
const context = await browser.newContext({ viewport: { width: 1440, height: 960 } });
await context.addInitScript(
([access, refresh]) => {
localStorage.setItem("dadao.access", access);
localStorage.setItem("dadao.refresh", refresh);
},
[tokens.access_token, tokens.refresh_token],
);
const page = await context.newPage();
const errors = [];
const bad = [];
page.on("pageerror", (e) => errors.push(String(e)));
page.on("response", (r) => {
if (r.url().includes("/knowledge?") && r.status() >= 400) bad.push(`${r.status()} ${r.url()}`);
});
await page.goto(`${WEB}/w/${ws.id}/knowledge`, { waitUntil: "networkidle" });
const cards = () => page.locator("li");
const first = await cards().count();
check("首屏有列表", first > 0, `count=${first}`);
let rounds = 0;
while (rounds < 20) {
const more = page.getByRole("button", { name: "加载更多" });
if ((await more.count()) === 0) break;
const before = await cards().count();
await more.first().click();
const grew = async () => (await cards().count()) > before;
for (let i = 0; i < 40 && !(await grew()); i += 1) await page.waitForTimeout(100);
if (!(await grew())) break;
rounds += 1;
}
const shown = await cards().count();
check("点「加载更多」能把列表翻到底", shown >= total, `shown=${shown} total=${total}`);
check("翻页接口没有报错", bad.length === 0, bad.slice(0, 3).join(" | "));
const toast = await page.locator("text=服务器内部错误").count();
check("页面上没有「服务器内部错误」", toast === 0);
check("控制台零报错", errors.length === 0, errors.slice(0, 2).join(" | "));
await page.screenshot({ path: "e2e/artifacts/knowledge-paging.png" });
await browser.close();
console.log(`\n${fail === 0 ? "ALL PASS" : "SOME FAILED"} — pass ${pass}, fail ${fail}`);
process.exit(fail === 0 ? 0 : 1);
}
main().catch((err) => {
console.error("脚本异常 —", err?.message ?? err);
process.exit(1);
});
+9 -2
View File
@@ -104,10 +104,17 @@ try {
page.getByRole("button", { name: "发布", exact: true }).click(),
]);
check("发布请求成功", publishResp.ok());
await page.getByText(/已发布 · 版本 \d+/, { exact: false }).waitFor({ timeout: 10_000 });
const published = await page.getByText(/已发布 · 版本 \d+/, { exact: false }).first().isVisible();
await page.getByText(/发布于 /, { exact: false }).first().waitFor({ timeout: 10_000 });
const published = await page.getByText(/发布于 /, { exact: false }).first().isVisible();
check("核对后可以发布", published);
// 发布之后也得能改:点「编辑」回到编辑器,改完还是已发布
const editButton = page.getByRole("button", { name: "编辑", exact: true }).last();
check("已发布的知识也有「编辑」按钮", await editButton.isVisible());
await editButton.click();
await page.locator(".vditor-ir").first().waitFor({ timeout: 10_000 });
check("点「编辑」回到编辑器", (await page.locator(".vditor-ir").count()) > 0);
const again = await fetch(`${API}/workspaces/${ws.id}/knowledge`, {
headers: { authorization: `Bearer ${token}` },
}).then((r) => r.json());
+221
View File
@@ -0,0 +1,221 @@
/**
* 登录方式与系统设置的验收:短信关着、邮箱能登录、SMTP 后台可配。
*
* 三个都要真跑,因为这三件事全是"接口回 200 但人用不了"的高危区:
* - 短信关着,登录页就不能出现「验证码」这一档(不然用户点进去只会看到 2017);
* - 邮箱 + 密码要能真的登进来,不是只把邮箱存进库;
* - SMTP 存进库之后要能立刻发信,别等到真要用的时候才发现授权码是错的。
*
* 授权码不进仓库:发信那一步只在给了 SMTP_PASS 环境变量时才跑。
*/
import { chromium } from "playwright-core";
import { mkdirSync, readFileSync } from "node:fs";
const WEB = process.env.WEB ?? "http://127.0.0.1:5180";
const API = process.env.API ?? "http://127.0.0.1:8100/api/v1";
const PHONE = process.env.PHONE ?? "13900000001";
const PWD_FILE = new URL("../../.local/e2e-account-password.txt", import.meta.url);
const OUT = "e2e/artifacts";
const SMTP_HOST = process.env.SMTP_HOST ?? "smtp.126.com";
const SMTP_USER = process.env.SMTP_USER ?? "";
const SMTP_PASS = process.env.SMTP_PASS ?? "";
let pass = 0;
let fail = 0;
function check(name, ok, extra = "") {
if (ok) {
pass += 1;
console.log(`PASS ${name}`);
} else {
fail += 1;
console.log(`FAIL ${name} ${extra}`);
}
}
function savedPassword() {
try {
return readFileSync(PWD_FILE, "utf8").trim() || null;
} catch {
return null;
}
}
async function post(path, body, token) {
const r = await fetch(`${API}${path}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify(body ?? {}),
});
return r.json();
}
async function get(path, token) {
const r = await fetch(`${API}${path}`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
return r.json();
}
async function patch(path, body, token) {
const r = await fetch(`${API}${path}`, {
method: "PATCH",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
body: JSON.stringify(body),
});
return r.json();
}
async function main() {
mkdirSync(OUT, { recursive: true });
const browser = await chromium.launch({
executablePath:
process.env.CHROME ??
`${process.env.LOCALAPPDATA}\\ms-playwright\\chromium-1223\\chrome-win64\\chrome.exe`,
});
const page = await browser.newPage({ viewport: { width: 1440, height: 960 } });
const errors = [];
page.on("console", (m) => m.type() === "error" && errors.push(m.text()));
page.on("pageerror", (e) => errors.push(String(e)));
const password = savedPassword();
// ① 服务端说清楚现在开着哪几条路
const methods = await get("/auth/methods");
check(
"① /auth/methods 说短信关着、密码与邮箱密码开着",
methods.code === 0 &&
methods.data.password === true &&
methods.data.email_password === true &&
methods.data.sms_code === false,
JSON.stringify(methods.data),
);
// ② 关着的那条路真的挡住了
const sms = await post("/auth/sms/send", { phone: PHONE });
check("② 发验证码的接口回 2017(不是静默成功)", sms.code === 2017, JSON.stringify(sms));
// ③ 登录页不该再出现「验证码」这一档
await page.goto(`${WEB}/login`);
await page.waitForSelector("#account");
const codeTab = await page.getByRole("button", { name: "验证码", exact: true }).count();
const emailTab = await page.getByRole("tab", { name: "邮箱" }).count();
const pwdInput = await page.locator("#password").count();
check("③ 登录页没有验证码那一档、有邮箱档与密码框", codeTab === 0 && emailTab === 1 && pwdInput === 1);
// ④ 手机号 + 密码,从界面登录进去
let loggedIn = false;
if (password) {
await page.getByLabel("手机号").fill(PHONE);
await page.locator("#password").fill(password);
await page.getByRole("button", { name: "登录", exact: true }).click();
loggedIn = await page
.waitForURL(`${WEB}/`, { timeout: 8000 })
.then(() => true)
.catch(() => false);
}
check("④ 手机号 + 密码能从登录页进首页", loggedIn, "(缺 .local/e2e-account-password.txt 时跳过)");
const tokens = loggedIn
? await page.evaluate(() => localStorage.getItem("dadao.access"))
: (await post("/auth/login/password", { phone: PHONE, password })).data?.tokens?.access_token;
// ⑤ 绑定邮箱,然后**用邮箱登录**
const email = `dadao-e2e-${Date.now()}@example.com`;
const bind = await patch("/auth/me", { email }, tokens);
check("⑤ 账号页能绑邮箱", bind.code === 0 && bind.data.user.email === email, JSON.stringify(bind));
const byEmail = await post("/auth/login/password", { email: email.toUpperCase(), password });
const emailLogin = await post("/auth/login/password", { email, password });
check(
"⑥ 邮箱 + 密码能登录(大小写不敏感)",
emailLogin.code === 0 && emailLogin.data?.tokens?.access_token,
JSON.stringify(emailLogin),
);
check("⑥b 全大写邮箱也能登录", byEmail.code === 0, JSON.stringify(byEmail));
// ⑦ 解绑之后这条路就该关掉,而不是留个后门
const unbind = await patch("/auth/me", { email: "" }, tokens);
const afterUnbind = await post("/auth/login/password", { email, password });
check(
"⑦ 解绑邮箱后邮箱登录回 2013",
unbind.code === 0 && unbind.data.user.email === null && afterUnbind.code === 2013,
JSON.stringify(afterUnbind),
);
// ⑧ 平台管理员才看得到「系统设置」
const me = await get("/auth/me", tokens);
check("⑧ 当前账号被标成平台管理员", me.data?.user?.platform_admin === true, JSON.stringify(me.data?.user));
await page.goto(`${WEB}/account`);
await page.waitForSelector('input[aria-label="昵称"]');
// 侧栏按 /auth/me 的 platform_admin 决定要不要渲染这一条,等它落下来再数
const settingsLink = await page
.waitForFunction(() => document.querySelectorAll('aside a[href="/settings"]').length, {
timeout: 5000,
})
.then((handle) => handle.jsonValue())
.catch(() => 0);
check("⑧b 侧栏里有「系统设置」入口", settingsLink === 1, `count=${settingsLink}`);
await page.screenshot({ path: `${OUT}/settings-entry.png` });
// ⑨ 系统设置页能读、能存;授权码只写不读
await page.goto(`${WEB}/settings`);
await page.waitForSelector('input[aria-label="服务器地址"]');
const before = await get("/settings/smtp", tokens);
check("⑨ 系统设置页读得到当前配置", before.code === 0 && before.data.password_set !== undefined);
if (SMTP_PASS) {
await page.getByLabel("服务器地址").fill(SMTP_HOST);
await page.getByLabel("端口").fill("465");
await page.locator('input[aria-label="账号"]').fill(SMTP_USER);
await page.getByLabel("授权码").fill(SMTP_PASS);
await page.getByRole("button", { name: "保存", exact: true }).click();
await page.waitForTimeout(1200);
const saved = await get("/settings/smtp", tokens);
check(
"⑩ 授权码存进库了,但接口不回显",
saved.data?.password_set === true &&
saved.data?.configured === true &&
!JSON.stringify(saved.data).includes(SMTP_PASS),
JSON.stringify(saved.data),
);
// ⑪ 真发一封,确认这条通道是通的(不是"存了个假配置")
const sent = await post("/settings/smtp/test", { to: SMTP_USER }, tokens);
check("⑪ 测试邮件发送成功", sent.code === 0, JSON.stringify(sent));
if (sent.code !== 0) {
console.log(` 发信失败原文:${sent.message}`);
}
await page.screenshot({ path: `${OUT}/settings-smtp.png` });
} else {
console.log("SKIP ⑩⑪ 没给 SMTP_PASS,跳过保存与发信");
}
// ⑫ 账号页上的邮箱框要能回填刚绑的值(说明前端读的是接口,不是本地缓存)
const rebind = await patch("/auth/me", { email }, tokens);
await page.goto(`${WEB}/account`);
await page.waitForSelector('input[aria-label="邮箱"]');
const emailInput = await page.waitForFunction(
() => {
const el = document.querySelector('input[aria-label="邮箱"]');
return el && el.value.includes("@") ? el.value : null;
},
{ timeout: 5000 },
);
const shownEmail = await emailInput.jsonValue();
check("⑫ 账号页回填刚绑的邮箱", rebind.code === 0 && shownEmail === email, shownEmail);
check("⑬ 控制台零报错", errors.length === 0, errors.slice(0, 2).join(" | "));
await browser.close();
console.log(`\n${fail === 0 ? "ALL PASS" : "SOME FAILED"} — pass ${pass}, fail ${fail}`);
process.exit(fail === 0 ? 0 : 1);
}
main().catch((err) => {
console.error("脚本异常", err);
process.exit(1);
});
+71
View File
@@ -0,0 +1,71 @@
/**
* 生产只读核对:首页、工作区页、账号页是不是同一套外壳,宽度是否一致。
*
* 只读:不设密码、不改昵称,只看界面。生产是内测模式(验证码 123456)。
* 注意发码有 60 秒节流:被挡住时要等,别把它当成登录坏了。
*/
import { chromium } from "playwright-core";
const WEB = process.env.WEB ?? "http://192.168.8.188:8200";
const PHONE = process.env.PHONE ?? "13605513683";
const CODE = process.env.CODE ?? "123456";
const browser = await chromium.launch({
executablePath:
process.env.CHROME ??
`${process.env.LOCALAPPDATA}\\ms-playwright\\chromium-1223\\chrome-win64\\chrome.exe`,
});
const page = await browser.newPage({ viewport: { width: 1440, height: 960 } });
const errors = [];
page.on("console", (m) => m.type() === "error" && errors.push(m.text()));
await page.goto(`${WEB}/login`);
await page.getByLabel("手机号").fill(PHONE);
for (let attempt = 1; attempt <= 3 && new URL(page.url()).pathname === "/login"; attempt += 1) {
await page.getByRole("button", { name: /获取验证码|重新获取|秒/ }).click();
await page.waitForTimeout(1500);
const text = await page.locator("body").innerText();
if (/秒后再试|太频繁/.test(text)) {
console.log(`发码被节流,等 61 秒(第 ${attempt} 次)`);
await page.waitForTimeout(61_000);
continue;
}
await page.getByLabel("验证码").fill(CODE);
await page.getByRole("button", { name: "登录" }).click();
await page.waitForTimeout(2500);
}
await page.waitForURL(`${WEB}/`, { timeout: 15_000 });
await page.waitForSelector("text=我的工作区");
const homeRail = await page.locator("aside").first().boundingBox();
console.log("首页侧栏宽度:", homeRail?.width);
await page.screenshot({ path: "e2e/artifacts/prod-home.png" });
await page.getByRole("button", { name: /我的备忘/ }).first().click();
await page.waitForURL(/\/w\//);
await page.waitForSelector("aside");
const wsRail = await page.locator("aside").first().boundingBox();
console.log(
"工作区侧栏宽度:",
wsRail?.width,
"与首页差值:",
Math.abs((wsRail?.width ?? 0) - (homeRail?.width ?? 0)),
);
await page.screenshot({ path: "e2e/artifacts/prod-workspace.png" });
await page.goto(`${WEB}/account`);
await page.waitForSelector("text=登录密码");
await page.waitForFunction(
() => document.querySelector('input[aria-label="昵称"]')?.value?.length > 0,
null,
{ timeout: 10_000 },
);
const accountRail = await page.locator("aside").first().boundingBox();
console.log("账号页侧栏宽度:", accountRail?.width);
const accountText = await page.locator("main").innerText();
console.log("账号页:", accountText.replace(/\s+/g, " | ").slice(0, 260));
await page.screenshot({ path: "e2e/artifacts/prod-account.png" });
console.log("控制台报错:", errors.length === 0 ? "无" : errors.slice(0, 3));
await browser.close();
+180
View File
@@ -0,0 +1,180 @@
/**
* PWA 验收脚本:装得上、离线开得开、有新版本提醒得了。
*
* 跑法(必须先有一个"生产同构"的站点:后端用 WEB_DIST_DIR 指向 web/dist):
* WEB_DIST_DIR=<repo>/web/dist ../backend/.venv/Scripts/python.exe -m uvicorn app.main:app --port 8101
* node web/e2e/pwa.mjs # 默认 BASE=http://127.0.0.1:8101
*
* 两个坑:
* 1. Service Worker 只在安全上下文(https / localhost)注册,所以这里必须走 127.0.0.1,
* 不能换成内网 IP——那台机器上要真的装上,得先有 https。
* 2. 无头 Chromium 不会触发 beforeinstallprompt,所以"安装"那段用 iPhone 的 UA 走
* iOS 分支(分享 → 添加到主屏幕),这是唯一能稳定断言到的安装引导。
*/
import { chromium } from "playwright-core";
import { mkdirSync, readFileSync } from "node:fs";
const BASE = process.env.BASE ?? "http://127.0.0.1:8101";
const PHONE = process.env.PHONE ?? "13900000001";
const CODE = process.env.CODE ?? "123456";
const PWD_FILE = new URL("../../.local/e2e-account-password.txt", import.meta.url);
const OUT = "e2e/artifacts";
const CHROME =
process.env.CHROME ??
`${process.env.LOCALAPPDATA}\\ms-playwright\\chromium-1223\\chrome-win64\\chrome.exe`;
const IOS_UA =
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1";
let pass = 0;
let fail = 0;
function check(name, ok, extra = "") {
if (ok) {
pass += 1;
console.log(`PASS ${name}`);
} else {
fail += 1;
console.log(`FAIL ${name} ${extra}`);
}
}
async function token() {
// 先试密码(快、不撞短信节流),不行再发验证码
try {
const saved = readFileSync(PWD_FILE, "utf8").trim();
const r = await fetch(`${BASE}/api/v1/auth/login/password`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ phone: PHONE, password: saved }),
});
const body = await r.json();
if (body?.code === 0) return body.data.tokens;
} catch {
// 没存过密码:往下走验证码
}
await fetch(`${BASE}/api/v1/auth/sms/send`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ phone: PHONE }),
});
const r = await fetch(`${BASE}/api/v1/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ phone: PHONE, code: CODE }),
});
const body = await r.json();
if (body?.code !== 0) throw new Error(`登录失败:${JSON.stringify(body)}`);
return body.data.tokens;
}
async function main() {
mkdirSync(OUT, { recursive: true });
const tokens = await token();
const browser = await chromium.launch({ executablePath: CHROME });
// ── 一、桌面尺寸:Service Worker 与 manifest ─────────────────────
const desktop = await browser.newContext({ viewport: { width: 1440, height: 900 } });
await desktop.addInitScript(
([access, refresh]) => {
localStorage.setItem("dadao.access", access);
localStorage.setItem("dadao.refresh", refresh);
},
[tokens.access_token, tokens.refresh_token],
);
const page = await desktop.newPage();
const errors = [];
page.on("pageerror", (e) => errors.push(String(e)));
await page.goto(`${BASE}/`, { waitUntil: "networkidle" });
const manifestHref = await page.getAttribute('link[rel="manifest"]', "href");
check("页面挂了 manifest", Boolean(manifestHref), String(manifestHref));
const manifest = await (await fetch(`${BASE}${manifestHref}`)).json();
check("manifest 名字是「达道Task」", manifest.short_name === "达道Task", JSON.stringify(manifest.name));
check("独立窗口打开(display=standalone)", manifest.display === "standalone", String(manifest.display));
check("三个图标齐全", (manifest.icons ?? []).length === 3, JSON.stringify(manifest.icons));
check(
"有一个 maskable 图标",
(manifest.icons ?? []).some((i) => String(i.purpose).includes("maskable")),
);
for (const icon of manifest.icons ?? []) {
const res = await fetch(`${BASE}/${String(icon.src).replace(/^\//, "")}`);
check(`图标可下载 ${icon.sizes}`, res.ok && res.headers.get("content-type") === "image/png");
}
const sw = await page.evaluate(async () => {
const reg = await navigator.serviceWorker.ready;
return { scope: reg.scope, state: reg.active?.state ?? "none" };
});
check("Service Worker 已激活", sw.state === "activated", JSON.stringify(sw));
check("作用域是整站", sw.scope.endsWith("/"), sw.scope);
const shellCached = await page.evaluate(async () => {
const cache = await caches.open("workbox-precache-v2-" + location.origin + "/");
const keys = await cache.keys();
return keys.some((k) => k.url.includes("index.html"));
});
check("页面骨架已预缓存", shellCached);
const themeColor = await page.getAttribute('meta[name="theme-color"]', "content");
check("主题色是品牌靛蓝", themeColor === "#4f46e5", String(themeColor));
check("桌面尺寸没有控制台报错", errors.length === 0, errors.join(" | "));
await desktop.close();
// ── 二、iPhone 尺寸:安装引导(iOS 只能靠「分享 → 添加到主屏幕」)─────
const phone = await browser.newContext({
viewport: { width: 390, height: 844 },
userAgent: IOS_UA,
deviceScaleFactor: 2,
});
await phone.addInitScript(
([access, refresh]) => {
localStorage.setItem("dadao.access", access);
localStorage.setItem("dadao.refresh", refresh);
},
[tokens.access_token, tokens.refresh_token],
);
const m = await phone.newPage();
await m.goto(`${BASE}/`, { waitUntil: "networkidle" });
await m.waitForSelector("text=把达道Task装到桌面", { timeout: 10000 });
const hint = await m.locator("text=把达道Task装到桌面").first().isVisible();
check("窄屏首页有安装引导", hint);
const iosText = await m.locator("text=添加到主屏幕").count();
check("iOS 给出的是「分享 → 添加到主屏幕」", iosText > 0);
await m.screenshot({ path: `${OUT}/pwa-install-hint.png` });
await m.getByRole("button", { name: "不再提示安装" }).click();
await m.reload({ waitUntil: "networkidle" });
const stillThere = await m.locator("text=把达道Task装到桌面").count();
check("关掉之后不再打扰(刷新也不回来)", stillThere === 0);
// ── 三、有新版时提醒,点「稍后」收起 ─────────────────────────────
await m.evaluate(() => window.dispatchEvent(new Event("dadao:pwa-update-ready")));
await m.waitForSelector("text=有新版了", { timeout: 5000 });
check("检测到新版本会提示刷新", (await m.locator("text=有新版了").count()) > 0);
await m.screenshot({ path: `${OUT}/pwa-update-prompt.png` });
await m.getByRole("button", { name: "稍后" }).click();
check("点「稍后」会收起", (await m.locator("text=有新版了").count()) === 0);
// ── 四、断网还能打开(这是装到桌面的实际价值)─────────────────────
await m.evaluate(() => localStorage.clear());
await m.reload({ waitUntil: "networkidle" });
await phone.setOffline(true);
await m.reload({ waitUntil: "domcontentloaded" }).catch(() => {});
const body = await m.locator("body").innerText();
check("断网后页面骨架仍能打开", body.length > 0 && /登录|达道/.test(body), body.slice(0, 80));
await phone.setOffline(false);
await m.screenshot({ path: `${OUT}/pwa-offline.png` });
await phone.close();
await browser.close();
console.log(`\n${fail === 0 ? "ALL PASS" : "SOME FAILED"} — pass ${pass}, fail ${fail}`);
process.exit(fail === 0 ? 0 : 1);
}
main().catch((err) => {
console.error("脚本异常 —", err?.message ?? err);
process.exit(1);
});
+59
View File
@@ -0,0 +1,59 @@
/**
* 拍两张文档用的截图:登录页(两种账号名 + 密码)、账号页(邮箱)、系统设置页。
* 需要前端 5180、后端 8100 起着;账号用 dev 的平台管理员。
*/
import { chromium } from "playwright-core";
import { mkdirSync, readFileSync } from "node:fs";
const WEB = process.env.WEB ?? "http://127.0.0.1:5180";
const API = process.env.API ?? "http://127.0.0.1:8100/api/v1";
const PHONE = process.env.PHONE ?? "13900000001";
const OUT = "e2e/artifacts";
const PWD_FILE = new URL("../../.local/e2e-account-password.txt", import.meta.url);
function savedPassword() {
try {
return readFileSync(PWD_FILE, "utf8").trim();
} catch {
return "";
}
}
const browser = await chromium.launch({
executablePath:
process.env.CHROME ??
`${process.env.LOCALAPPDATA}/ms-playwright/chromium-1223/chrome-win64/chrome.exe`,
});
const page = await browser.newPage({ viewport: { width: 1440, height: 960 } });
mkdirSync(OUT, { recursive: true });
await page.goto(`${WEB}/login`);
await page.waitForSelector("#account");
await page.screenshot({ path: `${OUT}/login-password.png` });
const r = await fetch(`${API}/auth/login/password`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ phone: PHONE, password: savedPassword() }),
});
const data = await r.json();
await page.addInitScript(
([a, rf]) => {
localStorage.setItem("dadao.access", a);
localStorage.setItem("dadao.refresh", rf);
},
[data.data.tokens.access_token, data.data.tokens.refresh_token],
);
await page.goto(`${WEB}/account`);
await page.waitForSelector('input[aria-label="邮箱"]');
await page.waitForTimeout(600);
await page.screenshot({ path: `${OUT}/account-page.png` });
await page.goto(`${WEB}/settings`);
await page.waitForSelector('input[aria-label="服务器地址"]');
await page.waitForTimeout(800);
await page.screenshot({ path: `${OUT}/settings-page.png` });
await browser.close();
console.log("screenshots written to", OUT);
+104
View File
@@ -0,0 +1,104 @@
/**
* 截图脚本:把导入的备忘录点开,看「浏览」样式是否真的渲染成 Markdown。
*
* 只用于出图,不做断言;账号用 dev 的 13900000001(导入数据的归属)。
*/
import { chromium } from "playwright-core";
import { mkdirSync } from "node:fs";
const WEB = process.env.WEB ?? "http://127.0.0.1:5180";
const API = process.env.API ?? "http://127.0.0.1:8100/api/v1";
const PHONE = process.env.PHONE ?? "13900000001";
const WS_NAME = process.env.WS_NAME ?? "我的备忘";
async function call(method, path, body, token) {
const res = await fetch(`${API}${path}`, {
method,
headers: {
"content-type": "application/json",
...(token ? { authorization: `Bearer ${token}` } : {}),
...(method === "GET" ? {} : { "idempotency-key": `shot-${Math.random()}` }),
},
body: body ? JSON.stringify(body) : undefined,
});
const text = await res.text();
const json = text ? JSON.parse(text) : {};
if (json.code !== 0) throw new Error(`${method} ${path} -> ${json.code} ${json.message}`);
return json.data;
}
async function loginAs(phone) {
// dev 下验证码固定,发码接口有 60 秒节流:能直接登录就直接登,被节流就等够了再发
try {
return await call("POST", "/auth/login", { phone, code: "123456" });
} catch {
/* 验证码过期或没发过,往下走发码 */
}
for (let i = 0; i < 2; i += 1) {
try {
await call("POST", "/auth/sms/send", { phone });
break;
} catch (err) {
if (!String(err.message).includes("2011") || i === 1) throw err;
await new Promise((r) => setTimeout(r, 61_000));
}
}
return await call("POST", "/auth/login", { phone, code: "123456" });
}
mkdirSync("e2e/artifacts", { recursive: true });
const login = await loginAs(PHONE);
const token = login.tokens.access_token;
const me = await call("GET", "/auth/me", undefined, token);
const ws = me.workspaces.find((w) => w.name === WS_NAME) ?? me.workspaces[0];
if (!ws) throw new Error(`${PHONE} 名下没有工作区`);
console.log(`工作区:${ws.name} ${ws.id}`);
const browser = await chromium.launch({
executablePath:
process.env.CHROME ??
`${process.env.LOCALAPPDATA}\\ms-playwright\\chromium-1223\\chrome-win64\\chrome.exe`,
});
const context = await browser.newContext({ viewport: { width: 1440, height: 960 } });
const page = await context.newPage();
await page.addInitScript(
([access, refresh]) => {
localStorage.setItem("dadao.access", access);
localStorage.setItem("dadao.refresh", refresh);
},
[token, login.tokens.refresh_token],
);
await page.goto(`${WEB}/w/${ws.id}/knowledge`, { waitUntil: "networkidle" });
await page.getByRole("heading", { name: "知识" }).waitFor({ timeout: 20_000 });
// 导入的备忘都是发布过的,切到「已发布」才看得到;ALL=1 时留在默认的「全部」
if (!process.env.ALL) {
await page.getByRole("button", { name: "已发布", exact: true }).first().click();
}
if (process.env.TITLE) {
// 挑一条带标题/列表的,截图里才看得出 Markdown 真的渲染了
const target = page.locator("li").filter({ hasText: process.env.TITLE }).first();
await target.waitFor({ timeout: 20_000 });
await page.screenshot({ path: "e2e/artifacts/memo-list.png" });
await target.getByRole("button", { name: "打开", exact: true }).click();
} else {
await page.getByRole("button", { name: "打开", exact: true }).first().waitFor({ timeout: 20_000 });
await page.waitForTimeout(600);
await page.screenshot({ path: "e2e/artifacts/memo-list.png" });
await page.getByRole("button", { name: "打开", exact: true }).first().click();
}
await page.locator(".prose-dadao").first().waitFor({ timeout: 20_000 });
await page.waitForTimeout(600);
await page.screenshot({ path: "e2e/artifacts/memo-read.png" });
if (process.env.EDIT) {
// 编辑态:正文编辑器要占满剩下的高度,不能只露几行
await page.getByRole("button", { name: "编辑", exact: true }).last().click();
await page.locator(".vditor-ir [contenteditable]").first().waitFor({ timeout: 20_000 });
await page.waitForTimeout(800);
await page.screenshot({ path: "e2e/artifacts/memo-edit.png" });
}
await browser.close();
console.log("OK");
+10 -3
View File
@@ -2,14 +2,21 @@
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>达道Task</title>
<meta name="description" content="达道Task:任务、工单、知识库,一条道走到底" />
<!-- 内联 SVG 图标:不额外发请求,也就不会有 favicon 404 -->
<!-- 和 App 内左上角、安卓桌面同一枚图标:靛蓝底 + 三条白线 + 一点淡紫 -->
<link
rel="icon"
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='14' fill='%233F5A68'/%3E%3Cpath d='M18 34l8 8 20-20' stroke='%23C2703D' stroke-width='7' fill='none' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='18' fill='%234F46E5'/%3E%3Cg stroke='%23FFFFFF' stroke-width='4' stroke-linecap='round'%3E%3Cpath d='M18 23h28'/%3E%3Cpath d='M18 32h19'/%3E%3Cpath d='M18 41h12'/%3E%3C/g%3E%3Ccircle cx='46' cy='41' r='6' fill='%23C7D2FE'/%3E%3C/svg%3E"
/>
<!-- 装到手机主屏之后:状态栏跟品牌色走,iOS 用不透明底图 -->
<meta name="theme-color" content="#4f46e5" />
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-title" content="达道Task" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="mobile-web-app-capable" content="yes" />
</head>
<body>
<div id="root"></div>
+4255 -17
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -29,6 +29,7 @@
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.2",
"vite": "^6.0.5"
"vite": "^6.0.5",
"vite-plugin-pwa": "^2.0.0"
}
}
+4640
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

+6
View File
@@ -1,6 +1,7 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
import { Navigate, Route, Routes, useNavigate } from "react-router-dom";
import { ToastProvider } from "./components/ui";
import { UpdatePrompt } from "./components/UpdatePrompt";
import { api, authExpired, session } from "./lib/client";
import type { User, Workspace } from "./lib/types";
import { LoginPage } from "./pages/Login";
@@ -12,6 +13,8 @@ import { TicketsPage } from "./pages/Tickets";
import { KnowledgePage } from "./pages/Knowledge";
import { NotificationsPage } from "./pages/Notifications";
import { JoinPage } from "./pages/Join";
import { AccountPage } from "./pages/Account";
import { SettingsPage } from "./pages/Settings";
interface AuthState {
user: User | null;
@@ -87,6 +90,8 @@ export function App() {
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/" element={<HomePage />} />
<Route path="/account" element={<AccountPage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/w/:wsId" element={<TasksPage />} />
<Route path="/w/:wsId/board" element={<BoardPage />} />
<Route path="/w/:wsId/tickets" element={<TicketsPage />} />
@@ -96,6 +101,7 @@ export function App() {
<Route path="/join/:wsId/:code" element={<JoinPage />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
<UpdatePrompt />
</AuthProvider>
</ToastProvider>
);

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