Compare commits
2
Commits
9a382e4e8c
...
7c84ff3b8d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c84ff3b8d | ||
|
|
ffae7e380f |
@@ -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 图片直链:短期签名,而不是把令牌写进正文
|
||||
|
||||
`<img>` 发不出 `Authorization` 头。两种解法:把图片读成 base64 内联(正文膨胀几十倍,破坏检索),
|
||||
|
||||
@@ -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] 一件事办完 → 自动出现一份草稿 → 核对 → 发布,全程不用手动复制粘贴
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 72 KiB |
@@ -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<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) {
|
||||
"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('/')
|
||||
|
||||
|
||||
@@ -645,20 +645,19 @@ async def patch_knowledge(
|
||||
if body.doc_type is not None and body.doc_type not in DOC_TYPES:
|
||||
raise AppError(ErrorCode.VALIDATION_ERROR, f"未知的知识类型:{body.doc_type}")
|
||||
|
||||
# 这里只放 Item 表自己的列:doc_type / summary 属于 knowledge_doc,
|
||||
# 混进来会直接 CompileError: Unconsumed column names
|
||||
values: dict[str, Any] = {}
|
||||
if body.title is not None:
|
||||
values["title"] = body.title.strip()
|
||||
if body.body_md is not None:
|
||||
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。
|
||||
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
|
||||
if values or meta_changed:
|
||||
if values or doc_changed or meta_changed:
|
||||
values["updated_at"] = datetime.now(timezone.utc)
|
||||
result = await session.execute(
|
||||
update(Item)
|
||||
|
||||
@@ -278,3 +278,28 @@ async def test_patch_knowledge_moves_and_clears_category(client: AsyncClient) ->
|
||||
)
|
||||
)
|
||||
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"]} == {"写法"}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -20,6 +20,11 @@ interface Props {
|
||||
wsId: string;
|
||||
/** 新建场景还没有条目 id:正文照样能写,只是暂时不能贴图(贴图前必须先存草稿) */
|
||||
itemId?: string;
|
||||
/**
|
||||
* 新建场景的兜底:要贴图时先老老实实把草稿存下来,拿到条目 id 再传。
|
||||
* 返回 null 表示没存成(由调用方自己提示),编辑器不会继续上传。
|
||||
*/
|
||||
ensureItemId?: () => Promise<string | null>;
|
||||
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<Attachment>(
|
||||
`/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 保存;存下来之后就能直接贴图、传附件"}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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<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 canAddCategory = parentDepth < 3 && Boolean(newCat.trim());
|
||||
@@ -581,17 +591,50 @@ function KnowledgeComposer({
|
||||
}
|
||||
}
|
||||
|
||||
/** 拿条目 id:已有就直接返回,没有就现存一条草稿;并发调用共用同一个请求。 */
|
||||
async function ensureDraft(): Promise<string | null> {
|
||||
if (draftId) return draftId;
|
||||
if (draftingRef.current) return draftingRef.current;
|
||||
const pending = (async () => {
|
||||
try {
|
||||
const doc = await api.post<Knowledge>(`/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<Knowledge>(`/workspaces/${wsId}/knowledge`, {
|
||||
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),
|
||||
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={
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button variant="primary" disabled={!canSave} onClick={() => void save()}>
|
||||
@@ -617,7 +660,9 @@ function KnowledgeComposer({
|
||||
取消
|
||||
</Button>
|
||||
<span className="ml-auto text-2xs text-ink-400">
|
||||
先存成草稿,核对发布后才进团队检索
|
||||
{draftId
|
||||
? "已经是草稿了:直接关掉会留在「待核对」里"
|
||||
: "先存成草稿,核对发布后才进团队检索"}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
@@ -717,12 +762,39 @@ function KnowledgeComposer({
|
||||
value={body}
|
||||
onChange={setBody}
|
||||
minHeight={320}
|
||||
ensureItemId={ensureDraft}
|
||||
onError={setError}
|
||||
placeholder={"写下要点、步骤、结论…\n\n标题、类型、分类、标签都在上面,正文就是这张备忘的主体。"}
|
||||
/>
|
||||
</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>}
|
||||
</div>
|
||||
</Sheet>
|
||||
|
||||
Reference in New Issue
Block a user