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`。
|
附件对象的字段:`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 内联(正文膨胀几十倍,破坏检索),
|
||||||
|
|||||||
@@ -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('/')
|
||||||
|
|
||||||
|
|||||||
@@ -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"]} == {"写法"}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,9 +139,18 @@ export function RichTextEditor({
|
|||||||
accept: "image/*",
|
accept: "image/*",
|
||||||
multiple: true,
|
multiple: true,
|
||||||
handler: async (files: File[]) => {
|
handler: async (files: File[]) => {
|
||||||
if (!itemId) {
|
// 新建时还没有条目 id:先让调用方把草稿存下来,拿到落点再传
|
||||||
onErrorRef.current?.("先保存这条内容,再回来贴图或传附件");
|
let targetId = itemId;
|
||||||
return null;
|
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/"));
|
const images = files.filter((file) => file.type.startsWith("image/"));
|
||||||
if (images.length === 0) {
|
if (images.length === 0) {
|
||||||
@@ -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,7 +236,9 @@ export function RichTextEditor({
|
|||||||
? "正在上传图片…"
|
? "正在上传图片…"
|
||||||
: itemId
|
: itemId
|
||||||
? "支持直接粘贴或拖入图片;内容按 Markdown 保存,模型和导出都读得懂"
|
? "支持直接粘贴或拖入图片;内容按 Markdown 保存,模型和导出都读得懂"
|
||||||
: "内容按 Markdown 保存;存下来之后就能直接贴图、传附件"}
|
: ensureItemId
|
||||||
|
? "支持直接粘贴或拖入图片:会先自动存成草稿,再传上去"
|
||||||
|
: "内容按 Markdown 保存;存下来之后就能直接贴图、传附件"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,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() {
|
async function save() {
|
||||||
if (!canSave) return;
|
if (!canSave) return;
|
||||||
|
const itemId = await ensureDraft();
|
||||||
|
if (!itemId) return;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
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(),
|
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,
|
...(categoryId ? { category_id: categoryId } : { clear_category: true }),
|
||||||
});
|
});
|
||||||
onCreated(doc);
|
onCreated(doc);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user