feat(rich): 安卓能编辑富文本了,图片直链换成短期签名,表格不再撑破抽屉
CI / backend (push) Canceled after 0s

三件欠账一次清:

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:
dadao
2026-10-29 03:32:53 +08:00
parent fbdf54cb4c
commit 17be9d1d42
24 changed files with 923 additions and 99 deletions
+20 -11
View File
@@ -396,31 +396,40 @@ Query:`doc_type`(默认 `postmortem`)。来源可以是任务或工单;
正文类字段(任务 `body_md`、工单说明、知识正文、评论、验收结论)**一律存 Markdown**,不存 HTML。
理由:能导出、能全文检索、能直接喂给 LLM;HTML 每次渲染都要重新清洗一遍,是隐患。
图片不是 base64 塞进正文,而是走附件接口拿一个稳定地址:
图片不是 base64 塞进正文,而是走附件接口拿地址。**库里存的是不带凭据的相对路径**:
```markdown
![票据截图.png](/api/v1/workspaces/{ws_id}/attachments/{attachment_id}/raw?token=...)
![票据截图.png](/api/v1/workspaces/{ws_id}/attachments/{attachment_id}/raw)
```
| 方法 | 路径 | 说明 |
|---|---|---|
| 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}/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` |
附件对象的字段:`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` 全量。
### 6.2 为什么图片直链要带 token
### 6.2 图片直链:短期签名,而不是把令牌写进正文
`<img>` 发不出 `Authorization` 头。两种解法:把图片读成 base64 内联(正文会膨胀几十倍,且破坏检索),
或者给一个生命周期短的签名地址。
`<img>` 发不出 `Authorization` 头。两种解法:把图片读成 base64 内联(正文膨胀几十倍,破坏检索),
或者给一个短期签名地址。取后者:
当前实现取的是"query 里带 access token",**并且只允许本工作区成员访问**(非成员一律 `8001`,
不区分"不存在"与"没权限",避免探测他人附件)。这是本地/内网阶段的折中,上生产前应换成
**带过期时间的一次性签名 URL**,见 `ROADMAP` 的 M7 安全清单。
- `raw_url` 形如 `…/raw?exp=1730000000&sig=<hmac>`,签名载荷是 `workspace_id:attachment_id:exp`,
密钥从 `JWT_SECRET` 域分离派生(`attachment-raw-v1`),不与登录令牌共用。
- 校验只看签名与过期时间,**不需要用户已登录**;签名里绑了 `workspace_id`,A 工作区签的链接
打到 B 工作区会被拒(`8003`)。
- 过期不宽限,刷新页面即可拿到新链接。
- 兼容旧路径:不带 `sig` 时仍可用 `?token=<access>` 走成员校验,供老客户端过渡。
为什么非要这样:令牌写进正文会落库,几天后过期就是满篇红叉;用户换设备后旧令牌失效,图片全挂。
把凭据放在"每次渲染时现签"的 URL 上,正文才能长期存活。
### 6.3 附件错误码
### 6.3 附件错误码
+10 -7
View File
@@ -360,7 +360,8 @@
- [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] 运行时资源自托管(`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 附件
@@ -368,8 +369,8 @@
- [x] 本地磁盘存储(`backend/app/services/storage.py`),key 由内容 sha256 决定;换对象存储只改这一个文件
- [x] 上限 `MAX_UPLOAD_MB=20`,声明大小与流式读取双重拦截,超限 `8002`
- [x] 列表带 `attachment_count`,详情带 `attachments`,避免一屏 N 次请求
- [x] 下载走带 `Authorization` 的 fetch 转 blob;`<img>` 直链走 `raw?token=`,只允许本工作区成员
- [ ] 图片直链改一次性签名 URL(当前 token 放 query,上生产前必须换)
- [x] 下载走带 `Authorization` 的 fetch 转 blob;`<img>` 直链走**短期签名** `raw?exp=&sig=`(HMAC 密钥由 `JWT_SECRET` 域分离派生,默认 30 分钟,跨工作区拒绝)
- [x] 图片直链改成签名 URL 后:正文里只存不带凭据的相对路径 `/raw`,凭据每次渲染现签,不再把会过期的令牌写进库
- [ ] 缩略图、图片压缩、病毒扫描:未做
### 5.3 编辑器
@@ -377,10 +378,12 @@
- [x] 粘贴与拖拽上传图片:`upload.handler` 里逐张落到附件接口,再 `insertMD` 插入 `![](attachmentUrl(...))`。**Vditor 不会替我们插入 handler 的返回值**(它只把字符串当提示语 `tip.show`),这点踩过坑
- [x] 令牌只在浏览器内存里存在:落库前 `stripAttachmentTokens`,渲染/装载前补回;`<img>` 带不了 Authorization 头,所以 `raw?token=` 是必要折中
- [x] `AttachmentPanel`:上传/下载/删除(删除二次确认,对应后端 `?confirm=true`)
- [x] 安卓:本轮未做富文本编辑,只做只读渲染 + 附件下载(M5 再议)
- [x] 安卓:`RichTextEditorScreen`(Kotlin 手写 Markdown 工具栏:B/I/标题/列表/引用/代码/链接/图片,选区包裹与行首前缀;预览用 **Markwon** 渲染,图片用 **Coil** 加载签名直链),从任务详情与知识点打开,保存按 `body_md` 回写
- [ ] 安卓编辑器保存后回到任务列表而不是原详情抽屉(体验缺口:用户要重新点开那条任务)
### 5.4 验证
- [x] 后端 `pytest` → **70 passed**(新增附件 5 个用例:上传下载、超限、跨租户、删除确认与权限、raw 直链鉴权)
- [x] `web/e2e/richtext.mjs` → **13 项全绿**(Vditor 与 lute 就绪、`**粗体**`/`` `代码` ``/`- 列表` 就地渲染、落库正文不含 token、贴图后 `/raw` 返回 200、附件重开仍在、控制台无报错)
- [x] 后端 `pytest` → **74 passed**(附件 8 个用例:上传下载、超限、跨租户、删除确认与权限、raw 签名校验、签名篡改与过期、跨工作区签名)
- [x] `web/e2e/richtext.mjs` → **15 项全绿**(Vditor 与 lute 就绪、`**粗体**`/`` `代码` ``/`- 列表` 就地渲染、落库正文不含凭据、贴图后 `/raw` 带签名且返回 200、附件重开仍在、表格渲染且不撑破抽屉、控制台无报错)
- [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`
+2 -2
View File
@@ -28,8 +28,8 @@
| 工单:升级不搬数据、受理→办理→提交→验收、驳回、改派、站内通知 | ✅ 后端 + Web + 安卓已通,双人 e2e 8 项全绿 |
| 知识:工单验收自动生成草稿、核对后发布、来源可追、手记 | ✅ 后端 + Web + 安卓已通,e2e 7 项全绿 |
| 界面:两端统一设计系统(纸白 / 墨绿 / 陶土橙,语义色、字号阶、组件库) | ✅ 已重做,见 [`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 项全绿);安卓暂只读 |
| 后端测试 | ✅ 70 passed,ruff 干净 |
| 富文本:备注 / 工单说明 / 知识正文支持图文混排与附件 | ✅ Web 用 Vditor 编辑、安卓用 Markwon 预览编辑(e2e 15 项全绿);接口见 [`2.Design/API.md`](2.Design/API.md) 第 6 节 |
| 后端测试 | ✅ 74 passed,ruff 干净 |
| 知识切片与语义检索(pgvector) | ⬜ 配置已就绪(`deploy/docker-compose.vector.yml`),本机 Docker 起不来,未跑过 |
| LLM 能力(结构化录入、问答带引用、人物画像) | ⬜ M4 |
+3
View File
@@ -73,5 +73,8 @@ dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
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")
}
@@ -19,12 +19,15 @@ import androidx.compose.ui.platform.LocalContext
import com.dadao.task.data.Api
import com.dadao.task.data.TokenStore
import com.dadao.task.data.Workspace
import androidx.compose.foundation.layout.Box
import com.dadao.task.ui.BoardScreen
import com.dadao.task.ui.DadaoTheme
import com.dadao.task.ui.EditRequest
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.RichTextEditorScreen
import com.dadao.task.ui.TaskListScreen
import com.dadao.task.ui.TicketsScreen
import com.dadao.task.ui.WorkspaceScaffold
@@ -68,6 +71,8 @@ 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) }
// 打开富文本编辑器时非空;关掉就恢复原来的界面
var editing by remember { mutableStateOf<EditRequest?>(null) }
// 有本地令牌就先静默校验一次:令牌过期/被吊销要退回登录页,而不是进了主页再崩。
LaunchedEffect(screen) {
@@ -122,6 +127,7 @@ private fun AppRoot(joinUri: String?) {
if (ws == null) {
screen = Screen.Home
} else {
Box {
WorkspaceScaffold(
workspace = ws,
tab = tab,
@@ -130,12 +136,55 @@ private fun AppRoot(joinUri: String?) {
onBackHome = { screen = Screen.Home },
) {
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.Knowledge -> KnowledgeScreen(api = api, workspace = ws)
WorkspaceTab.Knowledge -> KnowledgeScreen(
api = api,
workspace = ws,
onEdit = { editing = it },
)
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.JsonElement
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
@@ -136,8 +138,29 @@ data class ItemDetail(
val tags: List<Tag> = emptyList(),
val version: Int = 1,
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
data class Invitation(
val code: String,
@@ -223,6 +246,7 @@ data class KnowledgeDoc(
val tags: List<Tag> = emptyList(),
val knowledge: KnowledgeInfo = KnowledgeInfo(),
val source: SourceRef? = null,
val attachments: List<Attachment> = emptyList(),
)
@Serializable
@@ -287,6 +311,13 @@ private data class KnowledgeCreateRequest(
@Serializable
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
private data class KnowledgeUpdateRequest(
val version: Int,
@@ -451,6 +482,67 @@ class Api(private val tokens: TokenStore) {
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 工单 ----
/** role: assigned(派给我的)/ requested(我发起的)/ all。 */
@@ -55,7 +55,7 @@ private fun docTypeLabel(type: String): String = when (type) {
}
@Composable
fun KnowledgeScreen(api: Api, workspace: Workspace) {
fun KnowledgeScreen(api: Api, workspace: Workspace, onEdit: (EditRequest) -> Unit = {}) {
val scope = rememberCoroutineScope()
var tab by remember { mutableStateOf("draft") }
var docs by remember { mutableStateOf<List<KnowledgeDoc>>(emptyList()) }
@@ -168,6 +168,7 @@ fun KnowledgeScreen(api: Api, workspace: Workspace) {
workspace = workspace,
doc = target,
onClose = { open = null },
onEdit = onEdit,
onChanged = {
scope.launch { reload() }
},
@@ -249,6 +250,7 @@ private fun KnowledgeDetailDialog(
onClose: () -> Unit,
onChanged: () -> Unit,
onFail: (Throwable) -> Unit,
onEdit: (EditRequest) -> Unit,
) {
val scope = rememberCoroutineScope()
var action by remember { mutableStateOf(doc.status) }
@@ -288,6 +290,21 @@ private fun KnowledgeDetailDialog(
},
confirmButton = {
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) ->
TextButton(onClick = {
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, "![${created.filename}](${created.rawUrl.orEmpty()})\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,
onClose: () -> Unit,
onChanged: () -> Unit,
/** 进富文本编辑器改正文。标题与状态留在抽屉里,只有正文要更大地方。 */
onEdit: (ItemDetail) -> Unit = {},
) {
val sheet = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val scope = rememberCoroutineScope()
@@ -125,6 +127,8 @@ fun TaskDetailSheet(
)
Spacer(Modifier.width(6.dp))
if (d.kind == "ticket") SoftPill("工单", Clay, ClaySoft)
Spacer(Modifier.weight(1f))
GhostButton("编辑正文", onClick = { onEdit(d) })
}
Spacer(Modifier.height(8.dp))
OutlinedTextField(
@@ -51,7 +51,7 @@ private val views = listOf(
)
@Composable
fun TaskListScreen(api: Api, workspace: Workspace) {
fun TaskListScreen(api: Api, workspace: Workspace, onEdit: (EditRequest) -> Unit = {}) {
val scope = rememberCoroutineScope()
var view by remember { mutableStateOf("active") }
var mode by remember { mutableStateOf("list") }
@@ -193,6 +193,10 @@ fun TaskListScreen(api: Api, workspace: Workspace) {
itemId = id,
onClose = { openId = null },
onChanged = { scope.launch { reload() } },
onEdit = {
openId = null
onEdit(EditRequest(it.id, it.title, it.bodyMd, it.version))
},
)
}
}
+21 -6
View File
@@ -28,7 +28,7 @@ from app.models_attachment import Attachment
from app.models_task import Item
from app.schemas import ok
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"])
@@ -36,6 +36,7 @@ ALLOWED_KINDS = ("task", "ticket", "knowledge")
def _attachment_out(row: Attachment, viewer_id=None, role=None) -> dict:
settings = get_settings()
return {
"id": str(row.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,
"uploader_id": str(row.uploader_id),
"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"),
}
@@ -176,19 +182,28 @@ async def raw_attachment(
ws_id: uuid.UUID,
session: SessionDep,
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 里的 `![](…/raw?token=…)` 用:浏览器 <img> 无法附加 Authorization 头。
"""给 Markdown 里的图片用:浏览器 `<img>` 无法附加 Authorization 头。
token 走 query 是 Web 上的通行做法,但要接受它的代价:会进访问日志与浏览器历史。
所以这个接口**只读、只给本工作区成员**,且不返回 Content-Disposition(不当下载用)。
优先认 `?exp=&sig=` 的短期签名(由附件列表接口签发,只为这一张图、只活 30 分钟);
老链接里的 `?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:
user_id = decode_token(token, "access")
except Exception: # noqa: BLE001 —— 任何解码失败都按未认证处理
raise AppError(ErrorCode.UNAUTHORIZED, "登录已过期,请重新登录") from None
# 这个接口没有走 MemberDep(令牌在 query 里),RLS 不会自动绑租户,得手动绑一次
await bind_tenant(session, ws_id=ws_id, user_id=user_id)
member = await session.scalar(
+3
View File
@@ -67,6 +67,9 @@ class Settings(BaseSettings):
upload_dir: str = "var/uploads"
# 单文件上限。20MB 够放文档与截图;再大就该走对象存储 + 分片了。
max_upload_mb: int = 20
# 图片直链的签名有效期。短一点更安全,太长等于把凭据长期贴在页面上;
# 30 分钟够一次阅读/编辑,过期后刷新页面会重新签发。
attachment_url_ttl_seconds: int = 1800
# ── 知识库向量库(独立实例,M3 起用)───────────────────
# 刻意与业务库分开放:fnsvr-01 上的 PG 没有 pgvector,也不该为了装扩展去动它;
+45
View File
@@ -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)
+60 -2
View File
@@ -13,7 +13,10 @@ from typing import Any
import pytest
from httpx import AsyncClient
import uuid
from app.config import get_settings
from app.services import signing
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["cache-control"].startswith("private")
# 不带 token 直接 422(参数必填),带了别人的 token 一律当不存在
# 既没签名也没令牌:401(而不是把"缺凭据"说成"文件不存在")
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)
leaked = await client.get(
f"{BASE}/workspaces/{ws['id']}/attachments/{created['id']}/raw?token={outsider['token']}"
)
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
View File
@@ -3,8 +3,8 @@
*
* 走真浏览器而不是只测接口,因为要验的正是四件"只有真渲染才看得见"的事:
* 1. 输 `**粗体**` 之后编辑器里真的变成了 <strong>(IR 模式的输入规则生效);
* 2. 上传的图片在编辑器里能显示(说明 /raw 直链带对了令牌);
* 3. 存进库的正文是**干净**的(不含 ?token=),否则几天后满篇红叉;
* 2. 上传的图片在编辑器里能显示(说明 /raw 直链带对了签名);
* 3. 存进库的正文是**干净**的(不含 ?exp=/?sig=),否则几天后满篇红叉;
* 4. 重新打开抽屉,正文与附件都还在(证明落库而不是活在内存)。
*/
import { chromium } from "playwright-core";
@@ -123,7 +123,8 @@ try {
const saved = await call("GET", `/workspaces/${ws.id}/items/${item.id}`, null, token);
const body = String(saved.body_md ?? "");
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. 编辑器插入图片(走的是粘贴/拖拽同一段上传逻辑)——
const uploadInput = drawer.locator('.vditor input[type="file"]');
@@ -134,7 +135,11 @@ try {
await img.waitFor({ timeout: 10000 });
const src = (await img.getAttribute("src")) ?? "";
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);
check("图片资源能取到(/raw 返回 200)", rawHits.includes(200), rawHits.join(","));
@@ -164,6 +169,24 @@ try {
}
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"));
check("控制台与网络无报错", realProblems.length === 0, realProblems.slice(0, 3).join(" | "));
} catch (err) {
+5 -2
View File
@@ -1,5 +1,6 @@
import { useMemo } from "react";
import type { UrlResources } from "../lib/attachmentUrls";
import { renderMarkdown } from "../lib/markdown";
interface Props {
@@ -7,6 +8,8 @@ interface Props {
/** 列表里只给个开头时用它压小字号 */
compact?: boolean;
className?: string;
/** 附件 id → 当前可访问地址(后端签发的 raw_url) */
resources?: UrlResources;
}
/**
@@ -15,8 +18,8 @@ interface Props {
* renderMarkdown 内部已经把原文全部 HTML 转义过再套标签,
* 这里放心用 dangerouslySetInnerHTML(这是本仓库唯一允许用的地方)。
*/
export function Markdown({ source, compact = false, className = "" }: Props) {
const html = useMemo(() => renderMarkdown(source ?? ""), [source]);
export function Markdown({ source, compact = false, className = "", resources }: Props) {
const html = useMemo(() => renderMarkdown(source ?? "", resources), [source, resources]);
if (!source?.trim()) {
return <p className={`text-sm text-ink-400 ${className}`}>还没有内容。</p>;
+20 -5
View File
@@ -2,12 +2,14 @@ import { useEffect, useRef, useState } from "react";
import type { IVditorInstance } from "vditor/dist/index.js";
import { stripAttachmentTokens, tokenizeAttachmentUrls } from "../lib/attachmentUrls";
import { attachmentUrl, uploadFile } from "../lib/client";
import { applyAttachmentUrls, stripAttachmentTokens, type UrlResources } from "../lib/attachmentUrls";
import { canonicalAttachmentPath, uploadFile } from "../lib/client";
interface Attachment {
id: string;
filename: string;
/** 后端签发的直链(带短期签名),用来在编辑器里直接显示刚传的图 */
raw_url?: string;
}
interface Props {
@@ -23,6 +25,10 @@ interface Props {
minHeight?: number;
ariaLabel: string;
onError?: (message: string) => void;
/** 附件 id → 可访问地址;编辑器渲染图片时优先用它(签名比令牌安全且会轮换) */
resources?: UrlResources;
/** 编辑器里贴图上传成功后通知外层,好让附件面板立刻多一条 */
onUploaded?: (attachment: Attachment) => void;
}
/**
@@ -44,6 +50,8 @@ export function RichTextEditor({
minHeight = 240,
ariaLabel,
onError,
resources,
onUploaded,
}: Props) {
const hostRef = useRef<HTMLDivElement | null>(null);
const instRef = useRef<IVditorInstance | null>(null);
@@ -51,12 +59,16 @@ export function RichTextEditor({
const valueRef = useRef(value);
const onChangeRef = useRef(onChange);
const onErrorRef = useRef(onError);
const resourcesRef = useRef(resources);
const onUploadedRef = useRef(onUploaded);
const [ready, setReady] = useState(false);
const [uploading, setUploading] = useState(false);
valueRef.current = value;
onChangeRef.current = onChange;
onErrorRef.current = onError;
resourcesRef.current = resources;
onUploadedRef.current = onUploaded;
const disabledRef = useRef(disabled);
disabledRef.current = disabled;
@@ -76,7 +88,7 @@ export function RichTextEditor({
const instance = new Vditor(host, {
cdn: "/vditor", // 本地托管 lute/i18n/icon,不走公网 CDN(见 web/public/vditor)
mode: "ir",
value: tokenizeAttachmentUrls(valueRef.current),
value: applyAttachmentUrls(valueRef.current, resourcesRef.current),
height: minHeight,
placeholder,
theme: "classic",
@@ -132,7 +144,10 @@ export function RichTextEditor({
);
// 自己插入,不用 Vditor 代劳:它对 handler 的返回值只会
// tip.show(字符串) 当成提示语显示,并不会把内容插进正文。
instRef.current?.insertMD(`\n![](${attachmentUrl(wsId, created.id)})\n`);
const src =
created.raw_url ?? canonicalAttachmentPath(wsId, created.id);
instRef.current?.insertMD(`\n![](${src})\n`);
onUploadedRef.current?.(created);
}
return null;
} catch (error) {
@@ -179,7 +194,7 @@ export function RichTextEditor({
if (!inst) return;
const current = stripAttachmentTokens(inst.getValue());
if (current !== value) {
inst.setValue(tokenizeAttachmentUrls(value), true);
inst.setValue(applyAttachmentUrls(value, resourcesRef.current), true);
}
}, [value, ready]);
+17 -3
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useAuth } from "../App";
import { ApiError, api } from "../lib/client";
import { PRIORITY_LABEL, STATUS_LABEL } from "../lib/parse";
@@ -88,6 +88,14 @@ export function TaskDetail({
const [due, setDue] = useState("");
const [comments, setComments] = useState<Comment[]>([]);
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 [error, setError] = 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">
<p className="text-2xs text-ink-400">验收结论</p>
<div className="mt-1">
<Markdown source={ticket.resolution_md} compact />
<Markdown source={ticket.resolution_md} resources={resources} compact />
</div>
</div>
) : null}
@@ -497,6 +505,12 @@ export function TaskDetail({
itemId={item.id}
value={body}
onChange={setBody}
resources={resources}
onUploaded={(created) =>
setAttachments((prev) =>
prev.some((row) => row.id === created.id) ? prev : [...prev, created as never],
)
}
onError={(message) => setError(message)}
disabled={busy}
/>
@@ -605,7 +619,7 @@ export function TaskDetail({
)}
</span>
<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">
{new Date(c.created_at).toLocaleString("zh-CN")}
</p>
+17 -2
View File
@@ -261,16 +261,31 @@
border: 1px solid theme("colors.line.100");
}
/* 表格:宽表横向滚动(不撑破抽屉),窄表铺满 */
.prose-dadao table {
width: 100%;
display: block;
width: max-content;
min-width: 100%;
max-width: 100%;
overflow-x: auto;
border-collapse: collapse;
font-size: 0.9em;
margin: 0.6rem 0;
}
.prose-dadao th,
.prose-dadao td {
border: 1px solid theme("colors.line.200");
padding: 0.35rem 0.5rem;
padding: 0.4rem 0.6rem;
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 {
+25 -19
View File
@@ -1,39 +1,45 @@
/**
* 附件直链的令牌处理。
* 附件直链的地址处理。
*
* 库里存的 Markdown 必须是"干净"的:只记 `/api/v1/.../attachments/<id>/raw`。
* 原因有两条——
* 1. `<img>` 发不出 Authorization 头,图片只能靠 query 里的 token 鉴权;
* 2. token 会过期。要是把它写进正文,几天后整篇文档的图片全变红叉,
* 而且团队里每个人拿到的 token 还不一样,同一个字段没法共用。
* 库里存的 Markdown 必须是"干净"的:只记 `/api/v1/.../attachments/<id>/raw`,不带任何查询串。
* 原因有三条——
* 1. `<img>` 发不出 Authorization 头,图片只能靠 URL 自身携带凭据;
* 2. 凭据会过期(令牌 30 分钟、签名也是 30 分钟),写进正文就会几天后满篇红叉;
* 3. 同一个字段在团队里要能共用,而每个人的令牌不一样。
*
* 所以规则是:**存的时候剥掉,看的时候现加**。token 永远是当前会话的。
* 所以规则是:**存的时候剥干净,看的时候现配**。
* 优先用后端在附件列表里签好的 `raw_url`(短期签名,别人拿到也只能看这一张图的一小会儿),
* 没拿到签名时才退回令牌(兼容老链接,前端已不再主动生成)。
*/
import { session } from "./client";
const UUID = "[0-9a-fA-F-]{36}";
/** 已经带上(或没带)token 的附件直链 */
const RAW_URL = new RegExp(`(/api/v1/workspaces/${UUID}/attachments/${UUID}/raw)(?:\\?token=[^)\"'\\s]*)?`, "g");
/** 附件直链:可能带 token 或 exp/sig,全部视为"要替换的查询串" */
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 {
const token = session.access;
return token ? `?token=${encodeURIComponent(token)}` : "";
}
/** 保存前调用:把正文里所有 /raw?token=... 的令牌去掉。 */
/** 保存前调用:把正文里所有直链的查询串去掉。 */
export function stripAttachmentTokens(source: string): string {
return (source ?? "").replace(RAW_URL, (_all, url: string) => url);
}
/** 渲染或加载进编辑器前调用:给所有 /raw 链接补上当前令牌。 */
export function tokenizeAttachmentUrls(source: string): string {
const suffix = tokenSuffix();
if (!suffix) return stripAttachmentTokens(source);
return (source ?? "").replace(RAW_URL, (_all, url: string) => `${url}${suffix}`);
/** 渲染 / 装载前调用:按附件 id 换成当前可用的地址。 */
export function applyAttachmentUrls(source: string, resources?: UrlResources): string {
const fallback = tokenSuffix();
return (source ?? "").replace(RAW_URL, (_all, url: string, _ws: string, id: string) => {
const signed = resources?.[id];
if (signed) return signed;
return fallback ? `${url}${fallback}` : url;
});
}
/** 只处理 HTML 里的 img/src 与 a/href(已渲染的预览用)。 */
export function tokenizeAttachmentHtml(html: string): string {
return tokenizeAttachmentUrls(html);
}
/** 兼容旧名字:只处理 HTML(已渲染的预览用),逻辑同 applyAttachmentUrls。 */
export const tokenizeAttachmentHtml = applyAttachmentUrls;
+3 -3
View File
@@ -145,7 +145,7 @@ export async function uploadFile<T>(path: string, file: File): Promise<T> {
* 附件直链:`<img>` 带不了 Authorization 头,所以令牌走 query。
* 只在渲染 Markdown 图片时用,不要拿它去做普通的下载链接(那样 token 会留在浏览器历史里)。
*/
export function attachmentUrl(wsId: string, attachmentId: string): string {
const token = session.access ?? "";
return `${BASE}/workspaces/${wsId}/attachments/${attachmentId}/raw?token=${encodeURIComponent(token)}`;
/** 入库用的规范地址:不带任何查询串,抬头签名由渲染层按附件列表现配。 */
export function canonicalAttachmentPath(wsId: string, attachmentId: string): string {
return `${BASE}/workspaces/${wsId}/attachments/${attachmentId}/raw`;
}
+4 -4
View File
@@ -12,7 +12,7 @@
import DOMPurify from "dompurify";
import MarkdownIt from "markdown-it";
import { tokenizeAttachmentHtml } from "./attachmentUrls";
import { applyAttachmentUrls, type UrlResources } from "./attachmentUrls";
const md = new MarkdownIt({
html: false, // 不解析原始 HTML
@@ -55,13 +55,13 @@ md.renderer.rules.image = (tokens, idx, options, env, self) => {
};
/** 渲染成可直接塞进 innerHTML 的安全 HTML。 */
export function renderMarkdown(source: string): string {
export function renderMarkdown(source: string, resources?: UrlResources): string {
const safe = DOMPurify.sanitize(md.render(source ?? ""), {
// 图片走我们自己的 /raw 直链;data: 只放行图片,别的都去掉
ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto):|\/|#|data:image\/)/i,
});
// 库里存的是不带令牌的直链,渲染这一层再补上(见 lib/attachmentUrls.ts)
return tokenizeAttachmentHtml(safe);
// 库里存的是干净的直链,渲染这一层按附件列表换成带签名的地址(见 lib/attachmentUrls.ts)
return applyAttachmentUrls(safe, resources);
}
/** 纯文本摘要,用于列表里的一行预览(去掉标记符号,压缩空白)。 */
+2
View File
@@ -89,6 +89,8 @@ export interface Attachment {
size: number;
uploader_id: string;
created_at: string;
/** 后端签发的短期直链(带 exp/sig),正文图片直接用它 */
raw_url?: string;
can_delete: boolean;
}
+17 -1
View File
@@ -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 { AppShell } from "../components/AppShell";
import { AttachmentPanel } from "../components/AttachmentPanel";
@@ -302,6 +302,14 @@ function KnowledgeDrawer({
const [title, setTitle] = useState("");
const [body, setBody] = useState("");
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 load = useCallback(async () => {
@@ -459,6 +467,14 @@ function KnowledgeDrawer({
itemId={doc.id}
value={body}
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)}
disabled={!canEdit}
/>