Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
29aae12224 | ||
|
|
7668799040 | ||
|
|
35fe4d92cb | ||
|
|
50227c2114 | ||
|
|
b29286778f | ||
|
|
441b1b93e1 | ||
|
|
bbd30ec85e | ||
|
|
039e5b903c | ||
|
|
8b6594bf10 | ||
|
|
2ff1f6e9cd |
@@ -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/
|
||||
|
||||
@@ -396,7 +396,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 +408,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 +544,6 @@ Query:
|
||||
|
||||
### 6.3 附件错误码
|
||||
|
||||
### 6.3 附件错误码
|
||||
|
||||
| 码 | HTTP | 含义 |
|
||||
|---|---|---|
|
||||
| 8001 | 404 | 附件不存在,或不属于这个工作区 |
|
||||
@@ -558,7 +560,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. 健康与版本
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|---|---|---|
|
||||
|
||||
@@ -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. 已知限制(当前阶段)
|
||||
|
||||
| 限制 | 影响 | 解除方式 |
|
||||
|---|---|---|
|
||||
|
||||
@@ -291,15 +291,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 +417,36 @@
|
||||
- [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`
|
||||
|
||||
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 136 KiB |
|
After Width: | Height: | Size: 124 KiB |
|
After Width: | Height: | Size: 164 KiB |
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 137 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 112 KiB |
|
After Width: | Height: | Size: 137 KiB |
@@ -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")
|
||||
|
||||
@@ -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>
|
||||
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 7.8 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
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,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,
|
||||
)
|
||||
@@ -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))
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -66,6 +66,9 @@ class ErrorCode(IntEnum):
|
||||
ATTACHMENT_FORBIDDEN = 8003
|
||||
CONFIRM_REQUIRED = 8004
|
||||
|
||||
# 9xxx 客户端发布(OTA)
|
||||
APP_RELEASE_NOT_FOUND = 9001
|
||||
|
||||
|
||||
# 默认 HTTP 状态:业务码自己带语义,HTTP 只是传输层
|
||||
_HTTP_STATUS: dict[ErrorCode, int] = {
|
||||
@@ -111,6 +114,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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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="存活探针")
|
||||
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -200,13 +200,18 @@ async def test_publish_requires_admin(client: AsyncClient) -> None:
|
||||
assert published["status"] == "published"
|
||||
assert published["knowledge"]["published_at"] is not None
|
||||
|
||||
# 已发布的内容不许直接改,先撤回
|
||||
blocked = await client.patch(
|
||||
# 已发布的能直接改正文:改完后端自动重建切片,状态仍是已发布
|
||||
edited = unwrap(
|
||||
await client.patch(
|
||||
f"{V1}/workspaces/{ws['id']}/knowledge/{doc['id']}",
|
||||
json={"title": "值班手册 v2", "version": published["version"]},
|
||||
headers=owner["headers"],
|
||||
)
|
||||
assert blocked.json()["code"] == 7003
|
||||
)
|
||||
assert edited["title"] == "值班手册 v2"
|
||||
assert edited["knowledge"]["status_hint"] == "published"
|
||||
assert edited["version"] == published["version"] + 1
|
||||
published = edited
|
||||
|
||||
archived = unwrap(
|
||||
await client.post(
|
||||
@@ -218,6 +223,14 @@ async def test_publish_requires_admin(client: AsyncClient) -> None:
|
||||
assert archived["status"] == "archived"
|
||||
assert archived["knowledge"]["archived_at"] is not None
|
||||
|
||||
# 归档是先收起来;要改先恢复成草稿,免得改完还挂着"现役"的样子
|
||||
blocked = await client.patch(
|
||||
f"{V1}/workspaces/{ws['id']}/knowledge/{doc['id']}",
|
||||
json={"title": "值班手册 v3", "version": archived["version"]},
|
||||
headers=owner["headers"],
|
||||
)
|
||||
assert blocked.json()["code"] == 7003
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_illegal_action_reports_allowed(client: AsyncClient) -> None:
|
||||
|
||||
@@ -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] == [
|
||||
"技术开发",
|
||||
"技术开发 / 技术",
|
||||
"技术开发 / 技术 / 部署",
|
||||
"餐饮业务",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
// 先等抽屉把自己拉回来的旧标签填上——那一刻之后才没有"迟到的响应"来覆盖输入
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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,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"
|
||||
}`}
|
||||
>
|
||||
<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,10 +971,29 @@ function KnowledgeDrawer({
|
||||
footer={
|
||||
doc ? (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{canEdit ? (
|
||||
{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)
|
||||
@@ -949,6 +1007,8 @@ function KnowledgeDrawer({
|
||||
{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>
|
||||
|
||||
|
||||