Compare commits

...
Author SHA1 Message Date
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
70 changed files with 2981 additions and 246 deletions
+9
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,12 @@ 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/
+101 -5
View File
@@ -29,6 +29,10 @@
| 2010 | 429 | 验证码请求太频繁 | 倒计时 60 秒 |
| 2011 | 400 | 验证码错误/已过期 | 重发 |
| 2012 | 403 | 账号被禁用 | 联系管理员 |
| 2013 | 400 | 密码错误,或这个号还没设过密码 | 提示改用验证码登录 |
| 2014 | 400 | 该账号没有密码(保留码) | 先设密码 |
| 2015 | 422 | 密码太弱(少于 8 位 / 缺字母或数字 / 与旧密码相同) | 就地提示规则 |
| 2016 | 429 | 密码连错太多次(15 分钟内 10 次) | 提示稍后再试或用验证码 |
| 3001 | 404 | 工作区不存在 | — |
| 3003 | 403 | 不是该工作区成员 | **不要**提示"工作区不存在" |
| 3004 | 403 | 角色权限不足 | 隐藏对应按钮 |
@@ -91,9 +95,39 @@
返回同形状的 `tokens`。无状态 JWT,**服务端不维护黑名单**——token 泄露只能等过期,这是 M0 已知取舍。
### `POST /auth/login/password`
```json
{ "phone": "13800000000", "password": "dadao2026" }
```
返回与验证码登录同形状的 `{ user, tokens }`。**没设过密码的账号一律回 `2013`**,与"密码错"同一个码
(不区分账号是否存在,少一个探测面)。15 分钟内连错 10 次 → `2016`,期间正确密码也挡住。
密码校验是 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": "大道" }
```
返回 `{ "user": { … } }`。空昵称(strip 后为空)→ `1002`。手机号暂不支持自助换号。
### `GET /auth/me`
返回 `{ user, workspaces[] }`,`workspaces[].role` 是当前用户在该工作区的角色。
`user` 里带 `has_password`,前端据此决定账号页显示"设置密码"还是"修改密码"。
---
@@ -396,7 +430,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 +442,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 +578,6 @@ Query:
### 6.3 附件错误码
### 6.3 附件错误码
| 码 | HTTP | 含义 |
|---|---|---|
| 8001 | 404 | 附件不存在,或不属于这个工作区 |
@@ -558,7 +594,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. 健康与版本
| 方法 | 路径 | 说明 |
|---|---|---|
+59 -2
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,59 @@ 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. 运维
```bash
systemctl --user status dadaotask-api # 状态
@@ -107,7 +164,7 @@ 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. 已知限制(当前阶段)
## 7. 已知限制(当前阶段)
| 限制 | 影响 | 解除方式 |
|---|---|---|
+84 -2
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 仍待独立实例)
- [ ] 真实用两周:新同事靠知识库自己解决过一次问题(需要真实团队)
@@ -414,3 +418,81 @@
- [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`
- 脚本里两个坑写进了注释:① 短信 60 秒节流会让"发码→登录"整段失败(先看 429,别怪登录);
② 改密码要先给旧密码,脚本把上一轮的密码记在 `.local/e2e-account-password.txt` 里自己维护
### 7.6 没做的
- 安卓端登录页仍只有验证码,账号页也没有——下一步对齐(密码登录 + 改昵称 + 退出)
- 手机号换绑、头像上传未做(头像现在只有首字母)
Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 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: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

+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("#33443F"))
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 = {
@@ -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="#EFF5F3"
android:strokeWidth="5.06"
android:strokeLineCap="round" />
<path
android:pathData="M36.3,54 L59.6,54"
android:strokeColor="#EFF5F3"
android:strokeWidth="5.06"
android:strokeLineCap="round" />
<path
android:pathData="M36.3,65.4 L50.8,65.4"
android:strokeColor="#EFF5F3"
android:strokeWidth="5.06"
android:strokeLineCap="round" />
<path
android:fillColor="#C2703D"
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">#2F6054</color>
</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")
+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,
)
+132 -31
View File
@@ -1,4 +1,8 @@
"""认证:手机号 + 验证码。M0 不做密码、不做第三方登录。"""
"""认证:手机号 + 验证码,可选登录密码。
M0 只有验证码登录;密码是**可选**的第二条路:用户自己在账号设置里设一次,
之后登录页可以选「密码」;没设过密码的人只有验证码这一条,不受影响。
"""
from __future__ import annotations
@@ -14,7 +18,13 @@ 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=["认证"])
@@ -22,11 +32,36 @@ settings = get_settings()
PHONE_PATTERN = r"^1[3-9]\d{9}$"
# 密码登录连错这么多次就先锁一会儿:暴力猜密码的成本必须比猜验证码高
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 _user_out(user: AppUser) -> dict:
"""对外只给脱敏手机号;密码本身一个字节都不出去,只告诉客户端「设没设」。"""
return {
"id": str(user.id),
"phone": mask_phone(user.phone),
"nickname": user.nickname,
"avatar_url": user.avatar_url,
"has_password": bool(user.password_hash),
"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,6 +71,22 @@ class LoginIn(BaseModel):
code: str = Field(min_length=4, max_length=8)
class PasswordLoginIn(BaseModel):
phone: str = Field(pattern=PHONE_PATTERN)
password: str = Field(min_length=1, max_length=64)
class ProfilePatch(BaseModel):
nickname: str | None = Field(default=None, min_length=1, max_length=32)
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)
@@ -81,40 +132,54 @@ 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 秒;成功即清零,正常用户不会感知。
"""
store = get_store()
fail_key = f"pwd_fail:{body.phone}"
if int(await store.get(fail_key) or 0) >= PASSWORD_FAIL_LIMIT:
raise AppError(
ErrorCode.LOGIN_TOO_MANY,
f"密码错误次数过多,请 {PASSWORD_FAIL_TTL // 60} 分钟后再试,或用验证码登录",
)
user = (
await session.execute(select(AppUser).where(AppUser.phone == body.phone))
).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, "手机号或密码不对")
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 +191,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 +205,43 @@ 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
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})
+35 -4
View File
@@ -181,6 +181,31 @@ def _category_payload(
}
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():
@@ -358,7 +383,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)
@@ -636,11 +661,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 +715,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))
+5
View File
@@ -108,6 +108,11 @@ 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"
+12
View File
@@ -25,6 +25,10 @@ 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
# 3xxx 租户 / 权限
WORKSPACE_NOT_FOUND = 3001
@@ -66,6 +70,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 +90,10 @@ _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.WORKSPACE_NOT_FOUND: 404,
ErrorCode.NOT_A_MEMBER: 403,
ErrorCode.INSUFFICIENT_ROLE: 403,
@@ -111,6 +122,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,
}
+2 -1
View File
@@ -16,7 +16,7 @@ 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 .config import get_settings
from .db import SessionFactory, current_app_role, engine
from .errors import AppError, ErrorCode, http_status_for
@@ -115,6 +115,7 @@ 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.get("/health", tags=["运维"], summary="存活探针")
+2
View File
@@ -45,6 +45,8 @@ 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))
class Workspace(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
+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())
+187
View File
@@ -0,0 +1,187 @@
"""账号功能:昵称、密码设置/修改、密码登录。
这些都是"账号被占"的高风险入口,所以断言不只看 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",
"nickname",
"avatar_url",
"has_password",
"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
+19 -6
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(
f"{V1}/workspaces/{ws['id']}/knowledge/{doc['id']}",
json={"title": "值班手册 v2", "version": published["version"]},
headers=owner["headers"],
# 已发布的能直接改正文:改完后端自动重建切片,状态仍是已发布
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:
@@ -303,3 +303,29 @@ 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] == [
"技术开发",
"技术开发 / 技术",
"技术开发 / 技术 / 部署",
"餐饮业务",
]
+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
+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 });
// 先等抽屉把自己拉回来的旧标签填上——那一刻之后才没有"迟到的响应"来覆盖输入
+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());
+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");
+2
View File
@@ -12,6 +12,7 @@ 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";
interface AuthState {
user: User | null;
@@ -87,6 +88,7 @@ export function App() {
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/" element={<HomePage />} />
<Route path="/account" element={<AccountPage />} />
<Route path="/w/:wsId" element={<TasksPage />} />
<Route path="/w/:wsId/board" element={<BoardPage />} />
<Route path="/w/:wsId/tickets" element={<TicketsPage />} />
+19 -5
View File
@@ -11,6 +11,7 @@ import {
Inbox,
Logo,
Logout,
Settings,
TicketIcon,
Users,
X,
@@ -209,13 +210,26 @@ export function AppShell({
<div className="border-t border-line-200 px-3 py-3">
<div className="flex items-center gap-2">
<span className="grid h-7 w-7 place-items-center rounded-full bg-ink-200 text-2xs font-semibold text-ink-700">
<Link
to="/account"
title="账号设置"
className="grid h-7 w-7 shrink-0 place-items-center rounded-full bg-ink-200 text-2xs font-semibold text-ink-700 transition hover:bg-ink-300"
>
{initial(user?.nickname ?? "我")}
</span>
<div className="min-w-0 flex-1">
<div className="truncate text-xs font-medium text-ink-800">{user?.nickname}</div>
</Link>
<Link to="/account" className="group min-w-0 flex-1">
<div className="truncate text-xs font-medium text-ink-800 group-hover:text-primary-700">
{user?.nickname}
</div>
<div className="truncate text-2xs text-ink-400">{phoneMasked}</div>
</div>
</Link>
<Link
to="/account"
title="账号设置"
className="rounded-md p-1.5 text-ink-400 transition hover:bg-ink-100 hover:text-ink-700"
>
<Settings size={15} />
</Link>
<button
onClick={signOut}
title="退出登录"
+116
View File
@@ -0,0 +1,116 @@
import { Link } from "react-router-dom";
import { useAuth } from "../App";
import { Logout, Logo, Settings } from "./icons";
/**
* 工作区里侧栏长什么样,工作区外面(工作区列表、账号设置)就长什么样。
*
* 之前首页是居中窄栏、进了工作区又是全宽,宽度跳变一次、导航位置换一次,
* 像是两个产品。这里把外壳收成一处,首页与账号页复用它。
*/
export function SideRail({ activeId }: { activeId?: string | null }) {
const { user, workspaces, signOut } = useAuth();
const masked = user?.phone ? user.phone.replace(/^(\d{3})\d{4}(\d{4})$/, "$1 **** $2") : "";
return (
<>
{/* 桌面:左侧栏 */}
<aside className="sticky top-0 z-20 hidden h-screen w-60 shrink-0 flex-col border-r border-line-200 bg-paper-50 lg:flex xl:w-[264px]">
<div className="px-5 pb-4 pt-5">
<Link to="/" className="flex items-center gap-2.5">
<Logo size={30} />
<div className="leading-tight">
<div className="text-sm font-semibold tracking-tight text-ink-900">达道Task</div>
<div className="text-2xs text-ink-400">任务 · 工单 · 知识</div>
</div>
</Link>
</div>
<div className="mt-1 px-3">
<div className="px-2 pb-1 text-2xs font-medium text-ink-400">我的工作区</div>
</div>
<nav className="mt-1 flex flex-1 flex-col gap-0.5 overflow-y-auto px-3">
{workspaces.map((w) => {
const isActive = w.id === activeId;
return (
<Link
key={w.id}
to={`/w/${w.id}`}
className={`flex items-center gap-2 rounded-lg px-2 py-1.5 text-xs transition ${
isActive
? "bg-paper-200/70 font-medium text-ink-900"
: "text-ink-600 hover:bg-paper-100"
}`}
>
<span className="grid h-5 w-5 shrink-0 place-items-center rounded bg-line-100 text-2xs font-semibold text-ink-600">
{w.name.trim().slice(0, 1).toUpperCase()}
</span>
<span className="truncate">{w.name}</span>
</Link>
);
})}
{workspaces.length === 0 ? (
<p className="px-2 py-1.5 text-2xs leading-relaxed text-ink-400">
还没有工作区,右边建一个就能开始。
</p>
) : null}
<div className="my-1 h-px bg-line-100" />
<Link
to="/account"
className="flex items-center gap-2 rounded-lg px-2 py-1.5 text-xs text-ink-500 transition hover:bg-paper-100 hover:text-ink-800"
>
<Settings size={14} className="text-ink-400" />
账号设置
</Link>
</nav>
<div className="border-t border-line-200 px-3 py-3">
<div className="flex items-center gap-2">
<span className="grid h-7 w-7 shrink-0 place-items-center rounded-full bg-ink-200 text-2xs font-semibold text-ink-700">
{(user?.nickname ?? "我").trim().slice(0, 1).toUpperCase()}
</span>
<div className="min-w-0 flex-1">
<div className="truncate text-xs font-medium text-ink-800">{user?.nickname}</div>
<div className="truncate text-2xs text-ink-400">{masked}</div>
</div>
<button
onClick={signOut}
title="退出登录"
className="rounded-md p-1.5 text-ink-400 transition hover:bg-ink-100 hover:text-ink-700"
>
<Logout size={15} />
</button>
</div>
</div>
</aside>
{/* 移动端:顶条 */}
<header className="sticky top-0 z-20 border-b border-line-200 bg-paper-50/95 backdrop-blur lg:hidden">
<div className="flex items-center justify-between px-4 py-2.5">
<Link to="/" className="flex items-center gap-2">
<Logo size={24} />
<span className="text-sm font-semibold text-ink-900">达道Task</span>
</Link>
<div className="flex items-center gap-1">
<Link
to="/account"
className="rounded-md p-1.5 text-ink-400 hover:bg-ink-100"
aria-label="账号设置"
>
<Settings size={16} />
</Link>
<button
onClick={signOut}
className="rounded-md p-1.5 text-ink-400 hover:bg-ink-100"
aria-label="退出登录"
>
<Logout size={16} />
</button>
</div>
</div>
</header>
</>
);
}
+14
View File
@@ -220,6 +220,20 @@ export const Logout = (p: IconProps) => (
</svg>
);
export const Settings = (p: IconProps) => (
<svg {...base(p)}>
<circle cx="12" cy="12" r="3" />
<path d="M12 3.5v2M12 18.5v2M20.5 12h-2M5.5 12h-2M17.9 6.1l-1.4 1.4M7.5 16.5l-1.4 1.4M17.9 17.9l-1.4-1.4M7.5 7.5 6.1 6.1" />
</svg>
);
export const User = (p: IconProps) => (
<svg {...base(p)}>
<circle cx="12" cy="8.5" r="3.5" />
<path d="M5 20c0-3.3 3.1-5.5 7-5.5s7 2.2 7 5.5" />
</svg>
);
export const Flag = (p: IconProps) => (
<svg {...base(p)}>
<path d="M6 21V4.5M6 5h10.5l-1.7 3.6L16.5 12H6" />
+3
View File
@@ -2,6 +2,9 @@ export interface User {
id: string;
phone: string;
nickname: string;
avatar_url?: string | null;
/** 设没设登录密码;密码本身永远不会回到前端 */
has_password?: boolean;
created_at?: string;
}
+244
View File
@@ -0,0 +1,244 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useAuth } from "../App";
import { SideRail } from "../components/SideRail";
import { Button, Field, InlineAlert } from "../components/ui";
import { ApiError, api } from "../lib/client";
import type { User } from "../lib/types";
/**
* 账号设置:昵称、登录密码、退出登录。
*
* 密码是**可选**的第二条登录路径:没设过的人一直走验证码,
* 界面上的文案要能让人看懂"现在设的是第一道密码"还是"在改已有的密码"。
*/
/** 小节标题:一行标题 + 一句"这是干嘛的",比光秃秃的 h2 省解释。 */
function SectionLabel({ hint, children }: { hint?: string; children: React.ReactNode }) {
return (
<div>
<h2 className="text-sm font-medium text-ink-800">{children}</h2>
{hint ? <p className="mt-0.5 text-2xs text-ink-400">{hint}</p> : null}
</div>
);
}
export function AccountPage() {
const { user, loading, reload, signOut } = useAuth();
const navigate = useNavigate();
const [nickname, setNickname] = useState("");
const [profileBusy, setProfileBusy] = useState(false);
const [profileMsg, setProfileMsg] = useState<string | null>(null);
const [profileError, setProfileError] = useState<string | null>(null);
const [oldPassword, setOldPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirm, setConfirm] = useState("");
const [pwdBusy, setPwdBusy] = useState(false);
const [pwdMsg, setPwdMsg] = useState<string | null>(null);
const [pwdError, setPwdError] = useState<string | null>(null);
useEffect(() => {
if (!loading && !user) navigate("/login", { replace: true });
}, [loading, user, navigate]);
useEffect(() => {
if (user?.nickname) setNickname(user.nickname);
}, [user?.nickname]);
const hasPassword = Boolean(user?.has_password);
async function saveProfile(event: React.FormEvent) {
event.preventDefault();
setProfileBusy(true);
setProfileMsg(null);
setProfileError(null);
try {
await api.patch<User>("/auth/me", { nickname: nickname.trim() });
await reload();
setProfileMsg("昵称已更新");
} catch (err) {
setProfileError(err instanceof ApiError ? err.message : "保存失败");
} finally {
setProfileBusy(false);
}
}
async function savePassword(event: React.FormEvent) {
event.preventDefault();
setPwdMsg(null);
setPwdError(null);
if (newPassword !== confirm) {
setPwdError("两次输入的新密码不一样");
return;
}
setPwdBusy(true);
try {
await api.post("/auth/password", {
password: newPassword,
...(hasPassword ? { old_password: oldPassword } : {}),
});
await reload();
setOldPassword("");
setNewPassword("");
setConfirm("");
setPwdMsg(hasPassword ? "密码已修改,下次可用新密码登录" : "密码已设置,之后可以用密码登录");
} catch (err) {
setPwdError(err instanceof ApiError ? err.message : "保存失败");
} finally {
setPwdBusy(false);
}
}
return (
<div className="flex min-h-full flex-col bg-paper-100 lg:flex-row">
<SideRail />
<main className="min-w-0 flex-1">
<div className="w-full px-5 py-6 lg:px-8 lg:py-8 xl:px-12 2xl:px-16">
<div className="mb-7">
<h1 className="font-display text-2xl font-semibold">账号设置</h1>
<p className="mt-2 text-sm text-ink-500">
这里改的是你自己:昵称、登录密码、退出登录。工作区里的成员与权限在各自的工作区里管。
</p>
</div>
<div className="grid items-start gap-4 lg:grid-cols-[minmax(0,1.15fr)_minmax(0,1fr)]">
<section className="card p-5">
<SectionLabel hint="别人在成员列表里看到的名字">基本信息</SectionLabel>
<form className="mt-4 space-y-4" onSubmit={saveProfile}>
<div className="flex items-center gap-3">
<span className="grid h-12 w-12 place-items-center rounded-full bg-primary-900 text-base font-semibold text-paper-50">
{(user?.nickname ?? "我").trim().slice(0, 1).toUpperCase()}
</span>
<div className="min-w-0">
<div className="truncate text-sm font-medium text-ink-800">
{user?.nickname ?? "—"}
</div>
<div className="text-2xs text-ink-400">
{user?.created_at
? `加入于 ${new Date(user.created_at).toLocaleDateString("zh-CN")}`
: "—"}
</div>
</div>
</div>
<Field label="昵称">
<input
className="input"
value={nickname}
maxLength={32}
aria-label="昵称"
onChange={(e) => setNickname(e.target.value)}
/>
</Field>
<Field label="手机号">
<input
className="input bg-paper-100 text-ink-500"
value={user?.phone ?? ""}
readOnly
aria-label="手机号"
/>
</Field>
<p className="-mt-2 text-2xs leading-relaxed text-ink-400">
手机号就是登录账号,暂不支持自助换号;换号等于换账号,需要单独处理。
</p>
{profileMsg && <InlineAlert tone="success">{profileMsg}</InlineAlert>}
{profileError && <InlineAlert>{profileError}</InlineAlert>}
<Button
type="submit"
variant="primary"
size="sm"
disabled={profileBusy || !nickname.trim() || nickname.trim() === user?.nickname}
>
{profileBusy ? "保存中…" : "保存昵称"}
</Button>
</form>
</section>
<div className="space-y-4">
<section className="card p-5">
<SectionLabel hint="验证码始终可用,密码是可选的第二条路">登录密码</SectionLabel>
<p className="mt-3 text-xs leading-relaxed text-ink-500">
{hasPassword
? "已设置密码。登录页可以选「密码」进入;改密码要先输当前密码。"
: "还没有设置密码。现在只能用手机号 + 验证码登录;设一个密码,换手机或短信不通时也能进。"}
</p>
<form className="mt-4 space-y-3" onSubmit={savePassword}>
{hasPassword ? (
<Field label="当前密码">
<input
type="password"
className="input"
autoComplete="current-password"
value={oldPassword}
aria-label="当前密码"
onChange={(e) => setOldPassword(e.target.value)}
/>
</Field>
) : null}
<Field label={hasPassword ? "新密码" : "密码"}>
<input
type="password"
className="input"
autoComplete="new-password"
placeholder="至少 8 位,字母和数字都要有"
value={newPassword}
aria-label={hasPassword ? "新密码" : "密码"}
onChange={(e) => setNewPassword(e.target.value)}
/>
</Field>
<Field label="再输一次">
<input
type="password"
className="input"
autoComplete="new-password"
value={confirm}
aria-label="确认密码"
onChange={(e) => setConfirm(e.target.value)}
/>
</Field>
{pwdMsg && <InlineAlert tone="success">{pwdMsg}</InlineAlert>}
{pwdError && <InlineAlert>{pwdError}</InlineAlert>}
<Button
type="submit"
variant="primary"
size="sm"
disabled={
pwdBusy ||
newPassword.length < 8 ||
!confirm ||
(hasPassword && !oldPassword)
}
>
{pwdBusy ? "保存中…" : hasPassword ? "修改密码" : "设置密码"}
</Button>
</form>
</section>
<section className="card p-5">
<SectionLabel hint="在这台设备上清掉登录状态">退出</SectionLabel>
<p className="mt-3 text-xs leading-relaxed text-ink-500">
退出只会清掉这台浏览器里的令牌;工作区里的数据不受影响。
</p>
<Button variant="ghost" size="sm" className="mt-3" onClick={signOut}>
退出登录
</Button>
</section>
</div>
</div>
</div>
</main>
</div>
);
}
+137 -116
View File
@@ -1,7 +1,8 @@
import { useEffect, useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { useNavigate } from "react-router-dom";
import { useAuth } from "../App";
import { ArrowRight, ChevronRight, Logo, Logout, Plus } from "../components/icons";
import { ArrowRight, ChevronRight, Plus } from "../components/icons";
import { SideRail } from "../components/SideRail";
import { Button, EmptyState, Field, InlineAlert } from "../components/ui";
import { ApiError, api } from "../lib/client";
import type { Workspace } from "../lib/types";
@@ -19,10 +20,17 @@ function greeting() {
return "晚上好";
}
/**
* 首页 = 工作区总览。
*
* 壳和工作区里完全一样(同样的侧栏、同样的通栏内容区),
* 这样"点进工作区"只是换内容,宽度和导航位置都不跳。
*/
export function HomePage() {
const { user, workspaces, loading, reload, signOut } = useAuth();
const { user, workspaces, loading, reload } = useAuth();
const navigate = useNavigate();
const [name, setName] = useState("");
const [creating, setCreating] = useState(false);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
@@ -38,6 +46,7 @@ export function HomePage() {
try {
const ws = await api.post<Workspace>("/workspaces", { name: name.trim() });
setName("");
setCreating(false);
await reload();
navigate(`/w/${ws.id}`);
} catch (err) {
@@ -48,123 +57,135 @@ export function HomePage() {
}
return (
<div className="min-h-full bg-paper-100">
<header className="sticky top-0 z-20 border-b border-line-200 bg-paper-50/90 backdrop-blur">
<div className="mx-auto flex w-full max-w-5xl items-center justify-between px-5 py-3 lg:px-10">
<div className="flex items-center gap-2.5">
<Logo size={26} />
<span className="text-sm font-semibold tracking-tight text-ink-900">达道Task</span>
</div>
<div className="flex items-center gap-3">
<span className="hidden text-xs text-ink-500 sm:inline">
{user?.nickname}
{user?.phone ? ` · ${user.phone.replace(/^(\d{3})\d{4}(\d{4})$/, "$1****$2")}` : ""}
</span>
<Button variant="subtle" size="sm" onClick={signOut} title="退出登录">
<Logout size={14} />
退出
</Button>
</div>
</div>
</header>
<div className="flex min-h-full flex-col bg-paper-100 lg:flex-row">
<SideRail />
<main className="mx-auto w-full max-w-5xl px-5 py-8 lg:px-10 lg:py-12">
<div className="mb-9">
<p className="text-xs font-medium tracking-wide text-clay-600">
{new Date().toLocaleDateString("zh-CN", { month: "long", day: "numeric", weekday: "long" })}
</p>
<h1 className="mt-1.5 font-display text-3xl font-semibold">
{greeting()},{user?.nickname ?? ""}
</h1>
<p className="mt-2 max-w-xl text-sm leading-relaxed text-ink-500">
选一个工作区继续。任务、工单、知识都在里面,他们是同一件事的三个阶段。
</p>
</div>
<div className="grid gap-8 lg:grid-cols-[1.55fr_1fr]">
<section>
<div className="mb-3 flex items-center justify-between">
<h2 className="text-sm font-medium text-ink-600">我的工作区</h2>
<span className="text-2xs text-ink-400">{workspaces.length} 个</span>
</div>
{loading ? (
<div className="grid gap-3 sm:grid-cols-2">
{[0, 1].map((i) => (
<div key={i} className="card h-[92px] animate-pulse bg-paper-50" />
))}
</div>
) : workspaces.length === 0 ? (
<div className="card">
<EmptyState
icon={<Plus size={18} />}
title="还没有工作区"
hint="个人用也一样:先建一个,把「任务」放进去。需要别人帮忙时再升级成工单。"
/>
</div>
) : (
<ul className="grid gap-3 sm:grid-cols-2">
{workspaces.map((ws) => (
<li key={ws.id}>
<Link
to={`/w/${ws.id}`}
className="group card flex items-center gap-3 p-4 transition hover:-translate-y-0.5 hover:border-primary-300 hover:shadow-pop"
>
<span className="grid h-10 w-10 shrink-0 place-items-center rounded-xl bg-primary-900 text-sm font-semibold text-paper-50">
{ws.name.trim().slice(0, 1).toUpperCase()}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-ink-900">
{ws.name}
</span>
<span className="mt-0.5 block text-2xs text-ink-400">
{roleLabel(ws.role)} · {ws.plan === "free" ? "免费版" : ws.plan}
</span>
</span>
<ChevronRight
size={16}
className="shrink-0 text-ink-300 transition group-hover:translate-x-0.5 group-hover:text-primary-500"
/>
</Link>
</li>
))}
</ul>
)}
</section>
<aside className="card h-fit p-5">
<div className="flex items-center gap-2">
<span className="grid h-7 w-7 place-items-center rounded-lg bg-clay-50 text-clay-600">
<Plus size={15} />
</span>
<h2 className="text-sm font-medium text-ink-800">新建工作区</h2>
</div>
<p className="mt-2 text-xs leading-relaxed text-ink-400">
一个工作区对应一支团队或一个生活领域。成员之间共享任务、工单与知识。
<main className="min-w-0 flex-1">
<div className="w-full px-5 py-6 lg:px-8 lg:py-8 xl:px-12 2xl:px-16">
<div className="mb-7">
<p className="text-xs font-medium tracking-wide text-clay-600">
{new Date().toLocaleDateString("zh-CN", {
month: "long",
day: "numeric",
weekday: "long",
})}
</p>
<h1 className="mt-1.5 font-display text-3xl font-semibold">
{greeting()},{user?.nickname ?? ""}
</h1>
<p className="mt-2 max-w-2xl text-sm leading-relaxed text-ink-500">
选一个工作区继续。任务、工单、知识都在里面,他们是同一件事的三个阶段。
</p>
</div>
<form className="mt-4 space-y-3" onSubmit={createWorkspace}>
<Field label="名称">
<input
className="input"
placeholder="例如:达道科技 / 我的事项"
value={name}
maxLength={64}
onChange={(e) => setName(e.target.value)}
/>
</Field>
{error && <InlineAlert>{error}</InlineAlert>}
<Button type="submit" variant="primary" className="w-full" disabled={busy || !name.trim()}>
{busy ? "创建中…" : "创建"}
{busy ? null : <ArrowRight size={14} />}
</Button>
</form>
<div className="mt-4 border-t border-line-100 pt-3 text-2xs leading-relaxed text-ink-400">
收到邀请链接(<span className="font-mono">/join/…</span>)直接打开即可加入,
不需要别人手动拉你。
<div className="mb-3 flex items-center justify-between">
<h2 className="text-sm font-medium text-ink-600">我的工作区</h2>
<div className="flex items-center gap-3">
<span className="text-2xs text-ink-400">{workspaces.length} 个</span>
{!creating ? (
<Button variant="ghost" size="sm" onClick={() => setCreating(true)}>
<Plus size={14} />
新建
</Button>
) : null}
</div>
</aside>
</div>
{loading ? (
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
{[0, 1, 2].map((i) => (
<div key={i} className="card h-[92px] animate-pulse bg-paper-50" />
))}
</div>
) : (
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
{workspaces.map((ws) => (
<button
key={ws.id}
onClick={() => navigate(`/w/${ws.id}`)}
className="group card flex items-center gap-3 p-4 text-left transition hover:-translate-y-0.5 hover:border-primary-300 hover:shadow-pop"
>
<span className="grid h-10 w-10 shrink-0 place-items-center rounded-xl bg-primary-900 text-sm font-semibold text-paper-50">
{ws.name.trim().slice(0, 1).toUpperCase()}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-ink-900">{ws.name}</span>
<span className="mt-0.5 block text-2xs text-ink-400">
{roleLabel(ws.role)} · {ws.plan === "free" ? "免费版" : ws.plan}
</span>
</span>
<ChevronRight
size={16}
className="shrink-0 text-ink-300 transition group-hover:translate-x-0.5 group-hover:text-primary-500"
/>
</button>
))}
{creating ? (
<form
onSubmit={createWorkspace}
className="card space-y-3 border-clay-500/30 p-4 sm:col-span-2 xl:col-span-1"
>
<div className="flex items-center gap-2">
<span className="grid h-6 w-6 place-items-center rounded-md bg-clay-50 text-clay-600">
<Plus size={14} />
</span>
<span className="text-sm font-medium text-ink-800">新建工作区</span>
</div>
<Field label="名称">
<input
className="input"
placeholder="例如:达道科技 / 我的事项"
value={name}
maxLength={64}
autoFocus
onChange={(e) => setName(e.target.value)}
/>
</Field>
{error && <InlineAlert>{error}</InlineAlert>}
<div className="flex items-center gap-2">
<Button type="submit" variant="primary" size="sm" disabled={busy || !name.trim()}>
{busy ? "创建中…" : "创建"}
{busy ? null : <ArrowRight size={14} />}
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
setCreating(false);
setError(null);
}}
>
取消
</Button>
</div>
</form>
) : (
<button
onClick={() => setCreating(true)}
className="flex min-h-[92px] items-center justify-center gap-2 rounded-xl border border-dashed border-line-300 text-sm text-ink-400 transition hover:border-primary-300 hover:text-primary-600"
>
<Plus size={16} />
新建工作区
</button>
)}
{workspaces.length === 0 && !creating ? (
<div className="card sm:col-span-2 xl:col-span-3">
<EmptyState
icon={<Plus size={18} />}
title="还没有工作区"
hint="个人用也一样:先建一个,把「任务」放进去。需要别人帮忙时再升级成工单。"
/>
</div>
) : null}
</div>
)}
<p className="mt-6 text-2xs leading-relaxed text-ink-400">
收到邀请链接(<span className="font-mono">/join/…</span>)直接打开即可加入,不需要别人手动拉你。
</p>
</div>
</main>
</div>
+118 -23
View File
@@ -2,8 +2,9 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useParams } from "react-router-dom";
import { AppShell } from "../components/AppShell";
import { AttachmentPanel } from "../components/AttachmentPanel";
import { Markdown } from "../components/Markdown";
import { RichTextEditor } from "../components/RichTextEditor";
import { Link2, Paperclip, Plus, Search, Sparkle } from "../components/icons";
import { Link2, Paperclip, Pencil, Plus, Search, Sparkle } from "../components/icons";
import {
Button,
EmptyState,
@@ -22,11 +23,12 @@ import type {
SearchHit,
} from "../lib/types";
// 「全部」放第一位:进知识库第一眼是"我这儿都有些什么",而不是先看一堆待核对
const TABS = [
{ key: "all", label: "全部", statuses: [] },
{ key: "draft", label: "待核对", statuses: ["draft", "in_review"] },
{ key: "published", label: "已发布", statuses: ["published"] },
{ key: "archived", label: "已归档", statuses: ["archived"] },
{ key: "all", label: "全部", statuses: [] },
] as const;
const DOC_TYPE_LABEL: Record<string, string> = {
@@ -58,9 +60,29 @@ const ACTION_LABEL: Record<string, string> = {
restore: "恢复成草稿",
};
/**
* 正文编辑区的高度跟着窗口走。
* 知识是长文,编辑器默认 240px 只露出几行,写一段就得滚一次;
* 把「窗口高度减去标题/分类/标签/底部按钮」剩下的都给正文,屏幕越大写得越舒展。
*/
function useEditorHeight(reserve: number, floor = 360, ceiling = 960) {
const measure = () =>
Math.max(floor, Math.min(ceiling, (typeof window === "undefined" ? 900 : window.innerHeight) - reserve));
const [height, setHeight] = useState(measure);
useEffect(() => {
const onResize = () => setHeight(measure());
onResize();
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize);
// measure 只读 window 与固定参数,不需要进依赖
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [reserve, floor, ceiling]);
return height;
}
export function KnowledgePage() {
const { wsId = "" } = useParams();
const [tab, setTab] = useState<(typeof TABS)[number]["key"]>("draft");
const [tab, setTab] = useState<(typeof TABS)[number]["key"]>("all");
const [q, setQ] = useState("");
const [docs, setDocs] = useState<Knowledge[]>([]);
const [cursor, setCursor] = useState<string | null>(null);
@@ -537,7 +559,15 @@ function CategoryButton({
: "text-ink-600 hover:bg-white/60 hover:text-ink-800"
}`}
>
{children}
<span className="flex min-w-0 items-center gap-1">
{/* 二级及以下带一个点,光靠缩进在小屏上不够分得清是子分类还是换了一棵树 */}
{indent > 0 ? (
<span aria-hidden className="text-ink-300">
·
</span>
) : null}
{children}
</span>
</button>
);
}
@@ -567,6 +597,8 @@ function KnowledgeComposer({
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
/** 上面还有标题、类型、分类、标签与底部按钮,剩下的高度都给正文 */
const editorHeight = useEditorHeight(420);
/**
* 附件与图片都要落在具体条目上,可新建时条目还不存在。
* 解法是懒建草稿:第一次传附件/贴图时才 POST 一条草稿,之后一律 PATCH。
@@ -761,7 +793,7 @@ function KnowledgeComposer({
wsId={wsId}
value={body}
onChange={setBody}
minHeight={320}
minHeight={editorHeight}
ensureItemId={ensureDraft}
onError={setError}
placeholder={"写下要点、步骤、结论…\n\n标题、类型、分类、标签都在上面,正文就是这张备忘的主体。"}
@@ -822,6 +854,8 @@ function KnowledgeDrawer({
const [tags, setTags] = useState("");
const [categoryId, setCategoryId] = useState("");
const [attachments, setAttachments] = useState<Attachment[]>([]);
/** 编辑态上层还有标题、分类/标签、附件与底部按钮,正文吃掉剩下的大半屏 */
const editorHeight = useEditorHeight(340);
/** 附件 id → 带签名的直链(见 lib/attachmentUrls.ts) */
const resources = useMemo(() => {
const map: Record<string, string> = {};
@@ -831,6 +865,8 @@ function KnowledgeDrawer({
return map;
}, [attachments]);
const [busy, setBusy] = useState(false);
/** 打开是「读」还是「写」:读的时候渲染样式,只有点「编辑」才进编辑器。 */
const [editing, setEditing] = useState(false);
/** 已经载入完成的是哪一条知识。重复的 effect / 慢响应不该再覆盖用户正在敲的内容。 */
const loadedFor = useRef<string | null>(null);
@@ -845,6 +881,7 @@ function KnowledgeDrawer({
setTags(data.tags.map((t) => t.name).join(" "));
setCategoryId(data.category?.id ?? "");
setAttachments(data.attachments ?? []);
setEditing(false);
loadedFor.current = itemId;
} catch (err) {
setError(err instanceof ApiError ? err.message : "加载失败");
@@ -870,6 +907,7 @@ function KnowledgeDrawer({
version: doc.version,
});
setDoc(next);
setEditing(false);
onChanged();
} catch (err) {
if (err instanceof ApiError && err.code === 4009) {
@@ -903,7 +941,8 @@ function KnowledgeDrawer({
}
const status = doc?.knowledge.status_hint;
const canEdit = status === "draft" || status === "in_review";
// 已发布的也能改:改完后端会自动重建检索切片。归档是先收起来,要改得先「恢复成草稿」。
const canEdit = status === "draft" || status === "in_review" || status === "published";
const actions: { key: string; label: string; show: boolean }[] = [
{ key: "submit_review", label: ACTION_LABEL.submit_review, show: status === "draft" },
{ key: "withdraw", label: ACTION_LABEL.withdraw, show: status === "in_review" },
@@ -932,23 +971,44 @@ function KnowledgeDrawer({
footer={
doc ? (
<div className="flex flex-wrap items-center gap-2">
{canEdit ? (
<Button variant="primary" disabled={busy} onClick={() => void save()}>
{busy ? "保存中…" : "保存"}
</Button>
) : null}
{actions
.filter((a) => a.show)
.map((a) => (
<Button
key={a.key}
variant={a.key === "publish" ? "clay" : "ghost"}
disabled={busy}
onClick={() => void act(a.key)}
>
{a.label}
{editing ? (
<>
<Button variant="primary" disabled={busy} onClick={() => void save()}>
{busy ? "保存中…" : "保存"}
</Button>
))}
<Button
variant="ghost"
disabled={busy}
onClick={() => {
setEditing(false);
void load(true);
}}
>
取消
</Button>
</>
) : (
<>
{canEdit ? (
<Button variant="primary" onClick={() => setEditing(true)}>
<Pencil size={13} />
编辑
</Button>
) : null}
{actions
.filter((a) => a.show)
.map((a) => (
<Button
key={a.key}
variant={a.key === "publish" ? "clay" : "ghost"}
disabled={busy}
onClick={() => void act(a.key)}
>
{a.label}
</Button>
))}
</>
)}
<span className="ml-auto text-2xs text-ink-400">
{doc.knowledge.published_at
? `发布于 ${new Date(doc.knowledge.published_at).toLocaleString("zh-CN")}`
@@ -960,7 +1020,7 @@ function KnowledgeDrawer({
>
{!doc ? (
<p className="text-sm text-ink-400">{error ?? "加载中…"}</p>
) : (
) : !editing ? (
<div className="space-y-4">
<div className="rounded-xl border border-line-200 bg-paper-50 p-3.5 text-xs leading-relaxed text-ink-600">
<div className="flex items-center gap-1.5">
@@ -983,6 +1043,40 @@ function KnowledgeDrawer({
</p>
</div>
<div className="flex flex-wrap items-center gap-1.5 text-2xs">
<span className="chip bg-paper-200 text-ink-600">
{doc.category?.full_path ?? "未分类"}
</span>
{doc.tags.map((t) => (
<span key={t.id} className="chip bg-primary-50 text-primary-700">
#{t.name}
</span>
))}
</div>
{/* 看的时候就是渲染后的样子:标题、列表、表格、图片都按 Markdown 显示。
要改再点「编辑」,进来才是编辑器——两者不该混在同一个界面里。 */}
<div className="rounded-xl border border-line-200 bg-white px-4 py-4">
<Markdown source={doc.body_md} resources={resources} />
</div>
{attachments.length > 0 && (
<div className="rounded-xl border border-line-200 bg-paper-50 px-3 py-2.5">
<AttachmentPanel
wsId={wsId}
itemId={doc.id}
attachments={attachments}
onChange={setAttachments}
onError={(message) => setError(message)}
disabled
/>
</div>
)}
{error && <InlineAlert>{error}</InlineAlert>}
</div>
) : (
<div className="space-y-4">
<input
className="input text-base font-medium"
value={title}
@@ -1039,6 +1133,7 @@ function KnowledgeDrawer({
}
onError={(message) => setError(message)}
disabled={!canEdit}
minHeight={editorHeight}
/>
</div>
+75 -25
View File
@@ -37,6 +37,8 @@ export function LoginPage() {
const next = safeNext(params.get("next"));
const [phone, setPhone] = useState("");
const [code, setCode] = useState("");
const [password, setPassword] = useState("");
const [mode, setMode] = useState<"code" | "password">("code");
const [devCode, setDevCode] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
@@ -69,7 +71,13 @@ export function LoginPage() {
setError(null);
setBusy(true);
try {
const data = await api.post<{ user: User; tokens: Tokens }>("/auth/login", { phone, code });
const data =
mode === "code"
? await api.post<{ user: User; tokens: Tokens }>("/auth/login", { phone, code })
: await api.post<{ user: User; tokens: Tokens }>("/auth/login/password", {
phone,
password,
});
session.save(data.tokens);
await reload();
navigate(next ?? "/", { replace: true });
@@ -135,9 +143,36 @@ export function LoginPage() {
</div>
<h2 className="text-2xl font-semibold">登录</h2>
<p className="mt-1.5 text-sm text-ink-500">手机号验证码登录,第一次登录会自动创建账号。</p>
<p className="mt-1.5 text-sm text-ink-500">
{mode === "code"
? "手机号验证码登录,第一次登录会自动创建账号。"
: "用手机号和登录密码进入。没设过密码的,先用验证码登录一次再设。"}
</p>
<form className="mt-7 space-y-4" onSubmit={submit}>
<div className="mt-5 inline-flex rounded-lg bg-paper-200/60 p-0.5 text-xs">
{(
[
["code", "验证码"],
["password", "密码"],
] as const
).map(([key, label]) => (
<button
key={key}
type="button"
onClick={() => {
setMode(key);
setError(null);
}}
className={`rounded-[6px] px-3 py-1.5 font-medium transition ${
mode === key ? "bg-white text-ink-900 shadow-sm" : "text-ink-500 hover:text-ink-700"
}`}
>
{label}
</button>
))}
</div>
<form className="mt-5 space-y-4" onSubmit={submit}>
<Field label="手机号">
<input
id="phone"
@@ -151,29 +186,44 @@ export function LoginPage() {
/>
</Field>
<Field label="验证码">
<div className="flex gap-2">
{mode === "code" ? (
<Field label="验证码">
<div className="flex gap-2">
<input
id="code"
className="input h-11 tracking-[0.3em]"
inputMode="numeric"
autoComplete="one-time-code"
placeholder="6 位数字"
value={code}
onChange={(e) => setCode(e.target.value.trim())}
maxLength={6}
/>
<Button
type="button"
variant="ghost"
className="h-11 shrink-0 px-3.5"
disabled={!phoneValid || busy || countdown > 0}
onClick={sendCode}
>
{countdown > 0 ? `${countdown}s` : "获取验证码"}
</Button>
</div>
</Field>
) : (
<Field label="密码">
<input
id="code"
className="input h-11 tracking-[0.3em]"
inputMode="numeric"
autoComplete="one-time-code"
placeholder="6 位数字"
value={code}
onChange={(e) => setCode(e.target.value.trim())}
maxLength={6}
id="password"
type="password"
className="input h-11"
autoComplete="current-password"
placeholder="你的登录密码"
value={password}
onChange={(e) => setPassword(e.target.value)}
maxLength={64}
/>
<Button
type="button"
variant="ghost"
className="h-11 shrink-0 px-3.5"
disabled={!phoneValid || busy || countdown > 0}
onClick={sendCode}
>
{countdown > 0 ? `${countdown}s` : "获取验证码"}
</Button>
</div>
</Field>
</Field>
)}
{devCode && (
<div className="flex items-start gap-2 rounded-lg border border-clay-500/20 bg-clay-50 px-3 py-2 text-xs text-clay-700">
@@ -188,7 +238,7 @@ export function LoginPage() {
variant="primary"
size="lg"
className="mt-2 w-full"
disabled={busy || !phoneValid || code.length < 4}
disabled={busy || !phoneValid || (mode === "code" ? code.length < 4 : password.length < 1)}
>
{busy ? "处理中…" : "登录"}
</Button>