diff --git a/2.Design/ROADMAP-M0-M1.md b/2.Design/ROADMAP-M0-M1.md index 7fa956c..02e471f 100644 --- a/2.Design/ROADMAP-M0-M1.md +++ b/2.Design/ROADMAP-M0-M1.md @@ -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 仍待独立实例) - [ ] 真实用两周:新同事靠知识库自己解决过一次问题(需要真实团队) diff --git a/2.Design/screens/knowledge-read-view.png b/2.Design/screens/knowledge-read-view.png new file mode 100644 index 0000000..00be73a Binary files /dev/null and b/2.Design/screens/knowledge-read-view.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 8a07aca..a4aa17d 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 @@ -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)) diff --git a/web/e2e/knowledge-notebook.mjs b/web/e2e/knowledge-notebook.mjs index 93b1baa..ab7aea8 100644 --- a/web/e2e/knowledge-notebook.mjs +++ b/web/e2e/knowledge-notebook.mjs @@ -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 }); // 先等抽屉把自己拉回来的旧标签填上——那一刻之后才没有"迟到的响应"来覆盖输入 diff --git a/web/e2e/knowledge.mjs b/web/e2e/knowledge.mjs index 521b466..e6d2b11 100644 --- a/web/e2e/knowledge.mjs +++ b/web/e2e/knowledge.mjs @@ -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`, { diff --git a/web/e2e/shots-knowledge-view.mjs b/web/e2e/shots-knowledge-view.mjs new file mode 100644 index 0000000..c5b90fe --- /dev/null +++ b/web/e2e/shots-knowledge-view.mjs @@ -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"); diff --git a/web/src/pages/Knowledge.tsx b/web/src/pages/Knowledge.tsx index e6733e7..2096c3b 100644 --- a/web/src/pages/Knowledge.tsx +++ b/web/src/pages/Knowledge.tsx @@ -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(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 ? (
- {canEdit ? ( - - ) : null} - {actions - .filter((a) => a.show) - .map((a) => ( - - ))} + + + ) : ( + <> + {canEdit ? ( + + ) : null} + {actions + .filter((a) => a.show) + .map((a) => ( + + ))} + + )} {doc.knowledge.published_at ? `发布于 ${new Date(doc.knowledge.published_at).toLocaleString("zh-CN")}` @@ -960,7 +986,7 @@ function KnowledgeDrawer({ > {!doc ? (

{error ?? "加载中…"}

- ) : ( + ) : !editing ? (
@@ -983,6 +1009,40 @@ function KnowledgeDrawer({

+
+ + {doc.category?.full_path ?? "未分类"} + + {doc.tags.map((t) => ( + + #{t.name} + + ))} +
+ + {/* 看的时候就是渲染后的样子:标题、列表、表格、图片都按 Markdown 显示。 + 要改再点「编辑」,进来才是编辑器——两者不该混在同一个界面里。 */} +
+ +
+ + {attachments.length > 0 && ( +
+ setError(message)} + disabled + /> +
+ )} + + {error && {error}} +
+ ) : ( +