知识原来一律在抽屉里打开,正文贴着屏幕右半边,长文读十几屏眼睛一直在右边。 阅读是「坐下来读一会儿」,编辑是「改两句就走」,两件事不该共用一个容器。 - 新增阅读页 /w/:wsId/knowledge/:itemId:正文列限到 40rem 居中,28px 标题, 更新/发布/切片行,标签与来源,动作行放编辑与发布/归档/恢复 - 列表页与搜索结果改成跳阅读页,不再弹抽屉 - KnowledgeDrawer 加 initialEditing:进来就是编辑器,保存或取消直接关 - 文案常量搬到 lib/knowledge.ts,列表页与阅读页共用一份 顺带修三个 e2e 脚本:新建后进阅读页没有「关闭」按钮可点、侧栏色点按钮让树 顺序断言多出空串、翻页脚本还在用已关闭的短信登录(改密码登录)。
This commit is contained in:
+38
-86
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { AppShell } from "../components/AppShell";
|
||||
import { AttachmentPanel } from "../components/AttachmentPanel";
|
||||
import { Markdown } from "../components/Markdown";
|
||||
@@ -15,11 +15,19 @@ import {
|
||||
StatusPill,
|
||||
} from "../components/ui";
|
||||
import { ApiError, api } from "../lib/client";
|
||||
import {
|
||||
ACTION_LABEL,
|
||||
CATEGORY_PALETTE,
|
||||
DOC_TYPE_LABEL,
|
||||
HEX_COLOR_RE,
|
||||
SOURCE_LABEL,
|
||||
STATUS_LABEL,
|
||||
categoryColor,
|
||||
} from "../lib/knowledge";
|
||||
import type {
|
||||
Attachment,
|
||||
Knowledge,
|
||||
KnowledgeCategoryNode,
|
||||
KnowledgeStatus,
|
||||
SearchHit,
|
||||
} from "../lib/types";
|
||||
|
||||
@@ -31,68 +39,6 @@ const TABS = [
|
||||
{ key: "archived", label: "已归档", statuses: ["archived"] },
|
||||
] as const;
|
||||
|
||||
const DOC_TYPE_LABEL: Record<string, string> = {
|
||||
note: "笔记",
|
||||
howto: "怎么做",
|
||||
faq: "常见问题",
|
||||
postmortem: "复盘",
|
||||
decision: "决策",
|
||||
};
|
||||
|
||||
const STATUS_LABEL: Record<KnowledgeStatus, string> = {
|
||||
draft: "草稿",
|
||||
in_review: "待审议",
|
||||
published: "已发布",
|
||||
archived: "已归档",
|
||||
};
|
||||
|
||||
const SOURCE_LABEL: Record<string, string> = {
|
||||
manual: "手记",
|
||||
task: "来自任务",
|
||||
ticket: "来自工单",
|
||||
};
|
||||
|
||||
/**
|
||||
* 分类调色板。给的是“彼此能分开”的一组色,不是渐变——分类色的用途是让人
|
||||
* 在卡片墙上一眼分辨归属,颜色之间越差得开越好用。调色板没有的色可以手填。
|
||||
*/
|
||||
const CATEGORY_PALETTE = [
|
||||
"#4F46E5",
|
||||
"#0EA5E9",
|
||||
"#10B981",
|
||||
"#F59E0B",
|
||||
"#EF4444",
|
||||
"#EC4899",
|
||||
"#8B5CF6",
|
||||
"#14B8A6",
|
||||
"#64748B",
|
||||
"#84CC16",
|
||||
];
|
||||
|
||||
const HEX_COLOR_RE = /^#[0-9A-Fa-f]{6}$/;
|
||||
|
||||
/**
|
||||
* 分类实际用的颜色:设了就用设的,没设就按 id 稳定地挑一个调色板色。
|
||||
*
|
||||
* 为什么没设也要给色:老分类(加颜色列之前建的)全是空值,如果空值一律画灰边,
|
||||
* 刚上的“分类色”看起来就像没生效。按 id 取模是确定性的:同一条分类刷新前后同色,
|
||||
* 也不会因为新增一条分类而让别的分类换色。
|
||||
*/
|
||||
function categoryColor(cat: { id: string; color: string | null }): string {
|
||||
if (cat.color) return cat.color;
|
||||
let hash = 0;
|
||||
for (let i = 0; i < cat.id.length; i += 1) hash = (hash * 31 + cat.id.charCodeAt(i)) % 9973;
|
||||
return CATEGORY_PALETTE[hash % CATEGORY_PALETTE.length];
|
||||
}
|
||||
|
||||
const ACTION_LABEL: Record<string, string> = {
|
||||
submit_review: "提交审议",
|
||||
withdraw: "撤回审议",
|
||||
publish: "发布",
|
||||
archive: "归档",
|
||||
restore: "恢复成草稿",
|
||||
};
|
||||
|
||||
/**
|
||||
* 正文编辑区的高度跟着窗口走。
|
||||
* 知识是长文,编辑器默认 240px 只露出几行,写一段就得滚一次;
|
||||
@@ -115,13 +61,15 @@ function useEditorHeight(reserve: number, floor = 360, ceiling = 960) {
|
||||
|
||||
export function KnowledgePage() {
|
||||
const { wsId = "" } = useParams();
|
||||
// 阅读走独立页面:抽屉只用来编辑(短时间、单次动作),长文阅读要整屏宽度
|
||||
const navigate = useNavigate();
|
||||
const openDoc = (id: string) => navigate(`/w/${wsId}/knowledge/${id}`);
|
||||
const [tab, setTab] = useState<(typeof TABS)[number]["key"]>("all");
|
||||
const [q, setQ] = useState("");
|
||||
const [docs, setDocs] = useState<Knowledge[]>([]);
|
||||
const [cursor, setCursor] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [openId, setOpenId] = useState<string | null>(null);
|
||||
const [composing, setComposing] = useState(false);
|
||||
const [hits, setHits] = useState<SearchHit[] | null>(null);
|
||||
const [searching, setSearching] = useState(false);
|
||||
@@ -291,7 +239,7 @@ export function KnowledgePage() {
|
||||
setComposing(false);
|
||||
setTab("draft");
|
||||
setCatFilter(null);
|
||||
setOpenId(doc.id);
|
||||
openDoc(doc.id);
|
||||
void load(null);
|
||||
void loadCategories();
|
||||
}
|
||||
@@ -444,7 +392,7 @@ export function KnowledgePage() {
|
||||
{hit.content}
|
||||
</p>
|
||||
<div className="mt-2 text-right">
|
||||
<Button variant="ghost" size="sm" onClick={() => setOpenId(hit.item_id)}>
|
||||
<Button variant="ghost" size="sm" onClick={() => openDoc(hit.item_id)}>
|
||||
打开原文
|
||||
</Button>
|
||||
</div>
|
||||
@@ -488,7 +436,7 @@ export function KnowledgePage() {
|
||||
}
|
||||
className="card group flex items-start justify-between gap-3 p-4 transition hover:border-line-300 hover:shadow-pop"
|
||||
>
|
||||
<button className="min-w-0 flex-1 text-left" onClick={() => setOpenId(d.id)}>
|
||||
<button className="min-w-0 flex-1 text-left" onClick={() => openDoc(d.id)}>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="chip bg-primary-50 text-primary-700">
|
||||
{DOC_TYPE_LABEL[d.knowledge.doc_type] ?? d.knowledge.doc_type}
|
||||
@@ -538,7 +486,7 @@ export function KnowledgePage() {
|
||||
</div>
|
||||
) : null}
|
||||
</button>
|
||||
<Button variant="ghost" size="sm" className="shrink-0" onClick={() => setOpenId(d.id)}>
|
||||
<Button variant="ghost" size="sm" className="shrink-0" onClick={() => openDoc(d.id)}>
|
||||
打开
|
||||
</Button>
|
||||
</li>
|
||||
@@ -567,18 +515,6 @@ export function KnowledgePage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{openId && (
|
||||
<KnowledgeDrawer
|
||||
wsId={wsId}
|
||||
itemId={openId}
|
||||
categories={categories}
|
||||
onClose={() => setOpenId(null)}
|
||||
onChanged={() => {
|
||||
void load(null);
|
||||
void loadCategories();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@@ -1264,16 +1200,24 @@ function KnowledgeComposer({
|
||||
}
|
||||
|
||||
/** 知识详情抽屉:能改、能提交审议、能发布/归档。 */
|
||||
function KnowledgeDrawer({
|
||||
/**
|
||||
* 编辑抽屉。阅读不走这里——阅读有独立页面(pages/KnowledgeDoc.tsx)。
|
||||
*
|
||||
* `initialEditing` 为真时它就是"纯编辑器":进来直接是编辑态,保存或取消都关掉,
|
||||
* 不在这里再看一遍正文(阅读页就在它背后,没必要叠一层相同的排版)。
|
||||
*/
|
||||
export function KnowledgeDrawer({
|
||||
wsId,
|
||||
itemId,
|
||||
categories,
|
||||
initialEditing = false,
|
||||
onClose,
|
||||
onChanged,
|
||||
}: {
|
||||
wsId: string;
|
||||
itemId: string;
|
||||
categories: KnowledgeCategoryNode[];
|
||||
initialEditing?: boolean;
|
||||
onClose: () => void;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
@@ -1296,7 +1240,7 @@ function KnowledgeDrawer({
|
||||
}, [attachments]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
/** 打开是「读」还是「写」:读的时候渲染样式,只有点「编辑」才进编辑器。 */
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [editing, setEditing] = useState(initialEditing);
|
||||
/** 已经载入完成的是哪一条知识。重复的 effect / 慢响应不该再覆盖用户正在敲的内容。 */
|
||||
const loadedFor = useRef<string | null>(null);
|
||||
|
||||
@@ -1311,13 +1255,13 @@ function KnowledgeDrawer({
|
||||
setTags(data.tags.map((t) => t.name).join(" "));
|
||||
setCategoryId(data.category?.id ?? "");
|
||||
setAttachments(data.attachments ?? []);
|
||||
setEditing(false);
|
||||
setEditing(initialEditing);
|
||||
loadedFor.current = itemId;
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "加载失败");
|
||||
}
|
||||
},
|
||||
[wsId, itemId],
|
||||
[wsId, itemId, initialEditing],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -1337,8 +1281,12 @@ function KnowledgeDrawer({
|
||||
version: doc.version,
|
||||
});
|
||||
setDoc(next);
|
||||
setEditing(false);
|
||||
onChanged();
|
||||
if (initialEditing) {
|
||||
onClose();
|
||||
} else {
|
||||
setEditing(false);
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.code === 4009) {
|
||||
setError("刚被别人改过,已刷新为最新内容");
|
||||
@@ -1410,6 +1358,10 @@ function KnowledgeDrawer({
|
||||
variant="ghost"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
if (initialEditing) {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
setEditing(false);
|
||||
void load(true);
|
||||
}}
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
/**
|
||||
* 知识阅读页(`/w/:wsId/knowledge/:itemId`)。
|
||||
*
|
||||
* 为什么阅读要独立成页而不是抽屉:抽屉贴在右边、最大也就 1150px 的六成宽,
|
||||
* 长文在这个位置读十几屏,视线一直偏在屏幕右侧,很累。阅读本来就该是
|
||||
* 一整页、正文落在视线中间的一列(这里限宽 40rem,约 40 个汉字一行)。
|
||||
* 抽屉留给"改一下"——那是短时间的动作,也不常对同一篇反复做。
|
||||
*/
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||
import { AppShell } from "../components/AppShell";
|
||||
import { AttachmentPanel } from "../components/AttachmentPanel";
|
||||
import { Markdown } from "../components/Markdown";
|
||||
import { ChevronLeft, Link2, Pencil } from "../components/icons";
|
||||
import { Button, InlineAlert, SkeletonRows, StatusPill } from "../components/ui";
|
||||
import { ApiError, api } from "../lib/client";
|
||||
import { ACTION_LABEL, DOC_TYPE_LABEL, STATUS_LABEL, categoryColor } from "../lib/knowledge";
|
||||
import type { Attachment, Knowledge, KnowledgeCategoryNode } from "../lib/types";
|
||||
import { KnowledgeDrawer } from "./Knowledge";
|
||||
|
||||
export function KnowledgeDocPage() {
|
||||
const { wsId = "", itemId = "" } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const [doc, setDoc] = useState<Knowledge | null>(null);
|
||||
const [categories, setCategories] = useState<KnowledgeCategoryNode[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await api.get<Knowledge>(`/workspaces/${wsId}/knowledge/${itemId}`);
|
||||
setDoc(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "知识加载失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [wsId, itemId]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
// 换一篇就回到顶部:否则从长文点进短文,会停在上一页的滚动位置
|
||||
useEffect(() => {
|
||||
window.scrollTo({ top: 0 });
|
||||
}, [itemId]);
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
const page = await api.get<{ items: KnowledgeCategoryNode[] }>(
|
||||
`/workspaces/${wsId}/knowledge/categories`,
|
||||
);
|
||||
setCategories(page.items);
|
||||
} catch {
|
||||
// 分类只是编辑时的下拉选项,取不到不影响阅读
|
||||
}
|
||||
})();
|
||||
}, [wsId]);
|
||||
|
||||
/** 附件直链:正文里的图片按 attachment id 换成带签名的 raw_url */
|
||||
const resources = useMemo(() => {
|
||||
const map: Record<string, string> = {};
|
||||
for (const row of (doc?.attachments ?? []) as Attachment[]) {
|
||||
if (row.raw_url) map[row.id] = row.raw_url;
|
||||
}
|
||||
return map;
|
||||
}, [doc]);
|
||||
|
||||
async function act(action: string) {
|
||||
if (!doc) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const next = await api.post<Knowledge>(`/workspaces/${wsId}/knowledge/${itemId}/status`, {
|
||||
action,
|
||||
version: doc.version,
|
||||
});
|
||||
setDoc(next);
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "操作失败");
|
||||
await load();
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const status = doc?.knowledge.status_hint;
|
||||
const canEdit = status === "draft" || status === "in_review" || status === "published";
|
||||
const actions: { key: string; label: string; show: boolean }[] = [
|
||||
{ key: "submit_review", label: ACTION_LABEL.submit_review, show: status === "draft" },
|
||||
{ key: "withdraw", label: ACTION_LABEL.withdraw, show: status === "in_review" },
|
||||
{ key: "publish", label: ACTION_LABEL.publish, show: status === "draft" || status === "in_review" },
|
||||
{
|
||||
key: "archive",
|
||||
label: ACTION_LABEL.archive,
|
||||
show: status === "draft" || status === "in_review" || status === "published",
|
||||
},
|
||||
{ key: "restore", label: ACTION_LABEL.restore, show: status === "archived" },
|
||||
];
|
||||
|
||||
return (
|
||||
<AppShell wsId={wsId} active="knowledge">
|
||||
<div className="mx-auto w-full max-w-[46rem]">
|
||||
<div className="mb-4 flex flex-wrap items-center gap-2">
|
||||
<Link
|
||||
to={`/w/${wsId}/knowledge`}
|
||||
className="inline-flex items-center gap-1 text-xs text-ink-500 transition hover:text-ink-800"
|
||||
>
|
||||
<ChevronLeft size={13} />
|
||||
返回知识库
|
||||
</Link>
|
||||
<span className="text-2xs text-ink-300">/</span>
|
||||
<span className="max-w-[18rem] truncate text-2xs text-ink-400">
|
||||
{doc?.category?.full_path ?? "未分类"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4">
|
||||
<InlineAlert>{error}</InlineAlert>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && !doc ? (
|
||||
<div className="card overflow-hidden">
|
||||
<SkeletonRows rows={4} />
|
||||
</div>
|
||||
) : !doc ? (
|
||||
<div className="card p-6 text-center">
|
||||
<p className="text-sm text-ink-600">这条知识不在了,或者你没有权限看它。</p>
|
||||
<div className="mt-4">
|
||||
<Button variant="ghost" onClick={() => navigate(`/w/${wsId}/knowledge`)}>
|
||||
回到知识库
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<article className="card px-5 py-6 sm:px-8 sm:py-8">
|
||||
<div className="mx-auto w-full max-w-[40rem]">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<span className="chip bg-primary-50 text-primary-700">
|
||||
{DOC_TYPE_LABEL[doc.knowledge.doc_type] ?? doc.knowledge.doc_type}
|
||||
</span>
|
||||
<StatusPill
|
||||
status={doc.knowledge.status_hint}
|
||||
label={STATUS_LABEL[doc.knowledge.status_hint]}
|
||||
/>
|
||||
{doc.category ? (
|
||||
<span className="chip max-w-[18rem] truncate bg-paper-200 text-ink-600">
|
||||
<span
|
||||
aria-hidden
|
||||
className="mr-1 inline-block h-1.5 w-1.5 shrink-0 rounded-full align-middle"
|
||||
style={{ background: categoryColor(doc.category) }}
|
||||
/>
|
||||
{doc.category.full_path}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<h1 className="mt-3 text-2xl font-semibold leading-snug text-ink-900 sm:text-[28px]">
|
||||
{doc.title}
|
||||
</h1>
|
||||
|
||||
<p className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-2xs text-ink-400">
|
||||
<span>更新于 {new Date(doc.updated_at).toLocaleString("zh-CN")}</span>
|
||||
{doc.knowledge.published_at ? (
|
||||
<span>发布于 {new Date(doc.knowledge.published_at).toLocaleString("zh-CN")}</span>
|
||||
) : (
|
||||
<span>尚未发布,只有工作区里的人能看到</span>
|
||||
)}
|
||||
{doc.knowledge.chunk_count > 0 ? (
|
||||
<span>检索切片 {doc.knowledge.chunk_count} 段</span>
|
||||
) : null}
|
||||
</p>
|
||||
|
||||
{doc.tags.length > 0 ? (
|
||||
<div className="mt-3 flex flex-wrap gap-1">
|
||||
{doc.tags.map((t) => (
|
||||
<span key={t.id} className="chip bg-primary-50 text-primary-700">
|
||||
#{t.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-4 flex flex-wrap items-center gap-2 border-b border-line-200 pb-4">
|
||||
{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>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<Markdown source={doc.body_md} resources={resources} />
|
||||
</div>
|
||||
|
||||
{doc.source ? (
|
||||
<div className="mt-8 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">
|
||||
<Link2 size={12} className="text-ink-400" />
|
||||
<span>
|
||||
来源:{doc.source.kind === "ticket" ? "工单" : "任务"}「
|
||||
<Link
|
||||
to={
|
||||
doc.source.kind === "ticket"
|
||||
? `/w/${wsId}/tickets?open=${doc.source.id}`
|
||||
: `/w/${wsId}?open=${doc.source.id}`
|
||||
}
|
||||
className="link"
|
||||
>
|
||||
{doc.source.title}
|
||||
</Link>
|
||||
」
|
||||
</span>
|
||||
</div>
|
||||
{doc.knowledge.generated_by === "rule" ? (
|
||||
<p className="mt-1 text-ink-400">这份草稿由系统整理,请核对后再发布。</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{(doc.attachments?.length ?? 0) > 0 ? (
|
||||
<div className="mt-4 rounded-xl border border-line-200 bg-paper-50 px-3 py-2.5">
|
||||
<AttachmentPanel
|
||||
wsId={wsId}
|
||||
itemId={doc.id}
|
||||
attachments={(doc.attachments ?? []) as Attachment[]}
|
||||
onChange={() => void load()}
|
||||
onError={(message) => setError(message)}
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</article>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{editing && doc ? (
|
||||
<KnowledgeDrawer
|
||||
wsId={wsId}
|
||||
itemId={doc.id}
|
||||
categories={categories}
|
||||
initialEditing
|
||||
onClose={() => setEditing(false)}
|
||||
onChanged={() => void load()}
|
||||
/>
|
||||
) : null}
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user