之前抽屉一打开就是 Vditor 编辑器,正文是一堆 `##` 和 `-` 的原文, 导入的 84 条旧备忘看着尤其像草稿。现在分成「看」和「写」两态: - 默认只读:按 Markdown 渲染(markdown-it + DOMPurify),分类与标签是 chip, 附件列表只读;底部是「编辑」和状态动作,右侧显示发布于何时。 - 点「编辑」才挂编辑器;「取消」直接丢弃未保存改动(重新拉一次详情)。 - 安卓详情页同样用 Markwon 把正文排版后再显示,要改走「编辑正文」。 顺带修了 `web/e2e/knowledge.mjs` 里一条早就失效的断言:它等的是 「已发布 · 版本 N」,但界面上没有这行字(git 历史里也没有), 改成等抽屉底部的「发布于 …」。 验证:pytest 94 passed、ruff 全绿、tsc 0、vite build ✓; `knowledge-notebook.mjs` 16 项、`knowledge.mjs` 7 项 e2e 全绿。 安卓本轮仍未编译(本机 JDK 25 与 Gradle 8.7/AGP 8.7.3 不兼容)。
This commit is contained in:
@@ -294,12 +294,14 @@
|
||||
| 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.13 | 知识能挂附件:新建面板里直接上传(懒建草稿拿到条目 id 再传),详情里能看、能下载/打开 | 1.0 | [x] Web `AttachmentPanel` + `RichTextEditor.ensureItemId`(贴图/传附件前自动存草稿);安卓详情页附件列表 + `OpenDocument` 上传 + 浏览器打开;附件仍落在 `item` 上(`ALLOWED_KINDS` 含 knowledge) |
|
||||
| 3.14 | 点开一条知识默认是「看」:Markdown 渲染成正文样式;要改再点「编辑」进编辑器,取消即丢弃未保存改动 | 1.0 | [x] `KnowledgeDrawer` 分 `editing` 两态(只读用 `Markdown` + `prose-dadao`,编辑才挂 `RichTextEditor`);e2e 断言默认 `.vditor-ir` 为 0;截图 `2.Design/screens/knowledge-read-view.png` |
|
||||
|
||||
### M3 完成定义(DOD)
|
||||
- [x] 一件事办完 → 自动出现一份草稿 → 核对 → 发布,全程不用手动复制粘贴
|
||||
- [x] 每条知识都能追到来源(除手记),点开能看到来源事项
|
||||
- [x] 非管理员发布被拒;跨租户看不到别人的知识
|
||||
- [x] 知识是备忘录:标题、正文、标签、三级分类、搜索;新建时就能把这些一次填完(Web 与安卓一致)
|
||||
- [x] 点开知识默认看到的是排好版的正文,不是编辑器的原文;要改才点「编辑」
|
||||
- [x] 切片与语义检索跑通(`real[]` + SQL 余弦;pgvector 仍待独立实例)
|
||||
- [ ] 真实用两周:新同事靠知识库自己解决过一次问题(需要真实团队)
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 111 KiB |
@@ -43,6 +43,8 @@ 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.unit.sp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import com.dadao.task.data.Api
|
||||
@@ -51,6 +53,7 @@ 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 io.noties.markwon.Markwon
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -299,6 +302,37 @@ fun KnowledgeScreen(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 正文的「看」形态:Markwon 把 Markdown 排成排版好的正文。
|
||||
*
|
||||
* 编辑走 RichTextEditorScreen,所以这里永远是只读——点开一条知识先看到内容本身,
|
||||
* 而不是一堆 `##`、`-` 原样摆着。
|
||||
*/
|
||||
@Composable
|
||||
private fun KnowledgeBody(text: String) {
|
||||
if (text.isBlank()) {
|
||||
Text("(还没有正文,点「编辑正文」补齐)", fontSize = 13.sp, color = InkMuted)
|
||||
return
|
||||
}
|
||||
val context = LocalContext.current
|
||||
val markwon = remember { Markwon.create(context) }
|
||||
AndroidView(
|
||||
factory = { ctx ->
|
||||
android.widget.TextView(ctx).apply {
|
||||
setTextIsSelectable(true)
|
||||
textSize = 14f
|
||||
setLineSpacing(0f, 1.5f)
|
||||
setTextColor(android.graphics.Color.parseColor("#33443F"))
|
||||
markwon.setMarkdown(this, text)
|
||||
}
|
||||
},
|
||||
update = { view -> markwon.setMarkdown(view, text) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 80.dp),
|
||||
)
|
||||
}
|
||||
|
||||
private fun sourceLine(doc: KnowledgeDoc): String {
|
||||
val src = doc.source
|
||||
return if (src != null && src.title.isNotBlank()) {
|
||||
@@ -537,11 +571,8 @@ private fun KnowledgeDetailDialog(
|
||||
) {
|
||||
Text(sourceLine(doc), style = MaterialTheme.typography.labelSmall, color = InkMuted)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Text(
|
||||
doc.bodyMd.ifBlank { "(还没有正文,点「编辑正文」补齐)" },
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = InkSoft,
|
||||
)
|
||||
// 点开就是「看」:正文用 Markwon 排好版再显示,要看原文/改内容点「编辑正文」
|
||||
KnowledgeBody(doc.bodyMd)
|
||||
|
||||
// 附件:手册、日志、导出表格都能挂在这条知识下,点一下用浏览器打开
|
||||
Spacer(Modifier.height(14.dp))
|
||||
|
||||
@@ -170,6 +170,16 @@ try {
|
||||
),
|
||||
page.getByRole("button", { name: "打开", exact: true }).first().click(),
|
||||
]);
|
||||
// 详情默认是「看」:Markdown 渲染后的样子,不是编辑器
|
||||
await page.locator(".prose-dadao h2").first().waitFor({ timeout: 10_000 });
|
||||
check(
|
||||
"详情抽屉默认渲染样式(不是进来就编辑)",
|
||||
(await page.locator(".vditor-ir").count()) === 0 &&
|
||||
(await page.locator(".prose-dadao h2").first().textContent())?.includes("步骤") === true,
|
||||
);
|
||||
|
||||
// 要改才点「编辑」
|
||||
await page.getByRole("button", { name: "编辑", exact: true }).last().click();
|
||||
const tagInput = page.getByLabel("标签", { exact: true }).last();
|
||||
await tagInput.waitFor({ timeout: 10_000 });
|
||||
// 先等抽屉把自己拉回来的旧标签填上——那一刻之后才没有"迟到的响应"来覆盖输入
|
||||
|
||||
@@ -104,8 +104,8 @@ try {
|
||||
page.getByRole("button", { name: "发布", exact: true }).click(),
|
||||
]);
|
||||
check("发布请求成功", publishResp.ok());
|
||||
await page.getByText(/已发布 · 版本 \d+/, { exact: false }).waitFor({ timeout: 10_000 });
|
||||
const published = await page.getByText(/已发布 · 版本 \d+/, { exact: false }).first().isVisible();
|
||||
await page.getByText(/发布于 /, { exact: false }).first().waitFor({ timeout: 10_000 });
|
||||
const published = await page.getByText(/发布于 /, { exact: false }).first().isVisible();
|
||||
check("核对后可以发布", published);
|
||||
|
||||
const again = await fetch(`${API}/workspaces/${ws.id}/knowledge`, {
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* 截图脚本:把导入的备忘录点开,看「浏览」样式是否真的渲染成 Markdown。
|
||||
*
|
||||
* 只用于出图,不做断言;账号用 dev 的 13900000001(导入数据的归属)。
|
||||
*/
|
||||
import { chromium } from "playwright-core";
|
||||
import { mkdirSync } 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";
|
||||
const PHONE = process.env.PHONE ?? "13900000001";
|
||||
const WS_NAME = process.env.WS_NAME ?? "我的备忘";
|
||||
|
||||
async function call(method, path, body, token) {
|
||||
const res = await fetch(`${API}${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
...(token ? { authorization: `Bearer ${token}` } : {}),
|
||||
...(method === "GET" ? {} : { "idempotency-key": `shot-${Math.random()}` }),
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
const text = await res.text();
|
||||
const json = text ? JSON.parse(text) : {};
|
||||
if (json.code !== 0) throw new Error(`${method} ${path} -> ${json.code} ${json.message}`);
|
||||
return json.data;
|
||||
}
|
||||
|
||||
async function loginAs(phone) {
|
||||
// dev 下验证码固定,发码接口有 60 秒节流:能直接登录就直接登,被节流就等够了再发
|
||||
try {
|
||||
return await call("POST", "/auth/login", { phone, code: "123456" });
|
||||
} catch {
|
||||
/* 验证码过期或没发过,往下走发码 */
|
||||
}
|
||||
for (let i = 0; i < 2; i += 1) {
|
||||
try {
|
||||
await call("POST", "/auth/sms/send", { phone });
|
||||
break;
|
||||
} catch (err) {
|
||||
if (!String(err.message).includes("2011") || i === 1) throw err;
|
||||
await new Promise((r) => setTimeout(r, 61_000));
|
||||
}
|
||||
}
|
||||
return await call("POST", "/auth/login", { phone, code: "123456" });
|
||||
}
|
||||
|
||||
mkdirSync("e2e/artifacts", { recursive: true });
|
||||
|
||||
const login = await loginAs(PHONE);
|
||||
const token = login.tokens.access_token;
|
||||
const me = await call("GET", "/auth/me", undefined, token);
|
||||
const ws = me.workspaces.find((w) => w.name === WS_NAME) ?? me.workspaces[0];
|
||||
if (!ws) throw new Error(`${PHONE} 名下没有工作区`);
|
||||
console.log(`工作区:${ws.name} ${ws.id}`);
|
||||
|
||||
const browser = await chromium.launch({
|
||||
executablePath:
|
||||
process.env.CHROME ??
|
||||
`${process.env.LOCALAPPDATA}\\ms-playwright\\chromium-1223\\chrome-win64\\chrome.exe`,
|
||||
});
|
||||
const context = await browser.newContext({ viewport: { width: 1440, height: 960 } });
|
||||
const page = await context.newPage();
|
||||
|
||||
await page.addInitScript(
|
||||
([access, refresh]) => {
|
||||
localStorage.setItem("dadao.access", access);
|
||||
localStorage.setItem("dadao.refresh", refresh);
|
||||
},
|
||||
[token, login.tokens.refresh_token],
|
||||
);
|
||||
await page.goto(`${WEB}/w/${ws.id}/knowledge`, { waitUntil: "networkidle" });
|
||||
await page.getByRole("heading", { name: "知识" }).waitFor({ timeout: 20_000 });
|
||||
// 导入的备忘都是发布过的,切到「已发布」才看得到
|
||||
await page.getByRole("button", { name: "已发布", exact: true }).first().click();
|
||||
if (process.env.TITLE) {
|
||||
// 挑一条带标题/列表的,截图里才看得出 Markdown 真的渲染了
|
||||
const target = page.locator("li").filter({ hasText: process.env.TITLE }).first();
|
||||
await target.waitFor({ timeout: 20_000 });
|
||||
await page.screenshot({ path: "e2e/artifacts/memo-list.png" });
|
||||
await target.getByRole("button", { name: "打开", exact: true }).click();
|
||||
} else {
|
||||
await page.getByRole("button", { name: "打开", exact: true }).first().waitFor({ timeout: 20_000 });
|
||||
await page.waitForTimeout(600);
|
||||
await page.screenshot({ path: "e2e/artifacts/memo-list.png" });
|
||||
await page.getByRole("button", { name: "打开", exact: true }).first().click();
|
||||
}
|
||||
await page.locator(".prose-dadao").first().waitFor({ timeout: 20_000 });
|
||||
await page.waitForTimeout(600);
|
||||
await page.screenshot({ path: "e2e/artifacts/memo-read.png" });
|
||||
|
||||
await browser.close();
|
||||
console.log("OK");
|
||||
+78
-18
@@ -2,8 +2,9 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { AppShell } from "../components/AppShell";
|
||||
import { AttachmentPanel } from "../components/AttachmentPanel";
|
||||
import { Markdown } from "../components/Markdown";
|
||||
import { RichTextEditor } from "../components/RichTextEditor";
|
||||
import { Link2, Paperclip, Plus, Search, Sparkle } from "../components/icons";
|
||||
import { Link2, Paperclip, Pencil, Plus, Search, Sparkle } from "../components/icons";
|
||||
import {
|
||||
Button,
|
||||
EmptyState,
|
||||
@@ -831,6 +832,8 @@ function KnowledgeDrawer({
|
||||
return map;
|
||||
}, [attachments]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
/** 打开是「读」还是「写」:读的时候渲染样式,只有点「编辑」才进编辑器。 */
|
||||
const [editing, setEditing] = useState(false);
|
||||
/** 已经载入完成的是哪一条知识。重复的 effect / 慢响应不该再覆盖用户正在敲的内容。 */
|
||||
const loadedFor = useRef<string | null>(null);
|
||||
|
||||
@@ -845,6 +848,7 @@ function KnowledgeDrawer({
|
||||
setTags(data.tags.map((t) => t.name).join(" "));
|
||||
setCategoryId(data.category?.id ?? "");
|
||||
setAttachments(data.attachments ?? []);
|
||||
setEditing(false);
|
||||
loadedFor.current = itemId;
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "加载失败");
|
||||
@@ -870,6 +874,7 @@ function KnowledgeDrawer({
|
||||
version: doc.version,
|
||||
});
|
||||
setDoc(next);
|
||||
setEditing(false);
|
||||
onChanged();
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.code === 4009) {
|
||||
@@ -932,23 +937,44 @@ function KnowledgeDrawer({
|
||||
footer={
|
||||
doc ? (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{canEdit ? (
|
||||
<Button variant="primary" disabled={busy} onClick={() => void save()}>
|
||||
{busy ? "保存中…" : "保存"}
|
||||
</Button>
|
||||
) : null}
|
||||
{actions
|
||||
.filter((a) => a.show)
|
||||
.map((a) => (
|
||||
<Button
|
||||
key={a.key}
|
||||
variant={a.key === "publish" ? "clay" : "ghost"}
|
||||
disabled={busy}
|
||||
onClick={() => void act(a.key)}
|
||||
>
|
||||
{a.label}
|
||||
{editing ? (
|
||||
<>
|
||||
<Button variant="primary" disabled={busy} onClick={() => void save()}>
|
||||
{busy ? "保存中…" : "保存"}
|
||||
</Button>
|
||||
))}
|
||||
<Button
|
||||
variant="ghost"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
setEditing(false);
|
||||
void load(true);
|
||||
}}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{canEdit ? (
|
||||
<Button variant="primary" onClick={() => setEditing(true)}>
|
||||
<Pencil size={13} />
|
||||
编辑
|
||||
</Button>
|
||||
) : null}
|
||||
{actions
|
||||
.filter((a) => a.show)
|
||||
.map((a) => (
|
||||
<Button
|
||||
key={a.key}
|
||||
variant={a.key === "publish" ? "clay" : "ghost"}
|
||||
disabled={busy}
|
||||
onClick={() => void act(a.key)}
|
||||
>
|
||||
{a.label}
|
||||
</Button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
<span className="ml-auto text-2xs text-ink-400">
|
||||
{doc.knowledge.published_at
|
||||
? `发布于 ${new Date(doc.knowledge.published_at).toLocaleString("zh-CN")}`
|
||||
@@ -960,7 +986,7 @@ function KnowledgeDrawer({
|
||||
>
|
||||
{!doc ? (
|
||||
<p className="text-sm text-ink-400">{error ?? "加载中…"}</p>
|
||||
) : (
|
||||
) : !editing ? (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-xl border border-line-200 bg-paper-50 p-3.5 text-xs leading-relaxed text-ink-600">
|
||||
<div className="flex items-center gap-1.5">
|
||||
@@ -983,6 +1009,40 @@ function KnowledgeDrawer({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-1.5 text-2xs">
|
||||
<span className="chip bg-paper-200 text-ink-600">
|
||||
{doc.category?.full_path ?? "未分类"}
|
||||
</span>
|
||||
{doc.tags.map((t) => (
|
||||
<span key={t.id} className="chip bg-primary-50 text-primary-700">
|
||||
#{t.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 看的时候就是渲染后的样子:标题、列表、表格、图片都按 Markdown 显示。
|
||||
要改再点「编辑」,进来才是编辑器——两者不该混在同一个界面里。 */}
|
||||
<div className="rounded-xl border border-line-200 bg-white px-4 py-4">
|
||||
<Markdown source={doc.body_md} resources={resources} />
|
||||
</div>
|
||||
|
||||
{attachments.length > 0 && (
|
||||
<div className="rounded-xl border border-line-200 bg-paper-50 px-3 py-2.5">
|
||||
<AttachmentPanel
|
||||
wsId={wsId}
|
||||
itemId={doc.id}
|
||||
attachments={attachments}
|
||||
onChange={setAttachments}
|
||||
onError={(message) => setError(message)}
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <InlineAlert>{error}</InlineAlert>}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<input
|
||||
className="input text-base font-medium"
|
||||
value={title}
|
||||
|
||||
Reference in New Issue
Block a user