Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a118ad407 | ||
|
|
ea5d28304e | ||
|
|
57767c77a1 | ||
|
|
9ac3b46430 | ||
|
|
2dbe4cf675 | ||
|
|
7c84ff3b8d | ||
|
|
ffae7e380f | ||
|
|
9a382e4e8c | ||
|
|
ed31af3f46 | ||
|
|
91b3dde670 | ||
|
|
d079564d6c | ||
|
|
d509f0c335 |
@@ -227,6 +227,20 @@ canceled→ todo (恢复)
|
||||
工单**不是**另一张表里的另一种东西,它是一条 `kind='ticket'` 的 item 加一行 `ticket_ext`。
|
||||
所以详情仍然走 `GET /items/{item_id}`,响应里多一个 `ticket` 字段。
|
||||
|
||||
### 4.0 `POST /tickets`(手工开单)
|
||||
|
||||
```json
|
||||
{ "title": "核对 9 月发票", "body_md": "可选说明",
|
||||
"assignee_id": "uuid", "priority": 2, "due_at": "2026-10-30T10:00:00+08:00" }
|
||||
```
|
||||
|
||||
返回与详情同构的工单对象(item + `ticket`)。与 4.1 的区别只有一处:**没有来源任务**,
|
||||
`ticket.parent_item_id` 为 `null`,因此验收通过后不会回流任何任务(但同样会沉淀一份知识草稿)。
|
||||
|
||||
- `title` 必填、`assignee_id` 必须是工作区内 `active` 成员,否则 `1001`。
|
||||
- 初始状态 `open`,被指派人立刻收到 `ticket_assigned` 通知。
|
||||
- 手工单与升级单共用同一条 `item`,所以看板、评论、验收、知识沉淀全部复用,不需要另一套接口。
|
||||
|
||||
### 4.1 `POST /items/{item_id}/promote`(任务升级成工单)
|
||||
|
||||
```json
|
||||
@@ -286,8 +300,26 @@ canceled→ todo (恢复)
|
||||
| POST | `/notifications/{id}/read` | 标一条 |
|
||||
| POST | `/notifications/read-all` | 全标已读 |
|
||||
|
||||
`kind`:`ticket_assigned` / `ticket_submitted` / `ticket_approved` / `ticket_rejected` / `task_unblocked`。
|
||||
通知只发给当事人,同工作区里其他人看不到——这是"协同"与"广播"的区别。
|
||||
`kind` 一共七种,都是"某人在某件事上做了什么":
|
||||
|
||||
| kind | 什么时候发 | 发给谁 |
|
||||
|---|---|---|
|
||||
| `task_assigned` | 建任务/改任务时指派了人 | 被指派人 |
|
||||
| `task_status_changed` | 任务状态变化 | 创建人 + 办理人(除操作者本人) |
|
||||
| `item_commented` | 有人评论 | 创建人 + 办理人(除评论者本人) |
|
||||
| `ticket_assigned` | 升级成工单 / 手工开单 / 改派 | 办理人 |
|
||||
| `ticket_submitted` | 办理人提交验收 | 需求人 |
|
||||
| `ticket_approved` / `ticket_rejected` | 验收通过 / 退回 | 办理人 |
|
||||
| `task_unblocked` | 工单验收通过、原任务回流待办 | 原任务创建人 |
|
||||
|
||||
三条硬规则:
|
||||
|
||||
1. **不通知自己**:谁操作就跳过谁,否则每个人都会收到自己刚做的事。
|
||||
2. 通知里必须写清"谁 + 做了什么 + 在哪件事上",否则点开等于重新看一遍列表。
|
||||
3. 通知只是提示,不是凭证——点开之后目标接口会重新校验权限、重新取数据。
|
||||
|
||||
Web 端未读数由 `AppShell` 每 30 秒轮询一次;在通知页标已读后立刻派发 `notifications:changed` 事件刷新角标,不必等下一轮。
|
||||
安卓端同样按 30 秒/切页刷新。
|
||||
|
||||
### 4.6 工单的错误码
|
||||
|
||||
@@ -310,22 +342,30 @@ canceled→ todo (恢复)
|
||||
| `status` | 可重复;`draft`/`in_review`/`published`/`archived`,不传为全部 |
|
||||
| `doc_type` | `note`/`howto`/`faq`/`postmortem`/`decision` |
|
||||
| `source_item_id` | 按来源事项筛 |
|
||||
| `category_id` | 按分类筛,**包含子分类** |
|
||||
| `uncategorized` | `true` 只看未分类 |
|
||||
| `tag` | 按标签名筛(不区分大小写) |
|
||||
| `q` | 标题 / 正文 / 摘要子串 |
|
||||
| `limit` | 1–100,默认 30 |
|
||||
| `cursor` | 上一页的 `next_cursor` |
|
||||
|
||||
返回 `{items, next_cursor, has_more}`。每条是 `item` 字段 + `knowledge` + `source`:
|
||||
返回 `{items, next_cursor, has_more}`。每条是 `item` 字段 + `knowledge` + `category` + `source`:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "…", "kind": "knowledge", "title": "发票原件必须一起寄",
|
||||
"body_md": "…", "status": "published", "tags": [],
|
||||
"body_md": "…", "status": "published", "tags": [{"id": "…", "name": "财务"}],
|
||||
"knowledge": {
|
||||
"doc_type": "howto", "summary": "…", "status_hint": "published",
|
||||
"source_kind": "task", "source_item_id": "…", "generated_by": "human",
|
||||
"published_at": "2026-10-28T05:54:44Z", "archived_at": null,
|
||||
"chunk_count": 0, "author_id": "…"
|
||||
},
|
||||
"category": {
|
||||
"id": "…", "parent_id": "…", "name": "回滚", "depth": 3,
|
||||
"sort_order": 0, "version": 1,
|
||||
"path": ["研发", "部署", "回滚"], "full_path": "研发 / 部署 / 回滚"
|
||||
},
|
||||
"source": {"id": "…", "kind": "task", "title": "发票原件必须一起寄", "status": "done"}
|
||||
}
|
||||
```
|
||||
@@ -342,19 +382,43 @@ canceled→ todo (恢复)
|
||||
|
||||
```json
|
||||
{"title": "报销单要贴发票原件", "body_md": "…", "doc_type": "note",
|
||||
"tag_names": ["财务", "报销"], "category_id": "可选的分类 id",
|
||||
"source_item_id": "可选的来源事项 id"}
|
||||
```
|
||||
|
||||
- 不带 `source_item_id` → `source_kind=manual`(手记),允许没有来源。
|
||||
- 带 → 来源必须是同工作区的 `task`/`ticket`,否则 `7005`。
|
||||
- `category_id` 可省(未分类);跨工作区的分类一律按不存在处理(`7006`)。
|
||||
- `tag_names` 按名字去重创建,空格/逗号分隔由前端拆好再传。
|
||||
- 新知识一律 `draft`,不会直接对外可见。
|
||||
|
||||
### 5.4 `PATCH /knowledge/{item_id}` — 修改内容
|
||||
|
||||
必须带 `version`(乐观锁,冲突 `4009`)。可改 `title`/`body_md`/`summary`/`doc_type`。
|
||||
必须带 `version`(乐观锁,冲突 `4009`)。可改 `title`/`body_md`/`summary`/`doc_type`/`tag_names`/`category_id`;
|
||||
传 `clear_category: true` 表示移到未分类。
|
||||
**已发布的不许直接改**(`7003`,先撤回审议或归档),避免"发布后被悄悄改写"。
|
||||
|
||||
### 5.5 `POST /knowledge/{item_id}/status` — 状态动作
|
||||
### 5.5 知识分类 `/knowledge/categories`
|
||||
|
||||
分类是工作区级的三级小树,用来给知识安一个位置;它**不是权限边界**,只是归类。
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|---|---|---|
|
||||
| GET | `/knowledge/categories` | 扁平列表(按 depth/sort_order 排好),每条带 `doc_count`、`path`、`full_path`;另有 `max_depth=3` |
|
||||
| POST | `/knowledge/categories` | `{name, parent_id?, sort_order?}` |
|
||||
| PATCH | `/knowledge/categories/{id}` | `{name?, parent_id?, move?, sort_order?, version}` |
|
||||
| DELETE | `/knowledge/categories/{id}?force=false` | 有子分类不让删;有知识要 `force=true`,删后知识变未分类 |
|
||||
|
||||
三条规则:
|
||||
|
||||
1. **最多三级**。第四级不是"更细",是"找不到",创建/移动越界一律 `7007`。
|
||||
2. **同一层不能重名**(不区分大小写),否则 `7008`;不同层可以同名。
|
||||
3. **移动带着子树走**:把一棵三层树挪到第二层下面会让最深节点到第五层,这种移动直接拒绝(`7007`),
|
||||
不静默改层级。
|
||||
|
||||
`PATCH` 里 `move=true` 才处理 `parent_id`,避免"只改名字"被误当成移动。改名的 `version` 冲突是 `4009`。
|
||||
|
||||
### 5.6 `POST /knowledge/{item_id}/status` — 状态动作
|
||||
|
||||
| action | 允许的前置状态 | 结果 | 谁能做 |
|
||||
|---|---|---|---|
|
||||
@@ -369,7 +433,7 @@ canceled→ todo (恢复)
|
||||
|
||||
**发布要 owner/admin 的理由**:发布等于团队为这条结论背书,不能谁都能推。
|
||||
|
||||
### 5.6 `POST /items/{item_id}/knowledge` — 把一件事沉淀成知识
|
||||
### 5.7 `POST /items/{item_id}/knowledge` — 把一件事沉淀成知识
|
||||
|
||||
Query:`doc_type`(默认 `postmortem`)。来源可以是任务或工单;工单会带出验收结论。
|
||||
返回与 5.2 相同的知识对象;同一来源重复沉淀 → `7002`。
|
||||
@@ -377,7 +441,45 @@ Query:`doc_type`(默认 `postmortem`)。来源可以是任务或工单;
|
||||
**工单验收通过时,后端会自动调用这条逻辑**(`generated_by=rule`,`doc_type=postmortem`,
|
||||
正文开头固定一句"这份草稿由系统根据来源自动整理,请先核对再发布"),所以用户不需要记得点。
|
||||
|
||||
### 5.7 知识的错误码
|
||||
### 5.8 检索 `GET /knowledge/search`
|
||||
|
||||
Query:
|
||||
| 参数 | 说明 |
|
||||
|---|---|
|
||||
| `q` | 必填,1–200 字 |
|
||||
| `limit` | 1–30,默认 10 |
|
||||
| `mode` | `hybrid`(默认)/ `vector` / `keyword`,后两个用于对比效果 |
|
||||
|
||||
返回 `{query, mode, items[]}`,每项:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|---|---|
|
||||
| `item_id` / `title` | 来源知识,前端"打开原文"用 |
|
||||
| `doc_type` / `heading` | 类型与所在小节 |
|
||||
| `idx` / `content` | 第几片、切片正文 |
|
||||
| `score` | 加权总分,低于 `SEARCH_MIN_SCORE`(0.06)的结果**不返回** |
|
||||
| `vector_score` / `lexical_score` | 向量分与 trigram 词法分,便于排查"为什么没搜到" |
|
||||
|
||||
三条约定:
|
||||
|
||||
1. **只检索 `published`**。草稿是还没被人认可的话,进检索等于把猜测当结论引用出去——过滤写在 SQL 里,调用方改不了。
|
||||
2. **结果必须带来源**,能点回原文。这是 D3(知识必须有来源)的延伸:不给"出处不存在"的答案。
|
||||
3. **宁可返回空,也不凑数**。检索失败比误导便宜。
|
||||
|
||||
发布(`5.6` 的 `publish`)时会自动重建索引,用户发布完立刻能搜到;
|
||||
但改完正文不会自动重建,需要调用下面这条。
|
||||
|
||||
### `POST /knowledge/{item_id}/reindex` — 重建切片
|
||||
|
||||
返回 `{item_id, chunk_count, searchable}`。`searchable=false` 表示内容已建好切片但还不是 `published`,
|
||||
搜不到——这不是错误。
|
||||
|
||||
实现:切片存 `chunk` 表(`content` + `embedding real[]`),相似度用 SQL 函数 `app_cosine()` 计算。
|
||||
本机没有可用的 pgvector 实例(Docker 起不来,fnsvr-01 明确不动),所以先用 `real[]` 把链路跑通;
|
||||
将来换 pgvector 只改列类型与索引,接口不变。换 embedding 模型(`EMBEDDING_PROVIDER`)后
|
||||
**必须整体重建**,否则新旧向量混在一张表里,分数没法比较。
|
||||
|
||||
### 5.9 知识的错误码
|
||||
|
||||
| 码 | HTTP | 含义 |
|
||||
|---|---|---|
|
||||
@@ -386,6 +488,9 @@ Query:`doc_type`(默认 `postmortem`)。来源可以是任务或工单;
|
||||
| 7003 | 409 | 当前状态不允许这个动作 |
|
||||
| 7004 | 403 | 权限不足(如非管理员发布) |
|
||||
| 7005 | 409 | 来源事项无效或缺失 |
|
||||
| 7006 | 404 | 分类不存在,或不是本工作区的 |
|
||||
| 7007 | 422 | 分类层级超过三级,或移动后会让子树超过三级 |
|
||||
| 7008 | 409 | 分类重名 / 还有子分类 / 分类下还有知识(需 `force`) |
|
||||
|
||||
---
|
||||
|
||||
@@ -412,8 +517,12 @@ Query:`doc_type`(默认 `postmortem`)。来源可以是任务或工单;
|
||||
|
||||
附件对象的字段:`id / item_id / filename / content_type / size / sha256 / uploader_id / created_at / raw_url / can_delete`。
|
||||
`raw_url` 是**后端签发的短期直链**(默认 30 分钟),客户端直接用它渲染,不需要自己拼令牌。
|
||||
附件可挂在 `kind` 为 `task / ticket / knowledge` 的事项上(`ALLOWED_KINDS`),知识与任务、工单共用同一套接口;
|
||||
事项与知识的列表接口会附带 `attachment_count`,详情接口附带 `attachments` 全量。
|
||||
|
||||
新建知识时条目还不存在,前端用「懒建草稿」:点上传(或往正文里贴图)时先 `POST /knowledge` 存一条草稿拿到 `item_id`,
|
||||
再上传到 `/items/{item_id}/attachments`,之后的保存一律走 `PATCH /knowledge/{item_id}`。没传过附件就取消的人,库里不会留下空条目。
|
||||
|
||||
### 6.2 图片直链:短期签名,而不是把令牌写进正文
|
||||
|
||||
`<img>` 发不出 `Authorization` 头。两种解法:把图片读成 base64 内联(正文膨胀几十倍,破坏检索),
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
# 部署(生产 · openclaw-01)
|
||||
|
||||
> 适用于当前唯一的线上环境:`openclaw-01`(192.168.8.188),访问入口 `http://192.168.8.188:8200`。
|
||||
> 本地开发见 [`DEVELOPMENT.md`](DEVELOPMENT.md)。部署铁律见仓库根 [`AGENTS.md`](../AGENTS.md) 第 2 节。
|
||||
|
||||
## 1. 生产拓扑
|
||||
|
||||
```
|
||||
浏览器 ──HTTP──▶ openclaw-01:8200
|
||||
└ gunicorn(2 × UvicornWorker) ── app.main:app
|
||||
├ /api/v1/** 业务 API
|
||||
└ / 前端产物 web/dist(单页应用,404 回退 index.html)
|
||||
│
|
||||
┌────────────────────────┼────────────────────────┐
|
||||
▼ ▼ ▼
|
||||
fnsvr-01:8432 PG 17.6 fnsvr-01:6379 Redis 本机 backend/var/uploads
|
||||
库 dadaotask_prod db 6(独立库号) 附件文件
|
||||
角色 dadao_prod_app(RLS)
|
||||
角色 dadao_prod_owner(迁移)
|
||||
```
|
||||
|
||||
为什么是这个形状:
|
||||
|
||||
- **生产库和开发库在同一个 PG 实例**(fnsvr-01),所以生产用**独立的库 + 独立的角色**。复用 `dadao_app` 等于拿到开发口令就能连生产。迁移里的 `GRANT`/`DEFAULT PRIVILEGES` 按 `APP_DB_ROLE` / `OWNER_DB_ROLE` 生成,靠 `backend/app/roles.py` 注入。
|
||||
- **Redis 用 6 号库**隔离:共享实例没有密码,靠库号避免和别的项目撞键(验证码、幂等键)。
|
||||
- **没有 nginx**:openclaw 账号无 sudo 密码,改不了系统 nginx,所以后端单端口同时托管 API 与前端产物(同源,免 CORS)。root 愿意配合时按 `deploy/prod/nginx.dadaotask.conf.example` 加反代,不用改应用。
|
||||
- **用户级 systemd**:openclaw-01 上所有服务(easymemo、portal-site…)都是 `systemctl --user` 起的,本方案沿用,全程不需要 root。
|
||||
|
||||
## 2. 首次部署(一次性)
|
||||
|
||||
前置:能 SSH 到 openclaw-01;fnsvr-01 的 root 权限(只在建库这一步需要)。
|
||||
|
||||
### 2.1 建生产库与角色(在 fnsvr-01 上,root)
|
||||
|
||||
```bash
|
||||
# 口令自己生成,别用示例里的
|
||||
OWNER_PW='<dadao_prod_owner 的口令>' APP_PW='<dadao_prod_app 的口令>' \
|
||||
bash deploy/prod/provision-db.sh
|
||||
```
|
||||
|
||||
会创建:库 `dadaotask_prod`(owner `dadao_prod_owner`)、角色 `dadao_prod_owner` / `dadao_prod_app`(`NOSUPERUSER NOBYPASSRLS`,否则 RLS 会被跳过)、`public` schema 归 owner、`REVOKE CREATE ... FROM PUBLIC`。建表与 RLS 策略由 alembic 负责(2.4)。
|
||||
|
||||
### 2.2 代码落地(在 openclaw-01 上,openclaw 身份)
|
||||
|
||||
```bash
|
||||
git clone --branch main --single-branch http://gitea.tatta.cn/Tatta/DadaoTask.git ~/app/dadaotask
|
||||
```
|
||||
|
||||
### 2.3 配置(不进仓库)
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.config/dadaotask && chmod 700 ~/.config/dadaotask
|
||||
cp ~/app/dadaotask/deploy/prod/env.production.example ~/.config/dadaotask/env.production
|
||||
chmod 600 ~/.config/dadaotask/env.production
|
||||
vim ~/.config/dadaotask/env.production # 替换全部 __CHANGE_ME__
|
||||
```
|
||||
|
||||
必填项:`DATABASE_URL` / `MIGRATION_DATABASE_URL`(2.1 的口令)、`JWT_SECRET`(≥32 位随机串)、`REDIS_URL`、`WEB_DIST_DIR`、`UPLOAD_DIR`。
|
||||
|
||||
**当前状态是内测模式**:短信通道还没接(非 dev provider 直接 501),所以 `SMS_PROVIDER=dev` + `INTERNAL_TEST_LOGIN=true`,固定验证码 `123456`。启动日志会打 WARNING。**只允许内网访问**;短信通道接好后删掉 `INTERNAL_TEST_LOGIN` 并把 `SMS_PROVIDER` 换成真实供应商。
|
||||
|
||||
### 2.4 装服务单元
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.config/systemd/user
|
||||
cp ~/app/dadaotask/deploy/prod/dadaotask-api.service ~/.config/systemd/user/
|
||||
systemctl --user daemon-reload
|
||||
```
|
||||
|
||||
### 2.5 首次上线
|
||||
|
||||
```bash
|
||||
bash ~/app/dadaotask/deploy/prod/deploy.sh
|
||||
```
|
||||
|
||||
## 3. 日常发布(每次上线)
|
||||
|
||||
```bash
|
||||
# 本地:提交并 push 到 main(生产只跑仓库 HEAD)
|
||||
# 服务器:
|
||||
bash ~/app/dadaotask/deploy/prod/deploy.sh
|
||||
```
|
||||
|
||||
脚本做六件事,任何一步失败即中止:代码对齐 `origin/main`(`git reset --hard`,禁止服务器手工改)→ 装 Python 依赖 → `alembic upgrade head`(用迁移角色)→ **在服务器上** `npm ci && npm run build` → 写 `env.build` 并重启 → 校验 `/health` 与 `/version` 的 commit 等于 HEAD。
|
||||
|
||||
前端产物只在服务器上构建,**不允许**本地上传 dist(AGENTS.md 铁律 3)。
|
||||
|
||||
## 4. 部署后校验
|
||||
|
||||
```bash
|
||||
curl -s http://192.168.8.188:8200/health # {"status":"ok","db":true,...}
|
||||
curl -s http://192.168.8.188:8200/version # commit 必须等于 git rev-parse --short HEAD
|
||||
curl -sI http://192.168.8.188:8200/ | head -1 # 200,text/html(前端产物)
|
||||
```
|
||||
|
||||
浏览器验收:打开 `http://192.168.8.188:8200` → 手机号任意 + 验证码 `123456` 登录 → 任务/工单/知识各点一遍。
|
||||
|
||||
## 5. 运维
|
||||
|
||||
```bash
|
||||
systemctl --user status dadaotask-api # 状态
|
||||
systemctl --user restart dadaotask-api # 重启
|
||||
journalctl --user -u dadaotask-api -f # 日志(gunicorn 输出到 stdout)
|
||||
```
|
||||
|
||||
- 附件落盘:`backend/var/uploads`(按工作区分子目录),删库不删文件,备份时一起打包
|
||||
- 回滚:`cd ~/app/dadaotask && git reset --hard <旧 commit> && bash deploy/prod/deploy.sh`;**数据库迁移不回滚**,回滚代码前确认旧代码兼容新表结构
|
||||
- 端口:`8200`(systemd 单元与 `deploy.sh` 的 `PORT` 默认值,两处要一致)
|
||||
|
||||
## 6. 已知限制(当前阶段)
|
||||
|
||||
| 限制 | 影响 | 解除方式 |
|
||||
|---|---|---|
|
||||
| 没有 HTTPS / 域名 | 只能内网 IP 访问,令牌明文传输 | 域名 + nginx + 证书(需 root) |
|
||||
| 内测固定验证码 | 谁都能用任意手机号登录 | 接短信通道,关 `INTERNAL_TEST_LOGIN` |
|
||||
| 无 pgvector | 语义检索走 `real[]` 全表扫描,数据量大了会慢 | 上独立 pgvector 实例(`deploy/docker-compose.vector.yml`) |
|
||||
| 上传目录在本机磁盘 | 机器坏了附件就没了 | 备份 `backend/var/uploads`,或将来到对象存储 |
|
||||
| 共享 PG / Redis | 别人占满连接或内存会波及本站 | 独立实例 |
|
||||
@@ -189,8 +189,8 @@
|
||||
- [ ] 下拉刷新、只读缓存(Room)未做
|
||||
|
||||
### 1.6 通知与提醒(0.8d)
|
||||
- [ ] 站内通知表与未读角标 —— **主动推迟到 M2**:通知的绝大多数触发源是工单(被指派、被催、验收),
|
||||
M1 只有自己的任务,做出来也没人收到东西。表随 M2 的工单一起建,避免建两次。
|
||||
- [x] 站内通知表与未读角标 —— 随 M2 一起落地,七类触发(指派、改状态、评论、工单全流程);
|
||||
未读角标 30 秒轮询,标已读立刻刷新。原计划"推迟到 M2",M2 已兑现。
|
||||
- [ ] 每日摘要邮件(同上推迟)
|
||||
|
||||
### 1.7 测试与验收(0.7d)
|
||||
@@ -230,6 +230,8 @@
|
||||
- [x] `POST /items/{id}/promote`、`/ticket/{accept,start,submit,verify,reassign,cancel}`
|
||||
- [x] `GET /tickets?role=assigned|requested|all&status=`
|
||||
- [x] `GET|POST /items/{id}/links`、`DELETE /items/{id}/links/{link_id}`
|
||||
- [x] `POST /tickets` 手工开单:不经过任务直接指定人,初始 `open`,与升级单共用同一条 item
|
||||
- [x] 指派前校验"在册成员"(`_ensure_member`),否则任务会挂在一个没人看得到的名字上
|
||||
- [x] `GET /notifications`、`/unread-count`、`/read`、`/read-all`
|
||||
- [x] 抽出 `app/serializers.py`:任务与工单共用一份序列化,**根除"工单响应缺 tags 导致前端白屏"**
|
||||
|
||||
@@ -238,17 +240,27 @@
|
||||
- [x] 详情抽屉:按角色显示受理、开工、提交、验收、驳回、改派、取消;工单态顶部显示需求人/办理人/退回次数
|
||||
- [x] 任务详情底部"升级成工单"面板(从成员里选人,成功后提示"这条任务现在处于被阻塞")
|
||||
- [x] 导航加未读角标(30 秒对齐一次,操作后立刻刷新)
|
||||
- [x] 通知页 `/w/:wsId/notifications`:未读 / 全部两档,点开即标已读并跳到目标;深链 `?open=` 落地即展开那一条
|
||||
- [x] 工单页"新建"面板:手工开单(选人、优先级、说明),成功切到"我发起的"并展开
|
||||
- [x] 登录支持 `?next=` 回跳,邀请链接不再"登录完掉回首页"
|
||||
|
||||
### 2.5 安卓(1.5d)
|
||||
- [ ] 工单列表与动作 —— 只做到任务列表;本机无 JDK/Gradle,仍是**未编译**状态
|
||||
- [x] 工单列表与动作 —— 仅按"我是需求人 / 办理人 / 管理员"显隐动作按钮,不再人人可见
|
||||
- [x] 通知页与手工开单:五个 tab、未读数 30 秒刷新、通知点开跳列表并清标记
|
||||
- [ ] 本次未编译验证:本机 JDK 已升级到 25,Gradle 8.7 / AGP 8.7.3 不兼容,需装回 JDK 17 再 `assembleDebug`
|
||||
|
||||
### 2.6 测试与验收(0.7d)
|
||||
- [x] `backend/tests/test_tickets.py` 11 个用例:升级建单+原任务阻塞+通知、全流程验收回流、驳回、
|
||||
自验 403、非法流转、重复升级、非成员指派、角色作用域、跨租户、关系挂载、通知已读、**响应必带 tags**
|
||||
- [x] 全量 `pytest -q` → **57 passed**;`ruff check` / `format --check` 全 0
|
||||
- [x] `backend/tests/test_notifications.py` 6 个用例:改状态只通知对方(自己不动自己)、指派必须是成员、
|
||||
手工单全流程(开出→被指派人收到→受理/提交→验收,且没有来源任务)、越界指派 400/1001、
|
||||
评论通知对方、未读数与全部已读
|
||||
- [x] 全量 `pytest -q` → **85 passed**;`ruff check` / `format --check` 全 0
|
||||
- [x] `web/e2e/tickets.mjs` 双浏览器上下文双用户 e2e,8 项断言全绿:
|
||||
升级 → B 受理/开工/提交 → A 验收通过 → 原任务回流"待办" → 两端控制台零报错
|
||||
- [x] `web/e2e/notifications.mjs` 12 项断言全绿:A 建工作区邀 B → B 自动加入 → A 手工开单 →
|
||||
这张单没有来源任务 → B 收到"派给你的工单" → 通知列表有条目 → 点通知跳到工单并展开 →
|
||||
未读数归零 → 两端控制台零报错
|
||||
- [x] `web/e2e/smoke.mjs` 回归复跑(抽屉多了"选人"下拉后修正选择器)7 项全绿
|
||||
|
||||
### 2.7 通知真实投递
|
||||
@@ -272,21 +284,36 @@
|
||||
| 3.5 | 手动沉淀:任务/工单详情里"沉淀成知识" | 0.5 | [x] 有 e2e |
|
||||
| 3.6 | 手记:没有来源的知识(唯一允许 `source_kind=manual` 的入口) | 0.5 | [x] 手记页 + 列表 |
|
||||
| 3.7 | 检索:标题/正文/摘要子串 + 类型/状态筛选 + 游标分页 | 1.0 | [x] trigram 索引在 0002 已建 |
|
||||
| 3.8 | 切片与向量索引(`chunk` 表、pgvector HNSW) | 2.0 | [ ] 见下方说明 |
|
||||
| 3.9 | 安卓知识列表 | 1.0 | [ ] 与工单一起延后到编译链就绪 |
|
||||
| 3.10 | 文档与 e2e | 1.0 | [x] `web/e2e/knowledge.mjs` 7 项断言绿 |
|
||||
| 3.8 | 切片与向量索引(`chunk` 表、pgvector HNSW) | 2.0 | [x] **用 `real[]` + SQL 余弦先跑通**(见下方说明) |
|
||||
| 3.8.1 | 切片:按标题归段、代码块整块保留、超长按句切、相邻片重叠 80 字 | 0.5 | [x] `app/domain/chunking.py` |
|
||||
| 3.8.2 | 向量:`hash`(字符二元组)与 `openai` 两种提供方,换实现不动业务 | 0.5 | [x] `app/services/embedding.py` |
|
||||
| 3.8.3 | 混合检索:向量 0.6 + trigram 0.4,低于阈值不返回,只认 `published` | 1.0 | [x] 5 个用例 + `e2e/retrieval.mjs` 6 项 |
|
||||
| 3.8.4 | 索引生命周期:发布自动建、改正文需 `reindex`、换模型整体重建 | 0.5 | [x] `POST /knowledge/{id}/reindex` |
|
||||
| 3.9 | 安卓知识列表 | 1.0 | [x] 只读列表 + 详情动作(编辑正文走安卓编辑器) |
|
||||
| 3.10 | 文档与 e2e | 1.0 | [x] `knowledge.mjs` 7 项 + `retrieval.mjs` 6 项 |
|
||||
| 3.11 | 知识分类:工作区三级小树(`knowledge_category` + 迁移 0007 + RLS),知识可归一类,列表按分类子树 / 未分类 / 标签筛选 | 1.0 | [x] 增删改移 + 重名 `7008`、超三级 `7007`、删分类要 `force` |
|
||||
| 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) |
|
||||
|
||||
### M3 完成定义(DOD)
|
||||
- [x] 一件事办完 → 自动出现一份草稿 → 核对 → 发布,全程不用手动复制粘贴
|
||||
- [x] 每条知识都能追到来源(除手记),点开能看到来源事项
|
||||
- [x] 非管理员发布被拒;跨租户看不到别人的知识
|
||||
- [ ] 向量切片与语义检索(需要独立 pgvector 实例真正跑起来)
|
||||
- [x] 知识是备忘录:标题、正文、标签、三级分类、搜索;新建时就能把这些一次填完(Web 与安卓一致)
|
||||
- [x] 切片与语义检索跑通(`real[]` + SQL 余弦;pgvector 仍待独立实例)
|
||||
- [ ] 真实用两周:新同事靠知识库自己解决过一次问题(需要真实团队)
|
||||
|
||||
**关于 3.8 的说明**:本机 Docker 起不来、fnsvr-01 上也没有 pgvector 扩展,
|
||||
所以 `deploy/docker-compose.vector.yml` 与 `VECTOR_DATABASE_URL` 只有配置,**没有跑过**。
|
||||
按"写完不算完成"的规矩,它在真跑起来之前保持未勾。切片表设计要点已写进
|
||||
`2.Design/PRODUCT-PLAN.md` 的 D8,落地时不要改 `EMBEDDING_DIM`(改了要重算全量)。
|
||||
**关于 3.8 的说明(2026-10-29 已落地,但与计划不同)**:计划里写的是 pgvector + HNSW,
|
||||
实际本机 Docker 起不来、fnsvr-01 上也没有 pgvector 扩展且用户明确要求别动那台机器,
|
||||
所以先用 `chunk` 表(`embedding real[]`)+ SQL 函数 `app_cosine()` 把整条链路跑通:
|
||||
切片 → 向量(内置 hash 提供方)→ 混合打分(向量 0.6 + trigram 0.4)→ 阈值过滤 → 带来源返回。
|
||||
`deploy/docker-compose.vector.yml` 与 `VECTOR_DATABASE_URL` 保留,等向量实例真起来之后再切过去,
|
||||
换 pgvector 只改列类型与索引,应用层接口不变。
|
||||
|
||||
两条必须记住的约束:
|
||||
1. **内置 hash 向量不是语义模型**,只是"零依赖、可复现"的基线;上线前换 `EMBEDDING_PROVIDER=openai`
|
||||
并整体重建(`reindex_workspace`),否则新旧向量混表、分数不可比。
|
||||
2. **检索只认 `published`**,草稿永远搜不到——这条写在 SQL 的 WHERE 里,不靠调用方自觉。
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 223 KiB |
|
After Width: | Height: | Size: 155 KiB |
|
After Width: | Height: | Size: 168 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 71 KiB |
@@ -6,6 +6,7 @@
|
||||
- M0–M1 实施清单(含真实进度):[`2.Design/ROADMAP-M0-M1.md`](2.Design/ROADMAP-M0-M1.md)
|
||||
- 接口约定(已实现部分):[`2.Design/API.md`](2.Design/API.md)
|
||||
- 本地开发:[`2.Design/DEVELOPMENT.md`](2.Design/DEVELOPMENT.md)
|
||||
- 生产部署(openclaw-01):[`2.Design/DEPLOY.md`](2.Design/DEPLOY.md)
|
||||
- 中文命名方案:[`2.Design/NAMING.md`](2.Design/NAMING.md)
|
||||
|
||||
## 技术栈
|
||||
@@ -29,8 +30,8 @@
|
||||
| 知识:工单验收自动生成草稿、核对后发布、来源可追、手记 | ✅ 后端 + Web + 安卓已通,e2e 7 项全绿 |
|
||||
| 界面:两端统一设计系统(纸白 / 墨绿 / 陶土橙,语义色、字号阶、组件库) | ✅ 已重做,见 [`2.Design/ROADMAP-M0-M1.md`](2.Design/ROADMAP-M0-M1.md) 第 4 节 |
|
||||
| 富文本:备注 / 工单说明 / 知识正文支持图文混排与附件 | ✅ Web 用 Vditor 编辑、安卓用 Markwon 预览编辑(e2e 15 项全绿);接口见 [`2.Design/API.md`](2.Design/API.md) 第 6 节 |
|
||||
| 后端测试 | ✅ 74 passed,ruff 干净 |
|
||||
| 知识切片与语义检索(pgvector) | ⬜ 配置已就绪(`deploy/docker-compose.vector.yml`),本机 Docker 起不来,未跑过 |
|
||||
| 知识切片与语义检索(混合打分、只搜已发布、带来源) | ✅ 已跑通:`real[]` + SQL 余弦(pgvector 待独立实例),`pytest` 5 个用例 + `e2e/retrieval.mjs` 6 项 |
|
||||
| 后端测试 | ✅ 79 passed,ruff 干净 |
|
||||
| LLM 能力(结构化录入、问答带引用、人物画像) | ⬜ M4 |
|
||||
|
||||
## 快速开始
|
||||
|
||||
@@ -27,6 +27,7 @@ import com.dadao.task.ui.HomeScreen
|
||||
import com.dadao.task.ui.KnowledgeScreen
|
||||
import com.dadao.task.ui.LoginScreen
|
||||
import com.dadao.task.ui.MembersScreen
|
||||
import com.dadao.task.ui.NotificationsScreen
|
||||
import com.dadao.task.ui.RichTextEditorScreen
|
||||
import com.dadao.task.ui.TaskListScreen
|
||||
import com.dadao.task.ui.TicketsScreen
|
||||
@@ -71,8 +72,14 @@ private fun AppRoot(joinUri: String?) {
|
||||
var current by remember { mutableStateOf<Workspace?>(null) }
|
||||
var tab by remember { mutableStateOf(WorkspaceTab.Tasks) }
|
||||
var unread by remember { mutableStateOf(0) }
|
||||
// 通知页标了已读之后要立刻刷新角标:改这个数会触发下面的 LaunchedEffect
|
||||
var unreadRefresh by remember { mutableStateOf(0) }
|
||||
// 打开富文本编辑器时非空;关掉就恢复原来的界面
|
||||
var editing by remember { mutableStateOf<EditRequest?>(null) }
|
||||
// 编辑器保存成功的那条:回到工作区后重新打开它的详情,然后清掉
|
||||
var reopenItem by remember { mutableStateOf<String?>(null) }
|
||||
// 上面那条是任务还是知识:决定回哪个页面去重开
|
||||
var reopenKnowledge by remember { mutableStateOf(false) }
|
||||
|
||||
// 有本地令牌就先静默校验一次:令牌过期/被吊销要退回登录页,而不是进了主页再崩。
|
||||
LaunchedEffect(screen) {
|
||||
@@ -101,8 +108,8 @@ private fun AppRoot(joinUri: String?) {
|
||||
.onFailure { pendingJoin = null }
|
||||
}
|
||||
|
||||
// 工单未读数:进工作区就拉一次,切页面时再刷。
|
||||
LaunchedEffect(current?.id, tab) {
|
||||
// 未读数:进工作区、切页面、或通知页标了已读(unreadRefresh)时刷新
|
||||
LaunchedEffect(current?.id, tab, unreadRefresh) {
|
||||
val ws = current ?: return@LaunchedEffect
|
||||
unread = api.unreadCount(ws.id)
|
||||
}
|
||||
@@ -124,64 +131,88 @@ private fun AppRoot(joinUri: String?) {
|
||||
)
|
||||
Screen.Workspace -> {
|
||||
val ws = current
|
||||
val request = editing
|
||||
if (ws == null) {
|
||||
screen = Screen.Home
|
||||
} else {
|
||||
Box {
|
||||
WorkspaceScaffold(
|
||||
workspace = ws,
|
||||
tab = tab,
|
||||
unread = unread,
|
||||
onTab = { tab = it },
|
||||
onBackHome = { screen = Screen.Home },
|
||||
) {
|
||||
when (tab) {
|
||||
WorkspaceTab.Tasks -> TaskListScreen(
|
||||
api = api,
|
||||
workspace = ws,
|
||||
onEdit = { editing = it },
|
||||
} else if (request != null) {
|
||||
// 编辑器**替换**整页,而不是叠在上面:抽屉/对话框都是独立窗口,
|
||||
// 叠一层会被它们盖住,用户只能看到编辑器的一条边。
|
||||
RichTextEditorScreen(
|
||||
api = api,
|
||||
workspaceId = ws.id,
|
||||
itemId = request.itemId,
|
||||
initialTitle = request.title,
|
||||
initialBody = request.body,
|
||||
titleEnabled = request.titleEnabled,
|
||||
save = { title, body ->
|
||||
if (request.knowledge) {
|
||||
api.updateKnowledge(
|
||||
workspaceId = ws.id,
|
||||
itemId = request.itemId,
|
||||
version = request.version,
|
||||
title = title,
|
||||
body = body,
|
||||
)
|
||||
|
||||
WorkspaceTab.Tickets -> TicketsScreen(api = api, workspace = ws)
|
||||
WorkspaceTab.Knowledge -> KnowledgeScreen(
|
||||
api = api,
|
||||
workspace = ws,
|
||||
onEdit = { editing = it },
|
||||
} else {
|
||||
api.updateItem(
|
||||
workspaceId = ws.id,
|
||||
itemId = request.itemId,
|
||||
version = request.version,
|
||||
title = title,
|
||||
body = body,
|
||||
)
|
||||
|
||||
WorkspaceTab.Members -> MembersScreen(api = api, workspace = ws)
|
||||
}
|
||||
}
|
||||
// 保存成功才标记"回到哪条":取消退出不该改变任何界面状态。
|
||||
reopenItem = request.itemId
|
||||
reopenKnowledge = request.knowledge
|
||||
},
|
||||
// saved 参数由编辑器给:true 时上面已经设过 reopenItem,
|
||||
// 这里只负责把编辑器收起来,回到工作区后由对应页面重开详情。
|
||||
onClose = { _ -> editing = null },
|
||||
)
|
||||
} else {
|
||||
WorkspaceScaffold(
|
||||
workspace = ws,
|
||||
tab = tab,
|
||||
unread = unread,
|
||||
onTab = { tab = it },
|
||||
onBackHome = { screen = Screen.Home },
|
||||
) {
|
||||
when (tab) {
|
||||
WorkspaceTab.Tasks -> TaskListScreen(
|
||||
api = api,
|
||||
workspace = ws,
|
||||
onEdit = { editing = it },
|
||||
reopenItem = if (reopenItem != null && !reopenKnowledge) reopenItem else null,
|
||||
onReopened = { reopenItem = null },
|
||||
)
|
||||
|
||||
val request = editing
|
||||
if (request != null) {
|
||||
RichTextEditorScreen(
|
||||
WorkspaceTab.Tickets -> TicketsScreen(
|
||||
api = api,
|
||||
workspace = ws,
|
||||
focusId = reopenItem,
|
||||
onFocusHandled = { reopenItem = null },
|
||||
)
|
||||
WorkspaceTab.Knowledge -> KnowledgeScreen(
|
||||
api = api,
|
||||
workspace = ws,
|
||||
onEdit = { editing = it },
|
||||
reopenItem = if (reopenItem != null && reopenKnowledge) reopenItem else null,
|
||||
onReopened = { reopenItem = null },
|
||||
)
|
||||
|
||||
WorkspaceTab.Members -> MembersScreen(api = api, workspace = ws)
|
||||
|
||||
WorkspaceTab.Notifications -> NotificationsScreen(
|
||||
api = api,
|
||||
workspaceId = ws.id,
|
||||
itemId = request.itemId,
|
||||
initialTitle = request.title,
|
||||
initialBody = request.body,
|
||||
titleEnabled = request.titleEnabled,
|
||||
save = { title, body ->
|
||||
if (request.knowledge) {
|
||||
api.updateKnowledge(
|
||||
workspaceId = ws.id,
|
||||
itemId = request.itemId,
|
||||
version = request.version,
|
||||
title = title,
|
||||
body = body,
|
||||
)
|
||||
} else {
|
||||
api.updateItem(
|
||||
workspaceId = ws.id,
|
||||
itemId = request.itemId,
|
||||
version = request.version,
|
||||
title = title,
|
||||
body = body,
|
||||
)
|
||||
}
|
||||
onOpenItem = { itemId, isTicket ->
|
||||
// 通知点开后跳到对应列表并展开那一条;通知本身不带数据,页面自己回源
|
||||
reopenItem = itemId
|
||||
reopenKnowledge = false
|
||||
tab = if (isTicket) WorkspaceTab.Tickets else WorkspaceTab.Tasks
|
||||
},
|
||||
onClose = { editing = null },
|
||||
onUnreadChanged = { unreadRefresh += 1 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -236,6 +236,26 @@ data class SourceRef(
|
||||
val status: String = "",
|
||||
)
|
||||
|
||||
/** 知识分类:工作区级的三级小树,fullPath 形如「研发 / 部署 / 回滚」。 */
|
||||
@Serializable
|
||||
data class KnowledgeCategory(
|
||||
val id: String,
|
||||
val name: String = "",
|
||||
@SerialName("parent_id") val parentId: String? = null,
|
||||
val depth: Int = 1,
|
||||
@SerialName("sort_order") val sortOrder: Int = 0,
|
||||
val version: Int = 1,
|
||||
val path: List<String> = emptyList(),
|
||||
@SerialName("full_path") val fullPath: String = "",
|
||||
@SerialName("doc_count") val docCount: Int = 0,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class KnowledgeCategoryPage(
|
||||
val items: List<KnowledgeCategory> = emptyList(),
|
||||
@SerialName("max_depth") val maxDepth: Int = 3,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class KnowledgeDoc(
|
||||
val id: String,
|
||||
@@ -245,6 +265,7 @@ data class KnowledgeDoc(
|
||||
val version: Int = 1,
|
||||
val tags: List<Tag> = emptyList(),
|
||||
val knowledge: KnowledgeInfo = KnowledgeInfo(),
|
||||
val category: KnowledgeCategory? = null,
|
||||
val source: SourceRef? = null,
|
||||
val attachments: List<Attachment> = emptyList(),
|
||||
)
|
||||
@@ -258,6 +279,25 @@ data class KnowledgePage(
|
||||
|
||||
// ---- 请求体。每个都要 @Serializable,否则 encodeToString 找不到序列化器 ----
|
||||
|
||||
@Serializable
|
||||
data class AppNotification(
|
||||
val id: String,
|
||||
val kind: String = "",
|
||||
@SerialName("item_id") val itemId: String? = null,
|
||||
@SerialName("item_title") val itemTitle: String? = null,
|
||||
val title: String = "",
|
||||
val body: String = "",
|
||||
@SerialName("read_at") val readAt: String? = null,
|
||||
@SerialName("created_at") val createdAt: String = "",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class NotificationPage(
|
||||
val notifications: List<AppNotification> = emptyList(),
|
||||
@SerialName("next_cursor") val nextCursor: String? = null,
|
||||
@SerialName("has_more") val hasMore: Boolean = false,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class UnreadCount(@SerialName("unread") val unread: Int = 0)
|
||||
|
||||
@@ -306,11 +346,22 @@ private data class KnowledgeCreateRequest(
|
||||
val title: String,
|
||||
@SerialName("body_md") val bodyMd: String,
|
||||
@SerialName("doc_type") val docType: String,
|
||||
@SerialName("tag_names") val tagNames: List<String> = emptyList(),
|
||||
@SerialName("category_id") val categoryId: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class KnowledgeActionRequest(val action: String)
|
||||
|
||||
@Serializable
|
||||
private data class TicketCreateRequest(
|
||||
val title: String,
|
||||
@SerialName("body_md") val bodyMd: String? = null,
|
||||
@SerialName("assignee_id") val assigneeId: String,
|
||||
val priority: Int = 1,
|
||||
@SerialName("due_at") val dueAt: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class ItemUpdateRequest(
|
||||
val version: Int,
|
||||
@@ -323,6 +374,10 @@ private data class KnowledgeUpdateRequest(
|
||||
val version: Int,
|
||||
val title: String? = null,
|
||||
@SerialName("body_md") val bodyMd: String? = null,
|
||||
@SerialName("tag_names") val tagNames: List<String>? = null,
|
||||
@SerialName("category_id") val categoryId: String? = null,
|
||||
// 想把分类清成"未分类"就传 clear_category=true:只传 null 会被服务端当成"不改"
|
||||
@SerialName("clear_category") val clearCategory: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -427,6 +482,48 @@ class Api(private val tokens: TokenStore) {
|
||||
auth = true,
|
||||
)
|
||||
|
||||
// ---- M4 通知 ----
|
||||
|
||||
/** 通知默认只看未读;"全部"是查历史用的。 */
|
||||
suspend fun notifications(
|
||||
workspaceId: String,
|
||||
unreadOnly: Boolean = true,
|
||||
): NotificationPage = call(
|
||||
path = "/workspaces/$workspaceId/notifications?unread_only=$unreadOnly&limit=50",
|
||||
auth = true,
|
||||
)
|
||||
|
||||
// OkHttp 的 POST 必须带 body,这里发个空对象,服务端不需要入参
|
||||
suspend fun markNotificationRead(workspaceId: String, id: String): JsonElement = call(
|
||||
path = "/workspaces/$workspaceId/notifications/$id/read",
|
||||
body = "{}",
|
||||
auth = true,
|
||||
)
|
||||
|
||||
suspend fun markAllNotificationsRead(workspaceId: String): JsonElement = call(
|
||||
path = "/workspaces/$workspaceId/notifications/read-all",
|
||||
body = "{}",
|
||||
auth = true,
|
||||
)
|
||||
|
||||
// ---- M2 工单(手工开单)----
|
||||
|
||||
/** 不经过任务,直接开一张工单交给别人。 */
|
||||
suspend fun createTicket(
|
||||
workspaceId: String,
|
||||
title: String,
|
||||
assigneeId: String,
|
||||
bodyMd: String? = null,
|
||||
priority: Int = 1,
|
||||
dueAt: String? = null,
|
||||
): Ticket = call(
|
||||
path = "/workspaces/$workspaceId/tickets",
|
||||
body = json.encodeToString(
|
||||
TicketCreateRequest(title, bodyMd?.ifBlank { null }, assigneeId, priority, dueAt),
|
||||
),
|
||||
auth = true,
|
||||
)
|
||||
|
||||
/** 未读工单通知数,导航角标用。失败就当 0,不要因为角标让整页报错。 */
|
||||
suspend fun unreadCount(workspaceId: String): Int = runCatching {
|
||||
call<UnreadCount>(path = "/workspaces/$workspaceId/notifications/unread-count", auth = true).unread
|
||||
@@ -596,20 +693,39 @@ class Api(private val tokens: TokenStore) {
|
||||
workspaceId: String,
|
||||
statuses: List<String> = listOf("draft", "in_review"),
|
||||
query: String? = null,
|
||||
categoryId: String? = null,
|
||||
uncategorized: Boolean = false,
|
||||
): KnowledgePage {
|
||||
val params = buildString {
|
||||
append("?limit=30")
|
||||
statuses.forEach { append("&status=").append(it) }
|
||||
if (!query.isNullOrBlank()) append("&q=").append(java.net.URLEncoder.encode(query, "UTF-8"))
|
||||
if (!query.isNullOrBlank()) {
|
||||
append("&q=").append(java.net.URLEncoder.encode(query, "UTF-8"))
|
||||
}
|
||||
if (categoryId != null) append("&category_id=").append(categoryId)
|
||||
if (uncategorized) append("&uncategorized=true")
|
||||
}
|
||||
return call(path = "/workspaces/$workspaceId/knowledge$params", auth = true)
|
||||
}
|
||||
|
||||
/** 工作区三级分类小树(扁平返回,depth 表示层级)。 */
|
||||
suspend fun knowledgeCategories(workspaceId: String): KnowledgeCategoryPage =
|
||||
call(path = "/workspaces/$workspaceId/knowledge/categories", auth = true)
|
||||
|
||||
/** 手记:没有来源的知识,得自己写清楚。 */
|
||||
suspend fun createKnowledge(workspaceId: String, title: String, body: String = ""): KnowledgeDoc =
|
||||
suspend fun createKnowledge(
|
||||
workspaceId: String,
|
||||
title: String,
|
||||
body: String = "",
|
||||
docType: String = "note",
|
||||
tags: List<String> = emptyList(),
|
||||
categoryId: String? = null,
|
||||
): KnowledgeDoc =
|
||||
call(
|
||||
path = "/workspaces/$workspaceId/knowledge",
|
||||
body = json.encodeToString(KnowledgeCreateRequest(title, body, "note")),
|
||||
body = json.encodeToString(
|
||||
KnowledgeCreateRequest(title, body, docType, tags, categoryId),
|
||||
),
|
||||
auth = true,
|
||||
)
|
||||
|
||||
@@ -639,9 +755,14 @@ class Api(private val tokens: TokenStore) {
|
||||
version: Int,
|
||||
title: String? = null,
|
||||
body: String? = null,
|
||||
tags: List<String>? = null,
|
||||
categoryId: String? = null,
|
||||
clearCategory: Boolean = false,
|
||||
): KnowledgeDoc = call(
|
||||
path = "/workspaces/$workspaceId/knowledge/$itemId",
|
||||
body = json.encodeToString(KnowledgeUpdateRequest(version, title, body)),
|
||||
body = json.encodeToString(
|
||||
KnowledgeUpdateRequest(version, title, body, tags, categoryId, clearCategory),
|
||||
),
|
||||
auth = true,
|
||||
method = "PATCH",
|
||||
)
|
||||
|
||||
@@ -29,11 +29,12 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.dadao.task.data.Workspace
|
||||
|
||||
/** 手机端的五个去处。看板不是独立入口,而是任务页顶部的一个切换(屏幕太窄,塞不下六个 tab)。 */
|
||||
/** 手机端的去处。看板不是独立入口,而是任务页顶部的一个切换(屏幕太窄,塞不下更多 tab)。 */
|
||||
enum class WorkspaceTab(val label: String) {
|
||||
Tasks("任务"),
|
||||
Tickets("工单"),
|
||||
Knowledge("知识"),
|
||||
Notifications("通知"),
|
||||
Members("成员"),
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
package com.dadao.task.ui
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.provider.OpenableColumns
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
@@ -31,13 +40,20 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
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.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import com.dadao.task.data.Api
|
||||
import com.dadao.task.data.ApiException
|
||||
import com.dadao.task.data.Attachment
|
||||
import com.dadao.task.data.KnowledgeCategory
|
||||
import com.dadao.task.data.KnowledgeDoc
|
||||
import com.dadao.task.data.Workspace
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
private val knowledgeTabs = listOf(
|
||||
"draft" to "待核对",
|
||||
@@ -46,25 +62,51 @@ private val knowledgeTabs = listOf(
|
||||
"all" to "全部",
|
||||
)
|
||||
|
||||
private fun docTypeLabel(type: String): String = when (type) {
|
||||
"howto" -> "怎么做"
|
||||
"faq" -> "问答"
|
||||
"postmortem" -> "复盘"
|
||||
"decision" -> "决策"
|
||||
else -> "笔记"
|
||||
}
|
||||
private val docTypes = listOf(
|
||||
"note" to "笔记",
|
||||
"howto" to "怎么做",
|
||||
"faq" to "问答",
|
||||
"postmortem" to "复盘",
|
||||
"decision" to "决策",
|
||||
)
|
||||
|
||||
private fun docTypeLabel(type: String): String =
|
||||
docTypes.firstOrNull { it.first == type }?.second ?: "笔记"
|
||||
|
||||
private fun parseTags(raw: String): List<String> =
|
||||
raw.split(' ', '\n', ',', ',', '、')
|
||||
.map { it.trim().removePrefix("#") }
|
||||
.filter { it.isNotBlank() }
|
||||
.distinct()
|
||||
|
||||
/** 分类在筛选条里的显示名:用点前缀表达层级,手机上比缩进更稳。 */
|
||||
private fun catLabel(cat: KnowledgeCategory): String =
|
||||
"·".repeat((cat.depth - 1).coerceAtLeast(0)) + cat.name
|
||||
|
||||
@Composable
|
||||
fun KnowledgeScreen(api: Api, workspace: Workspace, onEdit: (EditRequest) -> Unit = {}) {
|
||||
fun KnowledgeScreen(
|
||||
api: Api,
|
||||
workspace: Workspace,
|
||||
onEdit: (EditRequest) -> Unit = {},
|
||||
/** 编辑器保存后要重新打开的那条。父层持有:编辑器打开时本页会被整体卸载。 */
|
||||
reopenItem: String? = null,
|
||||
onReopened: () -> Unit = {},
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
var tab by remember { mutableStateOf("draft") }
|
||||
var docs by remember { mutableStateOf<List<KnowledgeDoc>>(emptyList()) }
|
||||
var categories by remember { mutableStateOf<List<KnowledgeCategory>>(emptyList()) }
|
||||
var loading by remember { mutableStateOf(true) }
|
||||
var message by remember { mutableStateOf<String?>(null) }
|
||||
var open by remember { mutableStateOf<KnowledgeDoc?>(null) }
|
||||
var adding by remember { mutableStateOf(false) }
|
||||
var newTitle by remember { mutableStateOf("") }
|
||||
var newBody by remember { mutableStateOf("") }
|
||||
// null = 全部;"none" = 未分类;其它是分类 id(服务端含子分类)
|
||||
var catFilter by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
suspend fun loadCategories() {
|
||||
runCatching { api.knowledgeCategories(workspace.id) }
|
||||
.onSuccess { categories = it.items }
|
||||
}
|
||||
|
||||
suspend fun reload() {
|
||||
loading = true
|
||||
@@ -74,7 +116,15 @@ fun KnowledgeScreen(api: Api, workspace: Workspace, onEdit: (EditRequest) -> Uni
|
||||
"all" -> listOf("draft", "in_review", "published", "archived")
|
||||
else -> listOf("draft", "in_review")
|
||||
}
|
||||
runCatching { api.knowledge(workspace.id, statuses) }
|
||||
val filter = catFilter
|
||||
runCatching {
|
||||
api.knowledge(
|
||||
workspace.id,
|
||||
statuses,
|
||||
categoryId = if (filter != null && filter != "none") filter else null,
|
||||
uncategorized = filter == "none",
|
||||
)
|
||||
}
|
||||
.onSuccess {
|
||||
docs = it.items
|
||||
message = null
|
||||
@@ -83,7 +133,16 @@ fun KnowledgeScreen(api: Api, workspace: Workspace, onEdit: (EditRequest) -> Uni
|
||||
loading = false
|
||||
}
|
||||
|
||||
LaunchedEffect(tab) { reload() }
|
||||
LaunchedEffect(tab, catFilter) { reload() }
|
||||
LaunchedEffect(Unit) { loadCategories() }
|
||||
|
||||
// 从编辑器保存回来:本页是新建出来的,刷完列表再把那条详情重新打开。
|
||||
LaunchedEffect(reopenItem) {
|
||||
val target = reopenItem ?: return@LaunchedEffect
|
||||
reload()
|
||||
open = docs.firstOrNull { it.id == target }
|
||||
onReopened()
|
||||
}
|
||||
|
||||
fun fail(e: Throwable) {
|
||||
val code = (e as? ApiException)?.code ?: 0
|
||||
@@ -110,11 +169,30 @@ fun KnowledgeScreen(api: Api, workspace: Workspace, onEdit: (EditRequest) -> Uni
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(10.dp))
|
||||
GhostButton("记一条", onClick = {
|
||||
adding = true
|
||||
newTitle = ""
|
||||
newBody = ""
|
||||
})
|
||||
Row(
|
||||
modifier = Modifier.horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
SurfaceChip(
|
||||
text = "全部分类",
|
||||
selected = catFilter == null,
|
||||
onClick = { catFilter = null },
|
||||
)
|
||||
SurfaceChip(
|
||||
text = "未分类",
|
||||
selected = catFilter == "none",
|
||||
onClick = { catFilter = "none" },
|
||||
)
|
||||
categories.forEach { cat ->
|
||||
SurfaceChip(
|
||||
text = catLabel(cat),
|
||||
selected = catFilter == cat.id,
|
||||
onClick = { catFilter = cat.id },
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(10.dp))
|
||||
GhostButton("记一条", onClick = { adding = true })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,7 +204,7 @@ fun KnowledgeScreen(api: Api, workspace: Workspace, onEdit: (EditRequest) -> Uni
|
||||
if (!loading && docs.isEmpty()) {
|
||||
item {
|
||||
EmptyState(
|
||||
"还没有待核对的知识",
|
||||
if (catFilter == null) "还没有待核对的知识" else "这个分类下还没有知识",
|
||||
"把工单验收通过,或在任务详情里点「沉淀成知识」",
|
||||
)
|
||||
}
|
||||
@@ -150,6 +228,18 @@ fun KnowledgeScreen(api: Api, workspace: Workspace, onEdit: (EditRequest) -> Uni
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
val meta = buildList {
|
||||
doc.category?.let { add(it.fullPath.ifBlank { it.name }) }
|
||||
doc.tags.forEach { add("#${it.name}") }
|
||||
}
|
||||
if (meta.isNotEmpty()) {
|
||||
Text(
|
||||
meta.joinToString(" "),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = Primary,
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
}
|
||||
Text(
|
||||
sourceLine(doc),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
@@ -167,68 +257,44 @@ fun KnowledgeScreen(api: Api, workspace: Workspace, onEdit: (EditRequest) -> Uni
|
||||
api = api,
|
||||
workspace = workspace,
|
||||
doc = target,
|
||||
categories = categories,
|
||||
onClose = { open = null },
|
||||
onEdit = onEdit,
|
||||
onChanged = {
|
||||
scope.launch { reload() }
|
||||
scope.launch {
|
||||
reload()
|
||||
loadCategories()
|
||||
}
|
||||
},
|
||||
onCategoriesChanged = { scope.launch { loadCategories() } },
|
||||
onFail = { fail(it) },
|
||||
)
|
||||
}
|
||||
|
||||
if (adding) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { adding = false },
|
||||
title = { Text("记一条", style = MaterialTheme.typography.titleMedium) },
|
||||
text = {
|
||||
Column {
|
||||
Text(
|
||||
"手记没有来源,想沉淀某件具体的事,去那条任务的详情里点「沉淀成知识」。",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = InkMuted,
|
||||
)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
OutlinedTextField(
|
||||
value = newTitle,
|
||||
onValueChange = { newTitle = it },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
placeholder = { Text("一句话说清这条知识是什么", color = InkMuted) },
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
colors = TextFieldDefaults.colors(
|
||||
focusedContainerColor = Color.White,
|
||||
unfocusedContainerColor = Color.White,
|
||||
focusedIndicatorColor = Primary,
|
||||
unfocusedIndicatorColor = Line,
|
||||
),
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
OutlinedTextField(
|
||||
value = newBody,
|
||||
onValueChange = { newBody = it },
|
||||
modifier = Modifier.fillMaxWidth().heightIn(min = 90.dp),
|
||||
placeholder = { Text("正文(可空,之后再补)", color = InkMuted) },
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
colors = TextFieldDefaults.colors(
|
||||
focusedContainerColor = Color.White,
|
||||
unfocusedContainerColor = Color.White,
|
||||
focusedIndicatorColor = Primary,
|
||||
unfocusedIndicatorColor = Line,
|
||||
KnowledgeComposerDialog(
|
||||
api = api,
|
||||
workspace = workspace,
|
||||
categories = categories,
|
||||
onDismiss = { adding = false },
|
||||
onCreated = { created ->
|
||||
adding = false
|
||||
scope.launch {
|
||||
loadCategories()
|
||||
reload()
|
||||
// 正文交给富文本编辑器写:先落一条草稿,再直接进去写,不让用户对着纯文本框凑排版
|
||||
onEdit(
|
||||
EditRequest(
|
||||
itemId = created.id,
|
||||
title = created.title,
|
||||
body = created.bodyMd,
|
||||
version = created.version,
|
||||
knowledge = true,
|
||||
),
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
scope.launch {
|
||||
adding = false
|
||||
runCatching { api.createKnowledge(workspace.id, newTitle.trim(), newBody) }
|
||||
.onSuccess { reload() }
|
||||
.onFailure { fail(it) }
|
||||
}
|
||||
}) { Text("保存草稿") }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = { adding = false }) { Text("取消") } },
|
||||
containerColor = Paper,
|
||||
onFail = { fail(it) },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -242,18 +308,208 @@ private fun sourceLine(doc: KnowledgeDoc): String {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 新建知识:手机上也要能一次写全(标题/类型/分类/标签/正文)。
|
||||
* 用整屏 94% 宽的 Dialog 而不是 AlertDialog —— AlertDialog 的宽度上限装不下一篇备忘录。
|
||||
*/
|
||||
@Composable
|
||||
private fun KnowledgeComposerDialog(
|
||||
api: Api,
|
||||
workspace: Workspace,
|
||||
categories: List<KnowledgeCategory>,
|
||||
onDismiss: () -> Unit,
|
||||
onCreated: (KnowledgeDoc) -> Unit,
|
||||
onFail: (Throwable) -> Unit,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
var title by remember { mutableStateOf("") }
|
||||
var docType by remember { mutableStateOf("note") }
|
||||
var categoryId by remember { mutableStateOf<String?>(null) }
|
||||
var tags by remember { mutableStateOf("") }
|
||||
var saving by remember { mutableStateOf(false) }
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = onDismiss,
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth(0.94f)
|
||||
.fillMaxHeight(0.88f)
|
||||
.background(Paper, RoundedCornerShape(16.dp))
|
||||
.padding(16.dp),
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
"记一条知识",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
GhostButton("取消", onClick = onDismiss)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
PrimaryButton(
|
||||
"保存",
|
||||
enabled = !saving && title.isNotBlank(),
|
||||
onClick = {
|
||||
scope.launch {
|
||||
saving = true
|
||||
runCatching {
|
||||
api.createKnowledge(
|
||||
workspace.id,
|
||||
title.trim(),
|
||||
"",
|
||||
docType,
|
||||
parseTags(tags),
|
||||
categoryId,
|
||||
)
|
||||
}
|
||||
.onSuccess { onCreated(it) }
|
||||
.onFailure { onFail(it) }
|
||||
saving = false
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Column(
|
||||
Modifier.weight(1f).verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = title,
|
||||
onValueChange = { title = it },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
placeholder = { Text("一句话说清这条知识是什么", color = InkMuted) },
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
colors = TextFieldDefaults.colors(
|
||||
focusedContainerColor = Color.White,
|
||||
unfocusedContainerColor = Color.White,
|
||||
focusedIndicatorColor = Primary,
|
||||
unfocusedIndicatorColor = Line,
|
||||
),
|
||||
)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
SectionLabel("类型")
|
||||
Row(
|
||||
modifier = Modifier.horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
docTypes.forEach { (key, label) ->
|
||||
SurfaceChip(
|
||||
text = label,
|
||||
selected = docType == key,
|
||||
onClick = { docType = key },
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(10.dp))
|
||||
SectionLabel("分类")
|
||||
Row(
|
||||
modifier = Modifier.horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
SurfaceChip(
|
||||
text = "未分类",
|
||||
selected = categoryId == null,
|
||||
onClick = { categoryId = null },
|
||||
)
|
||||
categories.forEach { cat ->
|
||||
SurfaceChip(
|
||||
text = catLabel(cat),
|
||||
selected = categoryId == cat.id,
|
||||
onClick = { categoryId = cat.id },
|
||||
)
|
||||
}
|
||||
}
|
||||
if (categories.isEmpty()) {
|
||||
Text(
|
||||
"还没有分类,Web 端可以建三级分类",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = InkMuted,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(10.dp))
|
||||
OutlinedTextField(
|
||||
value = tags,
|
||||
onValueChange = { tags = it },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
placeholder = { Text("标签,空格或逗号分隔", color = InkMuted) },
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
colors = TextFieldDefaults.colors(
|
||||
focusedContainerColor = Color.White,
|
||||
unfocusedContainerColor = Color.White,
|
||||
focusedIndicatorColor = Primary,
|
||||
unfocusedIndicatorColor = Line,
|
||||
),
|
||||
)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
SectionLabel("正文")
|
||||
Text(
|
||||
"点右上「保存」先把这张备忘存成草稿,随后会直接打开编辑器写正文——排版、表格、贴图都在编辑器里。",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = InkMuted,
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun KnowledgeDetailDialog(
|
||||
api: Api,
|
||||
workspace: Workspace,
|
||||
doc: KnowledgeDoc,
|
||||
categories: List<KnowledgeCategory>,
|
||||
onClose: () -> Unit,
|
||||
onChanged: () -> Unit,
|
||||
onCategoriesChanged: () -> Unit,
|
||||
onFail: (Throwable) -> Unit,
|
||||
onEdit: (EditRequest) -> Unit,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val context = LocalContext.current
|
||||
var action by remember { mutableStateOf(doc.status) }
|
||||
var version by remember { mutableStateOf(doc.version) }
|
||||
var categoryId by remember { mutableStateOf(doc.category?.id) }
|
||||
var tags by remember { mutableStateOf(doc.tags.joinToString(" ") { it.name }) }
|
||||
var savingMeta by remember { mutableStateOf(false) }
|
||||
var metaNotice by remember { mutableStateOf<String?>(null) }
|
||||
/**
|
||||
* 附件:备忘录不只文字,手册、日志、表格都要能挂上来。
|
||||
* 附件必须落在条目上,所以这里只在已有的知识详情里传(新建时由正文里的编辑器负责)。
|
||||
*/
|
||||
var attachments by remember(doc.id) { mutableStateOf(doc.attachments) }
|
||||
var uploading by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(doc.id) {
|
||||
runCatching { api.attachments(workspace.id, doc.id) }.onSuccess { attachments = it }
|
||||
}
|
||||
|
||||
val picker = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
|
||||
if (uri == null) return@rememberLauncherForActivityResult
|
||||
scope.launch {
|
||||
uploading = true
|
||||
try {
|
||||
val bytes = withContext(Dispatchers.IO) {
|
||||
context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
|
||||
}
|
||||
if (bytes == null) {
|
||||
onFail(IllegalStateException("读不到这个文件,换个来源试试"))
|
||||
return@launch
|
||||
}
|
||||
val name = attachmentName(context, uri) ?: "file-${System.currentTimeMillis()}"
|
||||
val mime = context.contentResolver.getType(uri) ?: "application/octet-stream"
|
||||
val created = api.uploadAttachment(workspace.id, doc.id, name, mime, bytes)
|
||||
attachments = attachments + created
|
||||
} catch (e: Exception) {
|
||||
onFail(e)
|
||||
} finally {
|
||||
uploading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
val actions = when (action) {
|
||||
"draft" -> listOf("submit_review" to "提交审议", "publish" to "发布", "archive" to "归档")
|
||||
"in_review" -> listOf("withdraw" to "撤回审议", "publish" to "发布", "archive" to "归档")
|
||||
@@ -277,15 +533,105 @@ private fun KnowledgeDetailDialog(
|
||||
},
|
||||
text = {
|
||||
Column(
|
||||
Modifier.heightIn(max = 420.dp).verticalScroll(rememberScrollState()),
|
||||
Modifier.heightIn(max = 460.dp).verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
Text(sourceLine(doc), style = MaterialTheme.typography.labelSmall, color = InkMuted)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Text(
|
||||
doc.bodyMd.ifBlank { "(还没有正文,点「补一句」在 Web 端补齐更快)" },
|
||||
doc.bodyMd.ifBlank { "(还没有正文,点「编辑正文」补齐)" },
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = InkSoft,
|
||||
)
|
||||
|
||||
// 附件:手册、日志、导出表格都能挂在这条知识下,点一下用浏览器打开
|
||||
Spacer(Modifier.height(14.dp))
|
||||
SectionLabel("附件")
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
if (attachments.isEmpty()) "还没有附件" else "${attachments.size} 个",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = InkMuted,
|
||||
)
|
||||
Spacer(Modifier.width(10.dp))
|
||||
TextButton(
|
||||
enabled = !uploading,
|
||||
onClick = { picker.launch(arrayOf("*/*")) },
|
||||
) { Text(if (uploading) "上传中…" else "上传附件") }
|
||||
}
|
||||
attachments.forEach { row ->
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(vertical = 2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
row.filename,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = Ink,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Text(
|
||||
humanSize(row.size),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = InkMuted,
|
||||
)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
TextButton(
|
||||
onClick = {
|
||||
// raw_url 是带短时签名的相对路径,拼上当前后端地址才能在浏览器里打开
|
||||
val url = row.rawUrl?.let { api.currentOrigin() + it } ?: return@TextButton
|
||||
runCatching {
|
||||
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
|
||||
}.onFailure { onFail(it) }
|
||||
},
|
||||
) { Text("打开") }
|
||||
}
|
||||
}
|
||||
|
||||
// 已发布的内容不能直接改,分类/标签也跟着锁住,避免"发布后又悄悄换归属"
|
||||
if (action != "published") {
|
||||
Spacer(Modifier.height(14.dp))
|
||||
SectionLabel("分类")
|
||||
Row(
|
||||
modifier = Modifier.horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
SurfaceChip(
|
||||
text = "未分类",
|
||||
selected = categoryId == null,
|
||||
onClick = { categoryId = null },
|
||||
)
|
||||
categories.forEach { cat ->
|
||||
SurfaceChip(
|
||||
text = catLabel(cat),
|
||||
selected = categoryId == cat.id,
|
||||
onClick = { categoryId = cat.id },
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(10.dp))
|
||||
OutlinedTextField(
|
||||
value = tags,
|
||||
onValueChange = { tags = it },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
placeholder = { Text("标签,空格或逗号分隔", color = InkMuted) },
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
colors = TextFieldDefaults.colors(
|
||||
focusedContainerColor = Color.White,
|
||||
unfocusedContainerColor = Color.White,
|
||||
focusedIndicatorColor = Primary,
|
||||
unfocusedIndicatorColor = Line,
|
||||
),
|
||||
)
|
||||
if (metaNotice != null) {
|
||||
Text(
|
||||
metaNotice.orEmpty(),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = Primary,
|
||||
modifier = Modifier.padding(top = 6.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
@@ -297,7 +643,7 @@ private fun KnowledgeDetailDialog(
|
||||
itemId = doc.id,
|
||||
title = doc.title,
|
||||
body = doc.bodyMd,
|
||||
version = doc.version,
|
||||
version = version,
|
||||
titleEnabled = action == "draft",
|
||||
knowledge = true,
|
||||
),
|
||||
@@ -305,6 +651,34 @@ private fun KnowledgeDetailDialog(
|
||||
onClose()
|
||||
},
|
||||
) { Text("编辑正文") }
|
||||
if (action != "published") {
|
||||
TextButton(
|
||||
enabled = !savingMeta,
|
||||
onClick = {
|
||||
scope.launch {
|
||||
savingMeta = true
|
||||
runCatching {
|
||||
api.updateKnowledge(
|
||||
workspace.id,
|
||||
doc.id,
|
||||
version,
|
||||
tags = parseTags(tags),
|
||||
categoryId = categoryId,
|
||||
clearCategory = categoryId == null,
|
||||
)
|
||||
}
|
||||
.onSuccess {
|
||||
version = it.version
|
||||
metaNotice = "分类与标签已保存"
|
||||
onChanged()
|
||||
onCategoriesChanged()
|
||||
}
|
||||
.onFailure { onFail(it) }
|
||||
savingMeta = false
|
||||
}
|
||||
},
|
||||
) { Text("保存分类标签") }
|
||||
}
|
||||
actions.forEach { (key, label) ->
|
||||
TextButton(onClick = {
|
||||
scope.launch {
|
||||
@@ -328,3 +702,20 @@ private fun KnowledgeDetailDialog(
|
||||
containerColor = Paper,
|
||||
)
|
||||
}
|
||||
|
||||
/** 附件列表里显示的大小:人与手机都不需要精确到字节。 */
|
||||
private fun humanSize(bytes: Long): String = when {
|
||||
bytes < 1024 -> "$bytes B"
|
||||
bytes < 1024 * 1024 -> "${bytes / 1024} KB"
|
||||
else -> String.format("%.1f MB", bytes / 1024.0 / 1024.0)
|
||||
}
|
||||
|
||||
/** 从 content:// 里问出文件名:微信、文件管理器给的 uri 形态各不相同,能问就问。 */
|
||||
private fun attachmentName(context: android.content.Context, uri: Uri): String? =
|
||||
runCatching {
|
||||
context.contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)
|
||||
?.use { cursor ->
|
||||
if (cursor.moveToFirst()) cursor.getString(0) else null
|
||||
}
|
||||
}.getOrNull() ?: uri.lastPathSegment?.substringAfterLast('/')
|
||||
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
package com.dadao.task.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.dadao.task.data.Api
|
||||
import com.dadao.task.data.ApiException
|
||||
import com.dadao.task.data.AppNotification
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* 站内通知。
|
||||
*
|
||||
* 只做三件事:看有没有新动静、点开跳过去、标为已读。
|
||||
* 通知本身不是凭证——点开之后目标页面会重新拉数据,因为通知生成后事情可能又被改过。
|
||||
*/
|
||||
@Composable
|
||||
fun NotificationsScreen(
|
||||
api: Api,
|
||||
workspaceId: String,
|
||||
onOpenItem: (itemId: String, isTicket: Boolean) -> Unit,
|
||||
onUnreadChanged: () -> Unit,
|
||||
refreshKey: Int = 0,
|
||||
) {
|
||||
var unreadOnly by remember { mutableStateOf(true) }
|
||||
var rows by remember { mutableStateOf<List<AppNotification>>(emptyList()) }
|
||||
var loading by remember { mutableStateOf(true) }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
suspend fun reload() {
|
||||
loading = true
|
||||
error = null
|
||||
runCatching { api.notifications(workspaceId, unreadOnly) }
|
||||
.onSuccess { rows = it.notifications }
|
||||
.onFailure { error = verifyStatusCode((it as? ApiException)?.code ?: 0) }
|
||||
loading = false
|
||||
}
|
||||
|
||||
LaunchedEffect(workspaceId, unreadOnly, refreshKey) { reload() }
|
||||
|
||||
Column(Modifier.fillMaxSize().padding(horizontal = 16.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text("通知", fontSize = 22.sp, fontWeight = FontWeight.SemiBold, color = Ink)
|
||||
Box(Modifier.width(2.dp))
|
||||
SoftPill(
|
||||
text = "未读",
|
||||
fg = if (unreadOnly) Primary else InkMuted,
|
||||
bg = if (unreadOnly) PrimarySoft else PaperSunken,
|
||||
modifier = Modifier.clickable { unreadOnly = true },
|
||||
)
|
||||
SoftPill(
|
||||
text = "全部",
|
||||
fg = if (unreadOnly) InkMuted else Primary,
|
||||
bg = if (unreadOnly) PaperSunken else PrimarySoft,
|
||||
modifier = Modifier.clickable { unreadOnly = false },
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = if (unreadOnly) "有人派活、或你关注的事有进展时,会出现在这里" else "历史通知,方便回头找",
|
||||
fontSize = 12.sp,
|
||||
color = InkMuted,
|
||||
modifier = Modifier.padding(top = 4.dp, bottom = 10.dp),
|
||||
)
|
||||
|
||||
error?.let {
|
||||
Text(it, fontSize = 12.sp, color = Rust, modifier = Modifier.padding(bottom = 8.dp))
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
Box(Modifier.fillMaxWidth().padding(top = 40.dp), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator(color = Primary, strokeWidth = 2.dp, modifier = Modifier.size(22.dp))
|
||||
}
|
||||
return@Column
|
||||
}
|
||||
|
||||
if (rows.isEmpty()) {
|
||||
SoftCard(Modifier.fillMaxWidth()) {
|
||||
EmptyState(
|
||||
title = if (unreadOnly) "没有未读通知" else "还没有通知",
|
||||
desc = if (unreadOnly) "有动静时这里会提醒你。" else "所有历史通知都会留在这里。",
|
||||
)
|
||||
}
|
||||
return@Column
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
contentPadding = androidx.compose.foundation.layout.PaddingValues(bottom = 20.dp),
|
||||
) {
|
||||
items(rows, key = { it.id }) { n ->
|
||||
val isTicket = n.kind.startsWith("ticket_")
|
||||
SoftCard(
|
||||
Modifier.fillMaxWidth(),
|
||||
onClick = {
|
||||
scope.launch {
|
||||
if (n.readAt == null) {
|
||||
runCatching { api.markNotificationRead(workspaceId, n.id) }
|
||||
rows = rows.map { if (it.id == n.id) it.copy(readAt = "read") else it }
|
||||
onUnreadChanged()
|
||||
}
|
||||
n.itemId?.let { onOpenItem(it, isTicket) }
|
||||
}
|
||||
},
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.Top) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(26.dp)
|
||||
.background(if (n.readAt == null) PrimarySoft else PaperSunken, CircleShape),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = if (isTicket) "工" else "记",
|
||||
fontSize = 11.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = if (n.readAt == null) Primary else InkMuted,
|
||||
)
|
||||
}
|
||||
Box(Modifier.width(10.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = n.title,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = Ink,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
if (n.readAt == null) {
|
||||
Box(
|
||||
Modifier
|
||||
.padding(start = 6.dp)
|
||||
.size(6.dp)
|
||||
.background(Clay, CircleShape),
|
||||
)
|
||||
}
|
||||
}
|
||||
if (n.body.isNotBlank()) {
|
||||
Text(
|
||||
text = n.body,
|
||||
fontSize = 12.sp,
|
||||
color = InkSoft,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(top = 2.dp),
|
||||
)
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
n.itemTitle?.let {
|
||||
Text(
|
||||
text = it,
|
||||
fontSize = 11.sp,
|
||||
color = InkMuted,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
)
|
||||
}
|
||||
Text(formatTime(n.createdAt), fontSize = 11.sp, color = InkMuted)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -68,6 +68,8 @@ fun TaskDetailSheet(
|
||||
onChanged: () -> Unit,
|
||||
/** 进富文本编辑器改正文。标题与状态留在抽屉里,只有正文要更大地方。 */
|
||||
onEdit: (ItemDetail) -> Unit = {},
|
||||
/** 编辑器保存回来后变一次,抽屉据此重新拉数据——否则还显示保存前的旧正文。 */
|
||||
refreshKey: Int = 0,
|
||||
) {
|
||||
val sheet = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
val scope = rememberCoroutineScope()
|
||||
@@ -93,7 +95,7 @@ fun TaskDetailSheet(
|
||||
runCatching { api.me() }.onSuccess { meId = it.user.id }
|
||||
}
|
||||
|
||||
LaunchedEffect(itemId) { reload() }
|
||||
LaunchedEffect(itemId, refreshKey) { reload() }
|
||||
|
||||
fun fail(e: Throwable) {
|
||||
val code = (e as? ApiException)?.code ?: 0
|
||||
|
||||
@@ -51,7 +51,14 @@ private val views = listOf(
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun TaskListScreen(api: Api, workspace: Workspace, onEdit: (EditRequest) -> Unit = {}) {
|
||||
fun TaskListScreen(
|
||||
api: Api,
|
||||
workspace: Workspace,
|
||||
onEdit: (EditRequest) -> Unit = {},
|
||||
/** 编辑器保存后要重新打开的那条。父层持这个状态,因为编辑器打开时本页会被整体卸载。 */
|
||||
reopenItem: String? = null,
|
||||
onReopened: () -> Unit = {},
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
var view by remember { mutableStateOf("active") }
|
||||
var mode by remember { mutableStateOf("list") }
|
||||
@@ -60,6 +67,7 @@ fun TaskListScreen(api: Api, workspace: Workspace, onEdit: (EditRequest) -> Unit
|
||||
var draft by remember { mutableStateOf("") }
|
||||
var query by remember { mutableStateOf("") }
|
||||
var openId by remember { mutableStateOf<String?>(null) }
|
||||
var detailRefresh by remember { mutableStateOf(0) }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
suspend fun reload() {
|
||||
@@ -75,6 +83,15 @@ fun TaskListScreen(api: Api, workspace: Workspace, onEdit: (EditRequest) -> Unit
|
||||
|
||||
LaunchedEffect(view, query) { reload() }
|
||||
|
||||
// 从编辑器保存回来:本页是新建出来的,先刷数据再把那一条详情重新打开。
|
||||
LaunchedEffect(reopenItem) {
|
||||
val target = reopenItem ?: return@LaunchedEffect
|
||||
reload()
|
||||
detailRefresh += 1
|
||||
openId = target
|
||||
onReopened()
|
||||
}
|
||||
|
||||
fun fail(e: Throwable) {
|
||||
val code = (e as? ApiException)?.code ?: 0
|
||||
error = verifyStatusCode(code).ifBlank { e.message ?: "操作没成功" }
|
||||
@@ -193,10 +210,12 @@ fun TaskListScreen(api: Api, workspace: Workspace, onEdit: (EditRequest) -> Unit
|
||||
itemId = id,
|
||||
onClose = { openId = null },
|
||||
onChanged = { scope.launch { reload() } },
|
||||
// 抽屉是独立窗口,关掉才能让编辑器显示在上层;保存回来后由父层重新打开。
|
||||
onEdit = {
|
||||
openId = null
|
||||
onEdit(EditRequest(it.id, it.title, it.bodyMd, it.version))
|
||||
},
|
||||
refreshKey = detailRefresh,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.dadao.task.ui
|
||||
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@@ -9,6 +10,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.AlertDialog
|
||||
@@ -31,6 +33,7 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.dadao.task.data.Api
|
||||
import com.dadao.task.data.ApiException
|
||||
import com.dadao.task.data.Member
|
||||
import com.dadao.task.data.Ticket
|
||||
import com.dadao.task.data.Workspace
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -42,7 +45,12 @@ private val ticketRoles = listOf(
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun TicketsScreen(api: Api, workspace: Workspace) {
|
||||
fun TicketsScreen(
|
||||
api: Api,
|
||||
workspace: Workspace,
|
||||
focusId: String? = null,
|
||||
onFocusHandled: () -> Unit = {},
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
var role by remember { mutableStateOf("assigned") }
|
||||
var tickets by remember { mutableStateOf<List<Ticket>>(emptyList()) }
|
||||
@@ -52,6 +60,16 @@ fun TicketsScreen(api: Api, workspace: Workspace) {
|
||||
var note by remember { mutableStateOf("") }
|
||||
var resolution by remember { mutableStateOf("") }
|
||||
|
||||
// 手工开单:不经过任务,人得自己选
|
||||
var members by remember { mutableStateOf<List<Member>>(emptyList()) }
|
||||
var creating by remember { mutableStateOf(false) }
|
||||
var newTitle by remember { mutableStateOf("") }
|
||||
var newAssignee by remember { mutableStateOf("") }
|
||||
var newPriority by remember { mutableStateOf(1) }
|
||||
|
||||
// 动作按钮按"我是谁"决定显隐:需求人不去接收自己的单,办理人也不该自验
|
||||
var meId by remember { mutableStateOf("") }
|
||||
|
||||
suspend fun reload() {
|
||||
loading = true
|
||||
runCatching { api.tickets(workspace.id, role) }
|
||||
@@ -65,6 +83,18 @@ fun TicketsScreen(api: Api, workspace: Workspace) {
|
||||
|
||||
LaunchedEffect(role) { reload() }
|
||||
|
||||
// 从通知点进来:切到"全部"才看得见那张单,然后清掉待处理标记
|
||||
LaunchedEffect(focusId) {
|
||||
if (focusId == null) return@LaunchedEffect
|
||||
role = "all"
|
||||
onFocusHandled()
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
runCatching { api.members(workspace.id) }.onSuccess { members = it }
|
||||
runCatching { api.me() }.onSuccess { meId = it.user.id }
|
||||
}
|
||||
|
||||
fun fail(e: Throwable) {
|
||||
val code = (e as? ApiException)?.code ?: 0
|
||||
message = verifyStatusCode(code).ifBlank { e.message ?: "操作没成功" }
|
||||
@@ -95,10 +125,20 @@ fun TicketsScreen(api: Api, workspace: Workspace) {
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
ticketRoles.forEach { (key, label) ->
|
||||
SurfaceChip(text = label, selected = role == key, onClick = { role = key })
|
||||
}
|
||||
Spacer(Modifier.weight(1f))
|
||||
GhostButton("新建", onClick = {
|
||||
newTitle = ""
|
||||
newAssignee = ""
|
||||
newPriority = 1
|
||||
creating = true
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -140,21 +180,23 @@ fun TicketsScreen(api: Api, workspace: Workspace) {
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(10.dp))
|
||||
val isAssignee = ticket.ticket.assigneeId == meId
|
||||
val isRequester = ticket.ticket.requesterId == meId
|
||||
val isAdmin = workspace.role == "owner" || workspace.role == "admin"
|
||||
val canWork = isAssignee || isAdmin
|
||||
val canVerify = isRequester || isAdmin
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
when (ticket.status) {
|
||||
"open" -> PrimaryButton("接收", onClick = { act(ticket, "accept") })
|
||||
"accepted" -> PrimaryButton("开始办理", onClick = { act(ticket, "start") })
|
||||
"in_progress" -> PrimaryButton("提交验收", onClick = {
|
||||
scope.launch {
|
||||
runCatching { api.ticketAction(workspace.id, ticket.id, "submit") }
|
||||
.onSuccess {
|
||||
message = null
|
||||
reload()
|
||||
}
|
||||
.onFailure { fail(it) }
|
||||
}
|
||||
})
|
||||
"pending_verify" -> {
|
||||
"open" -> if (canWork) {
|
||||
PrimaryButton("接收", onClick = { act(ticket, "accept") })
|
||||
}
|
||||
"accepted" -> if (canWork) {
|
||||
PrimaryButton("开始办理", onClick = { act(ticket, "start") })
|
||||
}
|
||||
"in_progress" -> if (canWork) {
|
||||
PrimaryButton("提交验收", onClick = { act(ticket, "submit") })
|
||||
}
|
||||
"pending_verify" -> if (canVerify) {
|
||||
PrimaryButton("通过验收", onClick = {
|
||||
verifyTarget = ticket
|
||||
note = ""
|
||||
@@ -174,6 +216,17 @@ fun TicketsScreen(api: Api, workspace: Workspace) {
|
||||
})
|
||||
}
|
||||
}
|
||||
// 谁都不能替别人取消,但需求人/办理人/管理员可以中止
|
||||
if (ticket.status != "done" && ticket.status != "canceled" && (isRequester || isAssignee || isAdmin)) {
|
||||
GhostButton("取消", onClick = { act(ticket, "cancel") })
|
||||
}
|
||||
if (!canWork && !canVerify && ticket.status != "done" && ticket.status != "canceled") {
|
||||
Text(
|
||||
if (isRequester) "等对方处理" else "这条不是派给你的",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = InkMuted,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -181,6 +234,89 @@ fun TicketsScreen(api: Api, workspace: Workspace) {
|
||||
item { Spacer(Modifier.height(20.dp)) }
|
||||
}
|
||||
|
||||
if (creating) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { creating = false },
|
||||
title = { Text("新建工单", style = MaterialTheme.typography.titleMedium) },
|
||||
text = {
|
||||
Column {
|
||||
Text(
|
||||
"不经过任务也能开单。写清楚要办什么,指定一个人,对方会收到通知。",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = InkMuted,
|
||||
)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
OutlinedTextField(
|
||||
value = newTitle,
|
||||
onValueChange = { newTitle = it },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
placeholder = { Text("要办什么,例如:核对 9 月发票", color = InkMuted) },
|
||||
singleLine = true,
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
colors = TextFieldDefaults.colors(
|
||||
focusedContainerColor = Color.White,
|
||||
unfocusedContainerColor = Color.White,
|
||||
focusedIndicatorColor = Primary,
|
||||
unfocusedIndicatorColor = Line,
|
||||
),
|
||||
)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Text("交给谁", style = MaterialTheme.typography.labelMedium, color = InkMuted)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
modifier = Modifier.horizontalScroll(rememberScrollState()),
|
||||
) {
|
||||
members.forEach { m ->
|
||||
SurfaceChip(
|
||||
text = m.nickname.ifBlank { m.phone.takeLast(4) },
|
||||
selected = newAssignee == m.userId,
|
||||
onClick = { newAssignee = m.userId },
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Text("优先级", style = MaterialTheme.typography.labelMedium, color = InkMuted)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
listOf(0 to "无", 1 to "低", 2 to "中", 3 to "高").forEach { (v, label) ->
|
||||
SurfaceChip(
|
||||
text = label,
|
||||
selected = newPriority == v,
|
||||
onClick = { newPriority = v },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
enabled = newTitle.isNotBlank() && newAssignee.isNotBlank(),
|
||||
onClick = {
|
||||
scope.launch {
|
||||
runCatching {
|
||||
api.createTicket(
|
||||
workspace.id,
|
||||
newTitle.trim(),
|
||||
newAssignee,
|
||||
priority = newPriority,
|
||||
)
|
||||
}
|
||||
.onSuccess {
|
||||
creating = false
|
||||
message = null
|
||||
role = "requested"
|
||||
}
|
||||
.onFailure { fail(it) }
|
||||
}
|
||||
},
|
||||
) { Text("开单并通知对方") }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = { creating = false }) { Text("取消") } },
|
||||
containerColor = Paper,
|
||||
)
|
||||
}
|
||||
|
||||
val target = verifyTarget
|
||||
if (target != null) {
|
||||
AlertDialog(
|
||||
|
||||
@@ -24,14 +24,20 @@ JWT_SECRET=dev-only-change-me
|
||||
ACCESS_TOKEN_MINUTES=30
|
||||
REFRESH_TOKEN_DAYS=14
|
||||
|
||||
# 生产环境禁止 SMS_PROVIDER=dev(否则任意验证码可登录),启动即失败
|
||||
# 生产环境禁止 SMS_PROVIDER=dev(否则任意验证码可登录),启动即失败;
|
||||
# 内网试用确需固定验证码时显式设置 INTERNAL_TEST_LOGIN=true(启动会打 WARNING)
|
||||
SMS_PROVIDER=dev
|
||||
SMS_DEV_CODE=123456
|
||||
INTERNAL_TEST_LOGIN=false
|
||||
# 同一手机号两次发码的最小间隔(秒),测试里置 0 可关闭
|
||||
SMS_THROTTLE_SECONDS=60
|
||||
|
||||
CORS_ORIGINS=http://localhost:5180,http://127.0.0.1:5180
|
||||
|
||||
# 生产单端口部署:后端直接托管前端构建产物(同源,可留空 CORS)
|
||||
# 留空则只提供 API;部署方式见 2.Design/DEPLOY.md
|
||||
# WEB_DIST_DIR=/home/openclaw/app/dadaotask/web/dist
|
||||
|
||||
# ── 知识库向量库(独立的 PG + pgvector,M3 起用)──────────────────────
|
||||
# 单独实例,不动 fnsvr-01:那台没有 pgvector,且向量检索吃内存,不该和业务库挤。
|
||||
# 起法见 deploy/docker-compose.vector.yml。不配则 M3 的语义检索能力直接不可用。
|
||||
|
||||
@@ -12,13 +12,13 @@ import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
from app.roles import APP_ROLE, OWNER_ROLE
|
||||
|
||||
revision: str = "0001"
|
||||
down_revision: str | None = None
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
APP_ROLE = "dadao_app"
|
||||
|
||||
# 说明:asyncpg 走"预备语句"执行,**一条 execute 里只能有一条 SQL**,
|
||||
# 多条语句会报 `cannot insert multiple commands into a prepared statement`。
|
||||
# 所以这里按语句拆成元组,逐条执行。
|
||||
@@ -76,7 +76,7 @@ POLICY_STATEMENTS: tuple[str, ...] = (
|
||||
f"workspace, app_user, member, invitation, audit_log TO {APP_ROLE};"
|
||||
),
|
||||
(
|
||||
"ALTER DEFAULT PRIVILEGES FOR ROLE dadao_owner IN SCHEMA public "
|
||||
f"ALTER DEFAULT PRIVILEGES FOR ROLE {OWNER_ROLE} IN SCHEMA public "
|
||||
f"GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO {APP_ROLE};"
|
||||
),
|
||||
)
|
||||
|
||||
@@ -15,13 +15,13 @@ import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
from app.roles import APP_ROLE
|
||||
|
||||
revision: str = "0002"
|
||||
down_revision: str | None = "0001"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
APP_ROLE = "dadao_app"
|
||||
|
||||
TENANT_TABLES_0002: tuple[str, ...] = ("item", "tag", "item_tag", "comment", "activity")
|
||||
|
||||
_TIMESTAMP_COLUMNS = (
|
||||
|
||||
@@ -19,13 +19,13 @@ import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
from app.roles import APP_ROLE
|
||||
|
||||
revision: str = "0003"
|
||||
down_revision: str | None = "0002"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
APP_ROLE = "dadao_app"
|
||||
|
||||
TENANT_TABLES_0003: tuple[str, ...] = ("ticket_ext", "item_link", "notification")
|
||||
|
||||
TASK_STATUSES = ("inbox", "todo", "doing", "done", "blocked", "canceled")
|
||||
|
||||
@@ -21,13 +21,13 @@ import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
from app.roles import APP_ROLE
|
||||
|
||||
revision: str = "0004"
|
||||
down_revision: str | None = "0003"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
APP_ROLE = "dadao_app"
|
||||
|
||||
TENANT_TABLES_0004: tuple[str, ...] = ("knowledge_doc",)
|
||||
|
||||
TASK_STATUSES = ("inbox", "todo", "doing", "done", "blocked", "canceled")
|
||||
|
||||
@@ -11,6 +11,8 @@ from sqlalchemy.dialects.postgresql import UUID as PgUUID
|
||||
|
||||
from alembic import op
|
||||
|
||||
from app.roles import APP_ROLE
|
||||
|
||||
revision = "0005"
|
||||
down_revision = "0004"
|
||||
branch_labels = None
|
||||
@@ -55,7 +57,7 @@ def upgrade() -> None:
|
||||
"ALTER TABLE attachment ENABLE ROW LEVEL SECURITY",
|
||||
"CREATE POLICY attachment_isolation ON attachment "
|
||||
"USING (workspace_id = app_ws_id()) WITH CHECK (workspace_id = app_ws_id())",
|
||||
"GRANT SELECT, INSERT, UPDATE, DELETE ON attachment TO dadao_app",
|
||||
f"GRANT SELECT, INSERT, UPDATE, DELETE ON attachment TO {APP_ROLE}",
|
||||
):
|
||||
op.execute(statement)
|
||||
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
"""知识切片与检索(M3)
|
||||
|
||||
Revision ID: 0006
|
||||
Revises: 0005
|
||||
Create Date: 2026-10-29
|
||||
|
||||
建 `chunk` 表:知识正文按段切开,每片一行,带向量数组。
|
||||
|
||||
两个刻意的取舍:
|
||||
|
||||
1. **向量存 `real[]`,不依赖 pgvector**。本机拿不到带 pgvector 的实例(Docker 起不来,
|
||||
fnsvr-01 明确不动),但检索链路不能一直停在纸面上。`real[]` + 一条 SQL 余弦函数
|
||||
在小数据量下够用;将来换上 pgvector 只需改列类型、加 HNSW 索引,应用层接口不变。
|
||||
2. **检索范围只能是 `published`**。草稿是"还没被人认可的话",进检索等于让 AI 把
|
||||
猜测当结论引用出去——这条过滤写在查询里,不靠调用方自觉。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
|
||||
from app.roles import APP_ROLE
|
||||
|
||||
revision: str = "0006"
|
||||
down_revision: str = "0005"
|
||||
branch_labels: Sequence[str] | None = None
|
||||
depends_on: Sequence[str] | None = None
|
||||
|
||||
# 余弦相似度。写成 SQL 函数而不是在 Python 里算,是为了让过滤与排序留在数据库里做——
|
||||
# 把整表向量捞回应用算相似度,数据一多就会拖垮 API。
|
||||
COSINE_SQL = """
|
||||
CREATE OR REPLACE FUNCTION app_cosine(a real[], b real[]) RETURNS double precision
|
||||
LANGUAGE plpgsql IMMUTABLE PARALLEL SAFE AS $fn$
|
||||
DECLARE
|
||||
n int;
|
||||
dot double precision := 0;
|
||||
na double precision := 0;
|
||||
nb double precision := 0;
|
||||
BEGIN
|
||||
n := LEAST(array_length(a, 1), array_length(b, 1));
|
||||
IF n IS NULL THEN
|
||||
RETURN NULL;
|
||||
END IF;
|
||||
FOR i IN 1..n LOOP
|
||||
dot := dot + a[i] * b[i];
|
||||
na := na + a[i] * a[i];
|
||||
nb := nb + b[i] * b[i];
|
||||
END LOOP;
|
||||
IF na = 0 OR nb = 0 THEN
|
||||
RETURN 0;
|
||||
END IF;
|
||||
RETURN dot / sqrt(na * nb);
|
||||
END
|
||||
$fn$;
|
||||
"""
|
||||
|
||||
CREATE_SQL = """
|
||||
CREATE TABLE chunk (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id uuid NOT NULL,
|
||||
item_id uuid NOT NULL REFERENCES item(id) ON DELETE CASCADE,
|
||||
idx integer NOT NULL,
|
||||
heading varchar(200),
|
||||
content text NOT NULL,
|
||||
char_count integer NOT NULL DEFAULT 0,
|
||||
embedding real[],
|
||||
embed_model varchar(64),
|
||||
version integer NOT NULL DEFAULT 1,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
CONSTRAINT uq_chunk_item_idx UNIQUE (item_id, idx)
|
||||
)
|
||||
"""
|
||||
|
||||
# asyncpg 的执行器不接受多语句,一条 `execute` 只能带一句 SQL(0001 踩过同样的坑)
|
||||
INDEX_STATEMENTS = (
|
||||
"CREATE INDEX ix_chunk_ws_item ON chunk (workspace_id, item_id)",
|
||||
"COMMENT ON TABLE chunk IS '知识正文切片,检索的最小单位'",
|
||||
)
|
||||
|
||||
POLICY_SQL = (
|
||||
"ALTER TABLE chunk ENABLE ROW LEVEL SECURITY",
|
||||
"CREATE POLICY chunk_isolation ON chunk "
|
||||
"USING (workspace_id = app_ws_id()) WITH CHECK (workspace_id = app_ws_id())",
|
||||
f"GRANT SELECT, INSERT, UPDATE, DELETE ON chunk TO {APP_ROLE}",
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute(COSINE_SQL)
|
||||
op.execute(CREATE_SQL)
|
||||
for statement in INDEX_STATEMENTS:
|
||||
op.execute(statement)
|
||||
for statement in POLICY_SQL:
|
||||
op.execute(statement)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP POLICY IF EXISTS chunk_isolation ON chunk")
|
||||
op.execute("DROP TABLE IF EXISTS chunk")
|
||||
# 函数被别的东西引用时先别删;本轮只有切片在用,留一条注释说明为什么还留着。
|
||||
# op.execute("DROP FUNCTION IF EXISTS app_cosine(real[], real[])")
|
||||
@@ -0,0 +1,81 @@
|
||||
"""知识分类(M3 补充)
|
||||
|
||||
Revision ID: 0007
|
||||
Revises: 0006
|
||||
Create Date: 2026-10-30
|
||||
|
||||
知识库的基本形态是"备忘录",不是"任务":一条知识要有标题、正文、标签,
|
||||
还要能归类。这里补上分类树(一级大类 / 二级 / 三级,深不过三)。
|
||||
|
||||
两个取舍:
|
||||
|
||||
1. **分类独立成表,不塞进 item**。分类是工作区级的字典,一条知识挂一个分类;
|
||||
塞进 item 会让"分类"变成每条知识自己的一份副本,改个名字要改几百行。
|
||||
2. **删分类不删知识**。外键是 `ON DELETE SET NULL`:知识退回"未分类",
|
||||
分类只是入口,不是知识的容器。真要连内容一起删,那是删知识,得单独确认。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
|
||||
from app.roles import APP_ROLE
|
||||
|
||||
revision: str = "0007"
|
||||
down_revision: str = "0006"
|
||||
branch_labels: Sequence[str] | None = None
|
||||
depends_on: Sequence[str] | None = None
|
||||
|
||||
CREATE_SQL = """
|
||||
CREATE TABLE knowledge_category (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id uuid NOT NULL REFERENCES workspace(id) ON DELETE CASCADE,
|
||||
parent_id uuid REFERENCES knowledge_category(id) ON DELETE CASCADE,
|
||||
name varchar(40) NOT NULL,
|
||||
depth integer NOT NULL DEFAULT 1,
|
||||
sort_order integer NOT NULL DEFAULT 0,
|
||||
version integer NOT NULL DEFAULT 1,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
CONSTRAINT ck_kcat_depth CHECK (depth BETWEEN 1 AND 3),
|
||||
CONSTRAINT ck_kcat_name CHECK (length(btrim(name)) > 0)
|
||||
)
|
||||
"""
|
||||
|
||||
INDEX_STATEMENTS = (
|
||||
"CREATE INDEX ix_kcat_ws_parent ON knowledge_category (workspace_id, parent_id)",
|
||||
# 同一父级下不许重名。根分类的 parent_id 是 NULL,而 PG 里 NULL 互不相等,
|
||||
# 所以根要单独一条 `WHERE parent_id IS NULL` 的部分索引,否则可以建出两个同名大类。
|
||||
"CREATE UNIQUE INDEX uq_kcat_ws_parent_name ON knowledge_category "
|
||||
"(workspace_id, parent_id, lower(name)) WHERE parent_id IS NOT NULL",
|
||||
"CREATE UNIQUE INDEX uq_kcat_ws_root_name ON knowledge_category "
|
||||
"(workspace_id, lower(name)) WHERE parent_id IS NULL",
|
||||
"COMMENT ON TABLE knowledge_category IS '知识分类树,深不过三'",
|
||||
"ALTER TABLE knowledge_doc ADD COLUMN category_id uuid "
|
||||
"REFERENCES knowledge_category(id) ON DELETE SET NULL",
|
||||
"CREATE INDEX ix_knowledge_ws_category ON knowledge_doc (workspace_id, category_id)",
|
||||
)
|
||||
|
||||
POLICY_SQL = (
|
||||
"ALTER TABLE knowledge_category ENABLE ROW LEVEL SECURITY",
|
||||
"CREATE POLICY knowledge_category_isolation ON knowledge_category "
|
||||
"USING (workspace_id = app_ws_id()) WITH CHECK (workspace_id = app_ws_id())",
|
||||
f"GRANT SELECT, INSERT, UPDATE, DELETE ON knowledge_category TO {APP_ROLE}",
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute(CREATE_SQL)
|
||||
for statement in INDEX_STATEMENTS:
|
||||
op.execute(statement)
|
||||
for statement in POLICY_SQL:
|
||||
op.execute(statement)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP INDEX IF EXISTS ix_knowledge_ws_category")
|
||||
op.execute("ALTER TABLE knowledge_doc DROP COLUMN IF EXISTS category_id")
|
||||
op.execute("DROP POLICY IF EXISTS knowledge_category_isolation ON knowledge_category")
|
||||
op.execute("DROP TABLE IF EXISTS knowledge_category")
|
||||
@@ -27,10 +27,12 @@ from ..domain.task import (
|
||||
view_statuses,
|
||||
)
|
||||
from ..errors import AppError, ErrorCode
|
||||
from ..models import Member
|
||||
from ..models_task import Activity, Comment, Item, ItemTag, Tag
|
||||
from ..models_ticket import TicketExt
|
||||
from ..schemas import Pagination, encode_cursor, ok
|
||||
from ..serializers import item_out, tag_out, tags_of
|
||||
from ..services.notify import TASK_STATUS_CN, notify
|
||||
from .attachments import count_for_items, list_for_item
|
||||
|
||||
router = APIRouter(prefix="/workspaces/{ws_id}/items", tags=["任务"])
|
||||
@@ -98,6 +100,25 @@ async def _load_item(
|
||||
return item
|
||||
|
||||
|
||||
async def _ensure_member(session: AsyncSession, ws_id: uuid.UUID, user_id: uuid.UUID) -> None:
|
||||
"""被指派的人必须是这个工作区的在册成员。
|
||||
|
||||
不校验的话可以指派给任意 uuid:任务从此挂在"不存在的人"名下,
|
||||
既不会出现在任何人的列表里,也不会有人收到通知。
|
||||
"""
|
||||
row = (
|
||||
await session.execute(
|
||||
select(Member.id).where(
|
||||
Member.workspace_id == ws_id,
|
||||
Member.user_id == user_id,
|
||||
Member.status == "active",
|
||||
)
|
||||
)
|
||||
).first()
|
||||
if row is None:
|
||||
raise AppError(ErrorCode.BAD_REQUEST, "被指派的人不在这个工作区里")
|
||||
|
||||
|
||||
async def _tags_of(
|
||||
session: AsyncSession, ws_id: uuid.UUID, item_ids: list[uuid.UUID]
|
||||
) -> dict[uuid.UUID, list[Tag]]:
|
||||
@@ -452,6 +473,9 @@ async def create_item(body: CreateItemIn, ctx: MemberDep, session: SessionDep) -
|
||||
fields: dict[str, Any] = {"status": body.status, "completed_at": None}
|
||||
apply_status(fields, body.status, now=datetime.now(timezone.utc))
|
||||
|
||||
if body.assignee_id is not None:
|
||||
await _ensure_member(session, ctx.ws_id, body.assignee_id)
|
||||
|
||||
item = Item(
|
||||
id=uuid.uuid4(),
|
||||
workspace_id=ctx.ws_id,
|
||||
@@ -470,6 +494,17 @@ async def create_item(body: CreateItemIn, ctx: MemberDep, session: SessionDep) -
|
||||
if body.tag_names:
|
||||
await _set_tags(session, ctx.ws_id, item, body.tag_names)
|
||||
await _log(session, item, ctx.user.id, "created", {"status": item.status})
|
||||
if item.assignee_id is not None:
|
||||
await notify(
|
||||
session,
|
||||
ws_id=ctx.ws_id,
|
||||
user_ids=(item.assignee_id,),
|
||||
kind="task_assigned",
|
||||
item_id=item.id,
|
||||
title=f"有人把「{item.title}」交给你了",
|
||||
body=f"{ctx.user.nickname or '同事'} 指派给你,注意截止时间",
|
||||
actor_id=ctx.user.id,
|
||||
)
|
||||
|
||||
tags = await _tags_of(session, ctx.ws_id, [item.id])
|
||||
return ok(_item_out(item, tags.get(item.id)), message="已创建")
|
||||
@@ -576,6 +611,7 @@ async def update_item(
|
||||
values["assignee_id"] = None
|
||||
changes["assignee_id"] = None
|
||||
elif body.assignee_id is not None and body.assignee_id != item.assignee_id:
|
||||
await _ensure_member(session, ctx.ws_id, body.assignee_id)
|
||||
values["assignee_id"] = body.assignee_id
|
||||
changes["assignee_id"] = str(body.assignee_id)
|
||||
if body.clear_due_at:
|
||||
@@ -605,6 +641,18 @@ async def update_item(
|
||||
)
|
||||
item.version += 1
|
||||
|
||||
if values.get("assignee_id") is not None:
|
||||
await notify(
|
||||
session,
|
||||
ws_id=ctx.ws_id,
|
||||
user_ids=(values["assignee_id"],),
|
||||
kind="task_assigned",
|
||||
item_id=item.id,
|
||||
title=f"「{item.title}」转给你了",
|
||||
body=f"{ctx.user.nickname or '同事'} 把这件事交给你,原来的办理人已经卸下",
|
||||
actor_id=ctx.user.id,
|
||||
)
|
||||
|
||||
tags = await _tags_of(session, ctx.ws_id, [item.id])
|
||||
return ok(_item_out(item, tags.get(item.id)), message="已保存")
|
||||
|
||||
@@ -634,6 +682,20 @@ async def change_status(
|
||||
payload={"from": previous, "to": body.status},
|
||||
)
|
||||
tags = await _tags_of(session, ctx.ws_id, [item.id])
|
||||
await notify(
|
||||
session,
|
||||
ws_id=ctx.ws_id,
|
||||
user_ids=(item.creator_id, item.assignee_id),
|
||||
kind="task_status_changed",
|
||||
item_id=item.id,
|
||||
title=f"「{item.title}」变成{TASK_STATUS_CN.get(body.status, body.status)}了",
|
||||
body=(
|
||||
f"{ctx.user.nickname or '同事'} 把它从 "
|
||||
f"{TASK_STATUS_CN.get(previous, previous)} 改成了 "
|
||||
f"{TASK_STATUS_CN.get(body.status, body.status)}"
|
||||
),
|
||||
actor_id=ctx.user.id,
|
||||
)
|
||||
return ok(_item_out(item, tags.get(item.id)), message="状态已更新")
|
||||
|
||||
|
||||
@@ -671,6 +733,16 @@ async def add_comment(
|
||||
session.add(comment)
|
||||
await session.flush()
|
||||
await _log(session, item, ctx.user.id, "commented", {"comment_id": str(comment.id)})
|
||||
await notify(
|
||||
session,
|
||||
ws_id=ctx.ws_id,
|
||||
user_ids=(item.creator_id, item.assignee_id),
|
||||
kind="item_commented",
|
||||
item_id=item.id,
|
||||
title=f"「{item.title}」有新讨论",
|
||||
body=f"{ctx.user.nickname or '同事'}:{body.body_md[:120]}",
|
||||
actor_id=ctx.user.id,
|
||||
)
|
||||
return ok(
|
||||
{
|
||||
"id": str(comment.id),
|
||||
|
||||
@@ -21,18 +21,25 @@ from typing import Annotated, Any
|
||||
|
||||
from fastapi import APIRouter, Query
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import Select, and_, or_, select, update
|
||||
from sqlalchemy import Select, and_, func, or_, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..audit import record as audit_record
|
||||
from ..deps import MemberDep, SessionDep
|
||||
from ..domain.knowledge import can_publish_role, sidebar_fields, target_status
|
||||
from ..errors import AppError, ErrorCode
|
||||
from ..models_knowledge import DOC_TYPES, KNOWLEDGE_STATUSES, KnowledgeDoc
|
||||
from ..models_task import Activity, Item
|
||||
from ..models_knowledge import (
|
||||
DOC_TYPES,
|
||||
KNOWLEDGE_STATUSES,
|
||||
MAX_CATEGORY_DEPTH,
|
||||
KnowledgeCategory,
|
||||
KnowledgeDoc,
|
||||
)
|
||||
from ..models_task import Activity, Item, ItemTag, Tag
|
||||
from ..schemas import clamp_limit, decode_cursor, encode_cursor, ok
|
||||
from ..serializers import item_out, tags_of
|
||||
from ..services.knowledge import create_draft_from_source, find_doc_by_source
|
||||
from ..services.knowledge_index import reindex_document, search_knowledge
|
||||
from .attachments import count_for_items, list_for_item
|
||||
|
||||
router = APIRouter(prefix="/workspaces/{ws_id}", tags=["知识库"])
|
||||
@@ -46,6 +53,7 @@ class KnowledgeIn(BaseModel):
|
||||
doc_type: str = "note"
|
||||
source_item_id: uuid.UUID | None = None
|
||||
tag_names: list[str] = Field(default_factory=list)
|
||||
category_id: uuid.UUID | None = None
|
||||
|
||||
|
||||
class KnowledgePatch(BaseModel):
|
||||
@@ -54,6 +62,24 @@ class KnowledgePatch(BaseModel):
|
||||
summary: str | None = None
|
||||
doc_type: str | None = None
|
||||
tag_names: list[str] | None = None
|
||||
category_id: uuid.UUID | None = None
|
||||
# 单独一个开关:`category_id=None` 既可能表示"没改",也可能表示"清空分类",
|
||||
# 靠 pydantic 的未传/传 null 区分太隐晦,不如显式给一个。
|
||||
clear_category: bool = False
|
||||
version: int
|
||||
|
||||
|
||||
class CategoryIn(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=40)
|
||||
parent_id: uuid.UUID | None = None
|
||||
sort_order: int = 0
|
||||
|
||||
|
||||
class CategoryPatch(BaseModel):
|
||||
name: str | None = Field(default=None, min_length=1, max_length=40)
|
||||
parent_id: uuid.UUID | None = None
|
||||
move: bool = False
|
||||
sort_order: int | None = None
|
||||
version: int
|
||||
|
||||
|
||||
@@ -111,6 +137,7 @@ def _doc_out(doc: KnowledgeDoc) -> dict[str, Any]:
|
||||
"status_hint": doc.status_hint,
|
||||
"source_kind": doc.source_kind,
|
||||
"source_item_id": str(doc.source_item_id) if doc.source_item_id else None,
|
||||
"category_id": str(doc.category_id) if doc.category_id else None,
|
||||
"generated_by": doc.generated_by,
|
||||
"published_at": doc.published_at,
|
||||
"archived_at": doc.archived_at,
|
||||
@@ -119,12 +146,96 @@ def _doc_out(doc: KnowledgeDoc) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
# ── 分类 ────────────────────────────────────────────────────────────────
|
||||
async def _categories(
|
||||
session: AsyncSession, ws_id: uuid.UUID
|
||||
) -> dict[uuid.UUID, KnowledgeCategory]:
|
||||
"""一次把分类全捞出来。分类是工作区级的小字典(几十条),比按需查祖先便宜。"""
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(KnowledgeCategory).where(KnowledgeCategory.workspace_id == ws_id)
|
||||
)
|
||||
).scalars()
|
||||
return {row.id: row for row in rows}
|
||||
|
||||
|
||||
def _category_payload(
|
||||
cat: KnowledgeCategory, by_id: dict[uuid.UUID, KnowledgeCategory]
|
||||
) -> dict[str, Any]:
|
||||
"""分类带上完整路径:`研发 / 部署 / 回滚`。客户端不用自己去拼。"""
|
||||
names: list[str] = []
|
||||
node: KnowledgeCategory | None = cat
|
||||
while node is not None and len(names) < MAX_CATEGORY_DEPTH:
|
||||
names.append(node.name)
|
||||
node = by_id.get(node.parent_id) if node.parent_id else None
|
||||
names.reverse()
|
||||
return {
|
||||
"id": str(cat.id),
|
||||
"parent_id": str(cat.parent_id) if cat.parent_id else None,
|
||||
"name": cat.name,
|
||||
"depth": cat.depth,
|
||||
"sort_order": cat.sort_order,
|
||||
"version": cat.version,
|
||||
"path": names,
|
||||
"full_path": " / ".join(names),
|
||||
}
|
||||
|
||||
|
||||
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():
|
||||
if cat.parent_id:
|
||||
children.setdefault(cat.parent_id, []).append(cat.id)
|
||||
out = [root_id]
|
||||
stack = [root_id]
|
||||
while stack:
|
||||
for child in children.get(stack.pop(), []):
|
||||
out.append(child)
|
||||
stack.append(child)
|
||||
return out
|
||||
|
||||
|
||||
async def _resolve_category(
|
||||
session: AsyncSession, ws_id: uuid.UUID, category_id: uuid.UUID | None
|
||||
) -> KnowledgeCategory | None:
|
||||
if category_id is None:
|
||||
return None
|
||||
cat = await session.get(KnowledgeCategory, category_id)
|
||||
if cat is None or cat.workspace_id != ws_id:
|
||||
raise AppError(ErrorCode.KNOWLEDGE_CATEGORY_NOT_FOUND, "这个分类不存在")
|
||||
return cat
|
||||
|
||||
|
||||
async def _category_name_taken(
|
||||
session: AsyncSession,
|
||||
ws_id: uuid.UUID,
|
||||
parent_id: uuid.UUID | None,
|
||||
name: str,
|
||||
exclude: uuid.UUID | None = None,
|
||||
) -> bool:
|
||||
stmt = select(KnowledgeCategory.id).where(
|
||||
KnowledgeCategory.workspace_id == ws_id,
|
||||
func.lower(KnowledgeCategory.name) == name.strip().lower(),
|
||||
)
|
||||
stmt = (
|
||||
stmt.where(KnowledgeCategory.parent_id.is_(None))
|
||||
if parent_id is None
|
||||
else stmt.where(KnowledgeCategory.parent_id == parent_id)
|
||||
)
|
||||
if exclude is not None:
|
||||
stmt = stmt.where(KnowledgeCategory.id != exclude)
|
||||
return (await session.execute(stmt)).first() is not None
|
||||
|
||||
|
||||
async def _knowledge_out(
|
||||
session: AsyncSession, ws_id: uuid.UUID, item: Item, doc: KnowledgeDoc
|
||||
) -> dict[str, Any]:
|
||||
tags = (await tags_of(session, ws_id, [item.id])).get(item.id, [])
|
||||
data = item_out(item, tags)
|
||||
data["knowledge"] = _doc_out(doc)
|
||||
by_id = await _categories(session, ws_id)
|
||||
cat = by_id.get(doc.category_id) if doc.category_id else None
|
||||
data["category"] = _category_payload(cat, by_id) if cat else None
|
||||
data["source"] = await _source_out(session, ws_id, doc.source_item_id)
|
||||
data["attachments"] = await list_for_item(session, ws_id, item.id)
|
||||
data["attachment_count"] = len(data["attachments"])
|
||||
@@ -147,6 +258,9 @@ async def list_knowledge(
|
||||
status: Annotated[list[str] | None, Query()] = None,
|
||||
doc_type: Annotated[str | None, Query()] = None,
|
||||
source_item_id: Annotated[uuid.UUID | None, Query()] = None,
|
||||
category_id: Annotated[uuid.UUID | None, Query(description="含子分类")] = None,
|
||||
uncategorized: Annotated[bool, Query(description="只看未分类")] = False,
|
||||
tag: Annotated[str | None, Query(max_length=32)] = None,
|
||||
q: Annotated[str | None, Query(max_length=100)] = None,
|
||||
limit: Annotated[int, Query(ge=1, le=100, description="每页条数")] = 30,
|
||||
cursor: Annotated[str | None, Query(description="上一页返回的 next_cursor")] = None,
|
||||
@@ -164,6 +278,21 @@ async def list_knowledge(
|
||||
stmt = stmt.where(KnowledgeDoc.doc_type == doc_type)
|
||||
if source_item_id:
|
||||
stmt = stmt.where(KnowledgeDoc.source_item_id == source_item_id)
|
||||
by_id: dict[uuid.UUID, KnowledgeCategory] = {}
|
||||
if uncategorized:
|
||||
stmt = stmt.where(KnowledgeDoc.category_id.is_(None))
|
||||
elif category_id is not None:
|
||||
by_id = await _categories(session, ws_id)
|
||||
cat = by_id.get(category_id)
|
||||
if cat is None:
|
||||
raise AppError(ErrorCode.KNOWLEDGE_CATEGORY_NOT_FOUND, "这个分类不存在")
|
||||
stmt = stmt.where(KnowledgeDoc.category_id.in_(_subtree_ids(category_id, by_id)))
|
||||
if tag:
|
||||
stmt = (
|
||||
stmt.join(ItemTag, ItemTag.item_id == Item.id)
|
||||
.join(Tag, Tag.id == ItemTag.tag_id)
|
||||
.where(Tag.workspace_id == ws_id, func.lower(Tag.name) == tag.strip().lower())
|
||||
)
|
||||
if q:
|
||||
like = f"%{q.strip()}%"
|
||||
stmt = stmt.where(
|
||||
@@ -187,12 +316,16 @@ async def list_knowledge(
|
||||
has_more = len(rows) > limit
|
||||
rows = rows[:limit]
|
||||
tag_map = await tags_of(session, ws_id, [it.id for it, _ in rows])
|
||||
if not by_id:
|
||||
by_id = await _categories(session, ws_id)
|
||||
|
||||
items = []
|
||||
counts = await count_for_items(session, ws_id, [it.id for it, _ in rows])
|
||||
for item, doc in rows:
|
||||
data = item_out(item, tag_map.get(item.id, []))
|
||||
data["knowledge"] = _doc_out(doc)
|
||||
cat = by_id.get(doc.category_id) if doc.category_id else None
|
||||
data["category"] = _category_payload(cat, by_id) if cat else None
|
||||
data["source"] = await _source_out(session, ws_id, doc.source_item_id)
|
||||
data["attachment_count"] = counts.get(str(item.id), 0)
|
||||
items.append(data)
|
||||
@@ -203,6 +336,221 @@ async def list_knowledge(
|
||||
return ok({"items": items, "next_cursor": next_cursor, "has_more": has_more})
|
||||
|
||||
|
||||
# ── 分类的增删改查 ──────────────────────────────────────────────────────
|
||||
# 路由顺序:`/knowledge/categories` 必须排在 `/knowledge/{item_id}` 之前,
|
||||
# 否则 "categories" 会被当成 uuid 解析,直接 422。
|
||||
@router.get("/knowledge/categories", summary="知识分类")
|
||||
async def list_categories(ctx: MemberDep, session: SessionDep) -> dict:
|
||||
by_id = await _categories(session, ctx.ws_id)
|
||||
counts = dict(
|
||||
(
|
||||
await session.execute(
|
||||
select(KnowledgeDoc.category_id, func.count())
|
||||
.join(Item, Item.id == KnowledgeDoc.item_id)
|
||||
.where(
|
||||
KnowledgeDoc.workspace_id == ctx.ws_id,
|
||||
KnowledgeDoc.category_id.is_not(None),
|
||||
KnowledgeDoc.status_hint != "archived",
|
||||
Item.deleted_at.is_(None),
|
||||
)
|
||||
.group_by(KnowledgeDoc.category_id)
|
||||
)
|
||||
).all()
|
||||
)
|
||||
items = []
|
||||
for cat in sorted(by_id.values(), key=lambda c: (c.depth, c.sort_order, c.name)):
|
||||
data = _category_payload(cat, by_id)
|
||||
data["doc_count"] = counts.get(cat.id, 0)
|
||||
items.append(data)
|
||||
return ok({"items": items, "max_depth": MAX_CATEGORY_DEPTH})
|
||||
|
||||
|
||||
@router.post("/knowledge/categories", summary="新建分类")
|
||||
async def create_category(body: CategoryIn, ctx: MemberDep, session: SessionDep) -> dict:
|
||||
ws_id = ctx.ws_id
|
||||
depth = 1
|
||||
if body.parent_id is not None:
|
||||
parent = await _resolve_category(session, ws_id, body.parent_id)
|
||||
depth = parent.depth + 1
|
||||
if depth > MAX_CATEGORY_DEPTH:
|
||||
raise AppError(
|
||||
ErrorCode.KNOWLEDGE_CATEGORY_DEPTH,
|
||||
f"分类最多 {MAX_CATEGORY_DEPTH} 级,「{parent.name}」下面不能再建了",
|
||||
details={"parent_depth": parent.depth},
|
||||
)
|
||||
name = body.name.strip()
|
||||
if await _category_name_taken(session, ws_id, body.parent_id, name):
|
||||
raise AppError(ErrorCode.KNOWLEDGE_CATEGORY_CONFLICT, "同一层已经有同名的分类")
|
||||
cat = KnowledgeCategory(
|
||||
workspace_id=ws_id,
|
||||
parent_id=body.parent_id,
|
||||
name=name,
|
||||
depth=depth,
|
||||
sort_order=body.sort_order,
|
||||
)
|
||||
session.add(cat)
|
||||
await session.flush()
|
||||
await audit_record(
|
||||
session,
|
||||
workspace_id=ws_id,
|
||||
actor_user_id=ctx.user.id,
|
||||
action="knowledge.category.create",
|
||||
target_type="knowledge_category",
|
||||
target_id=str(cat.id),
|
||||
)
|
||||
by_id = await _categories(session, ws_id)
|
||||
return ok(_category_payload(cat, by_id))
|
||||
|
||||
|
||||
@router.patch("/knowledge/categories/{category_id}", summary="改名或移动分类")
|
||||
async def patch_category(
|
||||
category_id: uuid.UUID, body: CategoryPatch, ctx: MemberDep, session: SessionDep
|
||||
) -> dict:
|
||||
ws_id = ctx.ws_id
|
||||
cat = await session.get(KnowledgeCategory, category_id)
|
||||
if cat is None or cat.workspace_id != ws_id:
|
||||
raise AppError(ErrorCode.KNOWLEDGE_CATEGORY_NOT_FOUND, "这个分类不存在")
|
||||
by_id = await _categories(session, ws_id)
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
new_parent_id = cat.parent_id
|
||||
new_depth = cat.depth
|
||||
shift = 0
|
||||
subtree: list[uuid.UUID] = []
|
||||
if body.move:
|
||||
new_parent_id = body.parent_id
|
||||
if new_parent_id is not None and new_parent_id not in by_id:
|
||||
raise AppError(ErrorCode.KNOWLEDGE_CATEGORY_NOT_FOUND, "这个分类不存在")
|
||||
if new_parent_id == category_id:
|
||||
raise AppError(ErrorCode.KNOWLEDGE_CATEGORY_DEPTH, "不能把分类挂到自己下面")
|
||||
subtree = _subtree_ids(category_id, by_id)
|
||||
if new_parent_id is not None and new_parent_id in subtree:
|
||||
raise AppError(ErrorCode.KNOWLEDGE_CATEGORY_DEPTH, "不能把分类移到它自己的子分类下")
|
||||
new_depth = by_id[new_parent_id].depth + 1 if new_parent_id else 1
|
||||
deepest = max(by_id[cid].depth for cid in subtree)
|
||||
shift = new_depth - cat.depth
|
||||
if deepest + shift > MAX_CATEGORY_DEPTH:
|
||||
raise AppError(
|
||||
ErrorCode.KNOWLEDGE_CATEGORY_DEPTH,
|
||||
f"移动后子树最深到第 {deepest + shift} 级,超过 {MAX_CATEGORY_DEPTH} 级",
|
||||
details={"deepest_after_move": deepest + shift},
|
||||
)
|
||||
|
||||
name = body.name.strip() if body.name is not None else cat.name
|
||||
if name != cat.name and await _category_name_taken(
|
||||
session, ws_id, new_parent_id, name, exclude=category_id
|
||||
):
|
||||
raise AppError(ErrorCode.KNOWLEDGE_CATEGORY_CONFLICT, "同一层已经有同名的分类")
|
||||
|
||||
values: dict[str, Any] = {"name": name, "updated_at": now}
|
||||
if body.move:
|
||||
values.update(parent_id=new_parent_id, depth=new_depth)
|
||||
if body.sort_order is not None:
|
||||
values["sort_order"] = body.sort_order
|
||||
|
||||
result = await session.execute(
|
||||
update(KnowledgeCategory)
|
||||
.where(
|
||||
KnowledgeCategory.id == category_id,
|
||||
KnowledgeCategory.workspace_id == ws_id,
|
||||
KnowledgeCategory.version == body.version,
|
||||
)
|
||||
.values(version=KnowledgeCategory.version + 1, **values)
|
||||
)
|
||||
if result.rowcount == 0:
|
||||
await session.refresh(cat)
|
||||
raise AppError(
|
||||
ErrorCode.VERSION_CONFLICT,
|
||||
"这个分类刚被改过",
|
||||
details={"current_version": cat.version},
|
||||
)
|
||||
|
||||
# 子树跟着整体升降;只改被移动的那棵子树以外的行会留下 depth 不一致
|
||||
if body.move and shift:
|
||||
for cid in subtree:
|
||||
if cid == category_id:
|
||||
continue
|
||||
await session.execute(
|
||||
update(KnowledgeCategory)
|
||||
.where(KnowledgeCategory.id == cid, KnowledgeCategory.workspace_id == ws_id)
|
||||
.values(
|
||||
depth=KnowledgeCategory.depth + shift,
|
||||
updated_at=now,
|
||||
version=KnowledgeCategory.version + 1,
|
||||
)
|
||||
)
|
||||
await session.flush()
|
||||
fresh = await _categories(session, ws_id)
|
||||
return ok(_category_payload(fresh[category_id], fresh))
|
||||
|
||||
|
||||
@router.delete("/knowledge/categories/{category_id}", summary="删除分类")
|
||||
async def delete_category(
|
||||
category_id: uuid.UUID,
|
||||
ctx: MemberDep,
|
||||
session: SessionDep,
|
||||
force: Annotated[bool, Query(description="真删:里面的知识变成未分类")] = False,
|
||||
) -> dict:
|
||||
ws_id = ctx.ws_id
|
||||
cat = await session.get(KnowledgeCategory, category_id)
|
||||
if cat is None or cat.workspace_id != ws_id:
|
||||
raise AppError(ErrorCode.KNOWLEDGE_CATEGORY_NOT_FOUND, "这个分类不存在")
|
||||
children = (
|
||||
await session.execute(
|
||||
select(func.count())
|
||||
.select_from(KnowledgeCategory)
|
||||
.where(
|
||||
KnowledgeCategory.workspace_id == ws_id,
|
||||
KnowledgeCategory.parent_id == category_id,
|
||||
)
|
||||
)
|
||||
).scalar_one()
|
||||
if children:
|
||||
raise AppError(
|
||||
ErrorCode.KNOWLEDGE_CATEGORY_CONFLICT,
|
||||
f"「{cat.name}」下面还有 {children} 个子分类,先把它们移走或删掉",
|
||||
details={"children": children},
|
||||
)
|
||||
docs = (
|
||||
await session.execute(
|
||||
select(func.count())
|
||||
.select_from(KnowledgeDoc)
|
||||
.where(KnowledgeDoc.workspace_id == ws_id, KnowledgeDoc.category_id == category_id)
|
||||
)
|
||||
).scalar_one()
|
||||
if docs and not force:
|
||||
raise AppError(
|
||||
ErrorCode.KNOWLEDGE_CATEGORY_CONFLICT,
|
||||
f"「{cat.name}」里还有 {docs} 条知识,先移走,或者带 force 删成未分类",
|
||||
details={"doc_count": docs},
|
||||
)
|
||||
await session.delete(cat)
|
||||
await audit_record(
|
||||
session,
|
||||
workspace_id=ws_id,
|
||||
actor_user_id=ctx.user.id,
|
||||
action="knowledge.category.delete",
|
||||
target_type="knowledge_category",
|
||||
target_id=str(category_id),
|
||||
payload={"orphaned_docs": docs},
|
||||
)
|
||||
return ok({"deleted": str(category_id), "uncategorized_docs": docs})
|
||||
|
||||
|
||||
# 注意路由顺序:`/knowledge/search` 必须排在 `/knowledge/{item_id}` 之前,
|
||||
# 否则 "search" 会被当成 uuid 解析,返回 422。
|
||||
@router.get("/knowledge/search", summary="检索已发布知识")
|
||||
async def search(
|
||||
ctx: MemberDep,
|
||||
session: SessionDep,
|
||||
q: Annotated[str, Query(min_length=1, max_length=200, description="检索词")],
|
||||
limit: Annotated[int, Query(ge=1, le=30)] = 10,
|
||||
mode: Annotated[str, Query(pattern="^(hybrid|vector|keyword)$")] = "hybrid",
|
||||
) -> dict:
|
||||
hits = await search_knowledge(session, ctx.ws_id, q, limit=limit, mode=mode)
|
||||
return ok({"query": q, "mode": mode, "items": hits, "has_more": False})
|
||||
|
||||
|
||||
@router.get("/knowledge/{item_id}", summary="知识详情")
|
||||
async def get_knowledge(item_id: uuid.UUID, ctx: MemberDep, session: SessionDep) -> dict:
|
||||
item, doc = await _load_doc(session, ctx.ws_id, item_id)
|
||||
@@ -242,6 +590,7 @@ async def create_knowledge(body: KnowledgeIn, ctx: MemberDep, session: SessionDe
|
||||
ErrorCode.KNOWLEDGE_SOURCE_REQUIRED,
|
||||
"来源必须是与本知识同一工作区的任务或工单",
|
||||
)
|
||||
category = await _resolve_category(session, ws_id, body.category_id)
|
||||
|
||||
item = Item(
|
||||
workspace_id=ws_id,
|
||||
@@ -263,6 +612,7 @@ async def create_knowledge(body: KnowledgeIn, ctx: MemberDep, session: SessionDe
|
||||
doc_type=body.doc_type,
|
||||
status_hint="draft",
|
||||
summary=body.summary,
|
||||
category_id=category.id if category else None,
|
||||
generated_by="human",
|
||||
)
|
||||
session.add(doc)
|
||||
@@ -295,17 +645,19 @@ async def patch_knowledge(
|
||||
if body.doc_type is not None and body.doc_type not in DOC_TYPES:
|
||||
raise AppError(ErrorCode.VALIDATION_ERROR, f"未知的知识类型:{body.doc_type}")
|
||||
|
||||
# 这里只放 Item 表自己的列:doc_type / summary 属于 knowledge_doc,
|
||||
# 混进来会直接 CompileError: Unconsumed column names
|
||||
values: dict[str, Any] = {}
|
||||
if body.title is not None:
|
||||
values["title"] = body.title.strip()
|
||||
if body.body_md is not None:
|
||||
values["body_md"] = body.body_md
|
||||
if body.summary is not None:
|
||||
values["summary"] = body.summary
|
||||
if body.doc_type is not None:
|
||||
values["doc_type"] = body.doc_type
|
||||
|
||||
if values:
|
||||
# 分类/标签/类型也改的是这条知识的"样子",所以同样要推进 version:
|
||||
# 否则只改了标签的知识,下一次保存还会拿着旧 version 撞 4009。
|
||||
doc_changed = body.doc_type is not None or body.summary is not None
|
||||
meta_changed = body.clear_category or body.category_id is not None or body.tag_names is not None
|
||||
if values or doc_changed or meta_changed:
|
||||
values["updated_at"] = datetime.now(timezone.utc)
|
||||
result = await session.execute(
|
||||
update(Item)
|
||||
@@ -329,6 +681,11 @@ async def patch_knowledge(
|
||||
doc.doc_type = body.doc_type
|
||||
if body.summary is not None:
|
||||
doc.summary = body.summary
|
||||
if body.clear_category:
|
||||
doc.category_id = None
|
||||
elif body.category_id is not None:
|
||||
category = await _resolve_category(session, ws_id, body.category_id)
|
||||
doc.category_id = category.id if category else None
|
||||
if body.tag_names is not None:
|
||||
await _set_tags_for(session, ws_id, item, body.tag_names)
|
||||
await session.flush()
|
||||
@@ -396,9 +753,28 @@ async def change_status(
|
||||
payload={"to": to_status},
|
||||
)
|
||||
await session.flush()
|
||||
if body.action == "publish":
|
||||
# 发布后立刻建索引:检索只认 published,不在这里建就要等定时任务,
|
||||
# 而"刚发布却搜不到"是最容易被当成 bug 的体验。
|
||||
await reindex_document(session, ws_id, item.id)
|
||||
return ok(await _knowledge_out(session, ws_id, item, doc))
|
||||
|
||||
|
||||
@router.post("/knowledge/{item_id}/reindex", summary="重建这份知识的检索切片")
|
||||
async def reindex(item_id: uuid.UUID, ctx: MemberDep, session: SessionDep) -> dict:
|
||||
# 改完正文不重建,检索命中的还是旧句子——留一个能手动触发的入口
|
||||
_item, doc = await _load_doc(session, ctx.ws_id, item_id)
|
||||
count = await reindex_document(session, ctx.ws_id, item_id)
|
||||
await session.flush()
|
||||
return ok(
|
||||
{
|
||||
"item_id": item_id,
|
||||
"chunk_count": count,
|
||||
"searchable": doc.status_hint == "published",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# ── 从任务/工单沉淀 ─────────────────────────────────────────────────────
|
||||
@router.post("/items/{item_id}/knowledge", summary="把这件事沉淀成知识草稿")
|
||||
async def promote_to_knowledge(
|
||||
|
||||
@@ -39,6 +39,7 @@ from ..models_task import Activity, Item
|
||||
from ..models_ticket import ItemLink, Notification, TicketExt
|
||||
from ..schemas import Pagination, encode_cursor, ok
|
||||
from ..serializers import item_out, tags_of
|
||||
from ..services.notify import notify
|
||||
|
||||
router = APIRouter(prefix="/workspaces/{ws_id}", tags=["工单"])
|
||||
notify_router = APIRouter(prefix="/workspaces/{ws_id}/notifications", tags=["通知"])
|
||||
@@ -60,6 +61,16 @@ class ReassignIn(BaseModel):
|
||||
assignee_id: uuid.UUID
|
||||
|
||||
|
||||
class TicketCreateIn(BaseModel):
|
||||
"""手工开单:不经过任务升级,直接指定谁去办。"""
|
||||
|
||||
title: str = Field(min_length=1, max_length=200)
|
||||
body_md: str | None = Field(default=None, max_length=20000)
|
||||
assignee_id: uuid.UUID
|
||||
due_at: datetime | None = None
|
||||
priority: int = Field(default=0, ge=0, le=3)
|
||||
|
||||
|
||||
class VerifyIn(BaseModel):
|
||||
approve: bool
|
||||
note: str = Field(default="", max_length=MAX_REJECT_NOTE)
|
||||
@@ -139,16 +150,18 @@ async def _notify(
|
||||
item_id: uuid.UUID | None,
|
||||
title: str,
|
||||
body: str = "",
|
||||
actor_id: uuid.UUID | None = None,
|
||||
) -> None:
|
||||
session.add(
|
||||
Notification(
|
||||
workspace_id=ws_id,
|
||||
user_id=user_id,
|
||||
kind=kind,
|
||||
item_id=item_id,
|
||||
title=title[:120],
|
||||
body=body[:300],
|
||||
)
|
||||
"""单人便捷入口,实际写入交给 services.notify(去重与跳过自己在那里做)。"""
|
||||
await notify(
|
||||
session,
|
||||
ws_id=ws_id,
|
||||
user_ids=(user_id,),
|
||||
kind=kind,
|
||||
item_id=item_id,
|
||||
title=title,
|
||||
body=body,
|
||||
actor_id=actor_id,
|
||||
)
|
||||
|
||||
|
||||
@@ -520,6 +533,67 @@ async def reassign(
|
||||
return await _ticket_out(session, ctx.ws_id, ticket, ext)
|
||||
|
||||
|
||||
# -- 手工开单 -----------------------------------------------------------
|
||||
@router.post("/tickets", summary="手工开一张工单(不依赖任务升级)")
|
||||
async def create_ticket(body: TicketCreateIn, ctx: MemberDep, session: SessionDep) -> dict:
|
||||
"""开会、口头上被派活、临时找同事帮忙——这些事从来没有过"任务"。
|
||||
|
||||
工单不是任务的衍生物,只是"要别人办的事"的一种形态:它可以凭空存在,
|
||||
也可以由任务升级而来(那条路径见 promote)。两条路都落在 kind=ticket 的
|
||||
同一条 item 上,所以看板、评论、验收、沉淀知识全都共用。
|
||||
"""
|
||||
await _active_member_id(session, ctx.ws_id, body.assignee_id)
|
||||
|
||||
ticket = Item(
|
||||
workspace_id=ctx.ws_id,
|
||||
kind="ticket",
|
||||
title=body.title.strip(),
|
||||
body_md=body.body_md,
|
||||
status="open",
|
||||
priority=body.priority,
|
||||
creator_id=ctx.user.id,
|
||||
assignee_id=body.assignee_id,
|
||||
due_at=body.due_at,
|
||||
)
|
||||
session.add(ticket)
|
||||
await session.flush()
|
||||
|
||||
ext = TicketExt(
|
||||
item_id=ticket.id,
|
||||
workspace_id=ctx.ws_id,
|
||||
requester_id=ctx.user.id,
|
||||
assignee_id=body.assignee_id,
|
||||
parent_item_id=None,
|
||||
)
|
||||
session.add(ext)
|
||||
await session.flush()
|
||||
|
||||
await _log(session, ticket, ctx.user.id, "ticket_created", {"manual": True})
|
||||
await _notify(
|
||||
session,
|
||||
ws_id=ctx.ws_id,
|
||||
user_id=body.assignee_id,
|
||||
kind="ticket_assigned",
|
||||
item_id=ticket.id,
|
||||
title=f"有人请你办一件事:{ticket.title}",
|
||||
body=f"{ctx.user.nickname or '同事'} 直接给你开了张工单,办完提交验收",
|
||||
actor_id=ctx.user.id,
|
||||
)
|
||||
await audit_record(
|
||||
session,
|
||||
workspace_id=ctx.ws_id,
|
||||
actor_user_id=ctx.user.id,
|
||||
action="ticket.create",
|
||||
target_type="item",
|
||||
target_id=str(ticket.id),
|
||||
payload={"manual": True},
|
||||
)
|
||||
await session.flush()
|
||||
|
||||
ticket_out = await _item_out(session, ctx.ws_id, ticket)
|
||||
return ok({**ticket_out, "ticket": _ticket_ext_out(ext)}, message="已创建")
|
||||
|
||||
|
||||
# -- 工单列表 -----------------------------------------------------------
|
||||
@router.get("/tickets", summary="工单列表:我指派的 / 我发起的")
|
||||
async def list_tickets(
|
||||
|
||||
@@ -57,6 +57,9 @@ class Settings(BaseSettings):
|
||||
sms_throttle_seconds: int = 60
|
||||
sms_dev_code: str = "123456"
|
||||
sms_provider: Literal["dev", "aliyun", "tencent"] = "dev"
|
||||
# 内测开关:生产默认禁止固定验证码。确实要在内网试用时显式打开,
|
||||
# 启动会打 WARNING(见 main.py),其余生产校验照旧——绝不能配合公网暴露。
|
||||
internal_test_login: bool = False
|
||||
|
||||
# ── 幂等 ──────────────────────────────────────────────
|
||||
idempotency_enabled: bool = True
|
||||
@@ -71,6 +74,27 @@ class Settings(BaseSettings):
|
||||
# 30 分钟够一次阅读/编辑,过期后刷新页面会重新签发。
|
||||
attachment_url_ttl_seconds: int = 1800
|
||||
|
||||
# ── 知识检索(M3)──────────────────────────────────────
|
||||
# 切片大小与重叠:中文一段 600 字大约 400~600 token,够表达一个完整片段;
|
||||
# 重叠 80 字是为了不把跨段的话切断(检索命中时前后文还读得懂)。
|
||||
chunk_max_chars: int = 600
|
||||
chunk_overlap_chars: int = 80
|
||||
# 嵌入向量提供方:
|
||||
# hash —— 内置的字符 n-gram 哈希向量,离线可用、零依赖、可复现;
|
||||
# 质量不如真模型,但能把"检索链路"跑通,且换模型时接口不变。
|
||||
# openai —— 任何 OpenAI 兼容的 /embeddings 端点(BYOK)。
|
||||
embedding_provider: Literal["hash", "openai"] = "hash"
|
||||
embedding_base_url: str = "https://api.deepseek.com/v1"
|
||||
embedding_api_key: str | None = None
|
||||
embedding_model: str = "text-embedding-3-small"
|
||||
# 混合检索里向量与词法的权重。纯词法搜不到同义改写,纯向量搜不准专有名词,
|
||||
# 所以默认各占一半,可用环境变量调。
|
||||
search_vector_weight: float = 0.6
|
||||
search_lexical_weight: float = 0.4
|
||||
|
||||
# 低于这个分数就不返回:宁可"没找到",也不要塞一条不相干的让人误信。
|
||||
search_min_score: float = 0.06
|
||||
|
||||
# ── 知识库向量库(独立实例,M3 起用)───────────────────
|
||||
# 刻意与业务库分开放:fnsvr-01 上的 PG 没有 pgvector,也不该为了装扩展去动它;
|
||||
# 向量检索吃内存,和任务库挤在同一实例会互相拖累。
|
||||
@@ -79,6 +103,11 @@ class Settings(BaseSettings):
|
||||
embedding_dim: int = 1024
|
||||
embedding_model: str = "BAAI/bge-m3"
|
||||
|
||||
# ── 前端产物 ──────────────────────────────────────────
|
||||
# 生产没有 nginx 时由后端直接托管前端构建产物(同源,/api 无跨域)。
|
||||
# 指向 web/dist 的绝对路径;留空则只提供 API。
|
||||
web_dist_dir: str | None = None
|
||||
|
||||
# ── 构建标记(部署校验用)──────────────────────────────
|
||||
build_commit: str = "unknown"
|
||||
build_time: str = "unknown"
|
||||
@@ -100,8 +129,11 @@ class Settings(BaseSettings):
|
||||
problems.append("JWT_SECRET 仍是开发默认值或长度不足 32")
|
||||
if "dadao_dev_pw" in self.database_url:
|
||||
problems.append("DATABASE_URL 仍在用开发默认口令")
|
||||
if self.sms_provider == "dev":
|
||||
problems.append("生产环境不允许 SMS_PROVIDER=dev(任意验证码可登录)")
|
||||
if self.sms_provider == "dev" and not self.internal_test_login:
|
||||
problems.append(
|
||||
"生产环境不允许 SMS_PROVIDER=dev(任意验证码可登录);"
|
||||
"内网试用确需固定验证码时显式设置 INTERNAL_TEST_LOGIN=true"
|
||||
)
|
||||
if problems:
|
||||
raise RuntimeError("配置校验失败:" + ";".join(problems))
|
||||
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
"""把 Markdown 正文切成适合检索的小片段(M3)。
|
||||
|
||||
切片的目的是"喂给检索",不是"给人读",所以三条规则优先于其它考虑:
|
||||
|
||||
1. **不切坏结构**:代码块整块保留,表格不拆行,标题跟着它下面的段落走
|
||||
(单独一句"结论"离开上下文就没意义)。
|
||||
2. **片内自洽**:每片尽量包含一个完整意思,超长才按句号切。
|
||||
3. **相邻片有重叠**:跨段的话被切断时,前后片都能读到完整句子,命中后引用才不乱。
|
||||
|
||||
不引入分词器:中文另起 `zhparser` 要装扩展,而片段足够短时字符级检索已经够用。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
|
||||
HEADING_RE = re.compile(r"^(#{1,6})\s+(.+?)\s*$")
|
||||
FENCE_RE = re.compile(r"^\s*```")
|
||||
SENTENCE_RE = re.compile(r"[^。!?!?;;\n]+[。!?!?;;]?")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ChunkDraft:
|
||||
heading: str | None
|
||||
content: str
|
||||
|
||||
|
||||
def _split_long(text: str, max_chars: int) -> list[str]:
|
||||
"""把超长段落按句子切开;单句仍然超长就硬切。"""
|
||||
pieces: list[str] = []
|
||||
buffer = ""
|
||||
for sentence in SENTENCE_RE.findall(text):
|
||||
if not sentence.strip():
|
||||
continue
|
||||
if len(buffer) + len(sentence) <= max_chars:
|
||||
buffer += sentence
|
||||
continue
|
||||
if buffer.strip():
|
||||
pieces.append(buffer.strip())
|
||||
if len(sentence) <= max_chars:
|
||||
buffer = sentence
|
||||
else:
|
||||
for start in range(0, len(sentence), max_chars):
|
||||
pieces.append(sentence[start : start + max_chars].strip())
|
||||
buffer = ""
|
||||
if buffer.strip():
|
||||
pieces.append(buffer.strip())
|
||||
return [p for p in pieces if p]
|
||||
|
||||
|
||||
def split_markdown(
|
||||
text: str,
|
||||
*,
|
||||
max_chars: int = 600,
|
||||
overlap: int = 80,
|
||||
) -> list[ChunkDraft]:
|
||||
"""返回按顺序排列的切片。
|
||||
|
||||
`overlap` 是相邻片之间共享的尾部字数;不想要重叠就传 0。
|
||||
"""
|
||||
if not text or not text.strip():
|
||||
return []
|
||||
|
||||
normalized = text.replace("\r\n", "\n").replace("\r", "\n")
|
||||
blocks: list[tuple[str | None, str]] = []
|
||||
heading: str | None = None
|
||||
paragraph: list[str] = []
|
||||
in_fence = False
|
||||
|
||||
def flush() -> None:
|
||||
if paragraph:
|
||||
body = "\n".join(paragraph).strip()
|
||||
if body:
|
||||
blocks.append((heading, body))
|
||||
paragraph.clear()
|
||||
|
||||
for line in normalized.split("\n"):
|
||||
if FENCE_RE.match(line):
|
||||
in_fence = not in_fence
|
||||
paragraph.append(line)
|
||||
continue
|
||||
if not in_fence:
|
||||
match = HEADING_RE.match(line)
|
||||
if match:
|
||||
flush()
|
||||
heading = match.group(2).strip()
|
||||
continue
|
||||
if not line.strip():
|
||||
flush()
|
||||
continue
|
||||
paragraph.append(line)
|
||||
flush()
|
||||
|
||||
chunks: list[ChunkDraft] = []
|
||||
buffer = ""
|
||||
buffer_heading: str | None = None
|
||||
|
||||
def flush_buffer() -> None:
|
||||
nonlocal buffer, buffer_heading
|
||||
if buffer.strip():
|
||||
chunks.append(ChunkDraft(heading=buffer_heading, content=buffer.strip()))
|
||||
buffer = ""
|
||||
|
||||
for block_heading, body in blocks:
|
||||
parts = _split_long(body, max_chars) if len(body) > max_chars else [body]
|
||||
for part in parts:
|
||||
candidate = f"{buffer}\n\n{part}".strip() if buffer else part
|
||||
if len(candidate) <= max_chars:
|
||||
if not buffer:
|
||||
buffer_heading = block_heading
|
||||
buffer = candidate
|
||||
continue
|
||||
flush_buffer()
|
||||
# 新片开头带上上一片的尾巴,避免"半句话"被切断后两边都读不通
|
||||
tail = ""
|
||||
if overlap > 0 and chunks:
|
||||
tail = chunks[-1].content[-overlap:]
|
||||
if "\n" in tail:
|
||||
tail = tail.split("\n", 1)[-1]
|
||||
buffer_heading = block_heading
|
||||
buffer = f"{tail}\n\n{part}".strip() if tail else part
|
||||
if len(buffer) > max_chars:
|
||||
# 带尾巴后仍然超长:放弃尾巴,保证不超限
|
||||
buffer = part
|
||||
flush_buffer()
|
||||
return chunks
|
||||
@@ -56,6 +56,9 @@ class ErrorCode(IntEnum):
|
||||
KNOWLEDGE_STATUS_INVALID = 7003
|
||||
KNOWLEDGE_FORBIDDEN = 7004
|
||||
KNOWLEDGE_SOURCE_REQUIRED = 7005
|
||||
KNOWLEDGE_CATEGORY_NOT_FOUND = 7006
|
||||
KNOWLEDGE_CATEGORY_DEPTH = 7007
|
||||
KNOWLEDGE_CATEGORY_CONFLICT = 7008
|
||||
|
||||
# 8xxx 附件
|
||||
ATTACHMENT_NOT_FOUND = 8001
|
||||
@@ -101,6 +104,9 @@ _HTTP_STATUS: dict[ErrorCode, int] = {
|
||||
ErrorCode.KNOWLEDGE_STATUS_INVALID: 409,
|
||||
ErrorCode.KNOWLEDGE_FORBIDDEN: 403,
|
||||
ErrorCode.KNOWLEDGE_SOURCE_REQUIRED: 409,
|
||||
ErrorCode.KNOWLEDGE_CATEGORY_NOT_FOUND: 404,
|
||||
ErrorCode.KNOWLEDGE_CATEGORY_DEPTH: 422,
|
||||
ErrorCode.KNOWLEDGE_CATEGORY_CONFLICT: 409,
|
||||
ErrorCode.ATTACHMENT_NOT_FOUND: 404,
|
||||
ErrorCode.ATTACHMENT_TOO_LARGE: 413,
|
||||
ErrorCode.ATTACHMENT_FORBIDDEN: 403,
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
@@ -11,6 +12,9 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy import text
|
||||
from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||
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 .config import get_settings
|
||||
@@ -27,6 +31,11 @@ logger = get_logger("app")
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
settings.validate_runtime()
|
||||
if settings.env == "prod" and settings.internal_test_login:
|
||||
logger.warning(
|
||||
"内测登录已开启:任意手机号可用固定验证码登录,只能在内网访问,切勿暴露公网",
|
||||
extra={"extra_fields": {"sms_provider": settings.sms_provider}},
|
||||
)
|
||||
logger.info(
|
||||
"服务启动",
|
||||
extra={"extra_fields": {"env": settings.env, "commit": settings.build_commit}},
|
||||
@@ -138,3 +147,22 @@ async def version() -> dict:
|
||||
"api_prefix": settings.api_prefix,
|
||||
"db_role": role,
|
||||
}
|
||||
|
||||
|
||||
# ── 前端产物:单端口部署(没有 nginx 时也让一个端口提供完整站点)────────
|
||||
# 顺序很重要:必须挂在所有 API 路由之后,否则挂载 "/" 会吞掉 /api、/health、/version。
|
||||
class _SPAStaticFiles(StaticFiles):
|
||||
"""静态命中就返回,未命中回退 index.html——前端路由(如 /w/<id>)交给浏览器处理。"""
|
||||
|
||||
async def get_response(self, path: str, scope: Scope) -> Response:
|
||||
try:
|
||||
return await super().get_response(path, scope)
|
||||
except StarletteHTTPException as exc:
|
||||
if exc.status_code == 404:
|
||||
return await super().get_response("index.html", scope)
|
||||
raise
|
||||
|
||||
|
||||
_web_dist = Path(settings.web_dist_dir) if settings.web_dist_dir else None
|
||||
if _web_dist is not None and (_web_dist / "index.html").is_file():
|
||||
app.mount("/", _SPAStaticFiles(directory=str(_web_dist), html=True), name="web")
|
||||
|
||||
@@ -19,15 +19,18 @@ from datetime import datetime
|
||||
from sqlalchemy import (
|
||||
CheckConstraint,
|
||||
DateTime,
|
||||
Float,
|
||||
ForeignKey,
|
||||
Index,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import ARRAY
|
||||
from sqlalchemy.dialects.postgresql import UUID as PgUUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from .models import Base, Timestamps
|
||||
from .models import Base, Timestamps, new_uuid
|
||||
|
||||
# 知识状态机(与任务/工单共用 item.status 一列,取值集合不同)
|
||||
KNOWLEDGE_STATUSES: tuple[str, ...] = ("draft", "in_review", "published", "archived")
|
||||
@@ -36,6 +39,36 @@ DOC_TYPES: tuple[str, ...] = ("note", "howto", "faq", "postmortem", "decision")
|
||||
SOURCE_KINDS: tuple[str, ...] = ("manual", "task", "ticket")
|
||||
GENERATED_BY: tuple[str, ...] = ("human", "rule")
|
||||
|
||||
# 分类树最多三级。再深就不是"归档"而是"迷宫"了:找东西的人记不住第四层叫什么。
|
||||
MAX_CATEGORY_DEPTH = 3
|
||||
|
||||
|
||||
class KnowledgeCategory(Base, Timestamps):
|
||||
"""知识分类:一级大类 → 二级 → 三级,深不过三。
|
||||
|
||||
`depth` 是冗余列,但值得:每次筛选、移动子树都要判断层级,存下来就不用
|
||||
递归数祖先。写入时由 API 计算,读到不一致的行属于数据事故。
|
||||
"""
|
||||
|
||||
__tablename__ = "knowledge_category"
|
||||
__table_args__ = (
|
||||
Index("ix_kcat_ws_parent", "workspace_id", "parent_id"),
|
||||
CheckConstraint("depth BETWEEN 1 AND 3", name="ck_kcat_depth"),
|
||||
CheckConstraint("length(btrim(name)) > 0", name="ck_kcat_name"),
|
||||
{"comment": "知识分类树,深不过三"},
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(PgUUID(as_uuid=True), primary_key=True, default=new_uuid)
|
||||
workspace_id: Mapped[uuid.UUID] = mapped_column(
|
||||
PgUUID(as_uuid=True), ForeignKey("workspace.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
parent_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
PgUUID(as_uuid=True), ForeignKey("knowledge_category.id", ondelete="CASCADE")
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(40), nullable=False)
|
||||
depth: Mapped[int] = mapped_column(nullable=False, default=1)
|
||||
sort_order: Mapped[int] = mapped_column(nullable=False, default=0)
|
||||
|
||||
|
||||
class KnowledgeDoc(Base, Timestamps):
|
||||
"""知识的附加信息,与 item 一比一。"""
|
||||
@@ -44,6 +77,7 @@ class KnowledgeDoc(Base, Timestamps):
|
||||
__table_args__ = (
|
||||
Index("ix_knowledge_ws_status", "workspace_id", "status_hint"),
|
||||
Index("ix_knowledge_ws_source", "workspace_id", "source_item_id"),
|
||||
Index("ix_knowledge_ws_category", "workspace_id", "category_id"),
|
||||
CheckConstraint(
|
||||
"source_kind = 'manual' OR source_item_id IS NOT NULL",
|
||||
name="ck_knowledge_source_required",
|
||||
@@ -69,6 +103,10 @@ class KnowledgeDoc(Base, Timestamps):
|
||||
doc_type: Mapped[str] = mapped_column(String(16), nullable=False, default="note")
|
||||
# 冗余一份状态:列表页要按知识自己的口径筛,不想每次都回 item 表
|
||||
status_hint: Mapped[str] = mapped_column(String(16), nullable=False, default="draft")
|
||||
# 分类可以为空("未分类");分类被删时知识不跟着消失,只是回到未分类
|
||||
category_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
PgUUID(as_uuid=True), ForeignKey("knowledge_category.id", ondelete="SET NULL")
|
||||
)
|
||||
summary: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||
generated_by: Mapped[str] = mapped_column(String(16), nullable=False, default="human")
|
||||
published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
@@ -76,4 +114,34 @@ class KnowledgeDoc(Base, Timestamps):
|
||||
chunk_count: Mapped[int] = mapped_column(nullable=False, default=0)
|
||||
|
||||
|
||||
KNOWLEDGE_TENANT_TABLES: tuple[str, ...] = ("knowledge_doc",)
|
||||
class Chunk(Base):
|
||||
"""知识正文的切片。
|
||||
|
||||
为什么切片与文档同库:检索要按工作区过滤,跨库做权限校验就得把两边数据捞到应用里,
|
||||
既不安全也不快。向量本身是普通数组,换 pgvector 时只换列类型与索引。
|
||||
"""
|
||||
|
||||
__tablename__ = "chunk"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("item_id", "idx", name="uq_chunk_item_idx"),
|
||||
Index("ix_chunk_ws_item", "workspace_id", "item_id"),
|
||||
{"comment": "知识正文切片,检索的最小单位"},
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(PgUUID(as_uuid=True), primary_key=True, default=new_uuid)
|
||||
workspace_id: Mapped[uuid.UUID] = mapped_column(PgUUID(as_uuid=True), nullable=False)
|
||||
item_id: Mapped[uuid.UUID] = mapped_column(
|
||||
PgUUID(as_uuid=True), ForeignKey("item.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
# 片内顺序,用于把命中片段按原顺序还原上下文
|
||||
idx: Mapped[int] = mapped_column(nullable=False)
|
||||
heading: Mapped[str | None] = mapped_column(String(200))
|
||||
content: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
char_count: Mapped[int] = mapped_column(nullable=False, default=0)
|
||||
# 向量。hash 提供方是 256 维,OpenAI 兼容端点通常是 1024/1536 维,
|
||||
# 所以不固定维度——换模型时老切片要么重算,要么维度不匹配(检索会拒绝并提示重建)。
|
||||
embedding: Mapped[list[float] | None] = mapped_column(ARRAY(Float))
|
||||
embed_model: Mapped[str | None] = mapped_column(String(64))
|
||||
|
||||
|
||||
KNOWLEDGE_TENANT_TABLES: tuple[str, ...] = ("knowledge_category", "knowledge_doc", "chunk")
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
"""迁移里用到的数据库角色名。
|
||||
|
||||
为什么要有这个模块:RLS 的隔离强度取决于「应用角色不是表 owner、没有 BYPASSRLS」。
|
||||
开发和生产的库在同一个 PG 实例上(fnsvr-01:8432),如果两边共用 `dadao_app` 角色,
|
||||
拿到开发口令的人就能直连生产库——所以生产用独立角色,由迁移时的环境变量注入。
|
||||
|
||||
默认值就是开发/测试的角色名,所以本地和 CI 行为不变。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
|
||||
# PostgreSQL 标识符:小写字母/数字/下划线,且不能以数字开头
|
||||
_ROLE_RE = re.compile(r"^[a-z_][a-z0-9_]{0,62}$")
|
||||
|
||||
|
||||
def _role_from_env(env_name: str, default: str) -> str:
|
||||
value = os.getenv(env_name, default)
|
||||
if not _ROLE_RE.match(value):
|
||||
raise RuntimeError(f"{env_name} 不是合法的 PostgreSQL 角色名:{value!r}")
|
||||
return value
|
||||
|
||||
|
||||
# 迁移角色(owner,可建表,绕过 RLS)与应用角色(只能读写数据,受 RLS 约束)
|
||||
OWNER_ROLE = _role_from_env("OWNER_DB_ROLE", "dadao_owner")
|
||||
APP_ROLE = _role_from_env("APP_DB_ROLE", "dadao_app")
|
||||
@@ -0,0 +1,131 @@
|
||||
"""文本向量化(M3)。
|
||||
|
||||
为什么要抽象一层:今天用内置的哈希向量,明天换成 OpenAI 兼容的 `/embeddings`,
|
||||
后天可能换成自建模型。检索代码只依赖 `embed() -> list[float]`,换实现不动业务。
|
||||
|
||||
内置 `HashEmbedding` 的诚实说明:它不是"语义模型",是**字符 n-gram 的哈希词袋**。
|
||||
它能把同词、近义写法("发票"/"票据"这种)之外的东西区分开,抓不到真正的语义相似。
|
||||
之所以先上它:本机没有可用的向量库与模型服务,检索链路不能一直不落地;
|
||||
而且它零依赖、可复现(同样的文本永远同样的向量),适合做测试基线。
|
||||
真上线前应当把 `EMBEDDING_PROVIDER` 换成 `openai`,并把已发布知识整体重建一次。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import math
|
||||
import re
|
||||
from collections.abc import Sequence
|
||||
from typing import Protocol
|
||||
|
||||
import httpx
|
||||
|
||||
from ..config import Settings, get_settings
|
||||
|
||||
CJK_RE = re.compile(r"[\u4e00-\u9fff]")
|
||||
WORD_RE = re.compile(r"[a-z0-9]+")
|
||||
|
||||
|
||||
class EmbeddingProvider(Protocol):
|
||||
"""向量提供方。`name` 会写进切片,换模型后能看出哪些是旧向量。"""
|
||||
|
||||
name: str
|
||||
|
||||
async def embed(self, texts: Sequence[str]) -> list[list[float]]: ...
|
||||
|
||||
|
||||
def _tokens(text: str) -> list[str]:
|
||||
"""中文按字二元组、英文按词。
|
||||
|
||||
中文不用分词器:单字太碎("发""票"都对不上"发票"),二元组能拿到大多数
|
||||
有意义的搭配,又不需要维护词典。
|
||||
"""
|
||||
lowered = text.lower()
|
||||
tokens = WORD_RE.findall(lowered)
|
||||
chars = [c for c in lowered if CJK_RE.match(c)]
|
||||
tokens.extend(chars[i] + chars[i + 1] for i in range(len(chars) - 1))
|
||||
tokens.extend(chars)
|
||||
return tokens
|
||||
|
||||
|
||||
class HashEmbedding:
|
||||
"""字符 n-gram 的带符号哈希词袋,L2 归一化。"""
|
||||
|
||||
def __init__(self, dim: int = 256) -> None:
|
||||
self.dim = dim
|
||||
self.name = f"hash-{dim}"
|
||||
|
||||
async def embed(self, texts: Sequence[str]) -> list[list[float]]:
|
||||
return [self._one(text) for text in texts]
|
||||
|
||||
def _one(self, text: str) -> list[float]:
|
||||
vector = [0.0] * self.dim
|
||||
counts: dict[str, int] = {}
|
||||
for token in _tokens(text):
|
||||
counts[token] = counts.get(token, 0) + 1
|
||||
for token, count in counts.items():
|
||||
digest = hashlib.blake2b(token.encode("utf-8"), digest_size=8).digest()
|
||||
raw = int.from_bytes(digest, "big")
|
||||
index = raw % self.dim
|
||||
# 用最高位决定正负:无符号哈希会让不相干的词在向量里越加越像
|
||||
sign = 1.0 if (raw >> 63) & 1 else -1.0
|
||||
vector[index] += sign * (1.0 + math.log(count))
|
||||
norm = math.sqrt(sum(v * v for v in vector))
|
||||
if norm == 0:
|
||||
return vector
|
||||
return [v / norm for v in vector]
|
||||
|
||||
|
||||
class OpenAIEmbedding:
|
||||
"""任何 OpenAI 兼容的 `/embeddings` 端点(BYOK)。"""
|
||||
|
||||
def __init__(self, *, base_url: str, api_key: str, model: str) -> None:
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.api_key = api_key
|
||||
self.model = model
|
||||
self.name = f"openai:{model}"
|
||||
|
||||
async def embed(self, texts: Sequence[str]) -> list[list[float]]:
|
||||
if not texts:
|
||||
return []
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.post(
|
||||
f"{self.base_url}/embeddings",
|
||||
headers={"Authorization": f"Bearer {self.api_key}"},
|
||||
json={"model": self.model, "input": list(texts)},
|
||||
)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
# 按 index 排序:并发端点的返回顺序没有保证
|
||||
rows = sorted(payload["data"], key=lambda row: row["index"])
|
||||
return [row["embedding"] for row in rows]
|
||||
|
||||
|
||||
_provider: EmbeddingProvider | None = None
|
||||
|
||||
|
||||
def get_provider(settings: Settings | None = None) -> EmbeddingProvider:
|
||||
"""进程内单例:哈希实现无状态,OpenAI 实现也不需要每次新建连接池。"""
|
||||
global _provider
|
||||
if _provider is not None:
|
||||
return _provider
|
||||
settings = settings or get_settings()
|
||||
if settings.embedding_provider == "openai":
|
||||
if not settings.embedding_api_key:
|
||||
raise RuntimeError(
|
||||
"EMBEDDING_PROVIDER=openai 但没有配置 EMBEDDING_API_KEY;"
|
||||
"要么补上密钥,要么改回 hash"
|
||||
)
|
||||
_provider = OpenAIEmbedding(
|
||||
base_url=settings.embedding_base_url,
|
||||
api_key=settings.embedding_api_key,
|
||||
model=settings.embedding_model,
|
||||
)
|
||||
else:
|
||||
_provider = HashEmbedding()
|
||||
return _provider
|
||||
|
||||
|
||||
def reset_provider_for_tests() -> None:
|
||||
global _provider
|
||||
_provider = None
|
||||
@@ -0,0 +1,208 @@
|
||||
"""知识的切片索引与检索(M3)。
|
||||
|
||||
两条硬规则:
|
||||
|
||||
1. **只有 `published` 的知识能被检索到**。草稿是"还没被人认可的话",
|
||||
让它进检索等于把猜测当结论引用出去。过滤写在 SQL 里,不靠调用方自觉。
|
||||
2. **检索必须带来源**。每个命中都返回 `item_id` 与标题,用户能点回去看原文;
|
||||
这和 D3(知识必须有来源)是同一条原则的延伸——不给"出处不存在"的答案。
|
||||
|
||||
评分是向量与词法的加权和:
|
||||
- 向量负责"说的是同一件事但换了词";
|
||||
- 词法(pg_trgm 的 trigram 相似度)负责"专有名词、单号、人名"这类必须原样命中的词,
|
||||
而哈希向量对这类词并不敏感。
|
||||
两者都不足时宁可返回空,也不硬凑一条不相关的结果——检索失败比误导便宜。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import delete, select, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..config import get_settings
|
||||
from ..domain.chunking import split_markdown
|
||||
from ..errors import AppError, ErrorCode
|
||||
from ..models_knowledge import Chunk, KnowledgeDoc
|
||||
from ..models_task import Item
|
||||
from .embedding import get_provider
|
||||
|
||||
SEARCH_SQL = text(
|
||||
"""
|
||||
SELECT c.item_id,
|
||||
i.title,
|
||||
k.doc_type,
|
||||
c.idx,
|
||||
c.heading,
|
||||
c.content,
|
||||
coalesce(app_cosine(c.embedding, CAST(:qvec AS real[])), 0) AS vec_score,
|
||||
coalesce(similarity(c.content, :q), 0) AS lex_score
|
||||
FROM chunk c
|
||||
JOIN item i ON i.id = c.item_id AND i.workspace_id = c.workspace_id
|
||||
JOIN knowledge_doc k ON k.item_id = c.item_id AND k.workspace_id = c.workspace_id
|
||||
WHERE c.workspace_id = :ws
|
||||
AND k.status_hint = 'published'
|
||||
ORDER BY (
|
||||
coalesce(app_cosine(c.embedding, CAST(:qvec AS real[])), 0) * :vw
|
||||
+ coalesce(similarity(c.content, :q), 0) * :lw
|
||||
) DESC, c.item_id, c.idx
|
||||
LIMIT :limit
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def _vector_param(values: list[float]) -> list[float]:
|
||||
"""把向量交给驱动去编码。
|
||||
|
||||
一开始我拼成 `'{0.1,0.2}'` 字符串再 `CAST(... AS real[])`,asyncpg 会因为 CAST
|
||||
已经告诉它"这是数组"而拒绝字符串(`a sized iterable container expected`)。
|
||||
直接传 list 最省事;非有限值替换成 0,免得 NaN 进 SQL 变成错误输入。
|
||||
"""
|
||||
return [value if value == value and abs(value) != float("inf") else 0.0 for value in values]
|
||||
|
||||
|
||||
async def reindex_document(
|
||||
session: AsyncSession,
|
||||
ws_id: uuid.UUID,
|
||||
item_id: uuid.UUID,
|
||||
) -> int:
|
||||
"""重建一份知识的切片,返回切片数。
|
||||
|
||||
全量重建而不是增量:知识正文改动通常牵动上下几段,增量对齐的复杂度
|
||||
换不来多少收益(一份知识撑死几十片)。
|
||||
"""
|
||||
item = (
|
||||
await session.execute(select(Item).where(Item.workspace_id == ws_id, Item.id == item_id))
|
||||
).scalar_one_or_none()
|
||||
if item is None:
|
||||
raise AppError(ErrorCode.ITEM_NOT_FOUND, "这件事不存在")
|
||||
|
||||
doc = (
|
||||
await session.execute(
|
||||
select(KnowledgeDoc).where(
|
||||
KnowledgeDoc.workspace_id == ws_id,
|
||||
KnowledgeDoc.item_id == item_id,
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if doc is None:
|
||||
raise AppError(ErrorCode.KNOWLEDGE_NOT_FOUND, "这份知识不存在")
|
||||
|
||||
settings = get_settings()
|
||||
source = "\n\n".join(part for part in (item.title, item.body_md or "") if part)
|
||||
drafts = split_markdown(
|
||||
source,
|
||||
max_chars=settings.chunk_max_chars,
|
||||
overlap=settings.chunk_overlap_chars,
|
||||
)
|
||||
|
||||
provider = get_provider(settings)
|
||||
vectors = await provider.embed([draft.content for draft in drafts]) if drafts else []
|
||||
|
||||
await session.execute(
|
||||
delete(Chunk).where(Chunk.workspace_id == ws_id, Chunk.item_id == item_id)
|
||||
)
|
||||
for index, (draft, vector) in enumerate(zip(drafts, vectors, strict=True)):
|
||||
session.add(
|
||||
Chunk(
|
||||
workspace_id=ws_id,
|
||||
item_id=item_id,
|
||||
idx=index,
|
||||
heading=draft.heading,
|
||||
content=draft.content,
|
||||
char_count=len(draft.content),
|
||||
embedding=vector,
|
||||
embed_model=provider.name,
|
||||
)
|
||||
)
|
||||
doc.chunk_count = len(drafts)
|
||||
await session.flush()
|
||||
return len(drafts)
|
||||
|
||||
|
||||
async def search_knowledge(
|
||||
session: AsyncSession,
|
||||
ws_id: uuid.UUID,
|
||||
query: str,
|
||||
*,
|
||||
limit: int = 10,
|
||||
mode: str = "hybrid",
|
||||
) -> list[dict[str, Any]]:
|
||||
"""检索已发布知识,返回按相关度排序的切片。
|
||||
|
||||
`mode`: hybrid(默认)/ vector / keyword —— 前两者主要用于对比效果。
|
||||
"""
|
||||
cleaned = (query or "").strip()
|
||||
if not cleaned:
|
||||
return []
|
||||
|
||||
settings = get_settings()
|
||||
provider = get_provider(settings)
|
||||
query_vector = (await provider.embed([cleaned]))[0]
|
||||
|
||||
if mode == "vector":
|
||||
vector_weight, lexical_weight = 1.0, 0.0
|
||||
elif mode == "keyword":
|
||||
vector_weight, lexical_weight = 0.0, 1.0
|
||||
else:
|
||||
vector_weight, lexical_weight = (
|
||||
settings.search_vector_weight,
|
||||
settings.search_lexical_weight,
|
||||
)
|
||||
|
||||
rows = (
|
||||
await session.execute(
|
||||
SEARCH_SQL,
|
||||
{
|
||||
"qvec": _vector_param(query_vector),
|
||||
"q": cleaned,
|
||||
"ws": str(ws_id),
|
||||
"vw": vector_weight,
|
||||
"lw": lexical_weight,
|
||||
"limit": limit,
|
||||
},
|
||||
)
|
||||
).mappings()
|
||||
|
||||
hits: list[dict[str, Any]] = []
|
||||
for row in rows:
|
||||
vector_score = float(row["vec_score"] or 0.0)
|
||||
lexical_score = float(row["lex_score"] or 0.0)
|
||||
hits.append(
|
||||
{
|
||||
"item_id": row["item_id"],
|
||||
"title": row["title"],
|
||||
"doc_type": row["doc_type"],
|
||||
"idx": row["idx"],
|
||||
"heading": row["heading"],
|
||||
"content": row["content"],
|
||||
"score": round(vector_score * vector_weight + lexical_score * lexical_weight, 4),
|
||||
"vector_score": round(vector_score, 4),
|
||||
"lexical_score": round(lexical_score, 4),
|
||||
}
|
||||
)
|
||||
hits = [hit for hit in hits if hit["score"] >= settings.search_min_score]
|
||||
return hits
|
||||
|
||||
|
||||
async def reindex_workspace(session: AsyncSession, ws_id: uuid.UUID) -> int:
|
||||
"""把一个工作区里已发布的知识全部重建,返回处理的知识份数。
|
||||
|
||||
换 embedding 模型之后必须整体跑一次,否则新旧向量混在一张表里,
|
||||
相似度分数没法比较。
|
||||
"""
|
||||
item_ids = (
|
||||
await session.execute(
|
||||
select(KnowledgeDoc.item_id).where(
|
||||
KnowledgeDoc.workspace_id == ws_id,
|
||||
KnowledgeDoc.status_hint == "published",
|
||||
)
|
||||
)
|
||||
).scalars()
|
||||
count = 0
|
||||
for item_id in item_ids:
|
||||
await reindex_document(session, ws_id, item_id)
|
||||
count += 1
|
||||
return count
|
||||
@@ -0,0 +1,73 @@
|
||||
"""站内通知(M2 起用,M4 会接入外部推送)。
|
||||
|
||||
三条原则:
|
||||
|
||||
1. **不通知自己**。绝大多数通知都是由自己的操作触发的,
|
||||
不排除自己会让"未读"迅速变成一个没人看的数字。
|
||||
2. **通知里写清"谁、做了什么、在哪件事上"**。只写"状态已更新"等于没写,
|
||||
用户还得点进去猜。
|
||||
3. **通知只是提示,不是凭证**。点开后仍要按 `item_id` 重新拉数据,
|
||||
因为通知生成之后事情可能又被改过。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from collections.abc import Iterable
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..models_ticket import Notification
|
||||
|
||||
TICKET_STATUS_CN = {
|
||||
"open": "待接收",
|
||||
"accepted": "待办理",
|
||||
"in_progress": "办理中",
|
||||
"pending_verify": "待验收",
|
||||
"done": "已完成",
|
||||
"canceled": "已取消",
|
||||
}
|
||||
|
||||
TASK_STATUS_CN = {
|
||||
"inbox": "收件箱",
|
||||
"todo": "待办",
|
||||
"doing": "进行中",
|
||||
"blocked": "被阻塞",
|
||||
"done": "已完成",
|
||||
"canceled": "已取消",
|
||||
}
|
||||
|
||||
|
||||
async def notify(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
ws_id: uuid.UUID,
|
||||
user_ids: Iterable[uuid.UUID | None],
|
||||
kind: str,
|
||||
item_id: uuid.UUID | None,
|
||||
title: str,
|
||||
body: str = "",
|
||||
actor_id: uuid.UUID | None = None,
|
||||
) -> int:
|
||||
"""给若干人各写一条通知,返回实际写入条数。
|
||||
|
||||
去重与"跳过自己"在这里统一做一次——每个调用点都手写一遍迟早漏一个。
|
||||
"""
|
||||
seen: set[uuid.UUID] = set()
|
||||
count = 0
|
||||
for user_id in user_ids:
|
||||
if user_id is None or user_id == actor_id or user_id in seen:
|
||||
continue
|
||||
seen.add(user_id)
|
||||
session.add(
|
||||
Notification(
|
||||
workspace_id=ws_id,
|
||||
user_id=user_id,
|
||||
kind=kind,
|
||||
item_id=item_id,
|
||||
title=title[:120],
|
||||
body=body[:300],
|
||||
)
|
||||
)
|
||||
count += 1
|
||||
return count
|
||||
@@ -37,7 +37,9 @@ def sign(workspace_id: uuid.UUID, attachment_id: uuid.UUID, ttl_seconds: int | N
|
||||
return f"exp={exp}&sig={_digest(workspace_id, attachment_id, exp)}"
|
||||
|
||||
|
||||
def verify(workspace_id: uuid.UUID, attachment_id: uuid.UUID, exp: int | None, sig: str | None) -> bool:
|
||||
def verify(
|
||||
workspace_id: uuid.UUID, attachment_id: uuid.UUID, exp: int | None, sig: str | None
|
||||
) -> bool:
|
||||
if not exp or not sig:
|
||||
return False
|
||||
if exp < int(time.time()): # 过期就是过期,不给宽限
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
fastapi==0.115.6
|
||||
uvicorn[standard]==0.34.0
|
||||
# 生产进程管理:gunicorn + UvicornWorker(开发用 `uvicorn --reload` 即可)
|
||||
gunicorn==23.0.0
|
||||
sqlalchemy[asyncio]==2.0.36
|
||||
asyncpg==0.30.0
|
||||
alembic==1.14.0
|
||||
|
||||
@@ -8,13 +8,12 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
import uuid
|
||||
|
||||
from app.config import get_settings
|
||||
from app.services import signing
|
||||
|
||||
@@ -178,7 +177,7 @@ async def test_raw_url_uses_short_lived_signature(client: AsyncClient) -> None:
|
||||
"""图片直链自带短期签名:不需要把登录令牌贴进 URL,也不需要 Authorization 头。"""
|
||||
user, ws, item_id = await _ws_with_item(client)
|
||||
body = b"\x89PNG\r\n\x1a\n signed png"
|
||||
created = unwrap(await _upload(client, user, ws["id"], item_id, body, "签名图.png"))
|
||||
unwrap(await _upload(client, user, ws["id"], item_id, body, "签名图.png"))
|
||||
|
||||
listed = unwrap(
|
||||
await client.get(
|
||||
@@ -225,4 +224,4 @@ async def test_raw_url_signature_is_workspace_scoped(client: AsyncClient) -> Non
|
||||
f"{BASE}/workspaces/{ws_b['id']}/attachments/{created['id']}/raw?{query}"
|
||||
)
|
||||
assert crossed.status_code == 403
|
||||
assert crossed.json()["code"] == 8003
|
||||
assert crossed.json()["code"] == 8003
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
"""生产配置校验:固定验证码必须显式开关,否则启动即失败。
|
||||
|
||||
这两条是「部署纪律」的守门员:生产一旦误用 dev 验证码,等于任意手机号可登录。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.config import Settings
|
||||
|
||||
_STRONG_SECRET = "x" * 48
|
||||
_PROD_DB = "postgresql+asyncpg://dadao_prod_app:pw@192.168.8.160:8432/dadaotask_prod"
|
||||
|
||||
|
||||
def _prod(**overrides: object) -> Settings:
|
||||
values: dict[str, object] = {
|
||||
"env": "prod",
|
||||
"jwt_secret": _STRONG_SECRET,
|
||||
"database_url": _PROD_DB,
|
||||
"sms_provider": "aliyun",
|
||||
}
|
||||
values.update(overrides)
|
||||
return Settings(**values)
|
||||
|
||||
|
||||
def test_prod_rejects_dev_sms_without_switch() -> None:
|
||||
with pytest.raises(RuntimeError, match="SMS_PROVIDER=dev"):
|
||||
_prod(sms_provider="dev").validate_runtime()
|
||||
|
||||
|
||||
def test_prod_allows_dev_sms_with_internal_test_login() -> None:
|
||||
# 内测模式:显式开关后放行,启动时 main.lifespan 会打 WARNING
|
||||
_prod(sms_provider="dev", internal_test_login=True).validate_runtime()
|
||||
|
||||
|
||||
def test_prod_still_rejects_weak_jwt_secret() -> None:
|
||||
with pytest.raises(RuntimeError, match="JWT_SECRET"):
|
||||
_prod(jwt_secret="short").validate_runtime()
|
||||
@@ -0,0 +1,305 @@
|
||||
"""知识库的"备忘录"形态:分类树、正文、标签、筛选。
|
||||
|
||||
知识最容易做坏的地方,是把它做成"只填标题的任务"——那样的库三个月后没人会打开。
|
||||
所以这里断言的是:一条知识能装下正文和标签、能被归到三级分类里、能按分类和标签找回来。
|
||||
|
||||
三条边界单独盯着:
|
||||
|
||||
1. **三级到底**。第四级不是"更细",是"找不到";越界必须当场拒绝(7007)。
|
||||
2. **移动会带着子树一起动**。把一棵三层树挪到第二层下面,结果会是第五层,
|
||||
这种操作要么重算层级、要么拒绝——这里选择拒绝,因为静默改层级同样会让人找不到。
|
||||
3. **删分类不删知识**。有内容时必须显式 `force`,否则容易点一下就把别人的整理工作打散。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from .conftest import create_workspace, login, unwrap
|
||||
|
||||
V1 = "/api/v1"
|
||||
|
||||
|
||||
def _code(resp: Any) -> int:
|
||||
return int(resp.json().get("code", 0))
|
||||
|
||||
|
||||
async def _ws(client: AsyncClient) -> tuple[dict[str, Any], str]:
|
||||
user = await login(client)
|
||||
ws = await create_workspace(client, user, "知识库")
|
||||
return user, ws["id"]
|
||||
|
||||
|
||||
async def _category(
|
||||
client: AsyncClient, user: dict[str, Any], ws_id: str, name: str, parent_id: str | None = None
|
||||
) -> dict[str, Any]:
|
||||
return unwrap(
|
||||
await client.post(
|
||||
f"{V1}/workspaces/{ws_id}/knowledge/categories",
|
||||
json={"name": name, "parent_id": parent_id},
|
||||
headers=user["headers"],
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def _note(
|
||||
client: AsyncClient,
|
||||
user: dict[str, Any],
|
||||
ws_id: str,
|
||||
title: str,
|
||||
*,
|
||||
body: str = "",
|
||||
tags: list[str] | None = None,
|
||||
category_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return unwrap(
|
||||
await client.post(
|
||||
f"{V1}/workspaces/{ws_id}/knowledge",
|
||||
json={
|
||||
"title": title,
|
||||
"body_md": body,
|
||||
"doc_type": "note",
|
||||
"tag_names": tags or [],
|
||||
"category_id": category_id,
|
||||
},
|
||||
headers=user["headers"],
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def _list(
|
||||
client: AsyncClient, user: dict[str, Any], ws_id: str, **params: Any
|
||||
) -> list[dict[str, Any]]:
|
||||
return unwrap(
|
||||
await client.get(
|
||||
f"{V1}/workspaces/{ws_id}/knowledge", params=params, headers=user["headers"]
|
||||
)
|
||||
)["items"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_note_keeps_body_tags_and_category_path(client: AsyncClient) -> None:
|
||||
user, ws_id = await _ws(client)
|
||||
root = await _category(client, user, ws_id, "研发")
|
||||
child = await _category(client, user, ws_id, "部署", root["id"])
|
||||
leaf = await _category(client, user, ws_id, "回滚", child["id"])
|
||||
assert leaf["depth"] == 3
|
||||
assert leaf["full_path"] == "研发 / 部署 / 回滚"
|
||||
|
||||
doc = await _note(
|
||||
client,
|
||||
user,
|
||||
ws_id,
|
||||
"回滚前先停掉灰度",
|
||||
body="# 步骤\n\n1. 停灰度\n2. 回滚镜像\n",
|
||||
tags=["发布", "事故"],
|
||||
category_id=leaf["id"],
|
||||
)
|
||||
assert doc["category"]["id"] == leaf["id"]
|
||||
assert doc["category"]["full_path"] == "研发 / 部署 / 回滚"
|
||||
assert {t["name"] for t in doc["tags"]} == {"发布", "事故"}
|
||||
|
||||
detail = unwrap(
|
||||
await client.get(f"{V1}/workspaces/{ws_id}/knowledge/{doc['id']}", headers=user["headers"])
|
||||
)
|
||||
assert "停灰度" in detail["body_md"]
|
||||
assert detail["category"]["full_path"] == "研发 / 部署 / 回滚"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_category_depth_stops_at_three(client: AsyncClient) -> None:
|
||||
user, ws_id = await _ws(client)
|
||||
l1 = await _category(client, user, ws_id, "一级")
|
||||
l2 = await _category(client, user, ws_id, "二级", l1["id"])
|
||||
l3 = await _category(client, user, ws_id, "三级", l2["id"])
|
||||
resp = await client.post(
|
||||
f"{V1}/workspaces/{ws_id}/knowledge/categories",
|
||||
json={"name": "四级", "parent_id": l3["id"]},
|
||||
headers=user["headers"],
|
||||
)
|
||||
assert resp.status_code == 422, resp.text
|
||||
assert _code(resp) == 7007
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_move_cannot_exceed_depth_or_loop(client: AsyncClient) -> None:
|
||||
user, ws_id = await _ws(client)
|
||||
a = await _category(client, user, ws_id, "A")
|
||||
b = await _category(client, user, ws_id, "B", a["id"])
|
||||
c = await _category(client, user, ws_id, "C", b["id"])
|
||||
other = await _category(client, user, ws_id, "X")
|
||||
|
||||
# 把 A(下面还有 B、C)移到 C 下面:自己是自己的祖先
|
||||
resp = await client.patch(
|
||||
f"{V1}/workspaces/{ws_id}/knowledge/categories/{a['id']}",
|
||||
json={"move": True, "parent_id": c["id"], "version": a["version"]},
|
||||
headers=user["headers"],
|
||||
)
|
||||
assert resp.status_code in (409, 422), resp.text
|
||||
assert _code(resp) == 7007
|
||||
|
||||
# 把 A 移到 X 下面:A 现在是一级,B、C 跟着下沉,C 会到第四级 -> 拒绝
|
||||
resp = await client.patch(
|
||||
f"{V1}/workspaces/{ws_id}/knowledge/categories/{a['id']}",
|
||||
json={"move": True, "parent_id": other["id"], "version": a["version"]},
|
||||
headers=user["headers"],
|
||||
)
|
||||
assert _code(resp) == 7007
|
||||
del c, b
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rename_conflict_and_move_to_root(client: AsyncClient) -> None:
|
||||
user, ws_id = await _ws(client)
|
||||
a = await _category(client, user, ws_id, "研发")
|
||||
b = await _category(client, user, ws_id, "市场")
|
||||
dup = await client.post(
|
||||
f"{V1}/workspaces/{ws_id}/knowledge/categories",
|
||||
json={"name": "研发"},
|
||||
headers=user["headers"],
|
||||
)
|
||||
assert dup.status_code == 409, dup.text
|
||||
assert _code(dup) == 7008
|
||||
|
||||
child = await _category(client, user, ws_id, "部署", a["id"])
|
||||
moved = unwrap(
|
||||
await client.patch(
|
||||
f"{V1}/workspaces/{ws_id}/knowledge/categories/{child['id']}",
|
||||
json={"move": True, "parent_id": None, "version": child["version"]},
|
||||
headers=user["headers"],
|
||||
)
|
||||
)
|
||||
assert moved["parent_id"] is None
|
||||
assert moved["depth"] == 1
|
||||
assert moved["full_path"] == "部署"
|
||||
del b
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_filters_by_category_subtree_tag_and_search(client: AsyncClient) -> None:
|
||||
user, ws_id = await _ws(client)
|
||||
root = await _category(client, user, ws_id, "研发")
|
||||
sub = await _category(client, user, ws_id, "部署", root["id"])
|
||||
other = await _category(client, user, ws_id, "市场")
|
||||
|
||||
deep = await _note(
|
||||
client,
|
||||
user,
|
||||
ws_id,
|
||||
"灰度发布流程",
|
||||
body="先停灰度再回滚",
|
||||
tags=["发布"],
|
||||
category_id=sub["id"],
|
||||
)
|
||||
await _note(
|
||||
client, user, ws_id, "季度预算", body="市场预算表", tags=["财务"], category_id=other["id"]
|
||||
)
|
||||
loose = await _note(client, user, ws_id, "散记", body="还没归类", tags=["草稿"])
|
||||
|
||||
by_category = await _list(client, user, ws_id, category_id=root["id"])
|
||||
assert {d["id"] for d in by_category} == {deep["id"]}
|
||||
by_none = await _list(client, user, ws_id, uncategorized="true")
|
||||
assert {d["id"] for d in by_none} == {loose["id"]}
|
||||
assert {d["id"] for d in await _list(client, user, ws_id, tag="发布")} == {deep["id"]}
|
||||
assert {d["id"] for d in await _list(client, user, ws_id, q="回滚")} == {deep["id"]}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_category_needs_force_when_docs_exist(client: AsyncClient) -> None:
|
||||
user, ws_id = await _ws(client)
|
||||
cat = await _category(client, user, ws_id, "待清理")
|
||||
doc = await _note(client, user, ws_id, "有主的东西", category_id=cat["id"])
|
||||
|
||||
blocked = await client.delete(
|
||||
f"{V1}/workspaces/{ws_id}/knowledge/categories/{cat['id']}", headers=user["headers"]
|
||||
)
|
||||
assert blocked.status_code == 409, blocked.text
|
||||
assert _code(blocked) == 7008
|
||||
|
||||
forced = unwrap(
|
||||
await client.delete(
|
||||
f"{V1}/workspaces/{ws_id}/knowledge/categories/{cat['id']}?force=true",
|
||||
headers=user["headers"],
|
||||
)
|
||||
)
|
||||
assert forced["uncategorized_docs"] == 1
|
||||
detail = unwrap(
|
||||
await client.get(f"{V1}/workspaces/{ws_id}/knowledge/{doc['id']}", headers=user["headers"])
|
||||
)
|
||||
assert detail["category"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_category_is_tenant_scoped(client: AsyncClient) -> None:
|
||||
owner, ws_id = await _ws(client)
|
||||
intruder = await login(client)
|
||||
other_ws = await create_workspace(client, intruder, "别人的工作区")
|
||||
foreign = await _category(client, intruder, other_ws["id"], "他们的分类")
|
||||
|
||||
listed = unwrap(
|
||||
await client.get(f"{V1}/workspaces/{ws_id}/knowledge/categories", headers=owner["headers"])
|
||||
)
|
||||
assert listed["items"] == []
|
||||
|
||||
# 用别的工作区的分类 id 建知识:应被当作"不存在"
|
||||
resp = await client.post(
|
||||
f"{V1}/workspaces/{ws_id}/knowledge",
|
||||
json={"title": "越界", "category_id": foreign["id"]},
|
||||
headers=owner["headers"],
|
||||
)
|
||||
assert resp.status_code == 404, resp.text
|
||||
assert _code(resp) == 7006
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_patch_knowledge_moves_and_clears_category(client: AsyncClient) -> None:
|
||||
user, ws_id = await _ws(client)
|
||||
cat = await _category(client, user, ws_id, "研发")
|
||||
doc = await _note(client, user, ws_id, "一篇知识", category_id=cat["id"])
|
||||
|
||||
moved = unwrap(
|
||||
await client.patch(
|
||||
f"{V1}/workspaces/{ws_id}/knowledge/{doc['id']}",
|
||||
json={"category_id": cat["id"], "tag_names": ["运维"], "version": doc["version"]},
|
||||
headers=user["headers"],
|
||||
)
|
||||
)
|
||||
assert moved["category"]["name"] == "研发"
|
||||
assert {t["name"] for t in moved["tags"]} == {"运维"}
|
||||
|
||||
cleared = unwrap(
|
||||
await client.patch(
|
||||
f"{V1}/workspaces/{ws_id}/knowledge/{doc['id']}",
|
||||
json={"clear_category": True, "version": moved["version"]},
|
||||
headers=user["headers"],
|
||||
)
|
||||
)
|
||||
assert cleared["category"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_patch_knowledge_accepts_doc_type_and_bumps_version(client: AsyncClient) -> None:
|
||||
"""PATCH 只该写 items 自己的列:doc_type/summary 混进去会 CompileError(线上 500)。"""
|
||||
user, ws_id = await _ws(client)
|
||||
doc = await _note(client, user, ws_id, "先写结论再写过程", body="原始正文")
|
||||
|
||||
patched = unwrap(
|
||||
await client.patch(
|
||||
f"{V1}/workspaces/{ws_id}/knowledge/{doc['id']}",
|
||||
json={
|
||||
"version": doc["version"],
|
||||
"title": "先写结论再写过程",
|
||||
"body_md": "改过的正文",
|
||||
"doc_type": "howto",
|
||||
"tag_names": ["写法"],
|
||||
},
|
||||
headers=user["headers"],
|
||||
)
|
||||
)
|
||||
assert patched["knowledge"]["doc_type"] == "howto"
|
||||
assert patched["body_md"] == "改过的正文"
|
||||
assert patched["version"] == doc["version"] + 1
|
||||
assert {t["name"] for t in patched["tags"]} == {"写法"}
|
||||
@@ -0,0 +1,271 @@
|
||||
"""通知与手工开单。
|
||||
|
||||
这两个功能都有一个共同的翻车方式:**看起来有,实际没送到**。
|
||||
所以要断言的从来不是"接口 200",而是"该收到的人收到了、不该收到的人没收到"。
|
||||
|
||||
1. 自己改自己的任务不该给自己发通知(否则未读数两天就没人看了);
|
||||
2. 通知必须落到具体的人头上——被指派人、创建人,而不是"整个工作区";
|
||||
3. 手工开的工单要和升级来的工单走同一条路:能接受、能提交、能验收;
|
||||
4. 指派给工作区外的人必须当场拒绝,否则任务会挂在一个没人看得到的名字上。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from .conftest import create_workspace, login, unwrap
|
||||
|
||||
V1 = "/api/v1"
|
||||
|
||||
|
||||
async def _member(
|
||||
client: AsyncClient, owner: dict[str, Any], ws: dict[str, Any], role: str = "member"
|
||||
) -> dict[str, Any]:
|
||||
user = await login(client)
|
||||
inv = unwrap(
|
||||
await client.post(
|
||||
f"{V1}/workspaces/{ws['id']}/invitations", json={"role": role}, headers=owner["headers"]
|
||||
)
|
||||
)
|
||||
unwrap(
|
||||
await client.post(
|
||||
f"{V1}/workspaces/{ws['id']}/join",
|
||||
json={"code": inv["code"]},
|
||||
headers=user["headers"],
|
||||
)
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
async def _inbox(client: AsyncClient, user: dict[str, Any], ws_id: str) -> list[dict]:
|
||||
page = unwrap(
|
||||
await client.get(
|
||||
f"{V1}/workspaces/{ws_id}/notifications",
|
||||
params={"unread_only": "false"},
|
||||
headers=user["headers"],
|
||||
)
|
||||
)
|
||||
return page["notifications"]
|
||||
|
||||
|
||||
async def _task_assigned_to(
|
||||
client: AsyncClient, owner: dict[str, Any], ws_id: str, assignee_id: str, title: str
|
||||
) -> dict:
|
||||
return unwrap(
|
||||
await client.post(
|
||||
f"{V1}/workspaces/{ws_id}/items",
|
||||
json={"title": title, "status": "todo", "assignee_id": assignee_id},
|
||||
headers=owner["headers"],
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_task_status_change_notifies_the_other_side(client: AsyncClient) -> None:
|
||||
owner = await login(client)
|
||||
ws = await create_workspace(client, owner, "通知测试")
|
||||
helper = await _member(client, owner, ws, "member")
|
||||
|
||||
item = await _task_assigned_to(client, owner, ws["id"], helper["user_id"], "月度对账")
|
||||
|
||||
# owner 指派给 helper 时,helper 应该收到通知
|
||||
helper_inbox = await _inbox(client, helper, ws["id"])
|
||||
assert any(n["kind"] == "task_assigned" and n["item_id"] == item["id"] for n in helper_inbox)
|
||||
# owner 自己指派的,不该收到
|
||||
assert not [n for n in await _inbox(client, owner, ws["id"]) if n["kind"] == "task_assigned"]
|
||||
|
||||
# helper 把状态改成进行中 → owner(创建人)应该收到
|
||||
unwrap(
|
||||
await client.post(
|
||||
f"{V1}/workspaces/{ws['id']}/items/{item['id']}/status",
|
||||
json={"status": "doing", "version": item["version"]},
|
||||
headers=helper["headers"],
|
||||
)
|
||||
)
|
||||
owner_inbox = await _inbox(client, owner, ws["id"])
|
||||
changed = [n for n in owner_inbox if n["kind"] == "task_status_changed"]
|
||||
assert len(changed) == 1
|
||||
assert "进行中" in changed[0]["body"]
|
||||
assert changed[0]["item_title"] == "月度对账"
|
||||
|
||||
# helper 是操作者本人,不该收到自己那次改动的通知
|
||||
assert not [
|
||||
n for n in await _inbox(client, helper, ws["id"]) if n["kind"] == "task_status_changed"
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_assignee_must_be_a_member(client: AsyncClient) -> None:
|
||||
owner = await login(client)
|
||||
ws = await create_workspace(client, owner, "越界指派")
|
||||
outsider = await login(client) # 没加入这个工作区
|
||||
|
||||
resp = await client.post(
|
||||
f"{V1}/workspaces/{ws['id']}/items",
|
||||
json={"title": "指派给外人", "assignee_id": outsider["user_id"]},
|
||||
headers=owner["headers"],
|
||||
)
|
||||
assert resp.status_code == 400, resp.text
|
||||
assert resp.json()["code"] == 1001
|
||||
|
||||
item = unwrap(
|
||||
await client.post(
|
||||
f"{V1}/workspaces/{ws['id']}/items",
|
||||
json={"title": "不指派"},
|
||||
headers=owner["headers"],
|
||||
)
|
||||
)
|
||||
resp = await client.patch(
|
||||
f"{V1}/workspaces/{ws['id']}/items/{item['id']}",
|
||||
json={"assignee_id": outsider["user_id"], "version": item["version"]},
|
||||
headers=owner["headers"],
|
||||
)
|
||||
assert resp.status_code == 400, resp.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manual_ticket_runs_the_whole_flow(client: AsyncClient) -> None:
|
||||
"""手工开的工单不是"半个工单":接受、提交、验收都得能走。"""
|
||||
owner = await login(client)
|
||||
ws = await create_workspace(client, owner, "手工开单")
|
||||
helper = await _member(client, owner, ws, "member")
|
||||
|
||||
ticket = unwrap(
|
||||
await client.post(
|
||||
f"{V1}/workspaces/{ws['id']}/tickets",
|
||||
json={
|
||||
"title": "帮忙核对一遍报价单",
|
||||
"body_md": "客户催得急,今天下班前要",
|
||||
"assignee_id": helper["user_id"],
|
||||
"priority": 2,
|
||||
},
|
||||
headers=owner["headers"],
|
||||
)
|
||||
)
|
||||
assert ticket["status"] == "open"
|
||||
assert ticket["kind"] == "ticket"
|
||||
assert ticket["ticket"]["requester_id"] == owner["user_id"]
|
||||
assert ticket["ticket"]["assignee_id"] == helper["user_id"]
|
||||
assert ticket["ticket"]["parent_item_id"] is None # 没有来源任务
|
||||
|
||||
# 出现在被指派人的工单列表里
|
||||
assigned = unwrap(
|
||||
await client.get(
|
||||
f"{V1}/workspaces/{ws['id']}/tickets",
|
||||
params={"role": "assigned"},
|
||||
headers=helper["headers"],
|
||||
)
|
||||
)
|
||||
assert [t["id"] for t in assigned["tickets"]] == [ticket["id"]]
|
||||
|
||||
# 被指派人收到通知
|
||||
inbox = await _inbox(client, helper, ws["id"])
|
||||
assert any(n["kind"] == "ticket_assigned" and n["item_id"] == ticket["id"] for n in inbox)
|
||||
|
||||
# 接受 → 开工 → 提交 → 验收通过
|
||||
for action, body in (
|
||||
("accept", {}),
|
||||
("start", {}),
|
||||
("submit", {"note": "核对完了"}),
|
||||
):
|
||||
unwrap(
|
||||
await client.post(
|
||||
f"{V1}/workspaces/{ws['id']}/items/{ticket['id']}/ticket/{action}",
|
||||
json=body,
|
||||
headers=helper["headers"],
|
||||
)
|
||||
)
|
||||
done = unwrap(
|
||||
await client.post(
|
||||
f"{V1}/workspaces/{ws['id']}/items/{ticket['id']}/ticket/verify",
|
||||
json={"approve": True, "resolution_md": "确认了三处差异"},
|
||||
headers=owner["headers"],
|
||||
)
|
||||
)
|
||||
assert done["status"] == "done"
|
||||
# 验收是需求人做的,通知该发给办理人;需求人自己不该收到"你通过了"这种通知
|
||||
helper_inbox = await _inbox(client, helper, ws["id"])
|
||||
assert any(n["kind"] == "ticket_approved" for n in helper_inbox)
|
||||
assert not [n for n in await _inbox(client, owner, ws["id"]) if n["kind"] == "ticket_approved"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manual_ticket_rejects_outsider(client: AsyncClient) -> None:
|
||||
owner = await login(client)
|
||||
ws = await create_workspace(client, owner, "手工开单越界")
|
||||
outsider = await login(client)
|
||||
|
||||
resp = await client.post(
|
||||
f"{V1}/workspaces/{ws['id']}/tickets",
|
||||
json={"title": "给外人开单", "assignee_id": outsider["user_id"]},
|
||||
headers=owner["headers"],
|
||||
)
|
||||
assert resp.status_code == 400, resp.text
|
||||
assert resp.json()["code"] == 1001
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_comment_notifies_the_other_party(client: AsyncClient) -> None:
|
||||
owner = await login(client)
|
||||
ws = await create_workspace(client, owner, "评论通知")
|
||||
helper = await _member(client, owner, ws, "member")
|
||||
item = await _task_assigned_to(client, owner, ws["id"], helper["user_id"], "整理发票")
|
||||
|
||||
unwrap(
|
||||
await client.post(
|
||||
f"{V1}/workspaces/{ws['id']}/items/{item['id']}/comments",
|
||||
json={"body_md": "发票抬头要写公司全称"},
|
||||
headers=helper["headers"],
|
||||
)
|
||||
)
|
||||
owner_inbox = await _inbox(client, owner, ws["id"])
|
||||
assert any(n["kind"] == "item_commented" for n in owner_inbox)
|
||||
|
||||
# 操作者本人不该收到
|
||||
assert not [n for n in await _inbox(client, helper, ws["id"]) if n["kind"] == "item_commented"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unread_count_and_read_all(client: AsyncClient) -> None:
|
||||
owner = await login(client)
|
||||
ws = await create_workspace(client, owner, "未读数")
|
||||
helper = await _member(client, owner, ws, "member")
|
||||
await _task_assigned_to(client, owner, ws["id"], helper["user_id"], "第一件")
|
||||
await _task_assigned_to(client, owner, ws["id"], helper["user_id"], "第二件")
|
||||
|
||||
count = unwrap(
|
||||
await client.get(
|
||||
f"{V1}/workspaces/{ws['id']}/notifications/unread-count", headers=helper["headers"]
|
||||
)
|
||||
)
|
||||
assert count["unread"] == 2
|
||||
|
||||
first = (await _inbox(client, helper, ws["id"]))[0]
|
||||
unwrap(
|
||||
await client.post(
|
||||
f"{V1}/workspaces/{ws['id']}/notifications/{first['id']}/read",
|
||||
headers=helper["headers"],
|
||||
)
|
||||
)
|
||||
count = unwrap(
|
||||
await client.get(
|
||||
f"{V1}/workspaces/{ws['id']}/notifications/unread-count", headers=helper["headers"]
|
||||
)
|
||||
)
|
||||
assert count["unread"] == 1
|
||||
|
||||
marked = unwrap(
|
||||
await client.post(
|
||||
f"{V1}/workspaces/{ws['id']}/notifications/read-all", headers=helper["headers"]
|
||||
)
|
||||
)
|
||||
assert marked["marked"] == 1
|
||||
count = unwrap(
|
||||
await client.get(
|
||||
f"{V1}/workspaces/{ws['id']}/notifications/unread-count", headers=helper["headers"]
|
||||
)
|
||||
)
|
||||
assert count["unread"] == 0
|
||||
@@ -0,0 +1,200 @@
|
||||
"""M3 检索测试:切片、索引、跨租户。
|
||||
|
||||
检索最容易做错的地方不是"搜得准不准",而是"什么该被搜到、什么绝不能被搜到":
|
||||
|
||||
1. **草稿不能被检索到**。草稿是还没被人认可的话,让它出现等于把猜测当结论;
|
||||
2. **刚发布就能搜到**。发布后要立刻建索引,否则用户会以为发布失败;
|
||||
3. **检索结果必须带来源**,能点回原文;
|
||||
4. **别的租户搜不到我的知识**,即使关键词一模一样。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from httpx import AsyncClient
|
||||
|
||||
from .conftest import create_workspace, login, unwrap
|
||||
|
||||
V1 = "/api/v1"
|
||||
|
||||
|
||||
async def _knowledge(
|
||||
client: AsyncClient,
|
||||
user: dict[str, Any],
|
||||
ws_id: str,
|
||||
title: str,
|
||||
body: str,
|
||||
doc_type: str = "note",
|
||||
) -> dict[str, Any]:
|
||||
return unwrap(
|
||||
await client.post(
|
||||
f"{V1}/workspaces/{ws_id}/knowledge",
|
||||
json={"title": title, "body_md": body, "doc_type": doc_type},
|
||||
headers=user["headers"],
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def _publish(
|
||||
client: AsyncClient, user: dict[str, Any], ws_id: str, doc: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
return unwrap(
|
||||
await client.post(
|
||||
f"{V1}/workspaces/{ws_id}/knowledge/{doc['id']}/status",
|
||||
json={"action": "publish", "version": doc["version"]},
|
||||
headers=user["headers"],
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def _search(
|
||||
client: AsyncClient,
|
||||
user: dict[str, Any],
|
||||
ws_id: str,
|
||||
query: str,
|
||||
mode: str = "hybrid",
|
||||
) -> list[dict[str, Any]]:
|
||||
return unwrap(
|
||||
await client.get(
|
||||
f"{V1}/workspaces/{ws_id}/knowledge/search",
|
||||
params={"q": query, "mode": mode},
|
||||
headers=user["headers"],
|
||||
)
|
||||
)["items"]
|
||||
|
||||
|
||||
async def test_published_is_searchable_draft_is_not(client: AsyncClient) -> None:
|
||||
owner = await login(client)
|
||||
ws = await create_workspace(client, owner, "检索测试")
|
||||
|
||||
published = await _knowledge(
|
||||
client, owner, ws["id"], "报销发票怎么开", "电子发票原件必须一起寄,复印件不算。"
|
||||
)
|
||||
parked = await _knowledge(
|
||||
client, owner, ws["id"], "供应商账号变更", "变更收款账号需要法人签字并盖章。"
|
||||
)
|
||||
await _publish(client, owner, ws["id"], published)
|
||||
|
||||
hits = await _search(client, owner, ws["id"], "发票")
|
||||
ids = {hit["item_id"] for hit in hits}
|
||||
assert published["id"] in ids
|
||||
assert parked["id"] not in ids, "草稿不该出现在检索结果里"
|
||||
assert hits[0]["score"] > 0
|
||||
assert hits[0]["title"], "检索结果必须带来源标题"
|
||||
|
||||
# 还没验证过的知识,关键词再怎么对也不该冒出来
|
||||
draft_hits = await _search(client, owner, ws["id"], "法人签字")
|
||||
assert all(hit["item_id"] != parked["id"] for hit in draft_hits)
|
||||
|
||||
|
||||
async def test_publish_builds_chunks(client: AsyncClient) -> None:
|
||||
owner = await login(client)
|
||||
ws = await create_workspace(client, owner, "切片测试")
|
||||
|
||||
body = "\n\n".join(
|
||||
[
|
||||
"## 背景",
|
||||
"十月的报销单据被财务退回两次,第一次是发票复印件,第二次是缺少行程单。" * 20,
|
||||
"## 结论",
|
||||
"报销必须同时提供电子发票原件与行程单,缺一不可。" * 20,
|
||||
]
|
||||
)
|
||||
doc = await _knowledge(client, owner, ws["id"], "十月报销被退单复盘", body, "postmortem")
|
||||
published = await _publish(client, owner, ws["id"], doc)
|
||||
assert published["knowledge"]["chunk_count"] >= 2, "长正文应该被切成多片"
|
||||
|
||||
detail = unwrap(
|
||||
await client.get(
|
||||
f"{V1}/workspaces/{ws['id']}/knowledge/{doc['id']}", headers=owner["headers"]
|
||||
)
|
||||
)
|
||||
assert detail["knowledge"]["chunk_count"] >= 2
|
||||
|
||||
|
||||
async def test_reindex_picks_up_edits(client: AsyncClient) -> None:
|
||||
owner = await login(client)
|
||||
ws = await create_workspace(client, owner, "重建索引")
|
||||
|
||||
doc = await _knowledge(client, owner, ws["id"], "值班安排", "周一由张三负责。")
|
||||
published = await _publish(client, owner, ws["id"], doc)
|
||||
assert published["knowledge"]["chunk_count"] == 1
|
||||
|
||||
# 已发布的内容不许直接改(7003):产品上的路径是「归档 → 恢复成草稿 → 改」
|
||||
archived = unwrap(
|
||||
await client.post(
|
||||
f"{V1}/workspaces/{ws['id']}/knowledge/{doc['id']}/status",
|
||||
json={"action": "archive", "version": published["version"]},
|
||||
headers=owner["headers"],
|
||||
)
|
||||
)
|
||||
draft = unwrap(
|
||||
await client.post(
|
||||
f"{V1}/workspaces/{ws['id']}/knowledge/{doc['id']}/status",
|
||||
json={"action": "restore", "version": archived["version"]},
|
||||
headers=owner["headers"],
|
||||
)
|
||||
)
|
||||
patched = unwrap(
|
||||
await client.patch(
|
||||
f"{V1}/workspaces/{ws['id']}/knowledge/{doc['id']}",
|
||||
json={
|
||||
"body_md": "周一由张三负责,周三改由李四负责,节假日另行通知。",
|
||||
"version": draft["version"],
|
||||
},
|
||||
headers=owner["headers"],
|
||||
)
|
||||
)
|
||||
# 改完正文但没重建索引,旧切片还在——这就是 reindex 存在的理由
|
||||
before = await _search(client, owner, ws["id"], "李四")
|
||||
assert all("李四" not in hit["content"] for hit in before)
|
||||
|
||||
result = unwrap(
|
||||
await client.post(
|
||||
f"{V1}/workspaces/{ws['id']}/knowledge/{doc['id']}/reindex",
|
||||
headers=owner["headers"],
|
||||
)
|
||||
)
|
||||
assert result["chunk_count"] >= 1
|
||||
# 仍是草稿,重建了也不该被检索到
|
||||
assert result["searchable"] is False
|
||||
assert await _search(client, owner, ws["id"], "李四") == []
|
||||
|
||||
await _publish(client, owner, ws["id"], patched)
|
||||
after = await _search(client, owner, ws["id"], "李四")
|
||||
assert any("李四" in hit["content"] for hit in after)
|
||||
|
||||
|
||||
async def test_search_is_tenant_scoped(client: AsyncClient) -> None:
|
||||
alice = await login(client)
|
||||
bob = await login(client)
|
||||
ws_a = await create_workspace(client, alice, "甲队")
|
||||
ws_b = await create_workspace(client, bob, "乙队")
|
||||
|
||||
doc = await _knowledge(client, alice, ws_a["id"], "发票规则", "电子发票原件必须一起寄。")
|
||||
await _publish(client, alice, ws_a["id"], doc)
|
||||
|
||||
assert await _search(client, bob, ws_b["id"], "发票") == []
|
||||
# 直接拿别人的工作区搜也不行
|
||||
denied = await client.get(
|
||||
f"{V1}/workspaces/{ws_a['id']}/knowledge/search",
|
||||
params={"q": "发票"},
|
||||
headers=bob["headers"],
|
||||
)
|
||||
assert denied.status_code in (403, 404)
|
||||
|
||||
|
||||
async def test_keyword_mode_reports_lexical_score(client: AsyncClient) -> None:
|
||||
owner = await login(client)
|
||||
ws = await create_workspace(client, owner, "词法检索")
|
||||
|
||||
doc = await _knowledge(
|
||||
client, owner, ws["id"], "顺丰单号核对", "单号 SF1234567890 必须与发货记录一致。"
|
||||
)
|
||||
await _publish(client, owner, ws["id"], doc)
|
||||
|
||||
hits = await _search(client, owner, ws["id"], "SF1234567890", mode="keyword")
|
||||
assert hits, "带专有编号的查询在词法模式下应该命中"
|
||||
assert hits[0]["lexical_score"] > 0
|
||||
# keyword 模式里向量权重是 0:总分只由词法分决定
|
||||
assert hits[0]["score"] == hits[0]["lexical_score"]
|
||||
@@ -0,0 +1,33 @@
|
||||
# 达道Task 生产服务(用户级 systemd,openclaw-01 上不需要 root)
|
||||
#
|
||||
# 安装:
|
||||
# cp deploy/prod/dadaotask-api.service ~/.config/systemd/user/
|
||||
# systemctl --user daemon-reload
|
||||
# systemctl --user enable --now dadaotask-api
|
||||
#
|
||||
# 两份 EnvironmentFile:
|
||||
# env.production —— 真实配置与口令(chmod 600,不进仓库)
|
||||
# env.build —— 由 deploy.sh 每次部署重写(BUILD_COMMIT / BUILD_TIME),供 /version 校验
|
||||
[Unit]
|
||||
Description=DadaoTask API (prod, gunicorn + uvicorn workers)
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=/home/openclaw/app/dadaotask/backend
|
||||
EnvironmentFile=/home/openclaw/.config/dadaotask/env.production
|
||||
EnvironmentFile=/home/openclaw/.config/dadaotask/env.build
|
||||
Environment=PYTHONUNBUFFERED=1
|
||||
ExecStart=/home/openclaw/app/dadaotask/.venv/bin/gunicorn app.main:app \
|
||||
-k uvicorn.workers.UvicornWorker -w 2 \
|
||||
-b 0.0.0.0:8200 \
|
||||
--timeout 60 --graceful-timeout 30 --keep-alive 5 \
|
||||
--access-logfile - --error-logfile -
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
KillSignal=SIGTERM
|
||||
TimeoutStopSec=40
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
@@ -0,0 +1,85 @@
|
||||
#!/usr/bin/env bash
|
||||
# 生产部署脚本 —— openclaw-01(用户级 systemd,全程不需要 root)
|
||||
#
|
||||
# 遵守 AGENTS.md 的部署铁律:
|
||||
# 1. 生产只跑仓库 HEAD(git reset --hard origin/main,禁止服务器手工改)
|
||||
# 2. 前端产物只在服务器上由 HEAD 构建(npm ci && npm run build)
|
||||
# 3. 部署后校验 /version 的 commit == git rev-parse --short HEAD
|
||||
#
|
||||
# 用法(在 openclaw-01 上,openclaw 身份):
|
||||
# bash ~/app/dadaotask/deploy/prod/deploy.sh
|
||||
# 首次部署前准备好 ~/.config/dadaotask/env.production(见 env.production.example)。
|
||||
set -euo pipefail
|
||||
|
||||
APP_DIR=${APP_DIR:-/home/openclaw/app/dadaotask}
|
||||
CONF_DIR=${CONF_DIR:-$HOME/.config/dadaotask}
|
||||
ENV_FILE=$CONF_DIR/env.production
|
||||
SERVICE=${SERVICE:-dadaotask-api}
|
||||
PORT=${PORT:-8200}
|
||||
REPO_URL=${REPO_URL:-https://gitea.tatta.cn/Tatta/DadaoTask.git}
|
||||
# 服务器直连 pypi/npm 官方源很慢(实测 npm 包几百 KB 就能卡住),默认走国内镜像
|
||||
NPM_REGISTRY=${NPM_REGISTRY:-https://registry.npmmirror.com}
|
||||
|
||||
step() { printf '\n== %s ==\n' "$*"; }
|
||||
die() { printf '!! %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
step "0. 前置检查"
|
||||
test -f "$ENV_FILE" || die "缺少 $ENV_FILE(参考 deploy/prod/env.production.example)"
|
||||
|
||||
if ! command -v node >/dev/null; then die "node 未安装(前端必须在服务器上构建)"; fi
|
||||
node_major=$(node -v | sed 's/^v\([0-9]*\).*/\1/')
|
||||
[ "$node_major" -ge 20 ] || die "node 版本过低:$(node -v),需要 20+"
|
||||
|
||||
step "1. 代码对齐仓库 HEAD"
|
||||
if [ ! -d "$APP_DIR/.git" ]; then
|
||||
git clone --branch main --single-branch "$REPO_URL" "$APP_DIR"
|
||||
fi
|
||||
cd "$APP_DIR"
|
||||
git fetch --prune origin
|
||||
git checkout -q main
|
||||
git reset --hard -q origin/main # 生产不允许领先/落后仓库,也不允许脏改动
|
||||
COMMIT=$(git rev-parse HEAD)
|
||||
SHORT=$(git rev-parse --short HEAD)
|
||||
echo " HEAD=$COMMIT ($SHORT)"
|
||||
|
||||
step "2. Python 依赖"
|
||||
# openclaw-01 是 Debian/Ubuntu 系但没装 python3-venv(ensurepip 缺失),也没有 sudo;
|
||||
# 所以先建 --without-pip 的 venv,再用系统 pip 把 pip 装进去。镜像可用 env 覆盖。
|
||||
PIP_INDEX=${PIP_INDEX:-https://mirrors.aliyun.com/pypi/simple/}
|
||||
if ! [ -x .venv/bin/python ] || ! .venv/bin/python -m pip --version >/dev/null 2>&1; then
|
||||
rm -rf .venv
|
||||
python3 -m venv --without-pip .venv
|
||||
python3 -m pip --python .venv/bin/python install -q --index-url "$PIP_INDEX" pip setuptools wheel
|
||||
fi
|
||||
.venv/bin/python -m pip install -q --index-url "$PIP_INDEX" --upgrade -r backend/requirements.txt
|
||||
|
||||
step "3. 数据库迁移(迁移角色,非应用角色)"
|
||||
set -a; . "$ENV_FILE"; set +a
|
||||
( cd backend && ../.venv/bin/alembic upgrade head )
|
||||
|
||||
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
|
||||
chmod 700 "$CONF_DIR"
|
||||
printf 'BUILD_COMMIT=%s\nBUILD_TIME=%s\n' "$SHORT" "$(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
|
||||
systemctl --user restart "$SERVICE"
|
||||
|
||||
step "6. 校验(/health + /version 的 commit)"
|
||||
for _ in $(seq 1 30); do
|
||||
curl -fsS "http://127.0.0.1:$PORT/health" >/dev/null 2>&1 && break
|
||||
sleep 1
|
||||
done
|
||||
HEALTH=$(curl -fsS "http://127.0.0.1:$PORT/health") || die "健康检查失败:服务没起来(journalctl --user -u $SERVICE -n 100)"
|
||||
COMMIT_HTTP=$(curl -fsS "http://127.0.0.1:$PORT/version" | .venv/bin/python -c 'import json,sys; print(json.load(sys.stdin)["commit"])') \
|
||||
|| die "/version 读取失败"
|
||||
echo " health=$HEALTH"
|
||||
echo " version.commit=$COMMIT_HTTP"
|
||||
[ "$COMMIT_HTTP" = "$SHORT" ] || die "/version 的 commit($COMMIT_HTTP) 与 HEAD($SHORT) 不一致"
|
||||
|
||||
printf '\n部署完成:http://%s:%s (commit %s)\n' "$(hostname -I | awk '{print $1}')" "$PORT" "$SHORT"
|
||||
@@ -0,0 +1,37 @@
|
||||
# 生产环境变量模板(部署到 openclaw-01)
|
||||
# 用法:复制到 ~/.config/dadaotask/env.production(chmod 600),把 __CHANGE_ME__ 换成真实口令。
|
||||
# 真实口令不进仓库;本文件只描述字段。
|
||||
ENV=prod
|
||||
APP_NAME=达道Task
|
||||
|
||||
# ── 数据库:fnsvr-01 共享 PG 上**独立的库 + 独立的角色** ──────────────
|
||||
# 生产不复用 dev 的 dadao_app/dadao_owner 角色:同集群同角色 = 拿到开发口令即可连生产。
|
||||
# 角色名必须在迁移前注入(迁移里的 GRANT 按它生成)。
|
||||
OWNER_DB_ROLE=dadao_prod_owner
|
||||
APP_DB_ROLE=dadao_prod_app
|
||||
DATABASE_URL=postgresql+asyncpg://dadao_prod_app:__APP_PW__@192.168.8.160:8432/dadaotask_prod
|
||||
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
|
||||
|
||||
# ── 认证 ─────────────────────────────────────────────────────────────
|
||||
# 生产校验:长度 ≥ 32 且不是开发默认值
|
||||
JWT_SECRET=__CHANGE_ME_32_CHARS_OR_MORE__
|
||||
ACCESS_TOKEN_MINUTES=30
|
||||
REFRESH_TOKEN_DAYS=14
|
||||
|
||||
# 内测登录:短信通道还没接(非 dev provider 直接 501),先把固定验证码显式打开。
|
||||
# 只允许内网访问;短信通道接好后把这两行改成 SMS_PROVIDER=<供应商> 并删掉开关。
|
||||
INTERNAL_TEST_LOGIN=true
|
||||
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_DIR=/home/openclaw/app/dadaotask/web/dist
|
||||
UPLOAD_DIR=/home/openclaw/app/dadaotask/backend/var/uploads
|
||||
MAX_UPLOAD_MB=20
|
||||
@@ -0,0 +1,28 @@
|
||||
# 给 root 用的 nginx 反代(当前没启用:openclaw 账号无 sudo,先跑 8200 单端口)
|
||||
#
|
||||
# 装法(root):
|
||||
# cp deploy/prod/nginx.dadaotask.conf.example /etc/nginx/sites-available/dadaotask
|
||||
# ln -s /etc/nginx/sites-available/dadaotask /etc/nginx/sites-enabled/dadaotask
|
||||
# nginx -t && systemctl reload nginx
|
||||
#
|
||||
# 说明:后端已直接在 8200 上同时托管 API 和前端产物(web/dist),
|
||||
# 所以这里只做一层透传,不需要单独的静态站点 root。
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name _; # 有域名后改成真实域名
|
||||
|
||||
client_max_body_size 25m; # 附件上限 20MB,留余量
|
||||
gzip on;
|
||||
gzip_types text/css application/javascript application/json image/svg+xml;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8200;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 120s; # 知识检索/上传偶发慢请求
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
#!/usr/bin/env bash
|
||||
# 在 fnsvr-01 的共享 PostgreSQL 上给「生产」建独立的库与角色。
|
||||
#
|
||||
# 为什么独立角色:生产库和开发库在同一个实例(fnsvr-01:8432)。复用 dadao_app
|
||||
# 等于拿到开发口令就能连生产;所以生产用 dadao_prod_owner / dadao_prod_app,
|
||||
# 迁移里的 GRANT 与 DEFAULT PRIVILEGES 会按 APP_DB_ROLE / OWNER_DB_ROLE 生成。
|
||||
#
|
||||
# 在 fnsvr-01 上以 root 执行(需要 docker 权限):
|
||||
# OWNER_PW='...' APP_PW='...' bash deploy/prod/provision-db.sh
|
||||
#
|
||||
# 幂等:角色或库已存在时跳过创建(不会重置已有口令)。
|
||||
set -euo pipefail
|
||||
|
||||
CONTAINER=${CONTAINER:-1Panel-postgresql-wIKz}
|
||||
DB=${DB:-dadaotask_prod}
|
||||
OWNER_ROLE=${OWNER_ROLE:-dadao_prod_owner}
|
||||
APP_ROLE=${APP_ROLE:-dadao_prod_app}
|
||||
: "${OWNER_PW:?需要 OWNER_PW 环境变量(迁移角色口令)}"
|
||||
: "${APP_PW:?需要 APP_PW 环境变量(应用角色口令)}"
|
||||
|
||||
echo "== 建角色与库:$DB(owner=$OWNER_ROLE app=$APP_ROLE)=="
|
||||
docker exec -i "$CONTAINER" psql -U dbadmin -d postgres -v ON_ERROR_STOP=1 -q \
|
||||
-v owner_role="$OWNER_ROLE" -v owner_pw="$OWNER_PW" \
|
||||
-v app_role="$APP_ROLE" -v app_pw="$APP_PW" -v db="$DB" <<'SQL'
|
||||
SELECT format('CREATE ROLE %I LOGIN PASSWORD %L', :'owner_role', :'owner_pw')
|
||||
WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = :'owner_role')\gexec
|
||||
SELECT format('CREATE ROLE %I LOGIN PASSWORD %L', :'app_role', :'app_pw')
|
||||
WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = :'app_role')\gexec
|
||||
-- 应用角色绝不能是 superuser / bypassrls,否则 RLS 直接被跳过
|
||||
SELECT format('ALTER ROLE %I NOSUPERUSER NOBYPASSRLS NOCREATEDB NOCREATEROLE', :'app_role')\gexec
|
||||
SELECT format('ALTER ROLE %I NOSUPERUSER NOBYPASSRLS', :'owner_role')\gexec
|
||||
SELECT format('CREATE DATABASE %I OWNER %I', :'db', :'owner_role')
|
||||
WHERE NOT EXISTS (SELECT 1 FROM pg_database WHERE datname = :'db')\gexec
|
||||
SQL
|
||||
|
||||
echo "== 库内权限(schema 归 owner,app 只能读写数据)=="
|
||||
docker exec -i "$CONTAINER" psql -U dbadmin -d "$DB" -v ON_ERROR_STOP=1 -q \
|
||||
-v owner_role="$OWNER_ROLE" -v app_role="$APP_ROLE" <<'SQL'
|
||||
REVOKE CREATE ON SCHEMA public FROM PUBLIC;
|
||||
SELECT format('ALTER SCHEMA public OWNER TO %I', :'owner_role')\gexec
|
||||
SELECT format('GRANT CONNECT ON DATABASE %I TO %I', current_database(), :'app_role')\gexec
|
||||
SELECT format('GRANT USAGE ON SCHEMA public TO %I', :'app_role')\gexec
|
||||
SQL
|
||||
|
||||
echo "== 自检 =="
|
||||
docker exec -i "$CONTAINER" psql -U dbadmin -d "$DB" -tAc \
|
||||
"select current_database(), (select rolname from pg_roles where rolname='$APP_ROLE'),
|
||||
(select rolsuper from pg_roles where rolname='$APP_ROLE'),
|
||||
(select rolbypassrls from pg_roles where rolname='$APP_ROLE')"
|
||||
echo "完成。注意:建表与 RLS 由 alembic(deploy.sh 第 3 步)负责。"
|
||||
@@ -0,0 +1,248 @@
|
||||
/**
|
||||
* 知识备忘录形态 e2e:分类树 + 新建时一次填全(标题/类型/分类/标签/正文)+ 按分类筛。
|
||||
*
|
||||
* 与 knowledge.mjs 的分工:那个脚本验"沉淀 → 发布"闭环,这个只验"知识是最像备忘录的形态"。
|
||||
* 前面造数据走接口,界面部分点真实按钮。
|
||||
*/
|
||||
import { chromium } from "playwright-core";
|
||||
import { mkdirSync, 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 stamp = String(Date.now()).slice(-7);
|
||||
const phone = `137${stamp}0`;
|
||||
|
||||
let failures = 0;
|
||||
function check(name, ok, extra = "") {
|
||||
console.log(`${ok ? "PASS" : "FAIL"} ${name}${extra ? ` — ${extra}` : ""}`);
|
||||
if (!ok) failures += 1;
|
||||
}
|
||||
|
||||
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": `e2e-${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;
|
||||
}
|
||||
|
||||
const browser = await chromium.launch({
|
||||
executablePath:
|
||||
process.env.CHROME ??
|
||||
`${process.env.LOCALAPPDATA}\\ms-playwright\\chromium-1223\\chrome-win64\\chrome.exe`,
|
||||
});
|
||||
const consoleErrors = [];
|
||||
const context = await browser.newContext({ viewport: { width: 1280, height: 900 } });
|
||||
const page = await context.newPage();
|
||||
page.on("console", (m) => {
|
||||
if (m.type() === "error") consoleErrors.push(m.text());
|
||||
});
|
||||
page.on("response", (r) => {
|
||||
if (r.status() >= 400) consoleErrors.push(`${r.status()} ${r.url()}`);
|
||||
});
|
||||
|
||||
try {
|
||||
await call("POST", "/auth/sms/send", { phone });
|
||||
const login = await call("POST", "/auth/login", { phone, code: "123456" });
|
||||
const token = login.tokens.access_token;
|
||||
const ws = await call("POST", "/workspaces", { name: `知识备忘 ${stamp}` }, token);
|
||||
|
||||
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: 10_000 });
|
||||
|
||||
// ① 建两级分类:研发 / 部署
|
||||
await page.getByRole("button", { name: "+ 新建", exact: true }).click();
|
||||
await page.getByLabel("新分类名").fill("研发");
|
||||
await page.getByRole("button", { name: "创建", exact: true }).click();
|
||||
await page.getByRole("button", { name: /^研发/, exact: false }).first().waitFor();
|
||||
await page.getByRole("button", { name: "+ 新建", exact: true }).click();
|
||||
await page.getByLabel("新分类名").fill("部署");
|
||||
await page.getByLabel("父分类").selectOption({ label: "研发 下" });
|
||||
await page.getByRole("button", { name: "创建", exact: true }).click();
|
||||
await page.getByRole("button", { name: /^部署/, exact: false }).first().waitFor();
|
||||
check("能在侧栏建出「研发 / 部署」两级分类", true);
|
||||
|
||||
// ② 记一条:一个面板里填完标题/类型/分类/标签/正文
|
||||
await page.getByRole("button", { name: "记一条", exact: true }).click();
|
||||
await page.getByLabel("标题").fill("回滚前先停灰度");
|
||||
await page.getByLabel("类型").selectOption("howto");
|
||||
await page.getByLabel("分类", { exact: true }).selectOption({ label: "研发 / 部署" });
|
||||
await page.getByLabel("标签").fill("发布 事故");
|
||||
// 正文是富文本编辑器(Vditor),不是 textarea:先等它挂载,再用真实键盘输入
|
||||
const composerBody = page.locator('.vditor-ir [contenteditable="true"]').last();
|
||||
await composerBody.waitFor({ timeout: 20_000 });
|
||||
await composerBody.click();
|
||||
await page.keyboard.type("## 步骤", { delay: 10 });
|
||||
await page.keyboard.press("Enter");
|
||||
await page.keyboard.type("1. 停掉灰度流量", { delay: 10 });
|
||||
await page.keyboard.press("Enter");
|
||||
await page.keyboard.type("回滚镜像到上一个 tag", { delay: 10 });
|
||||
await page.keyboard.press("Enter");
|
||||
await page.keyboard.type("验证关键接口", { delay: 10 });
|
||||
check(
|
||||
"新建面板的正文是富文本编辑器",
|
||||
(await composerBody.getAttribute("aria-label")) === "正文",
|
||||
);
|
||||
await page.screenshot({ path: "e2e/artifacts/knowledge-composer-editor.png" });
|
||||
|
||||
// ②½ 附件:新建面板里就能传——点「添加附件」先把这条存成草稿,再上传
|
||||
mkdirSync("e2e/artifacts", { recursive: true });
|
||||
const samplePath = "e2e/artifacts/附件样例.txt";
|
||||
writeFileSync(samplePath, "命令行回滚手册:kubectl rollout undo deployment/api\n");
|
||||
await page.getByRole("button", { name: "添加附件", exact: true }).click();
|
||||
// 面板出现后再走真实的「上传」按钮 + filechooser:页面上不止一个 file input(编辑器也有)
|
||||
await page.getByRole("button", { name: "上传", exact: true }).waitFor({ timeout: 15_000 });
|
||||
const [chooser] = await Promise.all([
|
||||
page.waitForEvent("filechooser", { timeout: 15_000 }),
|
||||
page.getByRole("button", { name: "上传", exact: true }).click(),
|
||||
]);
|
||||
const [uploadResp] = await Promise.all([
|
||||
page.waitForResponse(
|
||||
(r) => r.url().includes("/attachments") && r.request().method() === "POST",
|
||||
{ timeout: 20_000 },
|
||||
),
|
||||
chooser.setFiles(samplePath),
|
||||
]);
|
||||
await page.getByText("附件样例.txt", { exact: false }).first().waitFor({ timeout: 10_000 });
|
||||
check("新建面板里能上传附件(先懒建草稿)", uploadResp.ok(), `HTTP ${uploadResp.status()}`);
|
||||
await page.screenshot({ path: "e2e/artifacts/knowledge-composer-attachment.png" });
|
||||
await page.getByRole("button", { name: "保存并继续写", exact: true }).click();
|
||||
|
||||
// 保存后直接打开详情:标题、分类路径、标签都在
|
||||
await page.getByText("回滚前先停灰度", { exact: false }).first().waitFor({ timeout: 10_000 });
|
||||
const drawerText = (await page.textContent("body")) ?? "";
|
||||
check(
|
||||
"新建面板能把分类与标签一起存下来",
|
||||
drawerText.includes("研发 / 部署") && drawerText.includes("发布"),
|
||||
);
|
||||
check("详情抽屉里能看到刚传的附件", drawerText.includes("附件样例.txt"));
|
||||
await page.screenshot({ path: "e2e/artifacts/knowledge-composer-saved.png", fullPage: false });
|
||||
|
||||
// ③ 列表卡片上能看到分类路径与标签(不用点开)
|
||||
await page.getByRole("button", { name: "关闭", exact: true }).click();
|
||||
const card = page.locator("li").filter({ hasText: "回滚前先停灰度" }).first();
|
||||
await card.waitFor({ timeout: 10_000 });
|
||||
const cardText = (await card.textContent()) ?? "";
|
||||
check("列表卡片带分类路径与标签", cardText.includes("研发 / 部署") && cardText.includes("#发布"));
|
||||
|
||||
// ④ 侧栏按分类筛:部署里有,未分类里没有
|
||||
await page.getByRole("button", { name: /^部署/, exact: false }).first().click();
|
||||
await page.waitForTimeout(400);
|
||||
check(
|
||||
"点「部署」能看到归属该分类的知识",
|
||||
await page.getByText("回滚前先停灰度", { exact: false }).first().isVisible(),
|
||||
);
|
||||
await page.getByRole("button", { name: "未分类", exact: true }).click();
|
||||
await page.waitForTimeout(400);
|
||||
const uncategorizedHasCard = await page
|
||||
.getByText("回滚前先停灰度", { exact: false })
|
||||
.first()
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
check("「未分类」里不出现已归类的知识", !uncategorizedHasCard);
|
||||
await page.screenshot({ path: "e2e/artifacts/knowledge-category-filter.png", fullPage: false });
|
||||
|
||||
// ⑤ 抽屉里能改标签与分类,改完接口里就是新的
|
||||
await page.getByRole("button", { name: "全部知识", exact: true }).click();
|
||||
// 先等抽屉自己把详情拉回来再填:GET 比手快一步的话,刚填的字会被它覆盖掉
|
||||
await Promise.all([
|
||||
page.waitForResponse(
|
||||
(r) =>
|
||||
r.url().includes("/knowledge/") &&
|
||||
r.request().method() === "GET" &&
|
||||
!r.url().endsWith("/knowledge/categories") &&
|
||||
r.status() === 200,
|
||||
),
|
||||
page.getByRole("button", { name: "打开", exact: true }).first().click(),
|
||||
]);
|
||||
const tagInput = page.getByLabel("标签", { exact: true }).last();
|
||||
await tagInput.waitFor({ timeout: 10_000 });
|
||||
// 先等抽屉把自己拉回来的旧标签填上——那一刻之后才没有"迟到的响应"来覆盖输入
|
||||
await page
|
||||
.waitForFunction(
|
||||
() => {
|
||||
const el = document.querySelector('input[aria-label="标签"]');
|
||||
return !!el && el.value.includes("事故");
|
||||
},
|
||||
null,
|
||||
{ timeout: 10_000 },
|
||||
)
|
||||
.catch(() => {});
|
||||
// 用真实键盘输入而不是 fill:fill 走原生 setter,React 的受控 input 有时收不到这次变更
|
||||
await tagInput.click();
|
||||
await tagInput.press("Control+A");
|
||||
await page.keyboard.type("发布 回滚", { delay: 20 });
|
||||
check("抽屉里的标签输入框拿到新值", (await tagInput.inputValue()) === "发布 回滚");
|
||||
const [patchResp] = await Promise.all([
|
||||
page.waitForResponse(
|
||||
(r) =>
|
||||
r.url().includes("/knowledge/") &&
|
||||
r.request().method() === "PATCH" &&
|
||||
r.status() === 200,
|
||||
),
|
||||
page.getByRole("button", { name: "保存", exact: true }).last().click(),
|
||||
]);
|
||||
check("抽屉里保存标签/分类成功", patchResp.ok());
|
||||
|
||||
const list = await call(
|
||||
"GET",
|
||||
`/workspaces/${ws.id}/knowledge?status=draft&status=in_review`,
|
||||
undefined,
|
||||
token,
|
||||
);
|
||||
const doc = list.items.find((d) => d.title === "回滚前先停灰度");
|
||||
check(
|
||||
"接口里标签已更新、分类是「研发 / 部署」",
|
||||
String((doc?.tags ?? []).map((t) => t.name).join(" ")).includes("回滚") &&
|
||||
doc?.category?.full_path === "研发 / 部署",
|
||||
`tags=${JSON.stringify((doc?.tags ?? []).map((t) => t.name))} category=${doc?.category?.full_path}`,
|
||||
);
|
||||
check(
|
||||
"正文按 Markdown 存下来了",
|
||||
String(doc?.body_md ?? "").includes("停掉灰度流量"),
|
||||
);
|
||||
// 列表只带 attachment_count(省一次 N+1),文件清单要问详情
|
||||
const detail = await call("GET", `/workspaces/${ws.id}/knowledge/${doc.id}`, undefined, token);
|
||||
check(
|
||||
"附件挂在这条知识上(详情回读得到)",
|
||||
(detail?.attachments ?? []).some((a) => a.filename === "附件样例.txt"),
|
||||
`attachments=${JSON.stringify((detail?.attachments ?? []).map((a) => a.filename))}`,
|
||||
);
|
||||
|
||||
// ⑥ 分类重名与三级上限:接口层面确认约束还在
|
||||
const dup = await fetch(
|
||||
`${API}/workspaces/${ws.id}/knowledge/categories`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({ name: "部署", parent_id: doc.category.parent_id }),
|
||||
},
|
||||
).then((r) => r.json());
|
||||
check("同层重名被拒(7008)", dup.code === 7008, `code=${dup.code}`);
|
||||
|
||||
check("浏览器控制台与网络无报错", consoleErrors.length === 0, consoleErrors.slice(0, 3).join(" | "));
|
||||
} catch (err) {
|
||||
failures += 1;
|
||||
console.log(`FAIL 脚本异常 — ${err.message}`);
|
||||
await page.screenshot({ path: "e2e/artifacts/knowledge-notebook-failure.png" }).catch(() => {});
|
||||
console.log((await page.textContent("body"))?.slice(0, 600));
|
||||
} finally {
|
||||
await browser.close();
|
||||
console.log(failures === 0 ? "\nALL PASS" : `\n${failures} FAILED`);
|
||||
process.exit(failures === 0 ? 0 : 1);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
// 通知与手工开单的端到端验收。
|
||||
// 两个真实用户:A 开单交给 B、B 收到通知并点开;A 改任务状态,B 也应收到提醒。
|
||||
import { chromium } from "playwright-core";
|
||||
|
||||
const BASE = process.env.BASE ?? "http://127.0.0.1:5180";
|
||||
const API = process.env.API ?? "http://127.0.0.1:8100/api/v1";
|
||||
const CHROME = process.env.CHROME ?? "C:/Users/Steven/AppData/Local/ms-playwright/chromium-1223/chrome-win64/chrome.exe";
|
||||
|
||||
const stamp = String(Date.now()).slice(-6);
|
||||
const phoneA = `135${stamp}00`;
|
||||
const phoneB = `138${String((Number(stamp) + 2222) % 1e8).padStart(8, "0")}`;
|
||||
const wsName = `通知${stamp}`;
|
||||
|
||||
let failed = 0;
|
||||
const ok = (cond, msg) => {
|
||||
if (cond) console.log(` PASS ${msg}`);
|
||||
else {
|
||||
failed += 1;
|
||||
console.log(` FAIL ${msg}`);
|
||||
}
|
||||
};
|
||||
|
||||
async function login(page, phone) {
|
||||
await page.goto(`${BASE}/login`, { waitUntil: "domcontentloaded" });
|
||||
await page.fill("#phone", phone);
|
||||
await page.getByRole("button", { name: "获取验证码" }).click();
|
||||
await page.waitForSelector("text=开发环境验证码", { timeout: 15000 });
|
||||
const code = (await page.locator("text=开发环境验证码").first().innerText()).match(/(\d{6})/)?.[1];
|
||||
await page.fill("#code", code);
|
||||
await page.getByRole("main").getByRole("button", { name: "登录", exact: true }).click();
|
||||
await page.waitForURL((u) => !u.pathname.startsWith("/login"), { timeout: 15000 });
|
||||
}
|
||||
|
||||
const browser = await chromium.launch({
|
||||
executablePath: CHROME,
|
||||
args: ["--no-sandbox", "--disable-dev-shm-usage"],
|
||||
});
|
||||
|
||||
const ctxA = await browser.newContext({ viewport: { width: 1440, height: 960 }, locale: "zh-CN" });
|
||||
const pageA = await ctxA.newPage();
|
||||
const errorsA = [];
|
||||
pageA.on("console", (m) => m.type() === "error" && errorsA.push(m.text()));
|
||||
pageA.on("response", (r) => r.status() >= 400 && errorsA.push(`${r.url()} ${r.status()}`));
|
||||
|
||||
try {
|
||||
await login(pageA, phoneA);
|
||||
console.log("PASS 1. A 登录");
|
||||
|
||||
// 建工作区
|
||||
await pageA.getByPlaceholder("例如:达道科技 / 我的事项").fill(wsName);
|
||||
await pageA.getByRole("button", { name: "创建" }).click();
|
||||
await pageA.waitForURL(/\/w\/[0-9a-f-]{36}/, { timeout: 20000 });
|
||||
const wsId = pageA.url().match(/\/w\/([0-9a-f-]{36})/)[1];
|
||||
console.log("PASS 2. 建工作区");
|
||||
|
||||
// 邀请 B 加入
|
||||
await pageA.goto(`${BASE}/w/${wsId}/members`, { waitUntil: "domcontentloaded" });
|
||||
await pageA.getByRole("button", { name: "邀请成员" }).click();
|
||||
await pageA.waitForSelector("text=/join/", { timeout: 15000 });
|
||||
const link = await pageA
|
||||
.locator("text=/join/")
|
||||
.first()
|
||||
.innerText()
|
||||
.then((t) => t.match(/https?:\/\/\S+join\/\S+/)?.[0]);
|
||||
ok(Boolean(link), "3. 拿到邀请链接");
|
||||
|
||||
const ctxB = await browser.newContext({ viewport: { width: 1440, height: 960 }, locale: "zh-CN" });
|
||||
const pageB = await ctxB.newPage();
|
||||
const errorsB = [];
|
||||
pageB.on("console", (m) => m.type() === "error" && errorsB.push(m.text()));
|
||||
pageB.on("response", (r) => r.status() >= 400 && errorsB.push(`${r.url()} ${r.status()}`));
|
||||
|
||||
await login(pageB, phoneB);
|
||||
await pageB.goto(link, { waitUntil: "domcontentloaded" });
|
||||
await pageB.waitForURL(new RegExp(`/w/${wsId}`), { timeout: 20000 });
|
||||
console.log("PASS 4. B 从邀请链接登录后自动加入");
|
||||
|
||||
// A 手工开一张工单交给 B
|
||||
await pageA.goto(`${BASE}/w/${wsId}/tickets`, { waitUntil: "domcontentloaded" });
|
||||
await pageA.getByRole("button", { name: "新建工单" }).click();
|
||||
await pageA.getByLabel("要办什么").fill(`核对 9 月发票${stamp}`);
|
||||
await pageA.getByLabel("交给谁").selectOption({ index: 1 });
|
||||
await pageA.getByRole("button", { name: "开单并通知对方" }).click();
|
||||
await pageA.waitForSelector(`text=核对 9 月发票${stamp}`, { timeout: 20000 });
|
||||
ok(true, "5. 手工工单开出(不经过任何任务)");
|
||||
|
||||
// 接口确认它确实是 kind=ticket 且没有来源任务
|
||||
const created = await pageA.evaluate(
|
||||
async (ws) => {
|
||||
const t = localStorage.getItem("dadao.access");
|
||||
const r = await fetch(`/api/v1/workspaces/${ws}/tickets?role=requested`, {
|
||||
headers: { Authorization: `Bearer ${t}` },
|
||||
});
|
||||
return r.json();
|
||||
},
|
||||
wsId,
|
||||
);
|
||||
const t0 = created.data.tickets[0];
|
||||
ok(t0?.kind === "ticket" && t0?.ticket?.parent_item_id === null, "6. 手工单没有来源任务");
|
||||
|
||||
// B 收到通知
|
||||
await pageB.goto(`${BASE}/w/${wsId}/notifications`, { waitUntil: "domcontentloaded" });
|
||||
await pageB.waitForSelector(`text=核对 9 月发票${stamp}`, { timeout: 20000 });
|
||||
ok(true, "7. B 收到「派给你的工单」通知");
|
||||
|
||||
const unreadBefore = await pageB.locator("li.card").count();
|
||||
ok(unreadBefore >= 1, "8. 未读列表里有条目");
|
||||
|
||||
// 点开通知:跳到工单页
|
||||
await pageB.locator("li.card").first().click();
|
||||
await pageB.waitForURL(/\/tickets\?open=/, { timeout: 20000 });
|
||||
await pageB.waitForSelector(`text=核对 9 月发票${stamp}`, { timeout: 20000 });
|
||||
ok(true, "9. 点通知跳到工单并展开那张单");
|
||||
|
||||
// 标记已读后未读数归零
|
||||
const unread = await pageB.evaluate(async (ws) => {
|
||||
const t = localStorage.getItem("dadao.access");
|
||||
const r = await fetch(`/api/v1/workspaces/${ws}/notifications/unread-count`, {
|
||||
headers: { Authorization: `Bearer ${t}` },
|
||||
});
|
||||
return (await r.json()).data.unread;
|
||||
}, wsId);
|
||||
ok(unread === 0, "10. 点开后未读数归零");
|
||||
|
||||
ok(errorsA.length === 0, `11. A 无报错${errorsA.slice(0, 2).join(" | ")}`);
|
||||
ok(errorsB.length === 0, `12. B 无报错${errorsB.slice(0, 2).join(" | ")}`);
|
||||
|
||||
await pageB.screenshot({ path: "e2e/artifacts/notifications.png" });
|
||||
await pageA.screenshot({ path: "e2e/artifacts/ticket-new.png" });
|
||||
} catch (err) {
|
||||
failed += 1;
|
||||
console.log("FAIL 异常:", err.message);
|
||||
await pageA.screenshot({ path: "e2e/artifacts/notifications-fail-a.png" }).catch(() => {});
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
console.log(failed === 0 ? "\nALL PASS" : `\n${failed} 项失败`);
|
||||
process.exit(failed === 0 ? 0 : 1);
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* 检索 e2e:发布的知识能被搜到,草稿搜不到,结果能点回原文。
|
||||
*
|
||||
* 只验证"用户能不能用上",切片与打分逻辑由后端测试覆盖。
|
||||
*/
|
||||
import { chromium } from "playwright-core";
|
||||
|
||||
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 stamp = String(Date.now()).slice(-7);
|
||||
const phone = `134${stamp}0`;
|
||||
|
||||
let failures = 0;
|
||||
function check(name, ok, extra = "") {
|
||||
console.log(`${ok ? "PASS" : "FAIL"} ${name}${extra ? ` — ${extra}` : ""}`);
|
||||
if (!ok) failures += 1;
|
||||
}
|
||||
|
||||
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": `e2e-${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;
|
||||
}
|
||||
|
||||
const browser = await chromium.launch({
|
||||
executablePath:
|
||||
process.env.CHROME ??
|
||||
`${process.env.LOCALAPPDATA}\\ms-playwright\\chromium-1223\\chrome-win64\\chrome.exe`,
|
||||
});
|
||||
const consoleErrors = [];
|
||||
const context = await browser.newContext({ viewport: { width: 1280, height: 900 } });
|
||||
const page = await context.newPage();
|
||||
page.on("console", (m) => {
|
||||
if (m.type() === "error") consoleErrors.push(m.text());
|
||||
});
|
||||
page.on("response", (r) => {
|
||||
if (r.status() >= 400) consoleErrors.push(`${r.status()} ${r.url()}`);
|
||||
});
|
||||
|
||||
try {
|
||||
await call("POST", "/auth/sms/send", { phone });
|
||||
const login = await call("POST", "/auth/login", { phone, code: "123456" });
|
||||
const token = login.tokens.access_token;
|
||||
const ws = await call("POST", "/workspaces", { name: `检索验收 ${stamp}` }, token);
|
||||
|
||||
const published = await call(
|
||||
"POST",
|
||||
`/workspaces/${ws.id}/knowledge`,
|
||||
{
|
||||
title: "快递单号对不上怎么处理",
|
||||
doc_type: "howto",
|
||||
body_md:
|
||||
"## 步骤\n\n先在顺丰后台按手机号反查单号,再和发货记录逐一核对。\n\n如果差超过两天,直接找客服开异常件工单。",
|
||||
},
|
||||
token,
|
||||
);
|
||||
await call(
|
||||
"POST",
|
||||
`/workspaces/${ws.id}/knowledge/${published.id}/status`,
|
||||
{ action: "publish", version: published.version },
|
||||
token,
|
||||
);
|
||||
|
||||
const draft = await call(
|
||||
"POST",
|
||||
`/workspaces/${ws.id}/knowledge`,
|
||||
{ title: "还没核对的草稿", doc_type: "note", body_md: "这条写的是异常件的赔付口径,尚未定稿。" },
|
||||
token,
|
||||
);
|
||||
|
||||
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" });
|
||||
|
||||
const box = page.getByPlaceholder(/搜标题或正文/);
|
||||
await box.fill("异常件单号核对");
|
||||
await box.press("Enter");
|
||||
await page.getByText(/命中 \d+ 段/).waitFor({ timeout: 10_000 });
|
||||
const summary = await page.getByText(/命中 \d+ 段/).first().textContent();
|
||||
check("检索返回了结果", /命中 [1-9]\d* 段/.test(summary ?? ""), summary ?? "");
|
||||
check("命中里带标题", await page.getByText("快递单号对不上怎么处理", { exact: false }).first().isVisible());
|
||||
check("草稿不会出现在检索结果里", !(await page.getByText("还没核对的草稿", { exact: false }).count()));
|
||||
await page.screenshot({ path: "e2e/artifacts/retrieval-hits.png", fullPage: false });
|
||||
|
||||
await page.getByRole("button", { name: "打开原文", exact: true }).first().click();
|
||||
await page.getByText(/已发布 · 版本 \d+/, { exact: false }).waitFor({ timeout: 10_000 });
|
||||
check("命中能点回原文", true);
|
||||
await page.getByRole("button", { name: "关闭", exact: true }).click();
|
||||
|
||||
await box.fill("量子纠缠");
|
||||
await box.press("Enter");
|
||||
await page.getByText("没有检索到相关内容", { exact: false }).waitFor({ timeout: 10_000 });
|
||||
check("不相干的关键词宁可返回空", true);
|
||||
|
||||
check("浏览器控制台与网络无报错", consoleErrors.length === 0, consoleErrors.slice(0, 3).join(" | "));
|
||||
} catch (err) {
|
||||
failures += 1;
|
||||
console.log(`FAIL 脚本异常 — ${err.message}`);
|
||||
await page.screenshot({ path: "e2e/artifacts/retrieval-failure.png" }).catch(() => {});
|
||||
console.log((await page.textContent("body"))?.slice(0, 600));
|
||||
} finally {
|
||||
await browser.close();
|
||||
console.log(failures === 0 ? "\nALL PASS" : `\n${failures} FAILED`);
|
||||
process.exit(failures === 0 ? 0 : 1);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
// 只为截图:登录一个已有通知的账号,截「通知页」与「新建工单」面板。
|
||||
import { chromium } from "playwright-core";
|
||||
|
||||
const BASE = "http://127.0.0.1:5180";
|
||||
const API = "http://127.0.0.1:8100/api/v1";
|
||||
const CHROME = "C:/Users/Steven/AppData/Local/ms-playwright/chromium-1223/chrome-win64/chrome.exe";
|
||||
|
||||
const login = async (phone) => {
|
||||
const send = await fetch(`${API}/auth/sms/send`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ phone }),
|
||||
}).then((r) => r.json());
|
||||
const code = send.data?.dev_code ?? "123456";
|
||||
const r = await fetch(`${API}/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ phone, code }),
|
||||
}).then((x) => x.json());
|
||||
return r.data;
|
||||
};
|
||||
|
||||
const browser = await chromium.launch({ executablePath: CHROME, args: ["--no-sandbox"] });
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 960 }, locale: "zh-CN" });
|
||||
|
||||
const auth = await login("13900009002");
|
||||
const me = await fetch(`${API}/auth/me`, {
|
||||
headers: { Authorization: `Bearer ${auth.tokens.access_token}` },
|
||||
}).then((r) => r.json());
|
||||
const ws = me.data.workspaces[0];
|
||||
await page.goto(`${BASE}/login`, { waitUntil: "domcontentloaded" });
|
||||
await page.evaluate(
|
||||
([a, r]) => {
|
||||
localStorage.setItem("dadao.access", a);
|
||||
localStorage.setItem("dadao.refresh", r);
|
||||
},
|
||||
[auth.tokens.access_token, auth.tokens.refresh_token],
|
||||
);
|
||||
|
||||
await page.goto(`${BASE}/w/${ws.id}/notifications`, { waitUntil: "domcontentloaded" });
|
||||
await page.waitForSelector("ul li", { timeout: 20000 });
|
||||
await page.screenshot({ path: "e2e/artifacts/ui-notifications.png" });
|
||||
|
||||
await page.goto(`${BASE}/w/${ws.id}/tickets`, { waitUntil: "domcontentloaded" });
|
||||
await page.getByRole("button", { name: "新建工单" }).click();
|
||||
await page.waitForSelector("text=交给谁", { timeout: 15000 });
|
||||
await page.screenshot({ path: "e2e/artifacts/ui-ticket-new.png" });
|
||||
|
||||
console.log("done", ws.name);
|
||||
await browser.close();
|
||||
@@ -10,6 +10,7 @@ import { TasksPage } from "./pages/Tasks";
|
||||
import { BoardPage } from "./pages/Board";
|
||||
import { TicketsPage } from "./pages/Tickets";
|
||||
import { KnowledgePage } from "./pages/Knowledge";
|
||||
import { NotificationsPage } from "./pages/Notifications";
|
||||
import { JoinPage } from "./pages/Join";
|
||||
|
||||
interface AuthState {
|
||||
@@ -90,6 +91,7 @@ export function App() {
|
||||
<Route path="/w/:wsId/board" element={<BoardPage />} />
|
||||
<Route path="/w/:wsId/tickets" element={<TicketsPage />} />
|
||||
<Route path="/w/:wsId/knowledge" element={<KnowledgePage />} />
|
||||
<Route path="/w/:wsId/notifications" element={<NotificationsPage />} />
|
||||
<Route path="/w/:wsId/members" element={<WorkspacePage />} />
|
||||
<Route path="/join/:wsId/:code" element={<JoinPage />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { Workspace } from "../lib/types";
|
||||
import {
|
||||
Book,
|
||||
ChevronDown,
|
||||
Bell,
|
||||
Columns,
|
||||
Inbox,
|
||||
Logo,
|
||||
@@ -15,13 +16,25 @@ import {
|
||||
X,
|
||||
} from "./icons";
|
||||
|
||||
export type Section = "tasks" | "board" | "tickets" | "knowledge" | "members";
|
||||
export type Section =
|
||||
| "tasks"
|
||||
| "board"
|
||||
| "tickets"
|
||||
| "knowledge"
|
||||
| "notifications"
|
||||
| "members";
|
||||
|
||||
const NAV: { key: Section; label: string; to: (id: string) => string; icon: ReactNode }[] = [
|
||||
{ key: "tasks", label: "任务", to: (id) => `/w/${id}`, icon: <Inbox size={16} /> },
|
||||
{ key: "board", label: "看板", to: (id) => `/w/${id}/board`, icon: <Columns size={16} /> },
|
||||
{ key: "tickets", label: "工单", to: (id) => `/w/${id}/tickets`, icon: <TicketIcon size={16} /> },
|
||||
{ key: "knowledge", label: "知识", to: (id) => `/w/${id}/knowledge`, icon: <Book size={16} /> },
|
||||
{
|
||||
key: "notifications",
|
||||
label: "通知",
|
||||
to: (id) => `/w/${id}/notifications`,
|
||||
icon: <Bell size={16} />,
|
||||
},
|
||||
{ key: "members", label: "成员", to: (id) => `/w/${id}/members`, icon: <Users size={16} /> },
|
||||
];
|
||||
|
||||
@@ -73,16 +86,22 @@ export function AppShell({
|
||||
let alive = true;
|
||||
const load = async () => {
|
||||
try {
|
||||
const r = await api.get<{ count: number }>(`/workspaces/${wsId}/notifications/unread-count`);
|
||||
if (alive) setUnread(r.count);
|
||||
const r = await api.get<{ unread: number }>(
|
||||
`/workspaces/${wsId}/notifications/unread-count`,
|
||||
);
|
||||
if (alive) setUnread(r.unread ?? 0);
|
||||
} catch {
|
||||
/* 未读数失败不该影响主流程 */
|
||||
}
|
||||
};
|
||||
// 通知页标了已读之后立刻刷新角标,不必等下一个轮询周期
|
||||
const onChange = () => void load();
|
||||
void load();
|
||||
window.addEventListener("notifications:changed", onChange);
|
||||
const timer = window.setInterval(load, 30_000);
|
||||
return () => {
|
||||
alive = false;
|
||||
window.removeEventListener("notifications:changed", onChange);
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, [wsId]);
|
||||
@@ -174,7 +193,7 @@ export function AppShell({
|
||||
<Link key={n.key} to={n.to(ws.id)} className={`nav-item ${isActive ? "nav-item-active" : ""}`}>
|
||||
<span className={isActive ? "text-primary-200" : "text-ink-400"}>{n.icon}</span>
|
||||
{n.label}
|
||||
{n.key === "tickets" && unread > 0 ? (
|
||||
{n.key === "notifications" && unread > 0 ? (
|
||||
<span
|
||||
className={`ml-auto rounded-full px-1.5 py-px text-2xs font-semibold tabular-nums ${
|
||||
isActive ? "bg-clay-500 text-white" : "bg-clay-500/12 text-clay-600"
|
||||
|
||||
@@ -18,7 +18,13 @@ interface Props {
|
||||
onChange: (next: string) => void;
|
||||
/** 图片要落到具体条目下,所以这两个是必填 */
|
||||
wsId: string;
|
||||
itemId: string;
|
||||
/** 新建场景还没有条目 id:正文照样能写,只是暂时不能贴图(贴图前必须先存草稿) */
|
||||
itemId?: string;
|
||||
/**
|
||||
* 新建场景的兜底:要贴图时先老老实实把草稿存下来,拿到条目 id 再传。
|
||||
* 返回 null 表示没存成(由调用方自己提示),编辑器不会继续上传。
|
||||
*/
|
||||
ensureItemId?: () => Promise<string | null>;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
/** 编辑器初始高度(px) */
|
||||
@@ -45,6 +51,7 @@ export function RichTextEditor({
|
||||
onChange,
|
||||
wsId,
|
||||
itemId,
|
||||
ensureItemId,
|
||||
placeholder = "写点什么…支持粘贴/拖入图片,也可以用工具栏排版",
|
||||
disabled = false,
|
||||
minHeight = 240,
|
||||
@@ -61,6 +68,7 @@ export function RichTextEditor({
|
||||
const onErrorRef = useRef(onError);
|
||||
const resourcesRef = useRef(resources);
|
||||
const onUploadedRef = useRef(onUploaded);
|
||||
const ensureItemIdRef = useRef(ensureItemId);
|
||||
const [ready, setReady] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
|
||||
@@ -69,6 +77,7 @@ export function RichTextEditor({
|
||||
onErrorRef.current = onError;
|
||||
resourcesRef.current = resources;
|
||||
onUploadedRef.current = onUploaded;
|
||||
ensureItemIdRef.current = ensureItemId;
|
||||
const disabledRef = useRef(disabled);
|
||||
disabledRef.current = disabled;
|
||||
|
||||
@@ -112,7 +121,8 @@ export function RichTextEditor({
|
||||
"line",
|
||||
"code",
|
||||
"inline-code",
|
||||
"upload",
|
||||
// 没有落点就不摆这个按钮,免得点了才报错;新建时靠 ensureItemId 先存草稿
|
||||
...(itemId || ensureItemId ? ["upload"] : []),
|
||||
"table",
|
||||
"|",
|
||||
"undo",
|
||||
@@ -129,6 +139,19 @@ export function RichTextEditor({
|
||||
accept: "image/*",
|
||||
multiple: true,
|
||||
handler: async (files: File[]) => {
|
||||
// 新建时还没有条目 id:先让调用方把草稿存下来,拿到落点再传
|
||||
let targetId = itemId;
|
||||
if (!targetId) {
|
||||
const ensured = await ensureItemIdRef.current?.();
|
||||
if (!ensured) {
|
||||
// 存草稿失败的原因由 ensureItemId 自己报,这里不叠一层噪音
|
||||
if (!ensureItemIdRef.current) {
|
||||
onErrorRef.current?.("先保存这条内容,再回来贴图或传附件");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
targetId = ensured;
|
||||
}
|
||||
const images = files.filter((file) => file.type.startsWith("image/"));
|
||||
if (images.length === 0) {
|
||||
onErrorRef.current?.("只能插入图片");
|
||||
@@ -139,7 +162,7 @@ export function RichTextEditor({
|
||||
try {
|
||||
for (const file of images) {
|
||||
const created = await uploadFile<Attachment>(
|
||||
`/workspaces/${wsId}/items/${itemId}/attachments`,
|
||||
`/workspaces/${wsId}/items/${targetId}/attachments`,
|
||||
file,
|
||||
);
|
||||
// 自己插入,不用 Vditor 代劳:它对 handler 的返回值只会
|
||||
@@ -211,7 +234,11 @@ export function RichTextEditor({
|
||||
<p className="mt-1 text-2xs text-ink-400">
|
||||
{uploading
|
||||
? "正在上传图片…"
|
||||
: "支持直接粘贴或拖入图片;内容按 Markdown 保存,模型和导出都读得懂"}
|
||||
: itemId
|
||||
? "支持直接粘贴或拖入图片;内容按 Markdown 保存,模型和导出都读得懂"
|
||||
: ensureItemId
|
||||
? "支持直接粘贴或拖入图片:会先自动存成草稿,再传上去"
|
||||
: "内容按 Markdown 保存;存下来之后就能直接贴图、传附件"}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -353,7 +353,7 @@ export function TaskDetail({
|
||||
<Sheet
|
||||
open
|
||||
onClose={onClose}
|
||||
width="max-w-2xl"
|
||||
size="xl"
|
||||
title={item ? item.title : "打开中…"}
|
||||
subtitle={meta}
|
||||
footer={
|
||||
|
||||
@@ -178,6 +178,13 @@ export const Inbox = (p: IconProps) => (
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const Bell = (p: IconProps) => (
|
||||
<svg {...base(p)}>
|
||||
<path d="M6 9a6 6 0 1 1 12 0c0 3.2.8 4.9 1.6 5.9.4.5 0 1.1-.6 1.1H5c-.6 0-1-.6-.6-1.1C5.2 13.9 6 12.2 6 9Z" />
|
||||
<path d="M10 19a2 2 0 0 0 4 0" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const Sparkle = (p: IconProps) => (
|
||||
<svg {...base(p)}>
|
||||
<path d="M12 3.5 13.6 9l5.4 1.6-5.4 1.6L12 17.5l-1.6-5.3L5 10.6 10.4 9Z" />
|
||||
@@ -227,6 +234,19 @@ export const Link2 = (p: IconProps) => (
|
||||
</svg>
|
||||
);
|
||||
|
||||
/** 放大到整屏 / 收回原来的宽度:长文和图片需要有地方放 */
|
||||
export const Expand = (p: IconProps) => (
|
||||
<svg {...base(p)}>
|
||||
<path d="M9 4H4v5M15 4h5v5M9 20H4v-5M15 20h5v-5" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const Collapse = (p: IconProps) => (
|
||||
<svg {...base(p)}>
|
||||
<path d="M4 9h5V4M20 9h-5V4M4 15h5v5M20 15h-5v5" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const More = (p: IconProps) => (
|
||||
<svg {...base(p)}>
|
||||
<circle cx="6" cy="12" r="1.2" fill="currentColor" stroke="none" />
|
||||
|
||||
@@ -8,7 +8,17 @@ import {
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { Check, X } from "./icons";
|
||||
import { Check, Collapse, Expand, X } from "./icons";
|
||||
|
||||
/**
|
||||
* 抽屉宽度档位。以前的抽屉默认 `max-w-xl`(576px),写长文像在门缝里写字;
|
||||
* 知识、工单这类要贴图的内容统一走 xl,上面还有"展开整屏"。
|
||||
*/
|
||||
const SHEET_SIZE = {
|
||||
md: "max-w-2xl",
|
||||
lg: "max-w-4xl",
|
||||
xl: "max-w-6xl",
|
||||
} as const;
|
||||
|
||||
/* ——————————————————————————— 按钮 ——————————————————————————— */
|
||||
|
||||
@@ -267,7 +277,8 @@ export function Sheet({
|
||||
subtitle,
|
||||
children,
|
||||
footer,
|
||||
width = "max-w-xl",
|
||||
size = "lg",
|
||||
width,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
@@ -275,8 +286,14 @@ export function Sheet({
|
||||
subtitle?: ReactNode;
|
||||
children: ReactNode;
|
||||
footer?: ReactNode;
|
||||
/** 宽度档位;长文/多图用 xl,右上方还能一键放满整屏 */
|
||||
size?: "md" | "lg" | "xl";
|
||||
/** 兜底:直接给 Tailwind 宽度类 */
|
||||
width?: string;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const sizeClass = width ?? SHEET_SIZE[size];
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
@@ -298,17 +315,31 @@ export function Sheet({
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={typeof title === "string" ? title : "详情"}
|
||||
className={`relative flex h-full w-full ${width} animate-slide-in-right flex-col border-l border-line-200 bg-white shadow-pop`}
|
||||
className={`relative flex h-full w-full ${
|
||||
expanded ? "max-w-none" : sizeClass
|
||||
} animate-slide-in-right flex-col border-l border-line-200 bg-white shadow-pop`}
|
||||
>
|
||||
<header className="flex items-start justify-between gap-3 border-b border-line-100 px-6 py-4">
|
||||
<div className="min-w-0">
|
||||
<div className="text-base font-semibold text-ink-900">{title}</div>
|
||||
{subtitle ? <div className="mt-0.5 text-xs text-ink-400">{subtitle}</div> : null}
|
||||
</div>
|
||||
<Button variant="subtle" size="sm" onClick={onClose} aria-label="关闭">
|
||||
<X size={15} />
|
||||
关闭
|
||||
</Button>
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
aria-label={expanded ? "收起到侧栏" : "展开整屏"}
|
||||
title={expanded ? "收起到侧栏" : "展开整屏"}
|
||||
>
|
||||
{expanded ? <Collapse size={15} /> : <Expand size={15} />}
|
||||
<span className="hidden sm:inline">{expanded ? "收起" : "展开"}</span>
|
||||
</Button>
|
||||
<Button variant="subtle" size="sm" onClick={onClose} aria-label="关闭">
|
||||
<X size={15} />
|
||||
关闭
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-5">{children}</div>
|
||||
{footer ? (
|
||||
|
||||
@@ -150,10 +150,52 @@ export interface KnowledgeSource {
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface KnowledgeCategory {
|
||||
id: string;
|
||||
parent_id: string | null;
|
||||
name: string;
|
||||
depth: number;
|
||||
sort_order: number;
|
||||
version: number;
|
||||
/** 从根到自己的名字链,后端拼好了,前端不用自己爬树 */
|
||||
path: string[];
|
||||
full_path: string;
|
||||
}
|
||||
|
||||
export interface KnowledgeCategoryNode extends KnowledgeCategory {
|
||||
doc_count: number;
|
||||
}
|
||||
|
||||
export interface Knowledge extends Item {
|
||||
knowledge: KnowledgeInfo;
|
||||
category: KnowledgeCategory | null;
|
||||
source: KnowledgeSource | null;
|
||||
attachments?: Attachment[];
|
||||
attachment_count?: number;
|
||||
activities?: Activity[];
|
||||
}
|
||||
|
||||
/** 一条检索命中:切片级结果,带来源,点得回原文。 */
|
||||
export interface SearchHit {
|
||||
item_id: string;
|
||||
title: string;
|
||||
doc_type: string;
|
||||
idx: number;
|
||||
heading: string | null;
|
||||
content: string;
|
||||
score: number;
|
||||
vector_score: number;
|
||||
lexical_score: number;
|
||||
}
|
||||
|
||||
/** 站内通知:只有"谁做了什么"和"指向哪条事项",详情永远回源取。 */
|
||||
export interface AppNotification {
|
||||
id: string;
|
||||
kind: string;
|
||||
item_id: string | null;
|
||||
item_title: string | null;
|
||||
title: string;
|
||||
body: string;
|
||||
read_at: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { useParams } from "react-router-dom";
|
||||
import { AppShell } from "../components/AppShell";
|
||||
import { AttachmentPanel } from "../components/AttachmentPanel";
|
||||
import { RichTextEditor } from "../components/RichTextEditor";
|
||||
import { Book, Link2, Plus, Search, Sparkle } from "../components/icons";
|
||||
import { Link2, Paperclip, Plus, Search, Sparkle } from "../components/icons";
|
||||
import {
|
||||
Button,
|
||||
EmptyState,
|
||||
@@ -14,7 +14,13 @@ import {
|
||||
StatusPill,
|
||||
} from "../components/ui";
|
||||
import { ApiError, api } from "../lib/client";
|
||||
import type { Attachment, Knowledge, KnowledgeStatus } from "../lib/types";
|
||||
import type {
|
||||
Attachment,
|
||||
Knowledge,
|
||||
KnowledgeCategoryNode,
|
||||
KnowledgeStatus,
|
||||
SearchHit,
|
||||
} from "../lib/types";
|
||||
|
||||
const TABS = [
|
||||
{ key: "draft", label: "待核对", statuses: ["draft", "in_review"] },
|
||||
@@ -61,10 +67,48 @@ export function KnowledgePage() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [openId, setOpenId] = useState<string | null>(null);
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [draftTitle, setDraftTitle] = useState("");
|
||||
const [composing, setComposing] = useState(false);
|
||||
const [hits, setHits] = useState<SearchHit[] | null>(null);
|
||||
const [searching, setSearching] = useState(false);
|
||||
/** null = 不按分类筛(全部);"none" = 未分类;其它是分类 id(含子分类) */
|
||||
const [catFilter, setCatFilter] = useState<string | null>(null);
|
||||
const [categories, setCategories] = useState<KnowledgeCategoryNode[]>([]);
|
||||
const [catBusy, setCatBusy] = useState(false);
|
||||
const reqId = useRef(0);
|
||||
|
||||
const loadCategories = useCallback(async () => {
|
||||
try {
|
||||
const page = await api.get<{ items: KnowledgeCategoryNode[] }>(
|
||||
`/workspaces/${wsId}/knowledge/categories`,
|
||||
);
|
||||
setCategories(page.items);
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "分类加载失败");
|
||||
}
|
||||
}, [wsId]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadCategories();
|
||||
}, [loadCategories]);
|
||||
|
||||
async function createCategory(name: string, parentId: string | null) {
|
||||
setCatBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const created = await api.post<KnowledgeCategoryNode>(
|
||||
`/workspaces/${wsId}/knowledge/categories`,
|
||||
{ name, parent_id: parentId },
|
||||
);
|
||||
await loadCategories();
|
||||
return created;
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "分类创建失败");
|
||||
return null;
|
||||
} finally {
|
||||
setCatBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const load = useCallback(
|
||||
async (nextCursor: string | null) => {
|
||||
// 切 tab / 改搜索会连发请求,慢的回来会盖掉新结果
|
||||
@@ -76,6 +120,8 @@ export function KnowledgePage() {
|
||||
const statuses = TABS.find((t) => t.key === tab)!.statuses;
|
||||
for (const s of statuses) qs.append("status", s);
|
||||
if (q.trim()) qs.set("q", q.trim());
|
||||
if (catFilter === "none") qs.set("uncategorized", "true");
|
||||
else if (catFilter) qs.set("category_id", catFilter);
|
||||
if (nextCursor) qs.set("cursor", nextCursor);
|
||||
const page = await api.get<{
|
||||
items: Knowledge[];
|
||||
@@ -92,29 +138,46 @@ export function KnowledgePage() {
|
||||
if (mine === reqId.current) setLoading(false);
|
||||
}
|
||||
},
|
||||
[wsId, tab, q],
|
||||
[wsId, tab, q, catFilter],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
void load(null);
|
||||
}, [load]);
|
||||
|
||||
async function addNote() {
|
||||
const title = draftTitle.trim();
|
||||
if (!title) return;
|
||||
try {
|
||||
const doc = await api.post<Knowledge>(`/workspaces/${wsId}/knowledge`, {
|
||||
title,
|
||||
doc_type: "note",
|
||||
});
|
||||
setDraftTitle("");
|
||||
setAdding(false);
|
||||
setTab("draft");
|
||||
setOpenId(doc.id);
|
||||
void load(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "保存失败");
|
||||
/**
|
||||
* 语义检索走的是切片接口,和上面的列表筛选不是一回事:
|
||||
* 列表按标题/正文做子串匹配,检索按向量 + trigram 打分,只覆盖已发布的知识。
|
||||
* 所以不把 q 直接接到列表上——两套结果混在一起会让人搞不清命中在哪。
|
||||
*/
|
||||
async function runSearch() {
|
||||
const keyword = q.trim();
|
||||
if (!keyword) {
|
||||
setHits(null);
|
||||
return;
|
||||
}
|
||||
setSearching(true);
|
||||
setError(null);
|
||||
try {
|
||||
const query = new URLSearchParams({ q: keyword, limit: "12" });
|
||||
const page = await api.get<{ items: SearchHit[] }>(
|
||||
`/workspaces/${wsId}/knowledge/search?${query.toString()}`,
|
||||
);
|
||||
setHits(page.items);
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "检索失败");
|
||||
} finally {
|
||||
setSearching(false);
|
||||
}
|
||||
}
|
||||
|
||||
function onCreated(doc: Knowledge) {
|
||||
setComposing(false);
|
||||
setTab("draft");
|
||||
setCatFilter(null);
|
||||
setOpenId(doc.id);
|
||||
void load(null);
|
||||
void loadCategories();
|
||||
}
|
||||
|
||||
const pendingCount = docs.filter((d) => d.knowledge.status_hint !== "published").length;
|
||||
@@ -133,12 +196,22 @@ export function KnowledgePage() {
|
||||
</span>
|
||||
<input
|
||||
className="input h-9 w-48 pl-8 text-sm"
|
||||
placeholder="搜标题或正文…"
|
||||
placeholder="搜标题或正文…(回车检索)"
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setQ(e.target.value);
|
||||
if (!e.target.value.trim()) setHits(null);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") void runSearch();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Button variant="primary" onClick={() => setAdding((v) => !v)}>
|
||||
<Button variant="ghost" disabled={searching || !q.trim()} onClick={() => void runSearch()}>
|
||||
<Search size={14} />
|
||||
{searching ? "检索中…" : "检索"}
|
||||
</Button>
|
||||
<Button variant="primary" onClick={() => setComposing(true)}>
|
||||
<Plus size={14} />
|
||||
记一条
|
||||
</Button>
|
||||
@@ -146,6 +219,18 @@ export function KnowledgePage() {
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-[220px_minmax(0,1fr)]">
|
||||
<CategorySidebar
|
||||
categories={categories}
|
||||
selected={catFilter}
|
||||
onSelect={(value) => {
|
||||
setCatFilter(value);
|
||||
setHits(null);
|
||||
}}
|
||||
busy={catBusy}
|
||||
onCreate={(name, parentId) => void createCategory(name, parentId)}
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<div className="mb-4 flex flex-wrap items-center gap-1.5">
|
||||
{TABS.map((t) => {
|
||||
const active = tab === t.key;
|
||||
@@ -170,41 +255,58 @@ export function KnowledgePage() {
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{adding && (
|
||||
<div className="card mb-4 p-4">
|
||||
<div className="flex items-start gap-2 text-xs text-ink-500">
|
||||
<span className="mt-0.5 text-clay-500">
|
||||
<Book size={14} />
|
||||
</span>
|
||||
<p className="leading-relaxed">
|
||||
手记的知识没有来源,它是你自己的笔记;想沉淀某件事,去那件事的详情里点「沉淀成知识」。
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-3 flex gap-2">
|
||||
<input
|
||||
className="input flex-1"
|
||||
placeholder="一句话标题,比如「报销单要贴发票原件」"
|
||||
value={draftTitle}
|
||||
autoFocus
|
||||
onChange={(e) => setDraftTitle(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") void addNote();
|
||||
}}
|
||||
/>
|
||||
<Button variant="primary" disabled={!draftTitle.trim()} onClick={() => void addNote()}>
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="mb-4">
|
||||
<InlineAlert>{error}</InlineAlert>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && docs.length === 0 ? (
|
||||
{hits !== null ? (
|
||||
<section className="space-y-2">
|
||||
<div className="flex items-baseline justify-between px-1">
|
||||
<p className="text-xs text-ink-500">
|
||||
在已发布的知识里检索「{q.trim()}」,命中 {hits.length} 段
|
||||
</p>
|
||||
<button className="link text-2xs" onClick={() => setHits(null)}>
|
||||
退出检索
|
||||
</button>
|
||||
</div>
|
||||
{hits.length === 0 ? (
|
||||
<div className="card">
|
||||
<EmptyState
|
||||
icon={<Search size={18} />}
|
||||
title="没有检索到相关内容"
|
||||
hint="只检索已发布的知识。换成更具体的说法,或者先把手头的草稿核对发布。"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
hits.map((hit) => (
|
||||
<article key={`${hit.item_id}-${hit.idx}`} className="card p-4">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<span className="chip bg-primary-50 text-primary-700">
|
||||
{DOC_TYPE_LABEL[hit.doc_type] ?? hit.doc_type}
|
||||
</span>
|
||||
{hit.heading ? (
|
||||
<span className="chip bg-paper-200 text-ink-600">{hit.heading}</span>
|
||||
) : null}
|
||||
<span className="ml-auto text-2xs text-ink-400">
|
||||
相关度 {hit.score.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-2 text-sm font-medium text-ink-900">{hit.title}</p>
|
||||
<p className="mt-1.5 line-clamp-3 whitespace-pre-wrap text-xs leading-relaxed text-ink-600">
|
||||
{hit.content}
|
||||
</p>
|
||||
<div className="mt-2 text-right">
|
||||
<Button variant="ghost" size="sm" onClick={() => setOpenId(hit.item_id)}>
|
||||
打开原文
|
||||
</Button>
|
||||
</div>
|
||||
</article>
|
||||
))
|
||||
)}
|
||||
</section>
|
||||
) : loading && docs.length === 0 ? (
|
||||
<div className="card overflow-hidden">
|
||||
<SkeletonRows rows={3} />
|
||||
</div>
|
||||
@@ -244,6 +346,11 @@ export function KnowledgePage() {
|
||||
自动整理
|
||||
</span>
|
||||
) : null}
|
||||
{d.category ? (
|
||||
<span className="chip max-w-[16rem] truncate bg-paper-200 text-ink-500">
|
||||
{d.category.full_path}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="mt-1.5 truncate text-sm font-medium text-ink-900 group-hover:text-primary-700">
|
||||
{d.title}
|
||||
@@ -256,6 +363,16 @@ export function KnowledgePage() {
|
||||
{d.source ? <span className="max-w-[22rem] truncate">「{d.source.title}」</span> : null}
|
||||
<span>{new Date(d.updated_at).toLocaleString("zh-CN")}</span>
|
||||
</p>
|
||||
{/* 标签是备忘录的检索入口,卡片上就露出来,不用点开才知道归在哪 */}
|
||||
{d.tags.length > 0 ? (
|
||||
<div className="mt-1.5 flex flex-wrap gap-1">
|
||||
{d.tags.slice(0, 5).map((t) => (
|
||||
<span key={t.id} className="chip bg-primary-50 text-primary-700">
|
||||
#{t.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</button>
|
||||
<Button variant="ghost" size="sm" className="shrink-0" onClick={() => setOpenId(d.id)}>
|
||||
打开
|
||||
@@ -272,28 +389,429 @@ export function KnowledgePage() {
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{composing && (
|
||||
<KnowledgeComposer
|
||||
wsId={wsId}
|
||||
categories={categories}
|
||||
catBusy={catBusy}
|
||||
onCreateCategory={createCategory}
|
||||
onClose={() => setComposing(false)}
|
||||
onCreated={onCreated}
|
||||
/>
|
||||
)}
|
||||
|
||||
{openId && (
|
||||
<KnowledgeDrawer
|
||||
wsId={wsId}
|
||||
itemId={openId}
|
||||
categories={categories}
|
||||
onClose={() => setOpenId(null)}
|
||||
onChanged={() => void load(null)}
|
||||
onChanged={() => {
|
||||
void load(null);
|
||||
void loadCategories();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
/** 左侧分类树:一层一层缩进,点一下就把列表收窄到该分类(含子分类)。 */
|
||||
function CategorySidebar({
|
||||
categories,
|
||||
selected,
|
||||
onSelect,
|
||||
busy,
|
||||
onCreate,
|
||||
}: {
|
||||
categories: KnowledgeCategoryNode[];
|
||||
selected: string | null;
|
||||
onSelect: (value: string | null) => void;
|
||||
busy: boolean;
|
||||
onCreate: (name: string, parentId: string | null) => void;
|
||||
}) {
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [name, setName] = useState("");
|
||||
const [parentId, setParentId] = useState("");
|
||||
|
||||
return (
|
||||
<aside className="card h-fit p-3 lg:sticky lg:top-4">
|
||||
<div className="flex items-center justify-between px-1 py-1">
|
||||
<span className="text-2xs font-medium uppercase tracking-wide text-ink-400">分类</span>
|
||||
<button className="link text-2xs" onClick={() => setAdding((v) => !v)}>
|
||||
{adding ? "取消" : "+ 新建"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{adding && (
|
||||
<form
|
||||
className="mt-2 space-y-1.5 rounded-lg border border-line-200 bg-paper-50 p-2"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const value = name.trim();
|
||||
if (!value) return;
|
||||
onCreate(value, parentId || null);
|
||||
setName("");
|
||||
setAdding(false);
|
||||
}}
|
||||
>
|
||||
<input
|
||||
className="input h-8 text-sm"
|
||||
placeholder="分类名,例如「部署」"
|
||||
value={name}
|
||||
autoFocus
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
aria-label="新分类名"
|
||||
/>
|
||||
<select
|
||||
className="input h-8 text-sm"
|
||||
value={parentId}
|
||||
onChange={(e) => setParentId(e.target.value)}
|
||||
aria-label="父分类"
|
||||
>
|
||||
<option value="">放在最外层</option>
|
||||
{categories
|
||||
.filter((c) => c.depth < 3)
|
||||
.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.full_path} 下
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Button variant="primary" size="sm" type="submit" disabled={busy || !name.trim()}>
|
||||
创建
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<nav className="mt-2 space-y-0.5">
|
||||
<CategoryButton active={selected === null} onClick={() => onSelect(null)}>
|
||||
全部知识
|
||||
</CategoryButton>
|
||||
<CategoryButton active={selected === "none"} onClick={() => onSelect("none")}>
|
||||
未分类
|
||||
</CategoryButton>
|
||||
{categories.map((c) => (
|
||||
<CategoryButton
|
||||
key={c.id}
|
||||
active={selected === c.id}
|
||||
indent={c.depth - 1}
|
||||
onClick={() => onSelect(c.id)}
|
||||
>
|
||||
<span className="truncate">{c.name}</span>
|
||||
{c.doc_count > 0 ? <span className="text-2xs text-ink-400">{c.doc_count}</span> : null}
|
||||
</CategoryButton>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{categories.length === 0 ? (
|
||||
<p className="mt-2 px-1 text-2xs leading-relaxed text-ink-400">
|
||||
还没有分类。先建一层「研发 / 部署」这样的目录,知识才有地方落脚。
|
||||
</p>
|
||||
) : null}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function CategoryButton({
|
||||
active,
|
||||
indent = 0,
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
active: boolean;
|
||||
indent?: number;
|
||||
onClick: () => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
style={{ paddingLeft: `${8 + indent * 14}px` }}
|
||||
className={`flex w-full items-center justify-between gap-2 rounded-lg py-1.5 pr-2 text-sm transition-colors ${
|
||||
active
|
||||
? "bg-white text-ink-900 shadow-sm ring-1 ring-line-200"
|
||||
: "text-ink-600 hover:bg-white/60 hover:text-ink-800"
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** 新建知识:标题、类型、分类、标签、正文一起填,不再是「输入标题就算完」。 */
|
||||
function KnowledgeComposer({
|
||||
wsId,
|
||||
categories,
|
||||
catBusy,
|
||||
onCreateCategory,
|
||||
onClose,
|
||||
onCreated,
|
||||
}: {
|
||||
wsId: string;
|
||||
categories: KnowledgeCategoryNode[];
|
||||
catBusy: boolean;
|
||||
onCreateCategory: (name: string, parentId: string | null) => Promise<KnowledgeCategoryNode | null>;
|
||||
onClose: () => void;
|
||||
onCreated: (doc: Knowledge) => void;
|
||||
}) {
|
||||
const [title, setTitle] = useState("");
|
||||
const [docType, setDocType] = useState("note");
|
||||
const [body, setBody] = useState("");
|
||||
const [tags, setTags] = useState("");
|
||||
const [categoryId, setCategoryId] = useState("");
|
||||
const [newCat, setNewCat] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
/**
|
||||
* 附件与图片都要落在具体条目上,可新建时条目还不存在。
|
||||
* 解法是懒建草稿:第一次传附件/贴图时才 POST 一条草稿,之后一律 PATCH。
|
||||
* 没传过附件的人,取消后什么都不会留在库里。
|
||||
*/
|
||||
const [draftId, setDraftId] = useState<string | null>(null);
|
||||
const [draftVersion, setDraftVersion] = useState(0);
|
||||
const [attachments, setAttachments] = useState<Attachment[]>([]);
|
||||
const draftingRef = useRef<Promise<string | null> | null>(null);
|
||||
|
||||
const parentDepth = categories.find((c) => c.id === categoryId)?.depth ?? 0;
|
||||
const canAddCategory = parentDepth < 3 && Boolean(newCat.trim());
|
||||
const canSave = Boolean(title.trim()) && !busy;
|
||||
|
||||
async function addCategory() {
|
||||
const name = newCat.trim();
|
||||
if (!name || !canAddCategory) return;
|
||||
const created = await onCreateCategory(name, categoryId || null);
|
||||
if (created) {
|
||||
setCategoryId(created.id);
|
||||
setNewCat("");
|
||||
}
|
||||
}
|
||||
|
||||
/** 拿条目 id:已有就直接返回,没有就现存一条草稿;并发调用共用同一个请求。 */
|
||||
async function ensureDraft(): Promise<string | null> {
|
||||
if (draftId) return draftId;
|
||||
if (draftingRef.current) return draftingRef.current;
|
||||
const pending = (async () => {
|
||||
try {
|
||||
const doc = await api.post<Knowledge>(`/workspaces/${wsId}/knowledge`, {
|
||||
title: title.trim() || "未命名知识",
|
||||
body_md: body,
|
||||
doc_type: docType,
|
||||
tag_names: tags.split(/[\s,,]+/).filter(Boolean),
|
||||
category_id: categoryId || null,
|
||||
});
|
||||
setDraftId(doc.id);
|
||||
setDraftVersion(doc.version);
|
||||
setAttachments(doc.attachments ?? []);
|
||||
setTitle(doc.title);
|
||||
setNotice("已存成草稿,现在可以继续写、贴图、传附件");
|
||||
return doc.id;
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "保存草稿失败");
|
||||
return null;
|
||||
} finally {
|
||||
draftingRef.current = null;
|
||||
}
|
||||
})();
|
||||
draftingRef.current = pending;
|
||||
return pending;
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!canSave) return;
|
||||
const itemId = await ensureDraft();
|
||||
if (!itemId) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const doc = await api.patch<Knowledge>(`/workspaces/${wsId}/knowledge/${itemId}`, {
|
||||
version: draftVersion,
|
||||
title: title.trim(),
|
||||
body_md: body,
|
||||
doc_type: docType,
|
||||
tag_names: tags.split(/[\s,,]+/).filter(Boolean),
|
||||
...(categoryId ? { category_id: categoryId } : { clear_category: true }),
|
||||
});
|
||||
onCreated(doc);
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "保存失败");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet
|
||||
open
|
||||
onClose={onClose}
|
||||
size="xl"
|
||||
title="记一条知识"
|
||||
subtitle="标题、正文、标签、分类一次填完;附件和贴图会先把这条存成草稿。"
|
||||
footer={
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button variant="primary" disabled={!canSave} onClick={() => void save()}>
|
||||
{busy ? "保存中…" : "保存并继续写"}
|
||||
</Button>
|
||||
<Button variant="ghost" disabled={busy} onClick={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<span className="ml-auto text-2xs text-ink-400">
|
||||
{draftId
|
||||
? "已经是草稿了:直接关掉会留在「待核对」里"
|
||||
: "先存成草稿,核对发布后才进团队检索"}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<label className="block">
|
||||
<span className="label">标题</span>
|
||||
<input
|
||||
className="input text-base font-medium"
|
||||
placeholder="一句话说清这条知识是什么,比如「回滚前先停掉灰度」"
|
||||
value={title}
|
||||
autoFocus
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
aria-label="标题"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<label className="block">
|
||||
<span className="label">类型</span>
|
||||
<select
|
||||
className="input"
|
||||
value={docType}
|
||||
onChange={(e) => setDocType(e.target.value)}
|
||||
aria-label="类型"
|
||||
>
|
||||
{Object.entries(DOC_TYPE_LABEL).map(([key, label]) => (
|
||||
<option key={key} value={key}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="label">分类</span>
|
||||
<select
|
||||
className="input"
|
||||
value={categoryId}
|
||||
onChange={(e) => setCategoryId(e.target.value)}
|
||||
aria-label="分类"
|
||||
>
|
||||
<option value="">未分类</option>
|
||||
{categories.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.full_path}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-line-200 bg-paper-50 p-2.5">
|
||||
<span className="label">在当前分类下新建子分类(最多三级)</span>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
className="input h-9 flex-1 text-sm"
|
||||
placeholder={parentDepth >= 3 ? "已经到第三级了" : "子分类名"}
|
||||
value={newCat}
|
||||
disabled={parentDepth >= 3}
|
||||
onChange={(e) => setNewCat(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
void addCategory();
|
||||
}
|
||||
}}
|
||||
aria-label="新建子分类"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
disabled={!canAddCategory || catBusy}
|
||||
onClick={() => void addCategory()}
|
||||
>
|
||||
新建
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="block">
|
||||
<span className="label">标签(空格或逗号分隔)</span>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="例如:发布 事故"
|
||||
value={tags}
|
||||
onChange={(e) => setTags(e.target.value)}
|
||||
aria-label="标签"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div>
|
||||
<span className="label">正文</span>
|
||||
{/* 和详情抽屉同一个富文本编辑器:新建时就该能排版、写表格,而不是先存一个纯文本框 */}
|
||||
<div className="rounded-xl border border-line-200 bg-paper-50 px-3 py-2">
|
||||
<RichTextEditor
|
||||
ariaLabel="正文"
|
||||
wsId={wsId}
|
||||
value={body}
|
||||
onChange={setBody}
|
||||
minHeight={320}
|
||||
ensureItemId={ensureDraft}
|
||||
onError={setError}
|
||||
placeholder={"写下要点、步骤、结论…\n\n标题、类型、分类、标签都在上面,正文就是这张备忘的主体。"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-line-200 bg-paper-50 px-3 py-2">
|
||||
{draftId ? (
|
||||
<AttachmentPanel
|
||||
wsId={wsId}
|
||||
itemId={draftId}
|
||||
attachments={attachments}
|
||||
onChange={setAttachments}
|
||||
onError={setError}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="inline-flex items-center gap-1.5 text-2xs font-medium text-ink-500">
|
||||
<Paperclip size={13} />
|
||||
附件
|
||||
</span>
|
||||
<Button variant="ghost" disabled={busy} onClick={() => void ensureDraft()}>
|
||||
添加附件
|
||||
</Button>
|
||||
<span className="text-2xs text-ink-400">
|
||||
附件要挂在条目上:点一下先把这条存成草稿,然后就能上传、下载
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{notice && <p className="text-2xs text-primary-700">{notice}</p>}
|
||||
{error && <InlineAlert>{error}</InlineAlert>}
|
||||
</div>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
/** 知识详情抽屉:能改、能提交审议、能发布/归档。 */
|
||||
function KnowledgeDrawer({
|
||||
wsId,
|
||||
itemId,
|
||||
categories,
|
||||
onClose,
|
||||
onChanged,
|
||||
}: {
|
||||
wsId: string;
|
||||
itemId: string;
|
||||
categories: KnowledgeCategoryNode[];
|
||||
onClose: () => void;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
@@ -301,6 +819,8 @@ function KnowledgeDrawer({
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [title, setTitle] = useState("");
|
||||
const [body, setBody] = useState("");
|
||||
const [tags, setTags] = useState("");
|
||||
const [categoryId, setCategoryId] = useState("");
|
||||
const [attachments, setAttachments] = useState<Attachment[]>([]);
|
||||
/** 附件 id → 带签名的直链(见 lib/attachmentUrls.ts) */
|
||||
const resources = useMemo(() => {
|
||||
@@ -311,18 +831,27 @@ function KnowledgeDrawer({
|
||||
return map;
|
||||
}, [attachments]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
/** 已经载入完成的是哪一条知识。重复的 effect / 慢响应不该再覆盖用户正在敲的内容。 */
|
||||
const loadedFor = useRef<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const data = await api.get<Knowledge>(`/workspaces/${wsId}/knowledge/${itemId}`);
|
||||
setDoc(data);
|
||||
setTitle(data.title);
|
||||
setBody(data.body_md);
|
||||
setAttachments(data.attachments ?? []);
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "加载失败");
|
||||
}
|
||||
}, [wsId, itemId]);
|
||||
const load = useCallback(
|
||||
async (force = false) => {
|
||||
if (!force && loadedFor.current === itemId) return;
|
||||
try {
|
||||
const data = await api.get<Knowledge>(`/workspaces/${wsId}/knowledge/${itemId}`);
|
||||
setDoc(data);
|
||||
setTitle(data.title);
|
||||
setBody(data.body_md);
|
||||
setTags(data.tags.map((t) => t.name).join(" "));
|
||||
setCategoryId(data.category?.id ?? "");
|
||||
setAttachments(data.attachments ?? []);
|
||||
loadedFor.current = itemId;
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "加载失败");
|
||||
}
|
||||
},
|
||||
[wsId, itemId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
@@ -336,6 +865,8 @@ function KnowledgeDrawer({
|
||||
const next = await api.patch<Knowledge>(`/workspaces/${wsId}/knowledge/${itemId}`, {
|
||||
title,
|
||||
body_md: body,
|
||||
tag_names: tags.split(/[\s,,]+/).filter(Boolean),
|
||||
...(categoryId ? { category_id: categoryId } : { clear_category: true }),
|
||||
version: doc.version,
|
||||
});
|
||||
setDoc(next);
|
||||
@@ -343,7 +874,7 @@ function KnowledgeDrawer({
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.code === 4009) {
|
||||
setError("刚被别人改过,已刷新为最新内容");
|
||||
await load();
|
||||
await load(true);
|
||||
} else {
|
||||
setError(err instanceof ApiError ? err.message : "保存失败");
|
||||
}
|
||||
@@ -365,7 +896,7 @@ function KnowledgeDrawer({
|
||||
onChanged();
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "操作失败");
|
||||
await load();
|
||||
await load(true);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -389,13 +920,13 @@ function KnowledgeDrawer({
|
||||
<Sheet
|
||||
open
|
||||
onClose={onClose}
|
||||
width="max-w-2xl"
|
||||
size="xl"
|
||||
title={doc ? doc.title : "知识"}
|
||||
subtitle={
|
||||
doc
|
||||
? `${DOC_TYPE_LABEL[doc.knowledge.doc_type] ?? doc.knowledge.doc_type} · ${
|
||||
STATUS_LABEL[doc.knowledge.status_hint]
|
||||
} · 版本 ${doc.version}`
|
||||
? [doc.category?.full_path, DOC_TYPE_LABEL[doc.knowledge.doc_type] ?? doc.knowledge.doc_type]
|
||||
.filter(Boolean)
|
||||
.join(" · ")
|
||||
: "加载中…"
|
||||
}
|
||||
footer={
|
||||
@@ -460,6 +991,37 @@ function KnowledgeDrawer({
|
||||
aria-label="标题"
|
||||
/>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<label className="block">
|
||||
<span className="label">分类</span>
|
||||
<select
|
||||
className="input"
|
||||
value={categoryId}
|
||||
disabled={!canEdit}
|
||||
onChange={(e) => setCategoryId(e.target.value)}
|
||||
aria-label="分类"
|
||||
>
|
||||
<option value="">未分类</option>
|
||||
{categories.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.full_path}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="label">标签(空格或逗号分隔)</span>
|
||||
<input
|
||||
className="input"
|
||||
value={tags}
|
||||
disabled={!canEdit}
|
||||
onChange={(e) => setTags(e.target.value)}
|
||||
placeholder="例如:发布 事故"
|
||||
aria-label="标签"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-line-200 bg-paper-50 px-3 py-2">
|
||||
<RichTextEditor
|
||||
ariaLabel="正文"
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
|
||||
import { AppShell } from "../components/AppShell";
|
||||
import { Bell, Check, TicketIcon, Inbox, Clock } from "../components/icons";
|
||||
import { Button, EmptyState, PageHeader, SkeletonRows } from "../components/ui";
|
||||
import { api, ApiError } from "../lib/client";
|
||||
import type { AppNotification } from "../lib/types";
|
||||
|
||||
const PAGE = 30;
|
||||
|
||||
/** 工单类通知点开跳到工单页,其余回任务页——都带 `?open=`,落地即展开那一条。 */
|
||||
function targetOf(wsId: string, n: AppNotification): string {
|
||||
if (!n.item_id) return `/w/${wsId}/notifications`;
|
||||
const isTicket = n.kind.startsWith("ticket_");
|
||||
return isTicket ? `/w/${wsId}/tickets?open=${n.item_id}` : `/w/${wsId}?open=${n.item_id}`;
|
||||
}
|
||||
|
||||
function iconOf(kind: string) {
|
||||
if (kind.startsWith("ticket_")) return <TicketIcon size={15} />;
|
||||
if (kind === "item_commented") return <Inbox size={15} />;
|
||||
return <Bell size={15} />;
|
||||
}
|
||||
|
||||
function ago(iso: string): string {
|
||||
const then = new Date(iso).getTime();
|
||||
const diff = Math.max(0, Date.now() - then);
|
||||
const min = Math.floor(diff / 60_000);
|
||||
if (min < 1) return "刚刚";
|
||||
if (min < 60) return `${min} 分钟前`;
|
||||
const hour = Math.floor(min / 60);
|
||||
if (hour < 24) return `${hour} 小时前`;
|
||||
const day = Math.floor(hour / 24);
|
||||
if (day < 30) return `${day} 天前`;
|
||||
return new Date(iso).toLocaleDateString("zh-CN");
|
||||
}
|
||||
|
||||
/** 通知页:默认只看未读——"全部"是查历史用的,不是日常入口。 */
|
||||
export function NotificationsPage() {
|
||||
const { wsId = "" } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const [tab, setTab] = useState<"unread" | "all">("unread");
|
||||
const [rows, setRows] = useState<AppNotification[]>([]);
|
||||
const [cursor, setCursor] = useState<string | null>(null);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(
|
||||
async (reset: boolean) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
unread_only: tab === "unread" ? "true" : "false",
|
||||
limit: String(PAGE),
|
||||
});
|
||||
if (!reset && cursor) params.set("cursor", cursor);
|
||||
const page = await api.get<{
|
||||
notifications: AppNotification[];
|
||||
next_cursor: string | null;
|
||||
has_more: boolean;
|
||||
}>(`/workspaces/${wsId}/notifications?${params.toString()}`);
|
||||
setRows((prev) => (reset ? page.notifications : [...prev, ...page.notifications]));
|
||||
setCursor(page.next_cursor);
|
||||
setHasMore(page.has_more);
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "通知加载失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[wsId, tab, cursor],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setRows([]);
|
||||
setCursor(null);
|
||||
void load(true);
|
||||
// 只在切 tab / 换工作区时重拉,翻页由按钮触发
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [wsId, tab]);
|
||||
|
||||
const unreadCount = useMemo(() => rows.filter((r) => !r.read_at).length, [rows]);
|
||||
|
||||
async function markRead(n: AppNotification) {
|
||||
if (!n.read_at) {
|
||||
try {
|
||||
await api.post(`/workspaces/${wsId}/notifications/${n.id}/read`);
|
||||
} catch {
|
||||
/* 标已读失败不拦着用户点进去看 */
|
||||
}
|
||||
setRows((prev) =>
|
||||
prev.map((r) => (r.id === n.id ? { ...r, read_at: new Date().toISOString() } : r)),
|
||||
);
|
||||
window.dispatchEvent(new Event("notifications:changed"));
|
||||
}
|
||||
navigate(targetOf(wsId, n));
|
||||
}
|
||||
|
||||
async function markAll() {
|
||||
try {
|
||||
await api.post(`/workspaces/${wsId}/notifications/read-all`);
|
||||
setRows((prev) => prev.map((r) => ({ ...r, read_at: r.read_at ?? new Date().toISOString() })));
|
||||
window.dispatchEvent(new Event("notifications:changed"));
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "操作失败");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AppShell wsId={wsId} active="notifications">
|
||||
<PageHeader
|
||||
eyebrow="事情有动静时,这里会告诉你"
|
||||
title="通知"
|
||||
desc="工单被接受、提交、验收,任务被指派、改状态,有人在你关注的事情下说话,都会落在这里。"
|
||||
actions={
|
||||
<>
|
||||
<Button variant="ghost" size="sm" onClick={() => void load(true)} disabled={loading}>
|
||||
刷新
|
||||
</Button>
|
||||
<Button variant="subtle" size="sm" onClick={() => void markAll()} disabled={!unreadCount}>
|
||||
全部已读
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="mb-3 flex items-center gap-1">
|
||||
{(
|
||||
[
|
||||
["unread", "未读"],
|
||||
["all", "全部"],
|
||||
] as const
|
||||
).map(([key, label]) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setTab(key)}
|
||||
className={`rounded-lg px-3 py-1.5 text-xs transition ${
|
||||
tab === key
|
||||
? "bg-ink-900 font-medium text-white"
|
||||
: "text-ink-500 hover:bg-ink-100 hover:text-ink-800"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
{tab === "unread" && unreadCount > 0 ? (
|
||||
<span className="ml-auto text-2xs text-ink-400">还有 {unreadCount} 条没看</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="mb-3 rounded-xl border border-rust-100 bg-rust-50 px-4 py-3 text-sm text-rust-700">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{loading && rows.length === 0 ? (
|
||||
<SkeletonRows rows={4} />
|
||||
) : rows.length === 0 ? (
|
||||
<div className="card">
|
||||
<EmptyState
|
||||
icon={<Bell size={20} />}
|
||||
title={tab === "unread" ? "没有未读通知" : "还没有通知"}
|
||||
hint={
|
||||
tab === "unread"
|
||||
? "有人给你派活、或你关注的事情有进展时,会出现在这里。"
|
||||
: "这里会留下所有历史通知,方便回头找。"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{rows.map((n) => (
|
||||
<li key={n.id} className={`card ${n.read_at ? "" : "border-primary-100 bg-primary-50/40"}`}>
|
||||
<button
|
||||
onClick={() => void markRead(n)}
|
||||
className="flex w-full items-start gap-3 px-4 py-3 text-left"
|
||||
>
|
||||
<span
|
||||
className={`mt-0.5 grid h-7 w-7 shrink-0 place-items-center rounded-full ${
|
||||
n.read_at ? "bg-ink-100 text-ink-500" : "bg-primary-500 text-white"
|
||||
}`}
|
||||
>
|
||||
{iconOf(n.kind)}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex items-baseline gap-2">
|
||||
<span className="truncate text-sm font-medium text-ink-900">{n.title}</span>
|
||||
{!n.read_at ? (
|
||||
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-clay-500" />
|
||||
) : null}
|
||||
<span className="ml-auto shrink-0 text-2xs text-ink-400">
|
||||
{ago(n.created_at)}
|
||||
</span>
|
||||
</span>
|
||||
{n.body ? (
|
||||
<span className="mt-1 block truncate text-xs text-ink-500">{n.body}</span>
|
||||
) : null}
|
||||
{n.item_title ? (
|
||||
<span className="mt-1.5 inline-flex items-center gap-1 text-2xs text-ink-400">
|
||||
<Clock size={11} />
|
||||
{n.item_title}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{hasMore ? (
|
||||
<div className="mt-4 flex justify-center">
|
||||
<Button variant="ghost" size="sm" onClick={() => void load(false)} disabled={loading}>
|
||||
加载更早
|
||||
</Button>
|
||||
</div>
|
||||
) : rows.length > 0 && !hasMore ? (
|
||||
<p className="mt-4 text-center text-2xs text-ink-300">
|
||||
<Check size={11} className="mr-1 inline" />
|
||||
没有更多了
|
||||
</p>
|
||||
) : null}
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useParams, useSearchParams } from "react-router-dom";
|
||||
import { AppShell } from "../components/AppShell";
|
||||
import { TaskDetail } from "../components/TaskDetail";
|
||||
import { Check, Inbox, Plus, Search } from "../components/icons";
|
||||
@@ -41,7 +41,9 @@ export function TasksPage() {
|
||||
const [draft, setDraft] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [openId, setOpenId] = useState<string | null>(null);
|
||||
// 从通知点进来时带 ?open=<id>,落地即展开那一条
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [openId, setOpenId] = useState<string | null>(searchParams.get("open"));
|
||||
const [activeIdx, setActiveIdx] = useState(0);
|
||||
const reqId = useRef(0);
|
||||
|
||||
@@ -333,7 +335,10 @@ export function TasksPage() {
|
||||
<TaskDetail
|
||||
wsId={wsId}
|
||||
itemId={openId}
|
||||
onClose={() => setOpenId(null)}
|
||||
onClose={() => {
|
||||
setOpenId(null);
|
||||
if (searchParams.get("open")) setSearchParams({}, { replace: true });
|
||||
}}
|
||||
onChanged={(updated) =>
|
||||
setItems((prev) => prev.map((it) => (it.id === updated.id ? updated : it)))
|
||||
}
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useParams, useSearchParams } from "react-router-dom";
|
||||
import { AppShell } from "../components/AppShell";
|
||||
import { TaskDetail } from "../components/TaskDetail";
|
||||
import { ChevronRight, TicketIcon } from "../components/icons";
|
||||
import { Button, EmptyState, InlineAlert, PageHeader, SkeletonRows, StatusPill } from "../components/ui";
|
||||
import { ChevronRight, Plus, TicketIcon } from "../components/icons";
|
||||
import {
|
||||
Button,
|
||||
EmptyState,
|
||||
InlineAlert,
|
||||
PageHeader,
|
||||
Sheet,
|
||||
SkeletonRows,
|
||||
StatusPill,
|
||||
useToast,
|
||||
} from "../components/ui";
|
||||
import { ApiError, api } from "../lib/client";
|
||||
import { STATUS_LABEL } from "../lib/parse";
|
||||
import type { Item, Ticket } from "../lib/types";
|
||||
import type { Item, Member, Ticket } from "../lib/types";
|
||||
|
||||
const ROLES = [
|
||||
{ key: "assigned", label: "派给我的", desc: "别人等你的" },
|
||||
@@ -25,14 +34,43 @@ const STATUS_FILTERS = [
|
||||
|
||||
export function TicketsPage() {
|
||||
const { wsId = "" } = useParams();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [role, setRole] = useState<(typeof ROLES)[number]["key"]>("assigned");
|
||||
const [status, setStatus] = useState<string>("");
|
||||
const [tickets, setTickets] = useState<Ticket[]>([]);
|
||||
const [cursor, setCursor] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [openId, setOpenId] = useState<string | null>(null);
|
||||
// 从通知点进来时带 ?open=<id>,落地即展开那一条
|
||||
const [openId, setOpenId] = useState<string | null>(searchParams.get("open"));
|
||||
const reqId = useRef(0);
|
||||
const { push: toast } = useToast();
|
||||
|
||||
// 手工开单:这条路径不经过任何任务,所以人得自己选
|
||||
const [members, setMembers] = useState<Member[]>([]);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [formTitle, setFormTitle] = useState("");
|
||||
const [formBody, setFormBody] = useState("");
|
||||
const [formAssignee, setFormAssignee] = useState("");
|
||||
const [formPriority, setFormPriority] = useState(1);
|
||||
const [formDue, setFormDue] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!wsId) return;
|
||||
let alive = true;
|
||||
void api
|
||||
.get<{ items: Member[] }>(`/workspaces/${wsId}/members?limit=100`)
|
||||
.then((page) => {
|
||||
if (alive) setMembers(page.items);
|
||||
})
|
||||
.catch(() => {
|
||||
/* 成员拉不到时,表单里就没人可选,但不该拦住列表 */
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [wsId]);
|
||||
|
||||
const load = useCallback(
|
||||
async (nextCursor: string | null) => {
|
||||
@@ -75,7 +113,8 @@ export function TicketsPage() {
|
||||
void load(null);
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.code === 4009) {
|
||||
setError("这条刚被别人改过,已为你刷新列表"); void load(null);
|
||||
setError("这条刚被别人改过,已为你刷新列表");
|
||||
void load(null);
|
||||
return;
|
||||
}
|
||||
setError(err instanceof ApiError ? err.message : "操作失败");
|
||||
@@ -84,6 +123,41 @@ export function TicketsPage() {
|
||||
|
||||
const currentRole = ROLES.find((r) => r.key === role);
|
||||
|
||||
async function createTicket() {
|
||||
if (!formTitle.trim()) {
|
||||
setError("工单得有个标题,别人才能知道要办什么");
|
||||
return;
|
||||
}
|
||||
if (!formAssignee) {
|
||||
setError("先选一个人——工单不指派就等于没人接");
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const created = await api.post<Ticket>(`/workspaces/${wsId}/tickets`, {
|
||||
title: formTitle.trim(),
|
||||
body_md: formBody.trim() || null,
|
||||
assignee_id: formAssignee,
|
||||
priority: formPriority,
|
||||
due_at: formDue ? new Date(formDue).toISOString() : null,
|
||||
});
|
||||
setCreating(false);
|
||||
setFormTitle("");
|
||||
setFormBody("");
|
||||
setFormAssignee("");
|
||||
setFormDue("");
|
||||
setFormPriority(1);
|
||||
setRole("requested");
|
||||
toast("工单已开出,对方会收到通知", "success");
|
||||
setOpenId(created.id);
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "开单失败");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AppShell wsId={wsId} active="tickets">
|
||||
<PageHeader
|
||||
@@ -91,18 +165,24 @@ export function TicketsPage() {
|
||||
desc="待接 → 办理中 → 待验收 → 完成。验收通过后,当初卡住的那条任务会自动回到待办。"
|
||||
eyebrow={<>需要别人搭把手的事,都在这里</>}
|
||||
actions={
|
||||
<select
|
||||
className="input h-9 w-36 text-sm"
|
||||
value={status}
|
||||
onChange={(e) => setStatus(e.target.value)}
|
||||
aria-label="按状态筛选"
|
||||
>
|
||||
{STATUS_FILTERS.map((s) => (
|
||||
<option key={s.key} value={s.key}>
|
||||
{s.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<>
|
||||
<select
|
||||
className="input h-9 w-36 text-sm"
|
||||
value={status}
|
||||
onChange={(e) => setStatus(e.target.value)}
|
||||
aria-label="按状态筛选"
|
||||
>
|
||||
{STATUS_FILTERS.map((s) => (
|
||||
<option key={s.key} value={s.key}>
|
||||
{s.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Button variant="primary" size="sm" onClick={() => setCreating(true)}>
|
||||
<Plus size={14} />
|
||||
新建工单
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -218,11 +298,118 @@ export function TicketsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 手工开单:不经过任务,直接把一件要别人帮忙的事开成工单 */}
|
||||
<Sheet
|
||||
open={creating}
|
||||
onClose={() => setCreating(false)}
|
||||
size="xl"
|
||||
title="新建工单"
|
||||
subtitle="不是从任务升级来的也可以——写清楚要办什么,指定一个人。"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={() => setCreating(false)} disabled={saving}>
|
||||
取消
|
||||
</Button>
|
||||
<Button variant="primary" onClick={() => void createTicket()} disabled={saving}>
|
||||
{saving ? "正在开单…" : "开单并通知对方"}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<label className="label" htmlFor="ticket-title">
|
||||
要办什么
|
||||
</label>
|
||||
<input
|
||||
id="ticket-title"
|
||||
className="input"
|
||||
value={formTitle}
|
||||
onChange={(e) => setFormTitle(e.target.value)}
|
||||
placeholder="一句能直接看懂的话,例如:核对 9 月发票与报销单"
|
||||
maxLength={200}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label" htmlFor="ticket-body">
|
||||
详细说明(可选)
|
||||
</label>
|
||||
<textarea
|
||||
id="ticket-body"
|
||||
className="input min-h-[96px] resize-y text-sm leading-6"
|
||||
value={formBody}
|
||||
onChange={(e) => setFormBody(e.target.value)}
|
||||
placeholder="背景、已有的线索、验收标准…对方看到的就是这些"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="label" htmlFor="ticket-assignee">
|
||||
交给谁
|
||||
</label>
|
||||
<select
|
||||
id="ticket-assignee"
|
||||
className="input"
|
||||
value={formAssignee}
|
||||
onChange={(e) => setFormAssignee(e.target.value)}
|
||||
>
|
||||
<option value="">选择一个人…</option>
|
||||
{members.map((m) => (
|
||||
<option key={m.user_id} value={m.user_id}>
|
||||
{m.nickname || m.user_id.slice(0, 8)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label" htmlFor="ticket-priority">
|
||||
优先级
|
||||
</label>
|
||||
<select
|
||||
id="ticket-priority"
|
||||
className="input"
|
||||
value={formPriority}
|
||||
onChange={(e) => setFormPriority(Number(e.target.value))}
|
||||
>
|
||||
<option value={0}>无</option>
|
||||
<option value={1}>低</option>
|
||||
<option value={2}>中</option>
|
||||
<option value={3}>高</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label" htmlFor="ticket-due">
|
||||
希望什么时候办好(可选)
|
||||
</label>
|
||||
<input
|
||||
id="ticket-due"
|
||||
type="datetime-local"
|
||||
className="input"
|
||||
value={formDue}
|
||||
onChange={(e) => setFormDue(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="rounded-xl border border-line-200 bg-paper-200/60 px-3.5 py-2.5 text-2xs leading-5 text-ink-500">
|
||||
开出后对方会收到通知,并按「待接 → 办理中 → 待验收 → 完成」推进。
|
||||
手工开的单没有来源任务,验收通过后不会回流任何任务,但同样会沉淀一份知识草稿。
|
||||
</p>
|
||||
</div>
|
||||
</Sheet>
|
||||
|
||||
{openId && (
|
||||
<TaskDetail
|
||||
wsId={wsId}
|
||||
itemId={openId}
|
||||
onClose={() => setOpenId(null)}
|
||||
onClose={() => {
|
||||
setOpenId(null);
|
||||
if (searchParams.get("open")) setSearchParams({}, { replace: true });
|
||||
}}
|
||||
onChanged={(updated: Item) =>
|
||||
setTickets((prev) => prev.map((t) => (t.id === updated.id ? { ...t, ...updated } : t)))
|
||||
}
|
||||
|
||||