CI / backend (push) Canceled after 0s
用户发来 https://mp.weixin.qq.com/s/ZEOsi4R4JcmZ_mhS6qpcww,存进库的正文只有 67 个字符 ("原文链接 + 一个句号")。复盘发现公众号换了渲染方式:新版页面把整篇塞进 window.cgiDataNew 这个 JS 对象由前端渲染,页面里根本没有 id="js_content"; 三种 UA 拿到的字节数完全一样,也不是 UA 的问题。 - 从 JS 字符串字面量里读正文(content_noencode / desc),按 \xNN、\uNNNN 反转义, 话题标签是双重转义过的 HTML,先 unescape 再走 DOM。 - 配图字段排在文字之后,所以前后各找 2500 字符合图;剩下没用过的 mmbiz 图按 12 张上限补在末尾(图文型文章靠就近配对会漏)。 - 解析顺序:旧 DOM → 新版 JS 数据 → 通用抽取,一层层退。 实测两篇:2373 字符 + 12 张图、2772 字符 + 13 张图,作者也都取到了。 同时补上用户提的两件: - 知识能删了:DELETE /knowledge/{id}?confirm=true,软删 + 真删检索切片, 只有作者或 owner/admin 能删;列表卡片(悬停出现)和阅读页动作行各一个入口。 删完详情也回 7001,否则收藏的链接还能打开一篇已删除的知识。 - 分类下面有标签清单了:GET /knowledge/tags 按标签数"有多少条知识用了它", 侧栏渲染成 #标签 + 条数,点一下筛选、再点取消。
295 lines
11 KiB
TypeScript
295 lines
11 KiB
TypeScript
/**
|
|
* 知识阅读页(`/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, Trash } 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";
|
|
|
|
/** 删除:确认后回列表页。后端是软删,但界面按"没了"处理最不容易让人误会。 */
|
|
async function remove() {
|
|
if (!doc) return;
|
|
if (!window.confirm(`删除「${doc.title}」?删了不会回来,检索里也会一起消失。`)) return;
|
|
setBusy(true);
|
|
setError(null);
|
|
try {
|
|
await api.del(`/workspaces/${wsId}/knowledge/${itemId}?confirm=true`);
|
|
navigate(`/w/${wsId}/knowledge`, { replace: true });
|
|
} catch (err) {
|
|
setError(err instanceof ApiError ? err.message : "删除失败");
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
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">
|
|
{/* 宽屏上别把正文挤成窄条:整块跟到 78rem,正文列限宽到 56rem(约 55 个汉字一行)。
|
|
再宽眼睛就要来回扫行了,留白比字长更难读。 */}
|
|
<div className="mx-auto w-full max-w-[78rem]">
|
|
<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-[56rem]">
|
|
<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>
|
|
))}
|
|
{/* 删除放最后、用弱色 + 垃圾桶:它是不可逆的,不该和"发布"抢注意力 */}
|
|
<Button variant="ghost" disabled={busy} className="ml-auto text-ink-400 hover:text-red-600" onClick={() => void remove()}>
|
|
<Trash size={13} />
|
|
删除
|
|
</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>
|
|
);
|
|
}
|