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}}