diff --git a/2.Design/API.md b/2.Design/API.md
index 64cfa02..caac97b 100644
--- a/2.Design/API.md
+++ b/2.Design/API.md
@@ -517,8 +517,12 @@ Query:
附件对象的字段:`id / item_id / filename / content_type / size / sha256 / uploader_id / created_at / raw_url / can_delete`。
`raw_url` 是**后端签发的短期直链**(默认 30 分钟),客户端直接用它渲染,不需要自己拼令牌。
+附件可挂在 `kind` 为 `task / ticket / knowledge` 的事项上(`ALLOWED_KINDS`),知识与任务、工单共用同一套接口;
事项与知识的列表接口会附带 `attachment_count`,详情接口附带 `attachments` 全量。
+新建知识时条目还不存在,前端用「懒建草稿」:点上传(或往正文里贴图)时先 `POST /knowledge` 存一条草稿拿到 `item_id`,
+再上传到 `/items/{item_id}/attachments`,之后的保存一律走 `PATCH /knowledge/{item_id}`。没传过附件就取消的人,库里不会留下空条目。
+
### 6.2 图片直链:短期签名,而不是把令牌写进正文
`
` 发不出 `Authorization` 头。两种解法:把图片读成 base64 内联(正文膨胀几十倍,破坏检索),
diff --git a/2.Design/ROADMAP-M0-M1.md b/2.Design/ROADMAP-M0-M1.md
index f191820..7fa956c 100644
--- a/2.Design/ROADMAP-M0-M1.md
+++ b/2.Design/ROADMAP-M0-M1.md
@@ -292,7 +292,8 @@
| 3.9 | 安卓知识列表 | 1.0 | [x] 只读列表 + 详情动作(编辑正文走安卓编辑器) |
| 3.10 | 文档与 e2e | 1.0 | [x] `knowledge.mjs` 7 项 + `retrieval.mjs` 6 项 |
| 3.11 | 知识分类:工作区三级小树(`knowledge_category` + 迁移 0007 + RLS),知识可归一类,列表按分类子树 / 未分类 / 标签筛选 | 1.0 | [x] 增删改移 + 重名 `7008`、超三级 `7007`、删分类要 `force` |
-| 3.12 | 备忘录形态:新建知识一次写全(标题/类型/分类/标签/正文),不再是"填个标题就完成";抽屉加宽 + 可整屏 | 1.0 | [x] Web `KnowledgeComposer` + 安卓新建后直进编辑器;正文就是详情同款富文本编辑器(Vditor / Compose 编辑器),未保存前不能贴图;`Sheet` 增 `size="md/lg/xl"` 与展开按钮;截图 `2.Design/screens/knowledge-composer.png`、`knowledge-categories.png` |
+| 3.12 | 备忘录形态:新建知识一次写全(标题/类型/分类/标签/正文),不再是"填个标题就完成";抽屉加宽 + 可整屏 | 1.0 | [x] Web `KnowledgeComposer` + 安卓新建后直进编辑器;正文就是详情同款富文本编辑器(Vditor / Compose 编辑器);`Sheet` 增 `size="md/lg/xl"` 与展开按钮;截图 `2.Design/screens/knowledge-composer.png`、`knowledge-categories.png` |
+| 3.13 | 知识能挂附件:新建面板里直接上传(懒建草稿拿到条目 id 再传),详情里能看、能下载/打开 | 1.0 | [x] Web `AttachmentPanel` + `RichTextEditor.ensureItemId`(贴图/传附件前自动存草稿);安卓详情页附件列表 + `OpenDocument` 上传 + 浏览器打开;附件仍落在 `item` 上(`ALLOWED_KINDS` 含 knowledge) |
### M3 完成定义(DOD)
- [x] 一件事办完 → 自动出现一份草稿 → 核对 → 发布,全程不用手动复制粘贴
diff --git a/2.Design/screens/knowledge-composer-attachment.png b/2.Design/screens/knowledge-composer-attachment.png
new file mode 100644
index 0000000..4799af5
Binary files /dev/null and b/2.Design/screens/knowledge-composer-attachment.png differ
diff --git a/android/app/src/main/java/com/dadao/task/ui/KnowledgeScreen.kt b/android/app/src/main/java/com/dadao/task/ui/KnowledgeScreen.kt
index 05bc40e..8a07aca 100644
--- a/android/app/src/main/java/com/dadao/task/ui/KnowledgeScreen.kt
+++ b/android/app/src/main/java/com/dadao/task/ui/KnowledgeScreen.kt
@@ -1,6 +1,12 @@
package com.dadao.task.ui
+import android.content.Intent
+import android.net.Uri
+import android.provider.OpenableColumns
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -34,16 +40,20 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import com.dadao.task.data.Api
import com.dadao.task.data.ApiException
+import com.dadao.task.data.Attachment
import com.dadao.task.data.KnowledgeCategory
import com.dadao.task.data.KnowledgeDoc
import com.dadao.task.data.Workspace
+import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
private val knowledgeTabs = listOf(
"draft" to "待核对",
@@ -459,12 +469,47 @@ private fun KnowledgeDetailDialog(
onEdit: (EditRequest) -> Unit,
) {
val scope = rememberCoroutineScope()
+ val context = LocalContext.current
var action by remember { mutableStateOf(doc.status) }
var version by remember { mutableStateOf(doc.version) }
var categoryId by remember { mutableStateOf(doc.category?.id) }
var tags by remember { mutableStateOf(doc.tags.joinToString(" ") { it.name }) }
var savingMeta by remember { mutableStateOf(false) }
var metaNotice by remember { mutableStateOf(null) }
+ /**
+ * 附件:备忘录不只文字,手册、日志、表格都要能挂上来。
+ * 附件必须落在条目上,所以这里只在已有的知识详情里传(新建时由正文里的编辑器负责)。
+ */
+ var attachments by remember(doc.id) { mutableStateOf(doc.attachments) }
+ var uploading by remember { mutableStateOf(false) }
+
+ LaunchedEffect(doc.id) {
+ runCatching { api.attachments(workspace.id, doc.id) }.onSuccess { attachments = it }
+ }
+
+ val picker = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
+ if (uri == null) return@rememberLauncherForActivityResult
+ scope.launch {
+ uploading = true
+ try {
+ val bytes = withContext(Dispatchers.IO) {
+ context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
+ }
+ if (bytes == null) {
+ onFail(IllegalStateException("读不到这个文件,换个来源试试"))
+ return@launch
+ }
+ val name = attachmentName(context, uri) ?: "file-${System.currentTimeMillis()}"
+ val mime = context.contentResolver.getType(uri) ?: "application/octet-stream"
+ val created = api.uploadAttachment(workspace.id, doc.id, name, mime, bytes)
+ attachments = attachments + created
+ } catch (e: Exception) {
+ onFail(e)
+ } finally {
+ uploading = false
+ }
+ }
+ }
val actions = when (action) {
"draft" -> listOf("submit_review" to "提交审议", "publish" to "发布", "archive" to "归档")
"in_review" -> listOf("withdraw" to "撤回审议", "publish" to "发布", "archive" to "归档")
@@ -498,6 +543,50 @@ private fun KnowledgeDetailDialog(
color = InkSoft,
)
+ // 附件:手册、日志、导出表格都能挂在这条知识下,点一下用浏览器打开
+ Spacer(Modifier.height(14.dp))
+ SectionLabel("附件")
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Text(
+ if (attachments.isEmpty()) "还没有附件" else "${attachments.size} 个",
+ style = MaterialTheme.typography.labelSmall,
+ color = InkMuted,
+ )
+ Spacer(Modifier.width(10.dp))
+ TextButton(
+ enabled = !uploading,
+ onClick = { picker.launch(arrayOf("*/*")) },
+ ) { Text(if (uploading) "上传中…" else "上传附件") }
+ }
+ attachments.forEach { row ->
+ Row(
+ Modifier.fillMaxWidth().padding(vertical = 2.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ row.filename,
+ style = MaterialTheme.typography.bodySmall,
+ color = Ink,
+ modifier = Modifier.weight(1f),
+ )
+ Text(
+ humanSize(row.size),
+ style = MaterialTheme.typography.labelSmall,
+ color = InkMuted,
+ )
+ Spacer(Modifier.width(4.dp))
+ TextButton(
+ onClick = {
+ // raw_url 是带短时签名的相对路径,拼上当前后端地址才能在浏览器里打开
+ val url = row.rawUrl?.let { api.currentOrigin() + it } ?: return@TextButton
+ runCatching {
+ context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
+ }.onFailure { onFail(it) }
+ },
+ ) { Text("打开") }
+ }
+ }
+
// 已发布的内容不能直接改,分类/标签也跟着锁住,避免"发布后又悄悄换归属"
if (action != "published") {
Spacer(Modifier.height(14.dp))
@@ -613,3 +702,20 @@ private fun KnowledgeDetailDialog(
containerColor = Paper,
)
}
+
+/** 附件列表里显示的大小:人与手机都不需要精确到字节。 */
+private fun humanSize(bytes: Long): String = when {
+ bytes < 1024 -> "$bytes B"
+ bytes < 1024 * 1024 -> "${bytes / 1024} KB"
+ else -> String.format("%.1f MB", bytes / 1024.0 / 1024.0)
+}
+
+/** 从 content:// 里问出文件名:微信、文件管理器给的 uri 形态各不相同,能问就问。 */
+private fun attachmentName(context: android.content.Context, uri: Uri): String? =
+ runCatching {
+ context.contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)
+ ?.use { cursor ->
+ if (cursor.moveToFirst()) cursor.getString(0) else null
+ }
+ }.getOrNull() ?: uri.lastPathSegment?.substringAfterLast('/')
+
diff --git a/web/e2e/knowledge-notebook.mjs b/web/e2e/knowledge-notebook.mjs
index c22075a..93b1baa 100644
--- a/web/e2e/knowledge-notebook.mjs
+++ b/web/e2e/knowledge-notebook.mjs
@@ -5,6 +5,7 @@
* 前面造数据走接口,界面部分点真实按钮。
*/
import { chromium } from "playwright-core";
+import { mkdirSync, writeFileSync } from "node:fs";
const WEB = process.env.WEB ?? "http://127.0.0.1:5180";
const API = process.env.API ?? "http://127.0.0.1:8100/api/v1";
@@ -98,6 +99,28 @@ try {
(await composerBody.getAttribute("aria-label")) === "正文",
);
await page.screenshot({ path: "e2e/artifacts/knowledge-composer-editor.png" });
+
+ // ②½ 附件:新建面板里就能传——点「添加附件」先把这条存成草稿,再上传
+ mkdirSync("e2e/artifacts", { recursive: true });
+ const samplePath = "e2e/artifacts/附件样例.txt";
+ writeFileSync(samplePath, "命令行回滚手册:kubectl rollout undo deployment/api\n");
+ await page.getByRole("button", { name: "添加附件", exact: true }).click();
+ // 面板出现后再走真实的「上传」按钮 + filechooser:页面上不止一个 file input(编辑器也有)
+ await page.getByRole("button", { name: "上传", exact: true }).waitFor({ timeout: 15_000 });
+ const [chooser] = await Promise.all([
+ page.waitForEvent("filechooser", { timeout: 15_000 }),
+ page.getByRole("button", { name: "上传", exact: true }).click(),
+ ]);
+ const [uploadResp] = await Promise.all([
+ page.waitForResponse(
+ (r) => r.url().includes("/attachments") && r.request().method() === "POST",
+ { timeout: 20_000 },
+ ),
+ chooser.setFiles(samplePath),
+ ]);
+ await page.getByText("附件样例.txt", { exact: false }).first().waitFor({ timeout: 10_000 });
+ check("新建面板里能上传附件(先懒建草稿)", uploadResp.ok(), `HTTP ${uploadResp.status()}`);
+ await page.screenshot({ path: "e2e/artifacts/knowledge-composer-attachment.png" });
await page.getByRole("button", { name: "保存并继续写", exact: true }).click();
// 保存后直接打开详情:标题、分类路径、标签都在
@@ -107,6 +130,7 @@ try {
"新建面板能把分类与标签一起存下来",
drawerText.includes("研发 / 部署") && drawerText.includes("发布"),
);
+ check("详情抽屉里能看到刚传的附件", drawerText.includes("附件样例.txt"));
await page.screenshot({ path: "e2e/artifacts/knowledge-composer-saved.png", fullPage: false });
// ③ 列表卡片上能看到分类路径与标签(不用点开)
@@ -188,7 +212,17 @@ try {
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(
diff --git a/web/src/components/RichTextEditor.tsx b/web/src/components/RichTextEditor.tsx
index ae9d00e..3f52b91 100644
--- a/web/src/components/RichTextEditor.tsx
+++ b/web/src/components/RichTextEditor.tsx
@@ -20,6 +20,11 @@ interface Props {
wsId: string;
/** 新建场景还没有条目 id:正文照样能写,只是暂时不能贴图(贴图前必须先存草稿) */
itemId?: string;
+ /**
+ * 新建场景的兜底:要贴图时先老老实实把草稿存下来,拿到条目 id 再传。
+ * 返回 null 表示没存成(由调用方自己提示),编辑器不会继续上传。
+ */
+ ensureItemId?: () => Promise;
placeholder?: string;
disabled?: boolean;
/** 编辑器初始高度(px) */
@@ -46,6 +51,7 @@ export function RichTextEditor({
onChange,
wsId,
itemId,
+ ensureItemId,
placeholder = "写点什么…支持粘贴/拖入图片,也可以用工具栏排版",
disabled = false,
minHeight = 240,
@@ -62,6 +68,7 @@ export function RichTextEditor({
const onErrorRef = useRef(onError);
const resourcesRef = useRef(resources);
const onUploadedRef = useRef(onUploaded);
+ const ensureItemIdRef = useRef(ensureItemId);
const [ready, setReady] = useState(false);
const [uploading, setUploading] = useState(false);
@@ -70,6 +77,7 @@ export function RichTextEditor({
onErrorRef.current = onError;
resourcesRef.current = resources;
onUploadedRef.current = onUploaded;
+ ensureItemIdRef.current = ensureItemId;
const disabledRef = useRef(disabled);
disabledRef.current = disabled;
@@ -113,8 +121,8 @@ export function RichTextEditor({
"line",
"code",
"inline-code",
- // 没有条目 id 就没有上传落点,索性不摆这个按钮,免得点了才报错
- ...(itemId ? ["upload"] : []),
+ // 没有落点就不摆这个按钮,免得点了才报错;新建时靠 ensureItemId 先存草稿
+ ...(itemId || ensureItemId ? ["upload"] : []),
"table",
"|",
"undo",
@@ -131,9 +139,18 @@ export function RichTextEditor({
accept: "image/*",
multiple: true,
handler: async (files: File[]) => {
- if (!itemId) {
- onErrorRef.current?.("先保存这条内容,再回来贴图或传附件");
- return null;
+ // 新建时还没有条目 id:先让调用方把草稿存下来,拿到落点再传
+ let targetId = itemId;
+ if (!targetId) {
+ const ensured = await ensureItemIdRef.current?.();
+ if (!ensured) {
+ // 存草稿失败的原因由 ensureItemId 自己报,这里不叠一层噪音
+ if (!ensureItemIdRef.current) {
+ onErrorRef.current?.("先保存这条内容,再回来贴图或传附件");
+ }
+ return null;
+ }
+ targetId = ensured;
}
const images = files.filter((file) => file.type.startsWith("image/"));
if (images.length === 0) {
@@ -145,7 +162,7 @@ export function RichTextEditor({
try {
for (const file of images) {
const created = await uploadFile(
- `/workspaces/${wsId}/items/${itemId}/attachments`,
+ `/workspaces/${wsId}/items/${targetId}/attachments`,
file,
);
// 自己插入,不用 Vditor 代劳:它对 handler 的返回值只会
@@ -219,7 +236,9 @@ export function RichTextEditor({
? "正在上传图片…"
: itemId
? "支持直接粘贴或拖入图片;内容按 Markdown 保存,模型和导出都读得懂"
- : "内容按 Markdown 保存;存下来之后就能直接贴图、传附件"}
+ : ensureItemId
+ ? "支持直接粘贴或拖入图片:会先自动存成草稿,再传上去"
+ : "内容按 Markdown 保存;存下来之后就能直接贴图、传附件"}
);
diff --git a/web/src/pages/Knowledge.tsx b/web/src/pages/Knowledge.tsx
index 85dae90..e6733e7 100644
--- a/web/src/pages/Knowledge.tsx
+++ b/web/src/pages/Knowledge.tsx
@@ -3,7 +3,7 @@ import { useParams } from "react-router-dom";
import { AppShell } from "../components/AppShell";
import { AttachmentPanel } from "../components/AttachmentPanel";
import { RichTextEditor } from "../components/RichTextEditor";
-import { Link2, Plus, Search, Sparkle } from "../components/icons";
+import { Link2, Paperclip, Plus, Search, Sparkle } from "../components/icons";
import {
Button,
EmptyState,
@@ -566,6 +566,16 @@ function KnowledgeComposer({
const [newCat, setNewCat] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState(null);
+ const [notice, setNotice] = useState(null);
+ /**
+ * 附件与图片都要落在具体条目上,可新建时条目还不存在。
+ * 解法是懒建草稿:第一次传附件/贴图时才 POST 一条草稿,之后一律 PATCH。
+ * 没传过附件的人,取消后什么都不会留在库里。
+ */
+ const [draftId, setDraftId] = useState(null);
+ const [draftVersion, setDraftVersion] = useState(0);
+ const [attachments, setAttachments] = useState([]);
+ const draftingRef = useRef | null>(null);
const parentDepth = categories.find((c) => c.id === categoryId)?.depth ?? 0;
const canAddCategory = parentDepth < 3 && Boolean(newCat.trim());
@@ -581,17 +591,50 @@ function KnowledgeComposer({
}
}
+ /** 拿条目 id:已有就直接返回,没有就现存一条草稿;并发调用共用同一个请求。 */
+ async function ensureDraft(): Promise {
+ if (draftId) return draftId;
+ if (draftingRef.current) return draftingRef.current;
+ const pending = (async () => {
+ try {
+ const doc = await api.post(`/workspaces/${wsId}/knowledge`, {
+ title: title.trim() || "未命名知识",
+ body_md: body,
+ doc_type: docType,
+ tag_names: tags.split(/[\s,,]+/).filter(Boolean),
+ category_id: categoryId || null,
+ });
+ setDraftId(doc.id);
+ setDraftVersion(doc.version);
+ setAttachments(doc.attachments ?? []);
+ setTitle(doc.title);
+ setNotice("已存成草稿,现在可以继续写、贴图、传附件");
+ return doc.id;
+ } catch (err) {
+ setError(err instanceof ApiError ? err.message : "保存草稿失败");
+ return null;
+ } finally {
+ draftingRef.current = null;
+ }
+ })();
+ draftingRef.current = pending;
+ return pending;
+ }
+
async function save() {
if (!canSave) return;
+ const itemId = await ensureDraft();
+ if (!itemId) return;
setBusy(true);
setError(null);
try {
- const doc = await api.post(`/workspaces/${wsId}/knowledge`, {
+ const doc = await api.patch(`/workspaces/${wsId}/knowledge/${itemId}`, {
+ version: draftVersion,
title: title.trim(),
body_md: body,
doc_type: docType,
tag_names: tags.split(/[\s,,]+/).filter(Boolean),
- category_id: categoryId || null,
+ ...(categoryId ? { category_id: categoryId } : { clear_category: true }),
});
onCreated(doc);
} catch (err) {
@@ -607,7 +650,7 @@ function KnowledgeComposer({
onClose={onClose}
size="xl"
title="记一条知识"
- subtitle="标题、正文、标签、分类一次填完;保存后可以继续贴图、传附件。"
+ subtitle="标题、正文、标签、分类一次填完;附件和贴图会先把这条存成草稿。"
footer={
- 先存成草稿,核对发布后才进团队检索
+ {draftId
+ ? "已经是草稿了:直接关掉会留在「待核对」里"
+ : "先存成草稿,核对发布后才进团队检索"}
}
@@ -717,12 +762,39 @@ function KnowledgeComposer({
value={body}
onChange={setBody}
minHeight={320}
+ ensureItemId={ensureDraft}
onError={setError}
placeholder={"写下要点、步骤、结论…\n\n标题、类型、分类、标签都在上面,正文就是这张备忘的主体。"}
/>
+
+ {draftId ? (
+
+ ) : (
+
+
+
+ 附件
+
+
+
+ 附件要挂在条目上:点一下先把这条存成草稿,然后就能上传、下载
+
+
+ )}
+
+
+ {notice && {notice}
}
{error && {error}}