三件欠账一次清:
1. 图片直链签名化
- 新增 `backend/app/services/signing.py`:HMAC-SHA256,密钥由 `JWT_SECRET` 域分离派生
(`attachment-raw-v1`),载荷 `workspace_id:attachment_id:exp`,默认 30 分钟
- `/raw` 支持 `?exp=&sig=`,不需要登录态;签名里绑了工作区,跨租户链接直接拒
- 附件对象多一个 `raw_url`,前端每次渲染现签,正文里只存不带凭据的 `/raw`
- 保留旧 `?token=` 通道,给未升级的客户端过渡
2. 安卓富文本编辑器
- `ui/RichTextEditorScreen.kt`:Markdown 工具栏(粗体/斜体/标题/列表/引用/代码/链接/图片),
选区包裹与行首前缀都按光标位置来;贴图走附件接口,插入签名直链
- 预览用 Markwon,图片用 Coil;正文落在一张白纸卡片里,不再贴屏幕边
- 从任务详情与知识点都能进,保存按 `body_md` 回写(PATCH 带 version)
3. 表格
- 只读渲染走 markdown-it 的 GFM 表,`display:block + overflow-x:auto`:
宽表横向滚动、窄表铺满,都不撑破抽屉
验证:pytest 74 passed;richtext 15 / smoke 7 / tickets 8 / knowledge 7 全绿;
tsc -b 0 错;vite build 通过(主包 299.42 kB / gzip 74.96 kB);
安卓 assembleDebug 成功并在 API 35 模拟器走通编辑与预览。
仍欠:保存后回列表而非回详情抽屉、图片压缩与缩略图、HTML 片段按文本显示。
This commit is contained in:
+20
-11
@@ -396,31 +396,40 @@ Query:`doc_type`(默认 `postmortem`)。来源可以是任务或工单;
|
|||||||
正文类字段(任务 `body_md`、工单说明、知识正文、评论、验收结论)**一律存 Markdown**,不存 HTML。
|
正文类字段(任务 `body_md`、工单说明、知识正文、评论、验收结论)**一律存 Markdown**,不存 HTML。
|
||||||
理由:能导出、能全文检索、能直接喂给 LLM;HTML 每次渲染都要重新清洗一遍,是隐患。
|
理由:能导出、能全文检索、能直接喂给 LLM;HTML 每次渲染都要重新清洗一遍,是隐患。
|
||||||
|
|
||||||
图片不是 base64 塞进正文,而是走附件接口拿一个稳定地址:
|
图片不是 base64 塞进正文,而是走附件接口拿地址。**库里存的是不带凭据的相对路径**:
|
||||||
|
|
||||||
```markdown
|
```markdown
|
||||||

|

|
||||||
```
|
```
|
||||||
|
|
||||||
| 方法 | 路径 | 说明 |
|
| 方法 | 路径 | 说明 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| POST | `/workspaces/{ws_id}/items/{item_id}/attachments` | multipart,字段名 `file`;返回 201 + 附件对象 |
|
| POST | `/workspaces/{ws_id}/items/{item_id}/attachments` | multipart,字段名 `file`;返回 201 + 附件对象 |
|
||||||
| GET | `/workspaces/{ws_id}/items/{item_id}/attachments` | `{items: [...]}` |
|
| GET | `/workspaces/{ws_id}/items/{item_id}/attachments` | `{items: [...]}`,每条含 `raw_url` |
|
||||||
| GET | `/workspaces/{ws_id}/attachments/{id}/download` | 下载(`Content-Disposition: attachment`) |
|
| GET | `/workspaces/{ws_id}/attachments/{id}/download` | 下载(`Content-Disposition: attachment`) |
|
||||||
| GET | `/workspaces/{ws_id}/attachments/{id}/raw?token=<access>` | 直链,给 `<img src>` 用 |
|
| GET | `/workspaces/{ws_id}/attachments/{id}/raw?exp=&sig=` | 直链,给 `<img src>` 用(签名见 6.2) |
|
||||||
| DELETE | `/workspaces/{ws_id}/attachments/{id}?confirm=true` | 上传者或管理员可删;不传 confirm 报 `8004` |
|
| DELETE | `/workspaces/{ws_id}/attachments/{id}?confirm=true` | 上传者或管理员可删;不传 confirm 报 `8004` |
|
||||||
|
|
||||||
附件对象的字段:`id / item_id / filename / content_type / size / sha256 / uploader_id / created_at / can_delete`。
|
附件对象的字段:`id / item_id / filename / content_type / size / sha256 / uploader_id / created_at / raw_url / can_delete`。
|
||||||
|
`raw_url` 是**后端签发的短期直链**(默认 30 分钟),客户端直接用它渲染,不需要自己拼令牌。
|
||||||
事项与知识的列表接口会附带 `attachment_count`,详情接口附带 `attachments` 全量。
|
事项与知识的列表接口会附带 `attachment_count`,详情接口附带 `attachments` 全量。
|
||||||
|
|
||||||
### 6.2 为什么图片直链要带 token
|
### 6.2 图片直链:短期签名,而不是把令牌写进正文
|
||||||
|
|
||||||
`<img>` 发不出 `Authorization` 头。两种解法:把图片读成 base64 内联(正文会膨胀几十倍,且破坏检索),
|
`<img>` 发不出 `Authorization` 头。两种解法:把图片读成 base64 内联(正文膨胀几十倍,破坏检索),
|
||||||
或者给一个生命周期短的签名地址。
|
或者给一个短期签名地址。取后者:
|
||||||
|
|
||||||
当前实现取的是"query 里带 access token",**并且只允许本工作区成员访问**(非成员一律 `8001`,
|
- `raw_url` 形如 `…/raw?exp=1730000000&sig=<hmac>`,签名载荷是 `workspace_id:attachment_id:exp`,
|
||||||
不区分"不存在"与"没权限",避免探测他人附件)。这是本地/内网阶段的折中,上生产前应换成
|
密钥从 `JWT_SECRET` 域分离派生(`attachment-raw-v1`),不与登录令牌共用。
|
||||||
**带过期时间的一次性签名 URL**,见 `ROADMAP` 的 M7 安全清单。
|
- 校验只看签名与过期时间,**不需要用户已登录**;签名里绑了 `workspace_id`,A 工作区签的链接
|
||||||
|
打到 B 工作区会被拒(`8003`)。
|
||||||
|
- 过期不宽限,刷新页面即可拿到新链接。
|
||||||
|
- 兼容旧路径:不带 `sig` 时仍可用 `?token=<access>` 走成员校验,供老客户端过渡。
|
||||||
|
|
||||||
|
为什么非要这样:令牌写进正文会落库,几天后过期就是满篇红叉;用户换设备后旧令牌失效,图片全挂。
|
||||||
|
把凭据放在"每次渲染时现签"的 URL 上,正文才能长期存活。
|
||||||
|
|
||||||
|
### 6.3 附件错误码
|
||||||
|
|
||||||
### 6.3 附件错误码
|
### 6.3 附件错误码
|
||||||
|
|
||||||
|
|||||||
@@ -360,7 +360,8 @@
|
|||||||
- [x] **编辑器不自研**(最初那版 textarea + 正则工具栏已删除):改用 [Vditor](https://github.com/Vanessa219/vditor) 4.0.1(MIT,中文优先,`ir` 模式所见即所得),落盘仍是 Markdown
|
- [x] **编辑器不自研**(最初那版 textarea + 正则工具栏已删除):改用 [Vditor](https://github.com/Vanessa219/vditor) 4.0.1(MIT,中文优先,`ir` 模式所见即所得),落盘仍是 Markdown
|
||||||
- [x] 选型依据:Vditor 11.4k★ / 依赖仅 `diff-match-patch` / 中文界面与文档齐全;TipTap 是 headless 且存 JSON 要另做序列化;`@toast-ui/react-editor` 停更在 2023 且 peer 依赖锁 React 17
|
- [x] 选型依据:Vditor 11.4k★ / 依赖仅 `diff-match-patch` / 中文界面与文档齐全;TipTap 是 headless 且存 JSON 要另做序列化;`@toast-ui/react-editor` 停更在 2023 且 peer 依赖锁 React 17
|
||||||
- [x] 运行时资源自托管(`web/scripts/vendor-vditor.mjs` → `public/vditor`,由 `predev`/`prebuild` 自动生成),**不走 unpkg CDN**——内网/离线环境下编辑器会一直卡在加载
|
- [x] 运行时资源自托管(`web/scripts/vendor-vditor.mjs` → `public/vditor`,由 `predev`/`prebuild` 自动生成),**不走 unpkg CDN**——内网/离线环境下编辑器会一直卡在加载
|
||||||
- [ ] 表格与 HTML 片段:暂不支持(Vditor 支持表格,只是我们的只读渲染器还没开)
|
- [x] 表格:Vditor 里能插能编;只读渲染(评论、动态、预览)走 `markdown-it` 的 GFM 表,`display:block + overflow-x:auto` 让宽表在抽屉里横向滚动、不撑破布局(e2e 断言表格宽度 ≤ 900px,实测 561px)
|
||||||
|
- [ ] HTML 片段:仍按文本显示(不开放 raw HTML 是有意的,避免把消毒责任交给调用方)
|
||||||
|
|
||||||
|
|
||||||
### 5.2 附件
|
### 5.2 附件
|
||||||
@@ -368,8 +369,8 @@
|
|||||||
- [x] 本地磁盘存储(`backend/app/services/storage.py`),key 由内容 sha256 决定;换对象存储只改这一个文件
|
- [x] 本地磁盘存储(`backend/app/services/storage.py`),key 由内容 sha256 决定;换对象存储只改这一个文件
|
||||||
- [x] 上限 `MAX_UPLOAD_MB=20`,声明大小与流式读取双重拦截,超限 `8002`
|
- [x] 上限 `MAX_UPLOAD_MB=20`,声明大小与流式读取双重拦截,超限 `8002`
|
||||||
- [x] 列表带 `attachment_count`,详情带 `attachments`,避免一屏 N 次请求
|
- [x] 列表带 `attachment_count`,详情带 `attachments`,避免一屏 N 次请求
|
||||||
- [x] 下载走带 `Authorization` 的 fetch 转 blob;`<img>` 直链走 `raw?token=`,只允许本工作区成员
|
- [x] 下载走带 `Authorization` 的 fetch 转 blob;`<img>` 直链走**短期签名** `raw?exp=&sig=`(HMAC 密钥由 `JWT_SECRET` 域分离派生,默认 30 分钟,跨工作区拒绝)
|
||||||
- [ ] 图片直链改一次性签名 URL(当前 token 放 query,上生产前必须换)
|
- [x] 图片直链改成签名 URL 后:正文里只存不带凭据的相对路径 `/raw`,凭据每次渲染现签,不再把会过期的令牌写进库
|
||||||
- [ ] 缩略图、图片压缩、病毒扫描:未做
|
- [ ] 缩略图、图片压缩、病毒扫描:未做
|
||||||
|
|
||||||
### 5.3 编辑器
|
### 5.3 编辑器
|
||||||
@@ -377,10 +378,12 @@
|
|||||||
- [x] 粘贴与拖拽上传图片:`upload.handler` 里逐张落到附件接口,再 `insertMD` 插入 `)`。**Vditor 不会替我们插入 handler 的返回值**(它只把字符串当提示语 `tip.show`),这点踩过坑
|
- [x] 粘贴与拖拽上传图片:`upload.handler` 里逐张落到附件接口,再 `insertMD` 插入 `)`。**Vditor 不会替我们插入 handler 的返回值**(它只把字符串当提示语 `tip.show`),这点踩过坑
|
||||||
- [x] 令牌只在浏览器内存里存在:落库前 `stripAttachmentTokens`,渲染/装载前补回;`<img>` 带不了 Authorization 头,所以 `raw?token=` 是必要折中
|
- [x] 令牌只在浏览器内存里存在:落库前 `stripAttachmentTokens`,渲染/装载前补回;`<img>` 带不了 Authorization 头,所以 `raw?token=` 是必要折中
|
||||||
- [x] `AttachmentPanel`:上传/下载/删除(删除二次确认,对应后端 `?confirm=true`)
|
- [x] `AttachmentPanel`:上传/下载/删除(删除二次确认,对应后端 `?confirm=true`)
|
||||||
- [x] 安卓:本轮未做富文本编辑,只做只读渲染 + 附件下载(M5 再议)
|
- [x] 安卓:`RichTextEditorScreen`(Kotlin 手写 Markdown 工具栏:B/I/标题/列表/引用/代码/链接/图片,选区包裹与行首前缀;预览用 **Markwon** 渲染,图片用 **Coil** 加载签名直链),从任务详情与知识点打开,保存按 `body_md` 回写
|
||||||
|
- [ ] 安卓编辑器保存后回到任务列表而不是原详情抽屉(体验缺口:用户要重新点开那条任务)
|
||||||
|
|
||||||
### 5.4 验证
|
### 5.4 验证
|
||||||
- [x] 后端 `pytest` → **70 passed**(新增附件 5 个用例:上传下载、超限、跨租户、删除确认与权限、raw 直链鉴权)
|
- [x] 后端 `pytest` → **74 passed**(附件 8 个用例:上传下载、超限、跨租户、删除确认与权限、raw 签名校验、签名篡改与过期、跨工作区签名)
|
||||||
- [x] `web/e2e/richtext.mjs` → **13 项全绿**(Vditor 与 lute 就绪、`**粗体**`/`` `代码` ``/`- 列表` 就地渲染、落库正文不含 token、贴图后 `/raw` 返回 200、附件重开仍在、控制台无报错)
|
- [x] `web/e2e/richtext.mjs` → **15 项全绿**(Vditor 与 lute 就绪、`**粗体**`/`` `代码` ``/`- 列表` 就地渲染、落库正文不含凭据、贴图后 `/raw` 带签名且返回 200、附件重开仍在、表格渲染且不撑破抽屉、控制台无报错)
|
||||||
- [x] 既有三套 e2e 回归通过(smoke 7 / tickets 8 / knowledge 7)
|
- [x] 既有三套 e2e 回归通过(smoke 7 / tickets 8 / knowledge 7)
|
||||||
- [x] `tsc -b` 0 错;`vite build` 主包 299.42 kB │ gzip 74.96 kB,Vditor 独立 chunk 384.73 kB │ gzip 135.82 kB(按需加载,只进详情抽屉)
|
- [x] `tsc -b` 0 错;`vite build` 主包 299.42 kB │ gzip 74.96 kB,Vditor 独立 chunk 385.24 kB │ gzip 136.05 kB(按需加载,只进详情抽屉)
|
||||||
|
- [x] 安卓 `assembleDebug` 成功(APK 10.1 MB),模拟器上走通「详情 → 编辑正文 → 预览 → 保存」,截图 `2.Design/screens/android-editor{,-preview}.png`
|
||||||
|
|||||||
@@ -28,8 +28,8 @@
|
|||||||
| 工单:升级不搬数据、受理→办理→提交→验收、驳回、改派、站内通知 | ✅ 后端 + Web + 安卓已通,双人 e2e 8 项全绿 |
|
| 工单:升级不搬数据、受理→办理→提交→验收、驳回、改派、站内通知 | ✅ 后端 + Web + 安卓已通,双人 e2e 8 项全绿 |
|
||||||
| 知识:工单验收自动生成草稿、核对后发布、来源可追、手记 | ✅ 后端 + Web + 安卓已通,e2e 7 项全绿 |
|
| 知识:工单验收自动生成草稿、核对后发布、来源可追、手记 | ✅ 后端 + Web + 安卓已通,e2e 7 项全绿 |
|
||||||
| 界面:两端统一设计系统(纸白 / 墨绿 / 陶土橙,语义色、字号阶、组件库) | ✅ 已重做,见 [`2.Design/ROADMAP-M0-M1.md`](2.Design/ROADMAP-M0-M1.md) 第 4 节 |
|
| 界面:两端统一设计系统(纸白 / 墨绿 / 陶土橙,语义色、字号阶、组件库) | ✅ 已重做,见 [`2.Design/ROADMAP-M0-M1.md`](2.Design/ROADMAP-M0-M1.md) 第 4 节 |
|
||||||
| 富文本:备注 / 工单说明 / 知识正文支持图文混排(Vditor 编辑器)与附件 | ✅ 后端 + Web 已通(附件接口见 [`2.Design/API.md`](2.Design/API.md) 第 6 节,e2e 13 项全绿);安卓暂只读 |
|
| 富文本:备注 / 工单说明 / 知识正文支持图文混排与附件 | ✅ Web 用 Vditor 编辑、安卓用 Markwon 预览编辑(e2e 15 项全绿);接口见 [`2.Design/API.md`](2.Design/API.md) 第 6 节 |
|
||||||
| 后端测试 | ✅ 70 passed,ruff 干净 |
|
| 后端测试 | ✅ 74 passed,ruff 干净 |
|
||||||
| 知识切片与语义检索(pgvector) | ⬜ 配置已就绪(`deploy/docker-compose.vector.yml`),本机 Docker 起不来,未跑过 |
|
| 知识切片与语义检索(pgvector) | ⬜ 配置已就绪(`deploy/docker-compose.vector.yml`),本机 Docker 起不来,未跑过 |
|
||||||
| LLM 能力(结构化录入、问答带引用、人物画像) | ⬜ M4 |
|
| LLM 能力(结构化录入、问答带引用、人物画像) | ⬜ M4 |
|
||||||
|
|
||||||
|
|||||||
@@ -73,5 +73,8 @@ dependencies {
|
|||||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
|
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
|
||||||
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
||||||
|
// Markdown 预览用成熟库渲染,不自己写解析器
|
||||||
|
implementation("io.noties.markwon:core:4.6.2")
|
||||||
|
implementation("io.coil-kt:coil-compose:2.7.0")
|
||||||
debugImplementation("androidx.compose.ui:ui-tooling")
|
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,12 +19,15 @@ import androidx.compose.ui.platform.LocalContext
|
|||||||
import com.dadao.task.data.Api
|
import com.dadao.task.data.Api
|
||||||
import com.dadao.task.data.TokenStore
|
import com.dadao.task.data.TokenStore
|
||||||
import com.dadao.task.data.Workspace
|
import com.dadao.task.data.Workspace
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import com.dadao.task.ui.BoardScreen
|
import com.dadao.task.ui.BoardScreen
|
||||||
import com.dadao.task.ui.DadaoTheme
|
import com.dadao.task.ui.DadaoTheme
|
||||||
|
import com.dadao.task.ui.EditRequest
|
||||||
import com.dadao.task.ui.HomeScreen
|
import com.dadao.task.ui.HomeScreen
|
||||||
import com.dadao.task.ui.KnowledgeScreen
|
import com.dadao.task.ui.KnowledgeScreen
|
||||||
import com.dadao.task.ui.LoginScreen
|
import com.dadao.task.ui.LoginScreen
|
||||||
import com.dadao.task.ui.MembersScreen
|
import com.dadao.task.ui.MembersScreen
|
||||||
|
import com.dadao.task.ui.RichTextEditorScreen
|
||||||
import com.dadao.task.ui.TaskListScreen
|
import com.dadao.task.ui.TaskListScreen
|
||||||
import com.dadao.task.ui.TicketsScreen
|
import com.dadao.task.ui.TicketsScreen
|
||||||
import com.dadao.task.ui.WorkspaceScaffold
|
import com.dadao.task.ui.WorkspaceScaffold
|
||||||
@@ -68,6 +71,8 @@ private fun AppRoot(joinUri: String?) {
|
|||||||
var current by remember { mutableStateOf<Workspace?>(null) }
|
var current by remember { mutableStateOf<Workspace?>(null) }
|
||||||
var tab by remember { mutableStateOf(WorkspaceTab.Tasks) }
|
var tab by remember { mutableStateOf(WorkspaceTab.Tasks) }
|
||||||
var unread by remember { mutableStateOf(0) }
|
var unread by remember { mutableStateOf(0) }
|
||||||
|
// 打开富文本编辑器时非空;关掉就恢复原来的界面
|
||||||
|
var editing by remember { mutableStateOf<EditRequest?>(null) }
|
||||||
|
|
||||||
// 有本地令牌就先静默校验一次:令牌过期/被吊销要退回登录页,而不是进了主页再崩。
|
// 有本地令牌就先静默校验一次:令牌过期/被吊销要退回登录页,而不是进了主页再崩。
|
||||||
LaunchedEffect(screen) {
|
LaunchedEffect(screen) {
|
||||||
@@ -122,6 +127,7 @@ private fun AppRoot(joinUri: String?) {
|
|||||||
if (ws == null) {
|
if (ws == null) {
|
||||||
screen = Screen.Home
|
screen = Screen.Home
|
||||||
} else {
|
} else {
|
||||||
|
Box {
|
||||||
WorkspaceScaffold(
|
WorkspaceScaffold(
|
||||||
workspace = ws,
|
workspace = ws,
|
||||||
tab = tab,
|
tab = tab,
|
||||||
@@ -130,12 +136,55 @@ private fun AppRoot(joinUri: String?) {
|
|||||||
onBackHome = { screen = Screen.Home },
|
onBackHome = { screen = Screen.Home },
|
||||||
) {
|
) {
|
||||||
when (tab) {
|
when (tab) {
|
||||||
WorkspaceTab.Tasks -> TaskListScreen(api = api, workspace = ws)
|
WorkspaceTab.Tasks -> TaskListScreen(
|
||||||
|
api = api,
|
||||||
|
workspace = ws,
|
||||||
|
onEdit = { editing = it },
|
||||||
|
)
|
||||||
|
|
||||||
WorkspaceTab.Tickets -> TicketsScreen(api = api, workspace = ws)
|
WorkspaceTab.Tickets -> TicketsScreen(api = api, workspace = ws)
|
||||||
WorkspaceTab.Knowledge -> KnowledgeScreen(api = api, workspace = ws)
|
WorkspaceTab.Knowledge -> KnowledgeScreen(
|
||||||
|
api = api,
|
||||||
|
workspace = ws,
|
||||||
|
onEdit = { editing = it },
|
||||||
|
)
|
||||||
|
|
||||||
WorkspaceTab.Members -> MembersScreen(api = api, workspace = ws)
|
WorkspaceTab.Members -> MembersScreen(api = api, workspace = ws)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val request = editing
|
||||||
|
if (request != null) {
|
||||||
|
RichTextEditorScreen(
|
||||||
|
api = api,
|
||||||
|
workspaceId = ws.id,
|
||||||
|
itemId = request.itemId,
|
||||||
|
initialTitle = request.title,
|
||||||
|
initialBody = request.body,
|
||||||
|
titleEnabled = request.titleEnabled,
|
||||||
|
save = { title, body ->
|
||||||
|
if (request.knowledge) {
|
||||||
|
api.updateKnowledge(
|
||||||
|
workspaceId = ws.id,
|
||||||
|
itemId = request.itemId,
|
||||||
|
version = request.version,
|
||||||
|
title = title,
|
||||||
|
body = body,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
api.updateItem(
|
||||||
|
workspaceId = ws.id,
|
||||||
|
itemId = request.itemId,
|
||||||
|
version = request.version,
|
||||||
|
title = title,
|
||||||
|
body = body,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onClose = { editing = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import kotlinx.serialization.encodeToString
|
|||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import kotlinx.serialization.json.JsonElement
|
import kotlinx.serialization.json.JsonElement
|
||||||
import okhttp3.MediaType.Companion.toMediaType
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
|
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||||
|
import okhttp3.MultipartBody
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import okhttp3.RequestBody.Companion.toRequestBody
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
@@ -136,8 +138,29 @@ data class ItemDetail(
|
|||||||
val tags: List<Tag> = emptyList(),
|
val tags: List<Tag> = emptyList(),
|
||||||
val version: Int = 1,
|
val version: Int = 1,
|
||||||
val activities: List<Activity> = emptyList(),
|
val activities: List<Activity> = emptyList(),
|
||||||
|
val attachments: List<Attachment> = emptyList(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 附件。[rawUrl] 是后端签发的短时直链(带 exp/sig),
|
||||||
|
* 正文里的图片就用它,**不要**把长期凭据写进正文。
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class Attachment(
|
||||||
|
val id: String,
|
||||||
|
@SerialName("item_id") val itemId: String = "",
|
||||||
|
val filename: String = "",
|
||||||
|
@SerialName("content_type") val contentType: String = "application/octet-stream",
|
||||||
|
val size: Long = 0,
|
||||||
|
@SerialName("uploader_id") val uploaderId: String = "",
|
||||||
|
@SerialName("created_at") val createdAt: String = "",
|
||||||
|
@SerialName("raw_url") val rawUrl: String? = null,
|
||||||
|
@SerialName("can_delete") val canDelete: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class AttachmentPage(val items: List<Attachment> = emptyList())
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class Invitation(
|
data class Invitation(
|
||||||
val code: String,
|
val code: String,
|
||||||
@@ -223,6 +246,7 @@ data class KnowledgeDoc(
|
|||||||
val tags: List<Tag> = emptyList(),
|
val tags: List<Tag> = emptyList(),
|
||||||
val knowledge: KnowledgeInfo = KnowledgeInfo(),
|
val knowledge: KnowledgeInfo = KnowledgeInfo(),
|
||||||
val source: SourceRef? = null,
|
val source: SourceRef? = null,
|
||||||
|
val attachments: List<Attachment> = emptyList(),
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@@ -287,6 +311,13 @@ private data class KnowledgeCreateRequest(
|
|||||||
@Serializable
|
@Serializable
|
||||||
private data class KnowledgeActionRequest(val action: String)
|
private data class KnowledgeActionRequest(val action: String)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class ItemUpdateRequest(
|
||||||
|
val version: Int,
|
||||||
|
val title: String? = null,
|
||||||
|
@SerialName("body_md") val bodyMd: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
private data class KnowledgeUpdateRequest(
|
private data class KnowledgeUpdateRequest(
|
||||||
val version: Int,
|
val version: Int,
|
||||||
@@ -451,6 +482,67 @@ class Api(private val tokens: TokenStore) {
|
|||||||
auth = true,
|
auth = true,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ---- 附件与富文本 ----
|
||||||
|
|
||||||
|
suspend fun attachments(workspaceId: String, itemId: String): List<Attachment> =
|
||||||
|
call<AttachmentPage>(
|
||||||
|
path = "/workspaces/$workspaceId/items/$itemId/attachments",
|
||||||
|
auth = true,
|
||||||
|
).items
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传附件。返回的 [Attachment.rawUrl] 是短时签名直链,
|
||||||
|
* 直接写进 Markdown 的 `` 即可,不需要把登录令牌塞进正文。
|
||||||
|
*/
|
||||||
|
suspend fun uploadAttachment(
|
||||||
|
workspaceId: String,
|
||||||
|
itemId: String,
|
||||||
|
filename: String,
|
||||||
|
mimeType: String,
|
||||||
|
bytes: ByteArray,
|
||||||
|
): Attachment = withContext(Dispatchers.IO) {
|
||||||
|
val form = MultipartBody.Builder()
|
||||||
|
.setType(MultipartBody.FORM)
|
||||||
|
.addFormDataPart(
|
||||||
|
"file",
|
||||||
|
filename,
|
||||||
|
bytes.toRequestBody(mimeType.toMediaTypeOrNull()),
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val builder = Request.Builder()
|
||||||
|
.url("$base/workspaces/$workspaceId/items/$itemId/attachments")
|
||||||
|
.post(form)
|
||||||
|
.header("Accept", "application/json")
|
||||||
|
tokens.accessToken?.let { builder.header("Authorization", "Bearer $it") }
|
||||||
|
|
||||||
|
try {
|
||||||
|
http.newCall(builder.build()).execute().use { response ->
|
||||||
|
val text = response.body?.string().orEmpty()
|
||||||
|
val envelope = runCatching { json.decodeFromString<Envelope<Attachment>>(text) }
|
||||||
|
.getOrElse { throw ApiException(1500, "上传失败(HTTP ${response.code})") }
|
||||||
|
if (envelope.code != 0) throw ApiException(envelope.code, envelope.message, envelope.requestId)
|
||||||
|
envelope.data ?: throw ApiException(1500, "上传响应缺少 data")
|
||||||
|
}
|
||||||
|
} catch (e: IOException) {
|
||||||
|
throw unreachable(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 保存正文/标题。PATCH 必须带 version,冲突由服务端返回 4009。 */
|
||||||
|
suspend fun updateItem(
|
||||||
|
workspaceId: String,
|
||||||
|
itemId: String,
|
||||||
|
version: Int,
|
||||||
|
title: String? = null,
|
||||||
|
body: String? = null,
|
||||||
|
): ItemDetail = call(
|
||||||
|
path = "/workspaces/$workspaceId/items/$itemId",
|
||||||
|
body = json.encodeToString(ItemUpdateRequest(version, title, body)),
|
||||||
|
auth = true,
|
||||||
|
method = "PATCH",
|
||||||
|
)
|
||||||
|
|
||||||
// ---- M2 工单 ----
|
// ---- M2 工单 ----
|
||||||
|
|
||||||
/** role: assigned(派给我的)/ requested(我发起的)/ all。 */
|
/** role: assigned(派给我的)/ requested(我发起的)/ all。 */
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ private fun docTypeLabel(type: String): String = when (type) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun KnowledgeScreen(api: Api, workspace: Workspace) {
|
fun KnowledgeScreen(api: Api, workspace: Workspace, onEdit: (EditRequest) -> Unit = {}) {
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
var tab by remember { mutableStateOf("draft") }
|
var tab by remember { mutableStateOf("draft") }
|
||||||
var docs by remember { mutableStateOf<List<KnowledgeDoc>>(emptyList()) }
|
var docs by remember { mutableStateOf<List<KnowledgeDoc>>(emptyList()) }
|
||||||
@@ -168,6 +168,7 @@ fun KnowledgeScreen(api: Api, workspace: Workspace) {
|
|||||||
workspace = workspace,
|
workspace = workspace,
|
||||||
doc = target,
|
doc = target,
|
||||||
onClose = { open = null },
|
onClose = { open = null },
|
||||||
|
onEdit = onEdit,
|
||||||
onChanged = {
|
onChanged = {
|
||||||
scope.launch { reload() }
|
scope.launch { reload() }
|
||||||
},
|
},
|
||||||
@@ -249,6 +250,7 @@ private fun KnowledgeDetailDialog(
|
|||||||
onClose: () -> Unit,
|
onClose: () -> Unit,
|
||||||
onChanged: () -> Unit,
|
onChanged: () -> Unit,
|
||||||
onFail: (Throwable) -> Unit,
|
onFail: (Throwable) -> Unit,
|
||||||
|
onEdit: (EditRequest) -> Unit,
|
||||||
) {
|
) {
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
var action by remember { mutableStateOf(doc.status) }
|
var action by remember { mutableStateOf(doc.status) }
|
||||||
@@ -288,6 +290,21 @@ private fun KnowledgeDetailDialog(
|
|||||||
},
|
},
|
||||||
confirmButton = {
|
confirmButton = {
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
onEdit(
|
||||||
|
EditRequest(
|
||||||
|
itemId = doc.id,
|
||||||
|
title = doc.title,
|
||||||
|
body = doc.bodyMd,
|
||||||
|
version = doc.version,
|
||||||
|
titleEnabled = action == "draft",
|
||||||
|
knowledge = true,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
onClose()
|
||||||
|
},
|
||||||
|
) { Text("编辑正文") }
|
||||||
actions.forEach { (key, label) ->
|
actions.forEach { (key, label) ->
|
||||||
TextButton(onClick = {
|
TextButton(onClick = {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
|
|||||||
@@ -0,0 +1,428 @@
|
|||||||
|
package com.dadao.task.ui
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import android.provider.OpenableColumns
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.horizontalScroll
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.BasicTextField
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.TextRange
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.input.TextFieldValue
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
|
import coil.compose.AsyncImage
|
||||||
|
import com.dadao.task.data.Api
|
||||||
|
import com.dadao.task.data.ApiException
|
||||||
|
import com.dadao.task.data.Attachment
|
||||||
|
import io.noties.markwon.Markwon
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
/** 打开编辑器需要的最小上下文:改的是哪条、改前是什么。 */
|
||||||
|
data class EditRequest(
|
||||||
|
val itemId: String,
|
||||||
|
val title: String,
|
||||||
|
val body: String,
|
||||||
|
val version: Int,
|
||||||
|
val titleEnabled: Boolean = true,
|
||||||
|
val knowledge: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 富文本编辑器(Markdown 存储)。
|
||||||
|
*
|
||||||
|
* 为什么存 Markdown 而不是 HTML:任务备注、工单说明、知识正文都要能被检索、
|
||||||
|
* 被 LLM 读、被导出;HTML 里混着行内样式,清洗成本远高于收益。
|
||||||
|
* 因此编辑区写 Markdown,预览用 Markwon 渲染,图片传附件后用签名直链插入。
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun RichTextEditorScreen(
|
||||||
|
api: Api,
|
||||||
|
workspaceId: String,
|
||||||
|
itemId: String,
|
||||||
|
initialTitle: String,
|
||||||
|
initialBody: String,
|
||||||
|
/** 保存动作由调用方决定:事项走 PATCH /items,知识走 PATCH /knowledge。 */
|
||||||
|
save: suspend (title: String?, body: String) -> Unit,
|
||||||
|
titleEnabled: Boolean = true,
|
||||||
|
onClose: (saved: Boolean) -> Unit,
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
var title by remember { mutableStateOf(initialTitle) }
|
||||||
|
var field by remember { mutableStateOf(TextFieldValue(initialBody)) }
|
||||||
|
var preview by remember { mutableStateOf(false) }
|
||||||
|
var saving by remember { mutableStateOf(false) }
|
||||||
|
var error by remember { mutableStateOf<String?>(null) }
|
||||||
|
var images by remember { mutableStateOf(emptyList<Attachment>()) }
|
||||||
|
|
||||||
|
// 传过的图片:进来先拉一份,预览区与「图片」栏都用它
|
||||||
|
LaunchedEffect(itemId) {
|
||||||
|
runCatching { api.attachments(workspaceId, itemId) }
|
||||||
|
.onSuccess { images = it.filter { row -> row.contentType.startsWith("image/") } }
|
||||||
|
}
|
||||||
|
|
||||||
|
val picker = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
|
||||||
|
if (uri == null) return@rememberLauncherForActivityResult
|
||||||
|
scope.launch {
|
||||||
|
saving = true
|
||||||
|
try {
|
||||||
|
val bytes = withContext(Dispatchers.IO) {
|
||||||
|
context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
|
||||||
|
}
|
||||||
|
if (bytes == null) {
|
||||||
|
error = "读不到这张图片,换个来源试试"
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
val name = displayName(context, uri) ?: "image-${System.currentTimeMillis()}.png"
|
||||||
|
val mime = context.contentResolver.getType(uri) ?: "image/png"
|
||||||
|
val created = api.uploadAttachment(workspaceId, itemId, name, mime, bytes)
|
||||||
|
images = images + created
|
||||||
|
// 插入用签名直链:它会过期,所以库里存的是干净的相对路径
|
||||||
|
field = insert(field, "})\n")
|
||||||
|
} catch (e: ApiException) {
|
||||||
|
error = e.message
|
||||||
|
} catch (e: Exception) {
|
||||||
|
error = "上传失败:${e.message ?: "未知错误"}"
|
||||||
|
} finally {
|
||||||
|
saving = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(Paper)
|
||||||
|
.statusBarsPadding(),
|
||||||
|
) {
|
||||||
|
// 顶部:取消 / 状态 / 预览 / 保存
|
||||||
|
Row(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
GhostButton("取消", onClick = { onClose(false) })
|
||||||
|
Spacer(Modifier.width(10.dp))
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
if (preview) "预览" else "编辑",
|
||||||
|
fontSize = 15.sp,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
color = Ink,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"${field.text.length} 字 · Markdown",
|
||||||
|
fontSize = 11.sp,
|
||||||
|
color = InkMuted,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (saving) {
|
||||||
|
CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp, color = Primary)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
}
|
||||||
|
PrimaryButton(if (preview) "继续写" else "预览", onClick = { preview = !preview })
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
PrimaryButton(
|
||||||
|
"保存",
|
||||||
|
enabled = !saving,
|
||||||
|
onClick = {
|
||||||
|
scope.launch {
|
||||||
|
saving = true
|
||||||
|
error = null
|
||||||
|
try {
|
||||||
|
save(if (titleEnabled) title else null, field.text)
|
||||||
|
onClose(true)
|
||||||
|
} catch (e: ApiException) {
|
||||||
|
error = if (e.code == 4009) "刚被别人改过,请退出重进再改一次" else e.message
|
||||||
|
} catch (e: Exception) {
|
||||||
|
error = e.message ?: "保存失败"
|
||||||
|
} finally {
|
||||||
|
saving = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
HorizontalDivider(color = Line)
|
||||||
|
|
||||||
|
error?.let {
|
||||||
|
Text(
|
||||||
|
it,
|
||||||
|
color = Rust,
|
||||||
|
fontSize = 13.sp,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(RustSoft)
|
||||||
|
.padding(horizontal = 14.dp, vertical = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 编辑区做成一张纸:标题、工具栏、正文在同一张纸上,边界清楚
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.clip(RoundedCornerShape(14.dp))
|
||||||
|
.background(Color.White)
|
||||||
|
.border(1.dp, Line, RoundedCornerShape(14.dp)),
|
||||||
|
) {
|
||||||
|
if (titleEnabled) {
|
||||||
|
BasicTextField(
|
||||||
|
value = title,
|
||||||
|
onValueChange = { title = it },
|
||||||
|
singleLine = true,
|
||||||
|
textStyle = TextStyle(
|
||||||
|
fontSize = 19.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = Ink,
|
||||||
|
),
|
||||||
|
cursorBrush = SolidColor(Primary),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||||
|
decorationBox = { inner ->
|
||||||
|
if (title.isBlank()) Text("标题", fontSize = 19.sp, color = InkMuted)
|
||||||
|
inner()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
HorizontalDivider(color = Line)
|
||||||
|
} else if (preview) {
|
||||||
|
Text(
|
||||||
|
title.ifBlank { initialTitle },
|
||||||
|
fontSize = 19.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = Ink,
|
||||||
|
modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||||
|
)
|
||||||
|
HorizontalDivider(color = Line)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preview) {
|
||||||
|
PreviewBody(field.text, images, Modifier.weight(1f))
|
||||||
|
} else {
|
||||||
|
Row(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.horizontalScroll(rememberScrollState())
|
||||||
|
.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
ToolButton("B", bold = true) { field = wrap(field, "**") }
|
||||||
|
ToolButton("I", italic = true) { field = wrap(field, "*") }
|
||||||
|
ToolButton("标题") { field = prefixLines(field, "## ") }
|
||||||
|
ToolButton("列表") { field = prefixLines(field, "- ") }
|
||||||
|
ToolButton("引用") { field = prefixLines(field, "> ") }
|
||||||
|
ToolButton("代码") { field = wrap(field, "`") }
|
||||||
|
ToolButton("链接") { field = insert(field, "[文字](https://)") }
|
||||||
|
ToolButton("图片") { picker.launch("image/*") }
|
||||||
|
}
|
||||||
|
HorizontalDivider(color = Line)
|
||||||
|
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||||
|
) {
|
||||||
|
BasicTextField(
|
||||||
|
value = field,
|
||||||
|
onValueChange = { field = it },
|
||||||
|
textStyle = TextStyle(fontSize = 15.sp, color = Ink, lineHeight = 24.sp),
|
||||||
|
cursorBrush = SolidColor(Primary),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.navigationBarsPadding(),
|
||||||
|
decorationBox = { inner ->
|
||||||
|
if (field.text.isBlank()) {
|
||||||
|
Text(
|
||||||
|
"写点什么。「B」「列表」在光标处插语法,图片直接选会自动上传。",
|
||||||
|
fontSize = 15.sp,
|
||||||
|
color = InkMuted,
|
||||||
|
lineHeight = 24.sp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
inner()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PreviewBody(
|
||||||
|
source: String,
|
||||||
|
images: List<Attachment>,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val markwon = remember { Markwon.create(context) }
|
||||||
|
val text = remember(source) {
|
||||||
|
// 预览里图片单独排一栏(Markwon 核心版不联网取图),正文里的图片语法换成占位说明
|
||||||
|
source.replace(Regex("!\\[[^\\]]*\\]\\([^)]*\\)"), "〔图片〕")
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(horizontal = 14.dp, vertical = 12.dp)
|
||||||
|
.navigationBarsPadding(),
|
||||||
|
) {
|
||||||
|
if (images.isNotEmpty()) {
|
||||||
|
Row(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.horizontalScroll(rememberScrollState()),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
images.forEach { image ->
|
||||||
|
AsyncImage(
|
||||||
|
model = image.rawUrl,
|
||||||
|
contentDescription = image.filename,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(96.dp)
|
||||||
|
.clip(RoundedCornerShape(10.dp)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (text.isBlank()) {
|
||||||
|
Text("还没有内容", fontSize = 14.sp, color = InkMuted)
|
||||||
|
} else {
|
||||||
|
AndroidView(
|
||||||
|
factory = { ctx ->
|
||||||
|
android.widget.TextView(ctx).apply {
|
||||||
|
setTextIsSelectable(true)
|
||||||
|
textSize = 15f
|
||||||
|
setPadding(0, 0, 0, 0)
|
||||||
|
setLineSpacing(0f, 1.5f)
|
||||||
|
setTextColor(android.graphics.Color.parseColor("#4A5854"))
|
||||||
|
markwon.setMarkdown(this, text)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
update = { view -> markwon.setMarkdown(view, text) },
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.heightIn(min = 120.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 工具条按钮:小、方、无多余留白,横滑容得下。 */
|
||||||
|
@Composable
|
||||||
|
private fun ToolButton(
|
||||||
|
label: String,
|
||||||
|
bold: Boolean = false,
|
||||||
|
italic: Boolean = false,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.clip(RoundedCornerShape(8.dp))
|
||||||
|
.background(PaperSunken)
|
||||||
|
.clickable { onClick() }
|
||||||
|
.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
fontSize = 13.sp,
|
||||||
|
color = InkSoft,
|
||||||
|
fontWeight = if (bold) FontWeight.Bold else FontWeight.Medium,
|
||||||
|
fontStyle = if (italic) FontStyle.Italic else null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 把选区包起来:没选就插一对并落光标在中间,选了就把选中的包进去。 */
|
||||||
|
private fun wrap(field: TextFieldValue, token: String): TextFieldValue {
|
||||||
|
val start = field.selection.start.coerceIn(0, field.text.length)
|
||||||
|
val end = field.selection.end.coerceIn(start, field.text.length)
|
||||||
|
val picked = field.text.substring(start, end)
|
||||||
|
val next = field.text.substring(0, start) + token + picked + token + field.text.substring(end)
|
||||||
|
val caret = if (picked.isEmpty()) start + token.length else start + picked.length + token.length * 2
|
||||||
|
return field.copy(text = next, selection = TextRange(caret))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 行首插前缀(标题/列表/引用),一次处理选区覆盖的每一行。 */
|
||||||
|
private fun prefixLines(field: TextFieldValue, prefix: String): TextFieldValue {
|
||||||
|
val start = field.selection.start.coerceIn(0, field.text.length)
|
||||||
|
val end = field.selection.end.coerceIn(start, field.text.length)
|
||||||
|
val lineStart = field.text.lastIndexOf('\n', (start - 1).coerceAtLeast(0)).let {
|
||||||
|
if (it < 0 || start == 0) 0 else it + 1
|
||||||
|
}
|
||||||
|
val before = field.text.substring(0, lineStart)
|
||||||
|
val block = field.text.substring(lineStart, end)
|
||||||
|
val after = field.text.substring(end)
|
||||||
|
val nextBlock = block.split('\n').joinToString("\n") { line ->
|
||||||
|
if (line.startsWith(prefix)) line else prefix + line
|
||||||
|
}
|
||||||
|
val next = before + nextBlock + after
|
||||||
|
return field.copy(text = next, selection = TextRange(lineStart + nextBlock.length))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun insert(field: TextFieldValue, snippet: String): TextFieldValue {
|
||||||
|
val start = field.selection.start.coerceIn(0, field.text.length)
|
||||||
|
val end = field.selection.end.coerceIn(start, field.text.length)
|
||||||
|
val next = field.text.substring(0, start) + snippet + field.text.substring(end)
|
||||||
|
return field.copy(text = next, selection = TextRange(start + snippet.length))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun displayName(context: Context, uri: Uri): String? = runCatching {
|
||||||
|
context.contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)
|
||||||
|
?.use { cursor -> if (cursor.moveToFirst()) cursor.getString(0) else null }
|
||||||
|
}.getOrNull()
|
||||||
@@ -66,6 +66,8 @@ fun TaskDetailSheet(
|
|||||||
itemId: String,
|
itemId: String,
|
||||||
onClose: () -> Unit,
|
onClose: () -> Unit,
|
||||||
onChanged: () -> Unit,
|
onChanged: () -> Unit,
|
||||||
|
/** 进富文本编辑器改正文。标题与状态留在抽屉里,只有正文要更大地方。 */
|
||||||
|
onEdit: (ItemDetail) -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val sheet = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
val sheet = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
@@ -125,6 +127,8 @@ fun TaskDetailSheet(
|
|||||||
)
|
)
|
||||||
Spacer(Modifier.width(6.dp))
|
Spacer(Modifier.width(6.dp))
|
||||||
if (d.kind == "ticket") SoftPill("工单", Clay, ClaySoft)
|
if (d.kind == "ticket") SoftPill("工单", Clay, ClaySoft)
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
|
GhostButton("编辑正文", onClick = { onEdit(d) })
|
||||||
}
|
}
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ private val views = listOf(
|
|||||||
)
|
)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun TaskListScreen(api: Api, workspace: Workspace) {
|
fun TaskListScreen(api: Api, workspace: Workspace, onEdit: (EditRequest) -> Unit = {}) {
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
var view by remember { mutableStateOf("active") }
|
var view by remember { mutableStateOf("active") }
|
||||||
var mode by remember { mutableStateOf("list") }
|
var mode by remember { mutableStateOf("list") }
|
||||||
@@ -193,6 +193,10 @@ fun TaskListScreen(api: Api, workspace: Workspace) {
|
|||||||
itemId = id,
|
itemId = id,
|
||||||
onClose = { openId = null },
|
onClose = { openId = null },
|
||||||
onChanged = { scope.launch { reload() } },
|
onChanged = { scope.launch { reload() } },
|
||||||
|
onEdit = {
|
||||||
|
openId = null
|
||||||
|
onEdit(EditRequest(it.id, it.title, it.bodyMd, it.version))
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ from app.models_attachment import Attachment
|
|||||||
from app.models_task import Item
|
from app.models_task import Item
|
||||||
from app.schemas import ok
|
from app.schemas import ok
|
||||||
from app.security import decode_token
|
from app.security import decode_token
|
||||||
from app.services import storage
|
from app.services import signing, storage
|
||||||
|
|
||||||
router = APIRouter(prefix="/workspaces/{ws_id}", tags=["attachments"])
|
router = APIRouter(prefix="/workspaces/{ws_id}", tags=["attachments"])
|
||||||
|
|
||||||
@@ -36,6 +36,7 @@ ALLOWED_KINDS = ("task", "ticket", "knowledge")
|
|||||||
|
|
||||||
|
|
||||||
def _attachment_out(row: Attachment, viewer_id=None, role=None) -> dict:
|
def _attachment_out(row: Attachment, viewer_id=None, role=None) -> dict:
|
||||||
|
settings = get_settings()
|
||||||
return {
|
return {
|
||||||
"id": str(row.id),
|
"id": str(row.id),
|
||||||
"item_id": str(row.item_id),
|
"item_id": str(row.item_id),
|
||||||
@@ -45,6 +46,11 @@ def _attachment_out(row: Attachment, viewer_id=None, role=None) -> dict:
|
|||||||
"sha256": row.sha256,
|
"sha256": row.sha256,
|
||||||
"uploader_id": str(row.uploader_id),
|
"uploader_id": str(row.uploader_id),
|
||||||
"created_at": row.created_at.isoformat(),
|
"created_at": row.created_at.isoformat(),
|
||||||
|
# 图片直链:自带短期签名,浏览器 <img> 直接能用,不需要把登录令牌塞进 URL
|
||||||
|
"raw_url": (
|
||||||
|
f"{settings.api_prefix}/workspaces/{row.workspace_id}"
|
||||||
|
f"/attachments/{row.id}/raw?{signing.sign(row.workspace_id, row.id)}"
|
||||||
|
),
|
||||||
"can_delete": row.uploader_id == viewer_id or role in ("owner", "admin"),
|
"can_delete": row.uploader_id == viewer_id or role in ("owner", "admin"),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,19 +182,28 @@ async def raw_attachment(
|
|||||||
ws_id: uuid.UUID,
|
ws_id: uuid.UUID,
|
||||||
session: SessionDep,
|
session: SessionDep,
|
||||||
attachment_id: uuid.UUID,
|
attachment_id: uuid.UUID,
|
||||||
token: Annotated[str, Query(description="access token(<img src> 带不了请求头)")],
|
exp: Annotated[int | None, Query(description="签名过期时间(unix 秒)")] = None,
|
||||||
|
sig: Annotated[str | None, Query(description="短期签名,见 services/signing.py")] = None,
|
||||||
|
token: Annotated[str | None, Query(description="兼容旧链接用的 access token")] = None,
|
||||||
):
|
):
|
||||||
"""给 Markdown 里的 `` 用:浏览器 <img> 无法附加 Authorization 头。
|
"""给 Markdown 里的图片用:浏览器 `<img>` 无法附加 Authorization 头。
|
||||||
|
|
||||||
token 走 query 是 Web 上的通行做法,但要接受它的代价:会进访问日志与浏览器历史。
|
优先认 `?exp=&sig=` 的短期签名(由附件列表接口签发,只为这一张图、只活 30 分钟);
|
||||||
所以这个接口**只读、只给本工作区成员**,且不返回 Content-Disposition(不当下载用)。
|
老链接里的 `?token=` 仍能用,但那是过渡期兼容,前端已不再生成。
|
||||||
"""
|
"""
|
||||||
|
if exp is not None or sig is not None:
|
||||||
|
if not signing.verify(ws_id, attachment_id, exp, sig):
|
||||||
|
raise AppError(ErrorCode.ATTACHMENT_FORBIDDEN, "图片链接已失效,请刷新页面")
|
||||||
|
# 这个接口不走 MemberDep,RLS 不会自动绑租户,得手动绑一次
|
||||||
|
await bind_tenant(session, ws_id=ws_id, user_id=None)
|
||||||
|
else:
|
||||||
|
if not token:
|
||||||
|
raise AppError(ErrorCode.UNAUTHORIZED, "缺少访问凭据")
|
||||||
try:
|
try:
|
||||||
user_id = decode_token(token, "access")
|
user_id = decode_token(token, "access")
|
||||||
except Exception: # noqa: BLE001 —— 任何解码失败都按未认证处理
|
except Exception: # noqa: BLE001 —— 任何解码失败都按未认证处理
|
||||||
raise AppError(ErrorCode.UNAUTHORIZED, "登录已过期,请重新登录") from None
|
raise AppError(ErrorCode.UNAUTHORIZED, "登录已过期,请重新登录") from None
|
||||||
|
|
||||||
# 这个接口没有走 MemberDep(令牌在 query 里),RLS 不会自动绑租户,得手动绑一次
|
|
||||||
await bind_tenant(session, ws_id=ws_id, user_id=user_id)
|
await bind_tenant(session, ws_id=ws_id, user_id=user_id)
|
||||||
|
|
||||||
member = await session.scalar(
|
member = await session.scalar(
|
||||||
|
|||||||
@@ -67,6 +67,9 @@ class Settings(BaseSettings):
|
|||||||
upload_dir: str = "var/uploads"
|
upload_dir: str = "var/uploads"
|
||||||
# 单文件上限。20MB 够放文档与截图;再大就该走对象存储 + 分片了。
|
# 单文件上限。20MB 够放文档与截图;再大就该走对象存储 + 分片了。
|
||||||
max_upload_mb: int = 20
|
max_upload_mb: int = 20
|
||||||
|
# 图片直链的签名有效期。短一点更安全,太长等于把凭据长期贴在页面上;
|
||||||
|
# 30 分钟够一次阅读/编辑,过期后刷新页面会重新签发。
|
||||||
|
attachment_url_ttl_seconds: int = 1800
|
||||||
|
|
||||||
# ── 知识库向量库(独立实例,M3 起用)───────────────────
|
# ── 知识库向量库(独立实例,M3 起用)───────────────────
|
||||||
# 刻意与业务库分开放:fnsvr-01 上的 PG 没有 pgvector,也不该为了装扩展去动它;
|
# 刻意与业务库分开放:fnsvr-01 上的 PG 没有 pgvector,也不该为了装扩展去动它;
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"""附件直链的短期签名。
|
||||||
|
|
||||||
|
为什么需要它:Markdown 里的图片是 `<img src="…">`,浏览器不会给它加 Authorization 头,
|
||||||
|
所以图片地址必须自带凭据。把**登录令牌**塞进 query 是最省事的做法,但代价明确:
|
||||||
|
令牌会进 Nginx access log、浏览器历史、以及任何复制粘贴出去的链接里,有效期还长达 30 分钟。
|
||||||
|
|
||||||
|
这里换成一个只为"这一张图"服务、只活 30 分钟的签名:
|
||||||
|
`?exp=<unix 秒>&sig=<hmac 前 32 位>`。它不能用来调任何别的接口,泄漏了也只影响一张图的一小段时间。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
|
||||||
|
# 域分离:即使有人拿 JWT 密钥来试,也拿不到同样的签名(反之亦然)
|
||||||
|
_DOMAIN = b"attachment-raw-v1"
|
||||||
|
|
||||||
|
|
||||||
|
def _key() -> bytes:
|
||||||
|
base = get_settings().jwt_secret.encode("utf-8")
|
||||||
|
return hmac.new(base, _DOMAIN, hashlib.sha256).digest()
|
||||||
|
|
||||||
|
|
||||||
|
def _digest(workspace_id: uuid.UUID, attachment_id: uuid.UUID, exp: int) -> str:
|
||||||
|
# 工作区也签进去,避免拿 A 工作区的签名去读 B 工作区的文件
|
||||||
|
payload = f"{workspace_id}:{attachment_id}:{exp}".encode()
|
||||||
|
return hmac.new(_key(), payload, hashlib.sha256).hexdigest()[:32]
|
||||||
|
|
||||||
|
|
||||||
|
def sign(workspace_id: uuid.UUID, attachment_id: uuid.UUID, ttl_seconds: int | None = None) -> str:
|
||||||
|
"""返回可直接拼在 `/raw` 后面的查询串。"""
|
||||||
|
ttl = ttl_seconds if ttl_seconds is not None else get_settings().attachment_url_ttl_seconds
|
||||||
|
exp = int(time.time()) + ttl
|
||||||
|
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:
|
||||||
|
if not exp or not sig:
|
||||||
|
return False
|
||||||
|
if exp < int(time.time()): # 过期就是过期,不给宽限
|
||||||
|
return False
|
||||||
|
return hmac.compare_digest(_digest(workspace_id, attachment_id, exp), sig)
|
||||||
@@ -13,7 +13,10 @@ from typing import Any
|
|||||||
import pytest
|
import pytest
|
||||||
from httpx import AsyncClient
|
from httpx import AsyncClient
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
|
from app.services import signing
|
||||||
|
|
||||||
from .conftest import create_workspace, login, unwrap
|
from .conftest import create_workspace, login, unwrap
|
||||||
|
|
||||||
@@ -160,11 +163,66 @@ async def test_raw_image_url_takes_token_in_query(client: AsyncClient) -> None:
|
|||||||
assert resp.headers["content-disposition"] == "inline"
|
assert resp.headers["content-disposition"] == "inline"
|
||||||
assert resp.headers["cache-control"].startswith("private")
|
assert resp.headers["cache-control"].startswith("private")
|
||||||
|
|
||||||
# 不带 token 直接 422(参数必填),带了别人的 token 一律当不存在
|
# 既没签名也没令牌:401(而不是把"缺凭据"说成"文件不存在")
|
||||||
missing = await client.get(f"{BASE}/workspaces/{ws['id']}/attachments/{created['id']}/raw")
|
missing = await client.get(f"{BASE}/workspaces/{ws['id']}/attachments/{created['id']}/raw")
|
||||||
assert missing.status_code == 422
|
assert missing.status_code == 401
|
||||||
|
assert missing.json()["code"] == 2001
|
||||||
outsider = await login(client)
|
outsider = await login(client)
|
||||||
leaked = await client.get(
|
leaked = await client.get(
|
||||||
f"{BASE}/workspaces/{ws['id']}/attachments/{created['id']}/raw?token={outsider['token']}"
|
f"{BASE}/workspaces/{ws['id']}/attachments/{created['id']}/raw?token={outsider['token']}"
|
||||||
)
|
)
|
||||||
assert leaked.json()["code"] == 8001
|
assert leaked.json()["code"] == 8001
|
||||||
|
|
||||||
|
|
||||||
|
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"))
|
||||||
|
|
||||||
|
listed = unwrap(
|
||||||
|
await client.get(
|
||||||
|
f"{BASE}/workspaces/{ws['id']}/items/{item_id}/attachments", headers=user["headers"]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
raw_url = listed["items"][0]["raw_url"]
|
||||||
|
assert "exp=" in raw_url and "sig=" in raw_url
|
||||||
|
assert "token=" not in raw_url # 不该再把登录令牌塞进 URL
|
||||||
|
|
||||||
|
# 裸请求(无 Authorization、无 cookie)也要能取到图——这才是 <img> 的真实处境
|
||||||
|
resp = await client.get(raw_url)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.content == body
|
||||||
|
|
||||||
|
|
||||||
|
async def test_raw_url_rejects_tampered_or_expired_signature(client: AsyncClient) -> None:
|
||||||
|
user, ws, item_id = await _ws_with_item(client)
|
||||||
|
created = unwrap(await _upload(client, user, ws["id"], item_id, b"data", "图.png"))
|
||||||
|
base = f"{BASE}/workspaces/{ws['id']}/attachments/{created['id']}/raw"
|
||||||
|
|
||||||
|
# 改一个字符就该失效:签名不是"看起来对就行"
|
||||||
|
good = signing.sign(uuid.UUID(ws["id"]), uuid.UUID(created["id"]))
|
||||||
|
tampered = good[:-1] + ("0" if good[-1] != "0" else "1")
|
||||||
|
bad = await client.get(f"{base}?{tampered}")
|
||||||
|
assert bad.status_code == 403
|
||||||
|
assert bad.json()["code"] == 8003
|
||||||
|
|
||||||
|
# 过期签名:就算 HMAC 算得对,时间过了也不给
|
||||||
|
stale = signing.sign(uuid.UUID(ws["id"]), uuid.UUID(created["id"]), ttl_seconds=-10)
|
||||||
|
expired = await client.get(f"{base}?{stale}")
|
||||||
|
assert expired.status_code == 403
|
||||||
|
assert expired.json()["code"] == 8003
|
||||||
|
|
||||||
|
|
||||||
|
async def test_raw_url_signature_is_workspace_scoped(client: AsyncClient) -> None:
|
||||||
|
"""拿 A 工作区签出来的链接去请求 B 工作区,必须失败(签名里带 workspace_id)。"""
|
||||||
|
user, ws_a, item_a = await _ws_with_item(client, "甲工作区")
|
||||||
|
ws_b = await create_workspace(client, user, "乙工作区")
|
||||||
|
created = unwrap(await _upload(client, user, ws_a["id"], item_a, b"data", "图.png"))
|
||||||
|
|
||||||
|
query = signing.sign(uuid.UUID(ws_a["id"]), uuid.UUID(created["id"]))
|
||||||
|
crossed = await client.get(
|
||||||
|
f"{BASE}/workspaces/{ws_b['id']}/attachments/{created['id']}/raw?{query}"
|
||||||
|
)
|
||||||
|
assert crossed.status_code == 403
|
||||||
|
assert crossed.json()["code"] == 8003
|
||||||
+27
-4
@@ -3,8 +3,8 @@
|
|||||||
*
|
*
|
||||||
* 走真浏览器而不是只测接口,因为要验的正是四件"只有真渲染才看得见"的事:
|
* 走真浏览器而不是只测接口,因为要验的正是四件"只有真渲染才看得见"的事:
|
||||||
* 1. 输 `**粗体**` 之后编辑器里真的变成了 <strong>(IR 模式的输入规则生效);
|
* 1. 输 `**粗体**` 之后编辑器里真的变成了 <strong>(IR 模式的输入规则生效);
|
||||||
* 2. 上传的图片在编辑器里能显示(说明 /raw 直链带对了令牌);
|
* 2. 上传的图片在编辑器里能显示(说明 /raw 直链带对了签名);
|
||||||
* 3. 存进库的正文是**干净**的(不含 ?token=),否则几天后满篇红叉;
|
* 3. 存进库的正文是**干净**的(不含 ?exp=/?sig=),否则几天后满篇红叉;
|
||||||
* 4. 重新打开抽屉,正文与附件都还在(证明落库而不是活在内存)。
|
* 4. 重新打开抽屉,正文与附件都还在(证明落库而不是活在内存)。
|
||||||
*/
|
*/
|
||||||
import { chromium } from "playwright-core";
|
import { chromium } from "playwright-core";
|
||||||
@@ -123,7 +123,8 @@ try {
|
|||||||
const saved = await call("GET", `/workspaces/${ws.id}/items/${item.id}`, null, token);
|
const saved = await call("GET", `/workspaces/${ws.id}/items/${item.id}`, null, token);
|
||||||
const body = String(saved.body_md ?? "");
|
const body = String(saved.body_md ?? "");
|
||||||
check("正文已落库", body.includes("必须") && body.includes("财务口径"), body.slice(0, 40));
|
check("正文已落库", body.includes("必须") && body.includes("财务口径"), body.slice(0, 40));
|
||||||
check("落库正文不含令牌", !body.includes("token="), body.includes("token=") ? "含 token=" : "");
|
const leaked = ["token=", "exp=", "sig="].filter((needle) => body.includes(needle));
|
||||||
|
check("落库正文不含凭据(token/exp/sig)", leaked.length === 0, leaked.join(","));
|
||||||
|
|
||||||
// —— 3. 编辑器插入图片(走的是粘贴/拖拽同一段上传逻辑)——
|
// —— 3. 编辑器插入图片(走的是粘贴/拖拽同一段上传逻辑)——
|
||||||
const uploadInput = drawer.locator('.vditor input[type="file"]');
|
const uploadInput = drawer.locator('.vditor input[type="file"]');
|
||||||
@@ -134,7 +135,11 @@ try {
|
|||||||
await img.waitFor({ timeout: 10000 });
|
await img.waitFor({ timeout: 10000 });
|
||||||
const src = (await img.getAttribute("src")) ?? "";
|
const src = (await img.getAttribute("src")) ?? "";
|
||||||
check("上传后编辑器里出现图片", Boolean(src), src.slice(0, 60));
|
check("上传后编辑器里出现图片", Boolean(src), src.slice(0, 60));
|
||||||
check("图片走带令牌的 /raw 直链", src.includes("/raw?token="), src.slice(0, 60));
|
check(
|
||||||
|
"图片走带签名的 /raw 直链",
|
||||||
|
src.includes("/raw?") && (src.includes("sig=") || src.includes("token=")),
|
||||||
|
src.slice(0, 80),
|
||||||
|
);
|
||||||
await page.waitForTimeout(800);
|
await page.waitForTimeout(800);
|
||||||
check("图片资源能取到(/raw 返回 200)", rawHits.includes(200), rawHits.join(","));
|
check("图片资源能取到(/raw 返回 200)", rawHits.includes(200), rawHits.join(","));
|
||||||
|
|
||||||
@@ -164,6 +169,24 @@ try {
|
|||||||
}
|
}
|
||||||
check("重新打开后附件仍在", stillThere);
|
check("重新打开后附件仍在", stillThere);
|
||||||
|
|
||||||
|
// —— 6. 只读渲染里的表格:宽表能横向滚动,不被挤成一坨 ——
|
||||||
|
const tableMd = [
|
||||||
|
"| 项目 | 金额 | 说明 |",
|
||||||
|
"| --- | --- | --- |",
|
||||||
|
"| 打印费 | 12.00 | 电子发票 |",
|
||||||
|
"| 快递费 | 23.50 | 到付 |",
|
||||||
|
].join("\n");
|
||||||
|
await call("POST", `/workspaces/${ws.id}/items/${item.id}/comments`, { body_md: tableMd }, token);
|
||||||
|
await drawer2.getByRole("button", { name: "关闭" }).click();
|
||||||
|
await page.getByRole("button", { name: "整理发票原件", exact: true }).click();
|
||||||
|
const drawer3 = page.getByRole("dialog");
|
||||||
|
await drawer3.waitFor();
|
||||||
|
const table = drawer3.locator(".prose-dadao table").first();
|
||||||
|
await table.waitFor({ timeout: 10000 });
|
||||||
|
check("评论区表格渲染出来了", (await drawer3.locator(".prose-dadao td").count()) >= 4);
|
||||||
|
const box = await table.boundingBox();
|
||||||
|
check("表格没有撑破抽屉宽度", box !== null && box.width <= 900, `${box?.width ?? "无"} px`);
|
||||||
|
|
||||||
const realProblems = problems.filter((p) => !p.includes("favicon"));
|
const realProblems = problems.filter((p) => !p.includes("favicon"));
|
||||||
check("控制台与网络无报错", realProblems.length === 0, realProblems.slice(0, 3).join(" | "));
|
check("控制台与网络无报错", realProblems.length === 0, realProblems.slice(0, 3).join(" | "));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
|
|
||||||
|
import type { UrlResources } from "../lib/attachmentUrls";
|
||||||
import { renderMarkdown } from "../lib/markdown";
|
import { renderMarkdown } from "../lib/markdown";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -7,6 +8,8 @@ interface Props {
|
|||||||
/** 列表里只给个开头时用它压小字号 */
|
/** 列表里只给个开头时用它压小字号 */
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
/** 附件 id → 当前可访问地址(后端签发的 raw_url) */
|
||||||
|
resources?: UrlResources;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -15,8 +18,8 @@ interface Props {
|
|||||||
* renderMarkdown 内部已经把原文全部 HTML 转义过再套标签,
|
* renderMarkdown 内部已经把原文全部 HTML 转义过再套标签,
|
||||||
* 这里放心用 dangerouslySetInnerHTML(这是本仓库唯一允许用的地方)。
|
* 这里放心用 dangerouslySetInnerHTML(这是本仓库唯一允许用的地方)。
|
||||||
*/
|
*/
|
||||||
export function Markdown({ source, compact = false, className = "" }: Props) {
|
export function Markdown({ source, compact = false, className = "", resources }: Props) {
|
||||||
const html = useMemo(() => renderMarkdown(source ?? ""), [source]);
|
const html = useMemo(() => renderMarkdown(source ?? "", resources), [source, resources]);
|
||||||
|
|
||||||
if (!source?.trim()) {
|
if (!source?.trim()) {
|
||||||
return <p className={`text-sm text-ink-400 ${className}`}>还没有内容。</p>;
|
return <p className={`text-sm text-ink-400 ${className}`}>还没有内容。</p>;
|
||||||
|
|||||||
@@ -2,12 +2,14 @@ import { useEffect, useRef, useState } from "react";
|
|||||||
|
|
||||||
import type { IVditorInstance } from "vditor/dist/index.js";
|
import type { IVditorInstance } from "vditor/dist/index.js";
|
||||||
|
|
||||||
import { stripAttachmentTokens, tokenizeAttachmentUrls } from "../lib/attachmentUrls";
|
import { applyAttachmentUrls, stripAttachmentTokens, type UrlResources } from "../lib/attachmentUrls";
|
||||||
import { attachmentUrl, uploadFile } from "../lib/client";
|
import { canonicalAttachmentPath, uploadFile } from "../lib/client";
|
||||||
|
|
||||||
interface Attachment {
|
interface Attachment {
|
||||||
id: string;
|
id: string;
|
||||||
filename: string;
|
filename: string;
|
||||||
|
/** 后端签发的直链(带短期签名),用来在编辑器里直接显示刚传的图 */
|
||||||
|
raw_url?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -23,6 +25,10 @@ interface Props {
|
|||||||
minHeight?: number;
|
minHeight?: number;
|
||||||
ariaLabel: string;
|
ariaLabel: string;
|
||||||
onError?: (message: string) => void;
|
onError?: (message: string) => void;
|
||||||
|
/** 附件 id → 可访问地址;编辑器渲染图片时优先用它(签名比令牌安全且会轮换) */
|
||||||
|
resources?: UrlResources;
|
||||||
|
/** 编辑器里贴图上传成功后通知外层,好让附件面板立刻多一条 */
|
||||||
|
onUploaded?: (attachment: Attachment) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -44,6 +50,8 @@ export function RichTextEditor({
|
|||||||
minHeight = 240,
|
minHeight = 240,
|
||||||
ariaLabel,
|
ariaLabel,
|
||||||
onError,
|
onError,
|
||||||
|
resources,
|
||||||
|
onUploaded,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const hostRef = useRef<HTMLDivElement | null>(null);
|
const hostRef = useRef<HTMLDivElement | null>(null);
|
||||||
const instRef = useRef<IVditorInstance | null>(null);
|
const instRef = useRef<IVditorInstance | null>(null);
|
||||||
@@ -51,12 +59,16 @@ export function RichTextEditor({
|
|||||||
const valueRef = useRef(value);
|
const valueRef = useRef(value);
|
||||||
const onChangeRef = useRef(onChange);
|
const onChangeRef = useRef(onChange);
|
||||||
const onErrorRef = useRef(onError);
|
const onErrorRef = useRef(onError);
|
||||||
|
const resourcesRef = useRef(resources);
|
||||||
|
const onUploadedRef = useRef(onUploaded);
|
||||||
const [ready, setReady] = useState(false);
|
const [ready, setReady] = useState(false);
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
|
|
||||||
valueRef.current = value;
|
valueRef.current = value;
|
||||||
onChangeRef.current = onChange;
|
onChangeRef.current = onChange;
|
||||||
onErrorRef.current = onError;
|
onErrorRef.current = onError;
|
||||||
|
resourcesRef.current = resources;
|
||||||
|
onUploadedRef.current = onUploaded;
|
||||||
const disabledRef = useRef(disabled);
|
const disabledRef = useRef(disabled);
|
||||||
disabledRef.current = disabled;
|
disabledRef.current = disabled;
|
||||||
|
|
||||||
@@ -76,7 +88,7 @@ export function RichTextEditor({
|
|||||||
const instance = new Vditor(host, {
|
const instance = new Vditor(host, {
|
||||||
cdn: "/vditor", // 本地托管 lute/i18n/icon,不走公网 CDN(见 web/public/vditor)
|
cdn: "/vditor", // 本地托管 lute/i18n/icon,不走公网 CDN(见 web/public/vditor)
|
||||||
mode: "ir",
|
mode: "ir",
|
||||||
value: tokenizeAttachmentUrls(valueRef.current),
|
value: applyAttachmentUrls(valueRef.current, resourcesRef.current),
|
||||||
height: minHeight,
|
height: minHeight,
|
||||||
placeholder,
|
placeholder,
|
||||||
theme: "classic",
|
theme: "classic",
|
||||||
@@ -132,7 +144,10 @@ export function RichTextEditor({
|
|||||||
);
|
);
|
||||||
// 自己插入,不用 Vditor 代劳:它对 handler 的返回值只会
|
// 自己插入,不用 Vditor 代劳:它对 handler 的返回值只会
|
||||||
// tip.show(字符串) 当成提示语显示,并不会把内容插进正文。
|
// tip.show(字符串) 当成提示语显示,并不会把内容插进正文。
|
||||||
instRef.current?.insertMD(`\n})\n`);
|
const src =
|
||||||
|
created.raw_url ?? canonicalAttachmentPath(wsId, created.id);
|
||||||
|
instRef.current?.insertMD(`\n\n`);
|
||||||
|
onUploadedRef.current?.(created);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -179,7 +194,7 @@ export function RichTextEditor({
|
|||||||
if (!inst) return;
|
if (!inst) return;
|
||||||
const current = stripAttachmentTokens(inst.getValue());
|
const current = stripAttachmentTokens(inst.getValue());
|
||||||
if (current !== value) {
|
if (current !== value) {
|
||||||
inst.setValue(tokenizeAttachmentUrls(value), true);
|
inst.setValue(applyAttachmentUrls(value, resourcesRef.current), true);
|
||||||
}
|
}
|
||||||
}, [value, ready]);
|
}, [value, ready]);
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { useAuth } from "../App";
|
import { useAuth } from "../App";
|
||||||
import { ApiError, api } from "../lib/client";
|
import { ApiError, api } from "../lib/client";
|
||||||
import { PRIORITY_LABEL, STATUS_LABEL } from "../lib/parse";
|
import { PRIORITY_LABEL, STATUS_LABEL } from "../lib/parse";
|
||||||
@@ -88,6 +88,14 @@ export function TaskDetail({
|
|||||||
const [due, setDue] = useState("");
|
const [due, setDue] = useState("");
|
||||||
const [comments, setComments] = useState<Comment[]>([]);
|
const [comments, setComments] = useState<Comment[]>([]);
|
||||||
const [attachments, setAttachments] = useState<Attachment[]>([]);
|
const [attachments, setAttachments] = useState<Attachment[]>([]);
|
||||||
|
/** 附件 id → 带签名的直链:正文里的图片靠它显示,不需要把登录令牌写进内容 */
|
||||||
|
const resources = useMemo(() => {
|
||||||
|
const map: Record<string, string> = {};
|
||||||
|
for (const row of attachments) {
|
||||||
|
if (row.raw_url) map[row.id] = row.raw_url;
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [attachments]);
|
||||||
const [draft, setDraft] = useState("");
|
const [draft, setDraft] = useState("");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [notice, setNotice] = useState<string | null>(null);
|
const [notice, setNotice] = useState<string | null>(null);
|
||||||
@@ -402,7 +410,7 @@ export function TaskDetail({
|
|||||||
<div className="mt-3 rounded-lg border border-line-200 bg-white p-3">
|
<div className="mt-3 rounded-lg border border-line-200 bg-white p-3">
|
||||||
<p className="text-2xs text-ink-400">验收结论</p>
|
<p className="text-2xs text-ink-400">验收结论</p>
|
||||||
<div className="mt-1">
|
<div className="mt-1">
|
||||||
<Markdown source={ticket.resolution_md} compact />
|
<Markdown source={ticket.resolution_md} resources={resources} compact />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -497,6 +505,12 @@ export function TaskDetail({
|
|||||||
itemId={item.id}
|
itemId={item.id}
|
||||||
value={body}
|
value={body}
|
||||||
onChange={setBody}
|
onChange={setBody}
|
||||||
|
resources={resources}
|
||||||
|
onUploaded={(created) =>
|
||||||
|
setAttachments((prev) =>
|
||||||
|
prev.some((row) => row.id === created.id) ? prev : [...prev, created as never],
|
||||||
|
)
|
||||||
|
}
|
||||||
onError={(message) => setError(message)}
|
onError={(message) => setError(message)}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
/>
|
/>
|
||||||
@@ -605,7 +619,7 @@ export function TaskDetail({
|
|||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<div className="min-w-0 flex-1 rounded-xl rounded-tl-sm bg-paper-100 px-3 py-2">
|
<div className="min-w-0 flex-1 rounded-xl rounded-tl-sm bg-paper-100 px-3 py-2">
|
||||||
<Markdown source={c.body_md} />
|
<Markdown source={c.body_md} resources={resources} />
|
||||||
<p className="mt-0.5 text-2xs text-ink-400">
|
<p className="mt-0.5 text-2xs text-ink-400">
|
||||||
{new Date(c.created_at).toLocaleString("zh-CN")}
|
{new Date(c.created_at).toLocaleString("zh-CN")}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
+17
-2
@@ -261,16 +261,31 @@
|
|||||||
border: 1px solid theme("colors.line.100");
|
border: 1px solid theme("colors.line.100");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 表格:宽表横向滚动(不撑破抽屉),窄表铺满 */
|
||||||
.prose-dadao table {
|
.prose-dadao table {
|
||||||
width: 100%;
|
display: block;
|
||||||
|
width: max-content;
|
||||||
|
min-width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
font-size: 0.9em;
|
font-size: 0.9em;
|
||||||
|
margin: 0.6rem 0;
|
||||||
}
|
}
|
||||||
.prose-dadao th,
|
.prose-dadao th,
|
||||||
.prose-dadao td {
|
.prose-dadao td {
|
||||||
border: 1px solid theme("colors.line.200");
|
border: 1px solid theme("colors.line.200");
|
||||||
padding: 0.35rem 0.5rem;
|
padding: 0.4rem 0.6rem;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.prose-dadao th {
|
||||||
|
background: theme("colors.paper.200");
|
||||||
|
font-weight: 600;
|
||||||
|
color: theme("colors.ink.800");
|
||||||
|
}
|
||||||
|
.prose-dadao tbody tr:nth-child(even) td {
|
||||||
|
background: theme("colors.paper.50");
|
||||||
}
|
}
|
||||||
|
|
||||||
.prose-compact {
|
.prose-compact {
|
||||||
|
|||||||
@@ -1,39 +1,45 @@
|
|||||||
/**
|
/**
|
||||||
* 附件直链的令牌处理。
|
* 附件直链的地址处理。
|
||||||
*
|
*
|
||||||
* 库里存的 Markdown 必须是"干净"的:只记 `/api/v1/.../attachments/<id>/raw`。
|
* 库里存的 Markdown 必须是"干净"的:只记 `/api/v1/.../attachments/<id>/raw`,不带任何查询串。
|
||||||
* 原因有两条——
|
* 原因有三条——
|
||||||
* 1. `<img>` 发不出 Authorization 头,图片只能靠 query 里的 token 鉴权;
|
* 1. `<img>` 发不出 Authorization 头,图片只能靠 URL 自身携带凭据;
|
||||||
* 2. token 会过期。要是把它写进正文,几天后整篇文档的图片全变红叉,
|
* 2. 凭据会过期(令牌 30 分钟、签名也是 30 分钟),写进正文就会几天后满篇红叉;
|
||||||
* 而且团队里每个人拿到的 token 还不一样,同一个字段没法共用。
|
* 3. 同一个字段在团队里要能共用,而每个人的令牌不一样。
|
||||||
*
|
*
|
||||||
* 所以规则是:**存的时候剥掉,看的时候现加**。token 永远是当前会话的。
|
* 所以规则是:**存的时候剥干净,看的时候现配**。
|
||||||
|
* 优先用后端在附件列表里签好的 `raw_url`(短期签名,别人拿到也只能看这一张图的一小会儿),
|
||||||
|
* 没拿到签名时才退回令牌(兼容老链接,前端已不再主动生成)。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { session } from "./client";
|
import { session } from "./client";
|
||||||
|
|
||||||
const UUID = "[0-9a-fA-F-]{36}";
|
const UUID = "[0-9a-fA-F-]{36}";
|
||||||
/** 已经带上(或没带)token 的附件直链 */
|
/** 附件直链:可能带 token 或 exp/sig,全部视为"要替换的查询串" */
|
||||||
const RAW_URL = new RegExp(`(/api/v1/workspaces/${UUID}/attachments/${UUID}/raw)(?:\\?token=[^)\"'\\s]*)?`, "g");
|
const RAW_URL = new RegExp(`(/api/v1/workspaces/${UUID}/attachments/(${UUID})/raw)(?:\\?[^)"'\\s]*)?`, "g");
|
||||||
|
|
||||||
|
/** 附件 id → 可访问地址(后端签发的 raw_url) */
|
||||||
|
export type UrlResources = Record<string, string>;
|
||||||
|
|
||||||
function tokenSuffix(): string {
|
function tokenSuffix(): string {
|
||||||
const token = session.access;
|
const token = session.access;
|
||||||
return token ? `?token=${encodeURIComponent(token)}` : "";
|
return token ? `?token=${encodeURIComponent(token)}` : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 保存前调用:把正文里所有 /raw?token=... 的令牌去掉。 */
|
/** 保存前调用:把正文里所有直链的查询串去掉。 */
|
||||||
export function stripAttachmentTokens(source: string): string {
|
export function stripAttachmentTokens(source: string): string {
|
||||||
return (source ?? "").replace(RAW_URL, (_all, url: string) => url);
|
return (source ?? "").replace(RAW_URL, (_all, url: string) => url);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 渲染或加载进编辑器前调用:给所有 /raw 链接补上当前令牌。 */
|
/** 渲染 / 装载前调用:按附件 id 换成当前可用的地址。 */
|
||||||
export function tokenizeAttachmentUrls(source: string): string {
|
export function applyAttachmentUrls(source: string, resources?: UrlResources): string {
|
||||||
const suffix = tokenSuffix();
|
const fallback = tokenSuffix();
|
||||||
if (!suffix) return stripAttachmentTokens(source);
|
return (source ?? "").replace(RAW_URL, (_all, url: string, _ws: string, id: string) => {
|
||||||
return (source ?? "").replace(RAW_URL, (_all, url: string) => `${url}${suffix}`);
|
const signed = resources?.[id];
|
||||||
|
if (signed) return signed;
|
||||||
|
return fallback ? `${url}${fallback}` : url;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 只处理 HTML 里的 img/src 与 a/href(已渲染的预览用)。 */
|
/** 兼容旧名字:只处理 HTML(已渲染的预览用),逻辑同 applyAttachmentUrls。 */
|
||||||
export function tokenizeAttachmentHtml(html: string): string {
|
export const tokenizeAttachmentHtml = applyAttachmentUrls;
|
||||||
return tokenizeAttachmentUrls(html);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ export async function uploadFile<T>(path: string, file: File): Promise<T> {
|
|||||||
* 附件直链:`<img>` 带不了 Authorization 头,所以令牌走 query。
|
* 附件直链:`<img>` 带不了 Authorization 头,所以令牌走 query。
|
||||||
* 只在渲染 Markdown 图片时用,不要拿它去做普通的下载链接(那样 token 会留在浏览器历史里)。
|
* 只在渲染 Markdown 图片时用,不要拿它去做普通的下载链接(那样 token 会留在浏览器历史里)。
|
||||||
*/
|
*/
|
||||||
export function attachmentUrl(wsId: string, attachmentId: string): string {
|
/** 入库用的规范地址:不带任何查询串,抬头签名由渲染层按附件列表现配。 */
|
||||||
const token = session.access ?? "";
|
export function canonicalAttachmentPath(wsId: string, attachmentId: string): string {
|
||||||
return `${BASE}/workspaces/${wsId}/attachments/${attachmentId}/raw?token=${encodeURIComponent(token)}`;
|
return `${BASE}/workspaces/${wsId}/attachments/${attachmentId}/raw`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
import DOMPurify from "dompurify";
|
import DOMPurify from "dompurify";
|
||||||
import MarkdownIt from "markdown-it";
|
import MarkdownIt from "markdown-it";
|
||||||
|
|
||||||
import { tokenizeAttachmentHtml } from "./attachmentUrls";
|
import { applyAttachmentUrls, type UrlResources } from "./attachmentUrls";
|
||||||
|
|
||||||
const md = new MarkdownIt({
|
const md = new MarkdownIt({
|
||||||
html: false, // 不解析原始 HTML
|
html: false, // 不解析原始 HTML
|
||||||
@@ -55,13 +55,13 @@ md.renderer.rules.image = (tokens, idx, options, env, self) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/** 渲染成可直接塞进 innerHTML 的安全 HTML。 */
|
/** 渲染成可直接塞进 innerHTML 的安全 HTML。 */
|
||||||
export function renderMarkdown(source: string): string {
|
export function renderMarkdown(source: string, resources?: UrlResources): string {
|
||||||
const safe = DOMPurify.sanitize(md.render(source ?? ""), {
|
const safe = DOMPurify.sanitize(md.render(source ?? ""), {
|
||||||
// 图片走我们自己的 /raw 直链;data: 只放行图片,别的都去掉
|
// 图片走我们自己的 /raw 直链;data: 只放行图片,别的都去掉
|
||||||
ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto):|\/|#|data:image\/)/i,
|
ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto):|\/|#|data:image\/)/i,
|
||||||
});
|
});
|
||||||
// 库里存的是不带令牌的直链,渲染这一层再补上(见 lib/attachmentUrls.ts)
|
// 库里存的是干净的直链,渲染这一层按附件列表换成带签名的地址(见 lib/attachmentUrls.ts)
|
||||||
return tokenizeAttachmentHtml(safe);
|
return applyAttachmentUrls(safe, resources);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 纯文本摘要,用于列表里的一行预览(去掉标记符号,压缩空白)。 */
|
/** 纯文本摘要,用于列表里的一行预览(去掉标记符号,压缩空白)。 */
|
||||||
|
|||||||
@@ -89,6 +89,8 @@ export interface Attachment {
|
|||||||
size: number;
|
size: number;
|
||||||
uploader_id: string;
|
uploader_id: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
/** 后端签发的短期直链(带 exp/sig),正文图片直接用它 */
|
||||||
|
raw_url?: string;
|
||||||
can_delete: boolean;
|
can_delete: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import { AppShell } from "../components/AppShell";
|
import { AppShell } from "../components/AppShell";
|
||||||
import { AttachmentPanel } from "../components/AttachmentPanel";
|
import { AttachmentPanel } from "../components/AttachmentPanel";
|
||||||
@@ -302,6 +302,14 @@ function KnowledgeDrawer({
|
|||||||
const [title, setTitle] = useState("");
|
const [title, setTitle] = useState("");
|
||||||
const [body, setBody] = useState("");
|
const [body, setBody] = useState("");
|
||||||
const [attachments, setAttachments] = useState<Attachment[]>([]);
|
const [attachments, setAttachments] = useState<Attachment[]>([]);
|
||||||
|
/** 附件 id → 带签名的直链(见 lib/attachmentUrls.ts) */
|
||||||
|
const resources = useMemo(() => {
|
||||||
|
const map: Record<string, string> = {};
|
||||||
|
for (const row of attachments) {
|
||||||
|
if (row.raw_url) map[row.id] = row.raw_url;
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [attachments]);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
@@ -459,6 +467,14 @@ function KnowledgeDrawer({
|
|||||||
itemId={doc.id}
|
itemId={doc.id}
|
||||||
value={body}
|
value={body}
|
||||||
onChange={setBody}
|
onChange={setBody}
|
||||||
|
resources={resources}
|
||||||
|
onUploaded={(created) =>
|
||||||
|
setAttachments((prev) =>
|
||||||
|
prev.some((row) => row.id === created.id)
|
||||||
|
? prev
|
||||||
|
: [...prev, created as unknown as Attachment],
|
||||||
|
)
|
||||||
|
}
|
||||||
onError={(message) => setError(message)}
|
onError={(message) => setError(message)}
|
||||||
disabled={!canEdit}
|
disabled={!canEdit}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user