Compare commits

...
2 Commits
Author SHA1 Message Date
Steven 7c84ff3b8d feat(knowledge): 知识能挂附件,新建时就能传
CI / backend (push) Canceled after 0s
知识是备忘录,备忘录不能只有字:手册、日志、导出表格都得能挂上去。
附件本来就落在 item 上(`ALLOWED_KINDS` 早含 knowledge),缺的是"新建时
还没有条目 id"这一段。

- 懒建草稿:`KnowledgeComposer` 第一次传附件(或往正文贴图)时先 `POST /knowledge`
  存一条草稿拿到 id,`draftingRef` 去重防止贴图与上传同时打出两条草稿;
  之后的保存走 `PATCH /knowledge/{id}`。没传过附件就取消,库里不留空条目。
- `RichTextEditor` 增 `ensureItemId`:没有 itemId 时先把草稿存下来再传图,
  于是"新建时不能贴图"这条限制也没了;工具栏上传按钮按是否有落点显隐。
- 新建面板加了附件区:有草稿 id 就是正常的 `AttachmentPanel`(上传/下载/删),
  没有就先给一个「添加附件」按钮,点一下建草稿。
- 安卓知识详情加附件区:列表(文件名 + 大小)、`OpenDocument` 上传任意文件、
  点「打开」用浏览器看签名直链。编辑器原本只能插图,现在手册/日志也能传。
- 文档:API.md 说明附件三类事项共用同一套接口,以及懒建草稿的先后顺序;
  ROADMAP 加 3.13。

验证:后端 pytest 94 passed、ruff 全绿;Web `tsc --noEmit` 通过、`vite build` 通过;
`knowledge-notebook.mjs` 15 项全绿(新增「新建面板里能上传附件」「详情抽屉里能看到
刚传的附件」「附件挂在知识上,详情回读得到」)。安卓端仍是 JDK 25 + Gradle 8.7
不兼容,本次未编译验证。
2026-10-30 16:18:32 +08:00
Steven ffae7e380f fix(knowledge): PATCH 知识时带上 doc_type 直接 500
`patch_knowledge` 把 `doc_type`/`summary` 也塞进了给 `items` 表的 UPDATE,
于是 SQLAlchemy 报 `CompileError: Unconsumed column names: doc_type`——改类型的
请求一律 500。这两个字段本来就属于 `knowledge_doc`,下面几行已有赋值,重复写
进 items 的 values 是错的。

现在 `values` 只放 items 的列(title/body_md),`doc_changed`(doc_type/summary)
单独参与"要不要推进 version"的判断,语义不变:只要这条知识的样子变了,
version 就得 +1,否则下一次保存会拿旧 version 撞 4009。

补一个用例:PATCH 同时改 title/body_md/doc_type/tag_names 应该 200,
doc_type 生效、version 前进一步。这个 bug 之前没暴露,是因为 Web 抽屉只改
标签和分类,没人用 PATCH 改过类型。
2026-10-30 16:18:05 +08:00
9 changed files with 280 additions and 20 deletions
+4
View File
@@ -517,8 +517,12 @@ Query:
附件对象的字段:`id / item_id / filename / content_type / size / sha256 / uploader_id / created_at / raw_url / can_delete`。 附件对象的字段:`id / item_id / filename / content_type / size / sha256 / uploader_id / created_at / raw_url / can_delete`。
`raw_url` 是**后端签发的短期直链**(默认 30 分钟),客户端直接用它渲染,不需要自己拼令牌。 `raw_url` 是**后端签发的短期直链**(默认 30 分钟),客户端直接用它渲染,不需要自己拼令牌。
附件可挂在 `kind` 为 `task / ticket / knowledge` 的事项上(`ALLOWED_KINDS`),知识与任务、工单共用同一套接口;
事项与知识的列表接口会附带 `attachment_count`,详情接口附带 `attachments` 全量。 事项与知识的列表接口会附带 `attachment_count`,详情接口附带 `attachments` 全量。
新建知识时条目还不存在,前端用「懒建草稿」:点上传(或往正文里贴图)时先 `POST /knowledge` 存一条草稿拿到 `item_id`,
再上传到 `/items/{item_id}/attachments`,之后的保存一律走 `PATCH /knowledge/{item_id}`。没传过附件就取消的人,库里不会留下空条目。
### 6.2 图片直链:短期签名,而不是把令牌写进正文 ### 6.2 图片直链:短期签名,而不是把令牌写进正文
`<img>` 发不出 `Authorization` 头。两种解法:把图片读成 base64 内联(正文膨胀几十倍,破坏检索), `<img>` 发不出 `Authorization` 头。两种解法:把图片读成 base64 内联(正文膨胀几十倍,破坏检索),
+2 -1
View File
@@ -292,7 +292,8 @@
| 3.9 | 安卓知识列表 | 1.0 | [x] 只读列表 + 详情动作(编辑正文走安卓编辑器) | | 3.9 | 安卓知识列表 | 1.0 | [x] 只读列表 + 详情动作(编辑正文走安卓编辑器) |
| 3.10 | 文档与 e2e | 1.0 | [x] `knowledge.mjs` 7 项 + `retrieval.mjs` 6 项 | | 3.10 | 文档与 e2e | 1.0 | [x] `knowledge.mjs` 7 项 + `retrieval.mjs` 6 项 |
| 3.11 | 知识分类:工作区三级小树(`knowledge_category` + 迁移 0007 + RLS),知识可归一类,列表按分类子树 / 未分类 / 标签筛选 | 1.0 | [x] 增删改移 + 重名 `7008`、超三级 `7007`、删分类要 `force` | | 3.11 | 知识分类:工作区三级小树(`knowledge_category` + 迁移 0007 + RLS),知识可归一类,列表按分类子树 / 未分类 / 标签筛选 | 1.0 | [x] 增删改移 + 重名 `7008`、超三级 `7007`、删分类要 `force` |
| 3.12 | 备忘录形态:新建知识一次写全(标题/类型/分类/标签/正文),不再是"填个标题就完成";抽屉加宽 + 可整屏 | 1.0 | [x] Web `KnowledgeComposer` + 安卓新建后直进编辑器;正文就是详情同款富文本编辑器(Vditor / Compose 编辑器),未保存前不能贴图;`Sheet` 增 `size="md/lg/xl"` 与展开按钮;截图 `2.Design/screens/knowledge-composer.png`、`knowledge-categories.png` | | 3.12 | 备忘录形态:新建知识一次写全(标题/类型/分类/标签/正文),不再是"填个标题就完成";抽屉加宽 + 可整屏 | 1.0 | [x] Web `KnowledgeComposer` + 安卓新建后直进编辑器;正文就是详情同款富文本编辑器(Vditor / Compose 编辑器);`Sheet` 增 `size="md/lg/xl"` 与展开按钮;截图 `2.Design/screens/knowledge-composer.png`、`knowledge-categories.png` |
| 3.13 | 知识能挂附件:新建面板里直接上传(懒建草稿拿到条目 id 再传),详情里能看、能下载/打开 | 1.0 | [x] Web `AttachmentPanel` + `RichTextEditor.ensureItemId`(贴图/传附件前自动存草稿);安卓详情页附件列表 + `OpenDocument` 上传 + 浏览器打开;附件仍落在 `item` 上(`ALLOWED_KINDS` 含 knowledge) |
### M3 完成定义(DOD) ### M3 完成定义(DOD)
- [x] 一件事办完 → 自动出现一份草稿 → 核对 → 发布,全程不用手动复制粘贴 - [x] 一件事办完 → 自动出现一份草稿 → 核对 → 发布,全程不用手动复制粘贴
Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

@@ -1,6 +1,12 @@
package com.dadao.task.ui package com.dadao.task.ui
import android.content.Intent
import android.net.Uri
import android.provider.OpenableColumns
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -34,16 +40,20 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogProperties
import com.dadao.task.data.Api import com.dadao.task.data.Api
import com.dadao.task.data.ApiException import com.dadao.task.data.ApiException
import com.dadao.task.data.Attachment
import com.dadao.task.data.KnowledgeCategory import com.dadao.task.data.KnowledgeCategory
import com.dadao.task.data.KnowledgeDoc import com.dadao.task.data.KnowledgeDoc
import com.dadao.task.data.Workspace import com.dadao.task.data.Workspace
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
private val knowledgeTabs = listOf( private val knowledgeTabs = listOf(
"draft" to "待核对", "draft" to "待核对",
@@ -459,12 +469,47 @@ private fun KnowledgeDetailDialog(
onEdit: (EditRequest) -> Unit, onEdit: (EditRequest) -> Unit,
) { ) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val context = LocalContext.current
var action by remember { mutableStateOf(doc.status) } var action by remember { mutableStateOf(doc.status) }
var version by remember { mutableStateOf(doc.version) } var version by remember { mutableStateOf(doc.version) }
var categoryId by remember { mutableStateOf(doc.category?.id) } var categoryId by remember { mutableStateOf(doc.category?.id) }
var tags by remember { mutableStateOf(doc.tags.joinToString(" ") { it.name }) } var tags by remember { mutableStateOf(doc.tags.joinToString(" ") { it.name }) }
var savingMeta by remember { mutableStateOf(false) } var savingMeta by remember { mutableStateOf(false) }
var metaNotice by remember { mutableStateOf<String?>(null) } var metaNotice by remember { mutableStateOf<String?>(null) }
/**
* 附件:备忘录不只文字,手册、日志、表格都要能挂上来。
* 附件必须落在条目上,所以这里只在已有的知识详情里传(新建时由正文里的编辑器负责)。
*/
var attachments by remember(doc.id) { mutableStateOf(doc.attachments) }
var uploading by remember { mutableStateOf(false) }
LaunchedEffect(doc.id) {
runCatching { api.attachments(workspace.id, doc.id) }.onSuccess { attachments = it }
}
val picker = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
if (uri == null) return@rememberLauncherForActivityResult
scope.launch {
uploading = true
try {
val bytes = withContext(Dispatchers.IO) {
context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
}
if (bytes == null) {
onFail(IllegalStateException("读不到这个文件,换个来源试试"))
return@launch
}
val name = attachmentName(context, uri) ?: "file-${System.currentTimeMillis()}"
val mime = context.contentResolver.getType(uri) ?: "application/octet-stream"
val created = api.uploadAttachment(workspace.id, doc.id, name, mime, bytes)
attachments = attachments + created
} catch (e: Exception) {
onFail(e)
} finally {
uploading = false
}
}
}
val actions = when (action) { val actions = when (action) {
"draft" -> listOf("submit_review" to "提交审议", "publish" to "发布", "archive" to "归档") "draft" -> listOf("submit_review" to "提交审议", "publish" to "发布", "archive" to "归档")
"in_review" -> listOf("withdraw" to "撤回审议", "publish" to "发布", "archive" to "归档") "in_review" -> listOf("withdraw" to "撤回审议", "publish" to "发布", "archive" to "归档")
@@ -498,6 +543,50 @@ private fun KnowledgeDetailDialog(
color = InkSoft, color = InkSoft,
) )
// 附件:手册、日志、导出表格都能挂在这条知识下,点一下用浏览器打开
Spacer(Modifier.height(14.dp))
SectionLabel("附件")
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
if (attachments.isEmpty()) "还没有附件" else "${attachments.size} 个",
style = MaterialTheme.typography.labelSmall,
color = InkMuted,
)
Spacer(Modifier.width(10.dp))
TextButton(
enabled = !uploading,
onClick = { picker.launch(arrayOf("*/*")) },
) { Text(if (uploading) "上传中…" else "上传附件") }
}
attachments.forEach { row ->
Row(
Modifier.fillMaxWidth().padding(vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
row.filename,
style = MaterialTheme.typography.bodySmall,
color = Ink,
modifier = Modifier.weight(1f),
)
Text(
humanSize(row.size),
style = MaterialTheme.typography.labelSmall,
color = InkMuted,
)
Spacer(Modifier.width(4.dp))
TextButton(
onClick = {
// raw_url 是带短时签名的相对路径,拼上当前后端地址才能在浏览器里打开
val url = row.rawUrl?.let { api.currentOrigin() + it } ?: return@TextButton
runCatching {
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
}.onFailure { onFail(it) }
},
) { Text("打开") }
}
}
// 已发布的内容不能直接改,分类/标签也跟着锁住,避免"发布后又悄悄换归属" // 已发布的内容不能直接改,分类/标签也跟着锁住,避免"发布后又悄悄换归属"
if (action != "published") { if (action != "published") {
Spacer(Modifier.height(14.dp)) Spacer(Modifier.height(14.dp))
@@ -613,3 +702,20 @@ private fun KnowledgeDetailDialog(
containerColor = Paper, containerColor = Paper,
) )
} }
/** 附件列表里显示的大小:人与手机都不需要精确到字节。 */
private fun humanSize(bytes: Long): String = when {
bytes < 1024 -> "$bytes B"
bytes < 1024 * 1024 -> "${bytes / 1024} KB"
else -> String.format("%.1f MB", bytes / 1024.0 / 1024.0)
}
/** 从 content:// 里问出文件名:微信、文件管理器给的 uri 形态各不相同,能问就问。 */
private fun attachmentName(context: android.content.Context, uri: Uri): String? =
runCatching {
context.contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)
?.use { cursor ->
if (cursor.moveToFirst()) cursor.getString(0) else null
}
}.getOrNull() ?: uri.lastPathSegment?.substringAfterLast('/')
+5 -6
View File
@@ -645,20 +645,19 @@ async def patch_knowledge(
if body.doc_type is not None and body.doc_type not in DOC_TYPES: if body.doc_type is not None and body.doc_type not in DOC_TYPES:
raise AppError(ErrorCode.VALIDATION_ERROR, f"未知的知识类型:{body.doc_type}") raise AppError(ErrorCode.VALIDATION_ERROR, f"未知的知识类型:{body.doc_type}")
# 这里只放 Item 表自己的列:doc_type / summary 属于 knowledge_doc,
# 混进来会直接 CompileError: Unconsumed column names
values: dict[str, Any] = {} values: dict[str, Any] = {}
if body.title is not None: if body.title is not None:
values["title"] = body.title.strip() values["title"] = body.title.strip()
if body.body_md is not None: if body.body_md is not None:
values["body_md"] = body.body_md values["body_md"] = body.body_md
if body.summary is not None:
values["summary"] = body.summary
if body.doc_type is not None:
values["doc_type"] = body.doc_type
# 分类/标签也改的是这条知识的"样子",所以同样要推进 version: # 分类/标签/类型也改的是这条知识的"样子",所以同样要推进 version:
# 否则只改了标签的知识,下一次保存还会拿着旧 version 撞 4009。 # 否则只改了标签的知识,下一次保存还会拿着旧 version 撞 4009。
doc_changed = body.doc_type is not None or body.summary is not None
meta_changed = body.clear_category or body.category_id is not None or body.tag_names is not None meta_changed = body.clear_category or body.category_id is not None or body.tag_names is not None
if values or meta_changed: if values or doc_changed or meta_changed:
values["updated_at"] = datetime.now(timezone.utc) values["updated_at"] = datetime.now(timezone.utc)
result = await session.execute( result = await session.execute(
update(Item) update(Item)
@@ -278,3 +278,28 @@ async def test_patch_knowledge_moves_and_clears_category(client: AsyncClient) ->
) )
) )
assert cleared["category"] is None assert cleared["category"] is None
@pytest.mark.asyncio
async def test_patch_knowledge_accepts_doc_type_and_bumps_version(client: AsyncClient) -> None:
"""PATCH 只该写 items 自己的列:doc_type/summary 混进去会 CompileError(线上 500)。"""
user, ws_id = await _ws(client)
doc = await _note(client, user, ws_id, "先写结论再写过程", body="原始正文")
patched = unwrap(
await client.patch(
f"{V1}/workspaces/{ws_id}/knowledge/{doc['id']}",
json={
"version": doc["version"],
"title": "先写结论再写过程",
"body_md": "改过的正文",
"doc_type": "howto",
"tag_names": ["写法"],
},
headers=user["headers"],
)
)
assert patched["knowledge"]["doc_type"] == "howto"
assert patched["body_md"] == "改过的正文"
assert patched["version"] == doc["version"] + 1
assert {t["name"] for t in patched["tags"]} == {"写法"}
+35 -1
View File
@@ -5,6 +5,7 @@
* 前面造数据走接口,界面部分点真实按钮。 * 前面造数据走接口,界面部分点真实按钮。
*/ */
import { chromium } from "playwright-core"; import { chromium } from "playwright-core";
import { mkdirSync, writeFileSync } from "node:fs";
const WEB = process.env.WEB ?? "http://127.0.0.1:5180"; const WEB = process.env.WEB ?? "http://127.0.0.1:5180";
const API = process.env.API ?? "http://127.0.0.1:8100/api/v1"; const API = process.env.API ?? "http://127.0.0.1:8100/api/v1";
@@ -98,6 +99,28 @@ try {
(await composerBody.getAttribute("aria-label")) === "正文", (await composerBody.getAttribute("aria-label")) === "正文",
); );
await page.screenshot({ path: "e2e/artifacts/knowledge-composer-editor.png" }); await page.screenshot({ path: "e2e/artifacts/knowledge-composer-editor.png" });
// ②½ 附件:新建面板里就能传——点「添加附件」先把这条存成草稿,再上传
mkdirSync("e2e/artifacts", { recursive: true });
const samplePath = "e2e/artifacts/附件样例.txt";
writeFileSync(samplePath, "命令行回滚手册:kubectl rollout undo deployment/api\n");
await page.getByRole("button", { name: "添加附件", exact: true }).click();
// 面板出现后再走真实的「上传」按钮 + filechooser:页面上不止一个 file input(编辑器也有)
await page.getByRole("button", { name: "上传", exact: true }).waitFor({ timeout: 15_000 });
const [chooser] = await Promise.all([
page.waitForEvent("filechooser", { timeout: 15_000 }),
page.getByRole("button", { name: "上传", exact: true }).click(),
]);
const [uploadResp] = await Promise.all([
page.waitForResponse(
(r) => r.url().includes("/attachments") && r.request().method() === "POST",
{ timeout: 20_000 },
),
chooser.setFiles(samplePath),
]);
await page.getByText("附件样例.txt", { exact: false }).first().waitFor({ timeout: 10_000 });
check("新建面板里能上传附件(先懒建草稿)", uploadResp.ok(), `HTTP ${uploadResp.status()}`);
await page.screenshot({ path: "e2e/artifacts/knowledge-composer-attachment.png" });
await page.getByRole("button", { name: "保存并继续写", exact: true }).click(); await page.getByRole("button", { name: "保存并继续写", exact: true }).click();
// 保存后直接打开详情:标题、分类路径、标签都在 // 保存后直接打开详情:标题、分类路径、标签都在
@@ -107,6 +130,7 @@ try {
"新建面板能把分类与标签一起存下来", "新建面板能把分类与标签一起存下来",
drawerText.includes("研发 / 部署") && drawerText.includes("发布"), drawerText.includes("研发 / 部署") && drawerText.includes("发布"),
); );
check("详情抽屉里能看到刚传的附件", drawerText.includes("附件样例.txt"));
await page.screenshot({ path: "e2e/artifacts/knowledge-composer-saved.png", fullPage: false }); await page.screenshot({ path: "e2e/artifacts/knowledge-composer-saved.png", fullPage: false });
// ③ 列表卡片上能看到分类路径与标签(不用点开) // ③ 列表卡片上能看到分类路径与标签(不用点开)
@@ -188,7 +212,17 @@ try {
doc?.category?.full_path === "研发 / 部署", doc?.category?.full_path === "研发 / 部署",
`tags=${JSON.stringify((doc?.tags ?? []).map((t) => t.name))} category=${doc?.category?.full_path}`, `tags=${JSON.stringify((doc?.tags ?? []).map((t) => t.name))} category=${doc?.category?.full_path}`,
); );
check("正文按 Markdown 存下来了", String(doc?.body_md ?? "").includes("停掉灰度流量")); check(
"正文按 Markdown 存下来了",
String(doc?.body_md ?? "").includes("停掉灰度流量"),
);
// 列表只带 attachment_count(省一次 N+1),文件清单要问详情
const detail = await call("GET", `/workspaces/${ws.id}/knowledge/${doc.id}`, undefined, token);
check(
"附件挂在这条知识上(详情回读得到)",
(detail?.attachments ?? []).some((a) => a.filename === "附件样例.txt"),
`attachments=${JSON.stringify((detail?.attachments ?? []).map((a) => a.filename))}`,
);
// ⑥ 分类重名与三级上限:接口层面确认约束还在 // ⑥ 分类重名与三级上限:接口层面确认约束还在
const dup = await fetch( const dup = await fetch(
+23 -4
View File
@@ -20,6 +20,11 @@ interface Props {
wsId: string; wsId: string;
/** 新建场景还没有条目 id:正文照样能写,只是暂时不能贴图(贴图前必须先存草稿) */ /** 新建场景还没有条目 id:正文照样能写,只是暂时不能贴图(贴图前必须先存草稿) */
itemId?: string; itemId?: string;
/**
* 新建场景的兜底:要贴图时先老老实实把草稿存下来,拿到条目 id 再传。
* 返回 null 表示没存成(由调用方自己提示),编辑器不会继续上传。
*/
ensureItemId?: () => Promise<string | null>;
placeholder?: string; placeholder?: string;
disabled?: boolean; disabled?: boolean;
/** 编辑器初始高度(px) */ /** 编辑器初始高度(px) */
@@ -46,6 +51,7 @@ export function RichTextEditor({
onChange, onChange,
wsId, wsId,
itemId, itemId,
ensureItemId,
placeholder = "写点什么…支持粘贴/拖入图片,也可以用工具栏排版", placeholder = "写点什么…支持粘贴/拖入图片,也可以用工具栏排版",
disabled = false, disabled = false,
minHeight = 240, minHeight = 240,
@@ -62,6 +68,7 @@ export function RichTextEditor({
const onErrorRef = useRef(onError); const onErrorRef = useRef(onError);
const resourcesRef = useRef(resources); const resourcesRef = useRef(resources);
const onUploadedRef = useRef(onUploaded); const onUploadedRef = useRef(onUploaded);
const ensureItemIdRef = useRef(ensureItemId);
const [ready, setReady] = useState(false); const [ready, setReady] = useState(false);
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
@@ -70,6 +77,7 @@ export function RichTextEditor({
onErrorRef.current = onError; onErrorRef.current = onError;
resourcesRef.current = resources; resourcesRef.current = resources;
onUploadedRef.current = onUploaded; onUploadedRef.current = onUploaded;
ensureItemIdRef.current = ensureItemId;
const disabledRef = useRef(disabled); const disabledRef = useRef(disabled);
disabledRef.current = disabled; disabledRef.current = disabled;
@@ -113,8 +121,8 @@ export function RichTextEditor({
"line", "line",
"code", "code",
"inline-code", "inline-code",
// 没有条目 id 就没有上传落点,索性不摆这个按钮,免得点了才报错 // 没有落点就不摆这个按钮,免得点了才报错;新建时靠 ensureItemId 先存草稿
...(itemId ? ["upload"] : []), ...(itemId || ensureItemId ? ["upload"] : []),
"table", "table",
"|", "|",
"undo", "undo",
@@ -131,10 +139,19 @@ export function RichTextEditor({
accept: "image/*", accept: "image/*",
multiple: true, multiple: true,
handler: async (files: File[]) => { handler: async (files: File[]) => {
if (!itemId) { // 新建时还没有条目 id:先让调用方把草稿存下来,拿到落点再传
let targetId = itemId;
if (!targetId) {
const ensured = await ensureItemIdRef.current?.();
if (!ensured) {
// 存草稿失败的原因由 ensureItemId 自己报,这里不叠一层噪音
if (!ensureItemIdRef.current) {
onErrorRef.current?.("先保存这条内容,再回来贴图或传附件"); onErrorRef.current?.("先保存这条内容,再回来贴图或传附件");
}
return null; return null;
} }
targetId = ensured;
}
const images = files.filter((file) => file.type.startsWith("image/")); const images = files.filter((file) => file.type.startsWith("image/"));
if (images.length === 0) { if (images.length === 0) {
onErrorRef.current?.("只能插入图片"); onErrorRef.current?.("只能插入图片");
@@ -145,7 +162,7 @@ export function RichTextEditor({
try { try {
for (const file of images) { for (const file of images) {
const created = await uploadFile<Attachment>( const created = await uploadFile<Attachment>(
`/workspaces/${wsId}/items/${itemId}/attachments`, `/workspaces/${wsId}/items/${targetId}/attachments`,
file, file,
); );
// 自己插入,不用 Vditor 代劳:它对 handler 的返回值只会 // 自己插入,不用 Vditor 代劳:它对 handler 的返回值只会
@@ -219,6 +236,8 @@ export function RichTextEditor({
? "正在上传图片…" ? "正在上传图片…"
: itemId : itemId
? "支持直接粘贴或拖入图片;内容按 Markdown 保存,模型和导出都读得懂" ? "支持直接粘贴或拖入图片;内容按 Markdown 保存,模型和导出都读得懂"
: ensureItemId
? "支持直接粘贴或拖入图片:会先自动存成草稿,再传上去"
: "内容按 Markdown 保存;存下来之后就能直接贴图、传附件"} : "内容按 Markdown 保存;存下来之后就能直接贴图、传附件"}
</p> </p>
</div> </div>
+80 -8
View File
@@ -3,7 +3,7 @@ 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";
import { RichTextEditor } from "../components/RichTextEditor"; import { RichTextEditor } from "../components/RichTextEditor";
import { Link2, Plus, Search, Sparkle } from "../components/icons"; import { Link2, Paperclip, Plus, Search, Sparkle } from "../components/icons";
import { import {
Button, Button,
EmptyState, EmptyState,
@@ -566,6 +566,16 @@ function KnowledgeComposer({
const [newCat, setNewCat] = useState(""); const [newCat, setNewCat] = useState("");
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
/**
* 附件与图片都要落在具体条目上,可新建时条目还不存在。
* 解法是懒建草稿:第一次传附件/贴图时才 POST 一条草稿,之后一律 PATCH。
* 没传过附件的人,取消后什么都不会留在库里。
*/
const [draftId, setDraftId] = useState<string | null>(null);
const [draftVersion, setDraftVersion] = useState(0);
const [attachments, setAttachments] = useState<Attachment[]>([]);
const draftingRef = useRef<Promise<string | null> | null>(null);
const parentDepth = categories.find((c) => c.id === categoryId)?.depth ?? 0; const parentDepth = categories.find((c) => c.id === categoryId)?.depth ?? 0;
const canAddCategory = parentDepth < 3 && Boolean(newCat.trim()); const canAddCategory = parentDepth < 3 && Boolean(newCat.trim());
@@ -581,18 +591,51 @@ function KnowledgeComposer({
} }
} }
async function save() { /** 拿条目 id:已有就直接返回,没有就现存一条草稿;并发调用共用同一个请求。 */
if (!canSave) return; async function ensureDraft(): Promise<string | null> {
setBusy(true); if (draftId) return draftId;
setError(null); if (draftingRef.current) return draftingRef.current;
const pending = (async () => {
try { try {
const doc = await api.post<Knowledge>(`/workspaces/${wsId}/knowledge`, { const doc = await api.post<Knowledge>(`/workspaces/${wsId}/knowledge`, {
title: title.trim(), title: title.trim() || "未命名知识",
body_md: body, body_md: body,
doc_type: docType, doc_type: docType,
tag_names: tags.split(/[\s,,]+/).filter(Boolean), tag_names: tags.split(/[\s,,]+/).filter(Boolean),
category_id: categoryId || null, category_id: categoryId || null,
}); });
setDraftId(doc.id);
setDraftVersion(doc.version);
setAttachments(doc.attachments ?? []);
setTitle(doc.title);
setNotice("已存成草稿,现在可以继续写、贴图、传附件");
return doc.id;
} catch (err) {
setError(err instanceof ApiError ? err.message : "保存草稿失败");
return null;
} finally {
draftingRef.current = null;
}
})();
draftingRef.current = pending;
return pending;
}
async function save() {
if (!canSave) return;
const itemId = await ensureDraft();
if (!itemId) return;
setBusy(true);
setError(null);
try {
const doc = await api.patch<Knowledge>(`/workspaces/${wsId}/knowledge/${itemId}`, {
version: draftVersion,
title: title.trim(),
body_md: body,
doc_type: docType,
tag_names: tags.split(/[\s,,]+/).filter(Boolean),
...(categoryId ? { category_id: categoryId } : { clear_category: true }),
});
onCreated(doc); onCreated(doc);
} catch (err) { } catch (err) {
setError(err instanceof ApiError ? err.message : "保存失败"); setError(err instanceof ApiError ? err.message : "保存失败");
@@ -607,7 +650,7 @@ function KnowledgeComposer({
onClose={onClose} onClose={onClose}
size="xl" size="xl"
title="记一条知识" title="记一条知识"
subtitle="标题、正文、标签、分类一次填完;保存后可以继续贴图、传附件。" subtitle="标题、正文、标签、分类一次填完;附件和贴图会先把这条存成草稿。"
footer={ footer={
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<Button variant="primary" disabled={!canSave} onClick={() => void save()}> <Button variant="primary" disabled={!canSave} onClick={() => void save()}>
@@ -617,7 +660,9 @@ function KnowledgeComposer({
取消 取消
</Button> </Button>
<span className="ml-auto text-2xs text-ink-400"> <span className="ml-auto text-2xs text-ink-400">
先存成草稿,核对发布后才进团队检索 {draftId
? "已经是草稿了:直接关掉会留在「待核对」里"
: "先存成草稿,核对发布后才进团队检索"}
</span> </span>
</div> </div>
} }
@@ -717,12 +762,39 @@ function KnowledgeComposer({
value={body} value={body}
onChange={setBody} onChange={setBody}
minHeight={320} minHeight={320}
ensureItemId={ensureDraft}
onError={setError} onError={setError}
placeholder={"写下要点、步骤、结论…\n\n标题、类型、分类、标签都在上面,正文就是这张备忘的主体。"} placeholder={"写下要点、步骤、结论…\n\n标题、类型、分类、标签都在上面,正文就是这张备忘的主体。"}
/> />
</div> </div>
</div> </div>
<div className="rounded-xl border border-line-200 bg-paper-50 px-3 py-2">
{draftId ? (
<AttachmentPanel
wsId={wsId}
itemId={draftId}
attachments={attachments}
onChange={setAttachments}
onError={setError}
/>
) : (
<div className="flex flex-wrap items-center gap-2">
<span className="inline-flex items-center gap-1.5 text-2xs font-medium text-ink-500">
<Paperclip size={13} />
附件
</span>
<Button variant="ghost" disabled={busy} onClick={() => void ensureDraft()}>
添加附件
</Button>
<span className="text-2xs text-ink-400">
附件要挂在条目上:点一下先把这条存成草稿,然后就能上传、下载
</span>
</div>
)}
</div>
{notice && <p className="text-2xs text-primary-700">{notice}</p>}
{error && <InlineAlert>{error}</InlineAlert>} {error && <InlineAlert>{error}</InlineAlert>}
</div> </div>
</Sheet> </Sheet>