463 lines
15 KiB
TypeScript
463 lines
15 KiB
TypeScript
import Link from "next/link";
|
||
import { useRouter } from "next/router";
|
||
import { useEffect, useRef, useState } from "react";
|
||
import {
|
||
ArrowLeft,
|
||
ArrowUpRight,
|
||
BookOpen,
|
||
Braces,
|
||
CalendarDays,
|
||
CheckSquare,
|
||
ChevronRight,
|
||
FileText,
|
||
GitBranch,
|
||
Globe2,
|
||
ListChecks,
|
||
Link2,
|
||
Pencil,
|
||
Target,
|
||
Trash2,
|
||
} from "lucide-react";
|
||
import GuidanceShell from "@/components/GuidanceShell";
|
||
import {
|
||
CopyButton,
|
||
KindBadge,
|
||
Notice,
|
||
Tabs,
|
||
kindNames,
|
||
} from "@/components/GuidanceUI";
|
||
import { guidanceXml, type Guidance } from "@/content/guides/catalog";
|
||
import s from "@/styles/guidance.module.css";
|
||
|
||
export type ReadingVersion = { revision: number; createdAt?: string };
|
||
export type ReadingLink = Guidance["links"][number] & { resolved: boolean };
|
||
export type ReaderProps = {
|
||
document: Guidance;
|
||
source: "example" | "community";
|
||
links: ReadingLink[];
|
||
versions: ReadingVersion[];
|
||
backlinks?: {
|
||
id: string;
|
||
revision?: number;
|
||
title: string;
|
||
source?: "example" | "community";
|
||
}[];
|
||
canEdit?: boolean;
|
||
withdrawn?: boolean;
|
||
};
|
||
const relationNames = {
|
||
guide: "可选指导",
|
||
context: "背景参考",
|
||
evidence: "事实依据",
|
||
};
|
||
function dateLabel(value?: string) {
|
||
return value
|
||
? new Intl.DateTimeFormat("zh-CN", {
|
||
year: "numeric",
|
||
month: "2-digit",
|
||
day: "2-digit",
|
||
timeZone: "UTC",
|
||
}).format(new Date(value))
|
||
: "协议样例";
|
||
}
|
||
export default function GuidanceReader({
|
||
document: doc,
|
||
source,
|
||
links,
|
||
versions,
|
||
backlinks = [],
|
||
canEdit = false,
|
||
withdrawn = false,
|
||
}: ReaderProps) {
|
||
const router = useRouter(),
|
||
dialog = useRef<HTMLDialogElement>(null);
|
||
const [tab, setTab] = useState("overview"),
|
||
[busy, setBusy] = useState(false),
|
||
[error, setError] = useState(""),
|
||
[isWithdrawn, setWithdrawn] = useState(withdrawn);
|
||
useEffect(() => {
|
||
setWithdrawn(withdrawn);
|
||
setError("");
|
||
setTab("overview");
|
||
}, [doc.id, withdrawn]);
|
||
const api = `/api/${source === "example" ? "guides" : "documents"}/${doc.id}?revision=${doc.revision}&format=xml`;
|
||
const current = versions.find((v) => v.revision === doc.revision),
|
||
xml = guidanceXml(doc);
|
||
const route = source === "example" ? "/guide/" : "/documents/";
|
||
const sectionLinks = [
|
||
["applies", "适用条件"],
|
||
["content", "指导正文"],
|
||
...(doc.steps.length ? [["steps", "建议步骤"]] : []),
|
||
...(doc.checks.length ? [["checks", "完成检查"]] : []),
|
||
["related", "条件链接"],
|
||
];
|
||
async function withdraw() {
|
||
setBusy(true);
|
||
setError("");
|
||
try {
|
||
const response = await fetch("/api/documents/" + doc.id, {
|
||
method: "DELETE",
|
||
});
|
||
const body = await response.json();
|
||
if (!response.ok) throw Error(body.error || "撤下失败,请稍后重试");
|
||
setWithdrawn(true);
|
||
dialog.current?.close();
|
||
} catch (e) {
|
||
setError(e instanceof Error ? e.message : "撤下失败,请稍后重试");
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
}
|
||
return (
|
||
<GuidanceShell title="文档详情">
|
||
<header className={s.readingHeader}>
|
||
<Link
|
||
className={s.backLink}
|
||
href={source === "example" ? "/" : "/documents"}
|
||
>
|
||
<ArrowLeft size={13} />
|
||
{source === "example" ? "返回探索文档" : "返回社区文档"}
|
||
</Link>
|
||
<div className={s.documentBadges}>
|
||
<KindBadge kind={doc.kind} />
|
||
<span className={s.neutralBadge}>
|
||
{source === "example" ? "协议样例" : "社区发布"}
|
||
</span>
|
||
<span className={s.neutralBadge}>v{doc.revision}</span>
|
||
{isWithdrawn && (
|
||
<span className={s.neutralBadge}>已撤下 · 历史版本</span>
|
||
)}
|
||
</div>
|
||
<h1>{doc.title}</h1>
|
||
<p className={s.readingSummary}>{doc.summary}</p>
|
||
<div className={s.readingMeta}>
|
||
<span>
|
||
<Globe2 size={13} />
|
||
公开文档
|
||
</span>
|
||
<span>
|
||
<GitBranch size={13} />
|
||
固定版本 {doc.revision}
|
||
</span>
|
||
{current?.createdAt && (
|
||
<span>
|
||
<CalendarDays size={13} />
|
||
{dateLabel(current.createdAt)}
|
||
</span>
|
||
)}
|
||
</div>
|
||
<div className={s.readingActions}>
|
||
<CopyButton
|
||
label="复制 Agent 读取地址"
|
||
value={() => window.location.origin + api}
|
||
/>
|
||
<a
|
||
href={api}
|
||
className={s.secondaryButton}
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
>
|
||
<Braces size={15} />
|
||
读取规范 XML
|
||
<ArrowUpRight size={13} />
|
||
</a>
|
||
{canEdit && !isWithdrawn && (
|
||
<Link className={s.secondaryButton} href={"/write?id=" + doc.id}>
|
||
<Pencil size={14} />
|
||
编辑最新版本
|
||
</Link>
|
||
)}
|
||
</div>
|
||
</header>
|
||
{isWithdrawn && (
|
||
<Notice tone="warning">
|
||
这篇文档已从公开目录撤下。你正在阅读固定历史版本,已有引用仍可继续使用。
|
||
</Notice>
|
||
)}
|
||
<div className={s.readingLayout}>
|
||
<section className={s.panel}>
|
||
<Tabs
|
||
tabs={[
|
||
{ id: "overview", label: "文档概览" },
|
||
{ id: "xml", label: "XML 源文档" },
|
||
{ id: "versions", label: "版本记录", count: versions.length },
|
||
]}
|
||
value={tab}
|
||
onChange={setTab}
|
||
/>
|
||
{tab === "overview" ? (
|
||
<article className={s.readingArticle}>
|
||
<section
|
||
id="applies"
|
||
className={`${s.proseSection} ${s.appliesCard}`}
|
||
>
|
||
<h2>
|
||
<Target size={16} />
|
||
何时使用这篇文档
|
||
</h2>
|
||
<p>{doc.applies}</p>
|
||
</section>
|
||
<section id="content" className={s.proseSection}>
|
||
<h2>
|
||
<FileText size={16} />
|
||
指导正文
|
||
</h2>
|
||
<div className={s.proseBody}>{doc.body}</div>
|
||
</section>
|
||
{!!doc.steps.length && (
|
||
<section id="steps" className={s.proseSection}>
|
||
<h2>
|
||
<ListChecks size={17} />
|
||
建议步骤
|
||
</h2>
|
||
<ol className={s.stepList}>
|
||
{doc.steps.map((step, index) => (
|
||
<li key={index}>{step}</li>
|
||
))}
|
||
</ol>
|
||
</section>
|
||
)}
|
||
{!!doc.checks.length && (
|
||
<section id="checks" className={s.proseSection}>
|
||
<h2>
|
||
<CheckSquare size={16} />
|
||
完成检查
|
||
</h2>
|
||
<ul className={s.checkList}>
|
||
{doc.checks.map((check, index) => (
|
||
<li key={index}>{check}</li>
|
||
))}
|
||
</ul>
|
||
</section>
|
||
)}
|
||
<section id="related" className={s.proseSection}>
|
||
<h2>
|
||
<Link2 size={16} />
|
||
按需继续阅读
|
||
<span className={s.neutralBadge}>{links.length}</span>
|
||
</h2>
|
||
<p className={s.formDescription}>
|
||
只在条件适用、且当前任务需要时,打开下一篇文档。
|
||
</p>
|
||
{links.map((link, index) => {
|
||
const content = (
|
||
<>
|
||
<div>
|
||
<strong>{link.label}</strong>
|
||
<p>当{link.when}</p>
|
||
<small>
|
||
<span>{relationNames[link.relation]}</span>
|
||
<span>·</span>
|
||
<span>v{link.revision}</span>
|
||
{!link.resolved && (
|
||
<span>· 固定版本缺失,请跳过</span>
|
||
)}
|
||
</small>
|
||
</div>
|
||
{link.resolved && <ArrowUpRight size={15} />}
|
||
</>
|
||
);
|
||
return link.resolved ? (
|
||
<Link
|
||
className={s.relationCard}
|
||
key={index}
|
||
href={
|
||
(link.target.startsWith("user-")
|
||
? "/documents/"
|
||
: "/guide/") +
|
||
link.target +
|
||
"?revision=" +
|
||
link.revision
|
||
}
|
||
>
|
||
{content}
|
||
</Link>
|
||
) : (
|
||
<div
|
||
className={`${s.relationCard} ${s.unresolved}`}
|
||
key={index}
|
||
>
|
||
{content}
|
||
</div>
|
||
);
|
||
})}
|
||
{!links.length && (
|
||
<p className={s.proseBody}>
|
||
这篇文档可以独立使用,没有需要继续读取的关联文档。
|
||
</p>
|
||
)}
|
||
</section>
|
||
</article>
|
||
) : tab === "xml" ? (
|
||
<>
|
||
<div className={s.xmlToolbar}>
|
||
<span>{doc.id}.xml</span>
|
||
<CopyButton value={xml} label="复制 XML" />
|
||
</div>
|
||
<pre className={s.codeBlock}>{xml}</pre>
|
||
</>
|
||
) : (
|
||
<div className={s.versionsList}>
|
||
{versions.map((version) => (
|
||
<div className={s.versionRow} key={version.revision}>
|
||
<div>
|
||
<div className={s.buttonRow}>
|
||
<strong>版本 {version.revision}</strong>
|
||
{version.revision === doc.revision && (
|
||
<span className={s.neutralBadge}>当前阅读</span>
|
||
)}
|
||
</div>
|
||
<p>{dateLabel(version.createdAt)} · 固定版本</p>
|
||
</div>
|
||
<Link
|
||
className={s.textLink}
|
||
href={route + doc.id + "?revision=" + version.revision}
|
||
>
|
||
读取
|
||
<ChevronRight size={13} />
|
||
</Link>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</section>
|
||
<aside className={s.readingSidebar}>
|
||
<section className={s.sideCard}>
|
||
<h3>文档信息</h3>
|
||
<dl className={s.metadataList}>
|
||
<div>
|
||
<dt>类型</dt>
|
||
<dd>{kindNames[doc.kind]}</dd>
|
||
</div>
|
||
<div>
|
||
<dt>来源</dt>
|
||
<dd>
|
||
{source === "example"
|
||
? "啡跃协议样例"
|
||
: canEdit
|
||
? "你的文档"
|
||
: "社区作者"}
|
||
</dd>
|
||
</div>
|
||
<div>
|
||
<dt>当前版本</dt>
|
||
<dd>
|
||
<select
|
||
aria-label="选择阅读版本"
|
||
value={doc.revision}
|
||
onChange={(e) =>
|
||
void router.push(
|
||
route + doc.id + "?revision=" + e.target.value,
|
||
)
|
||
}
|
||
>
|
||
{versions.map((v) => (
|
||
<option key={v.revision} value={v.revision}>
|
||
v{v.revision}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</dd>
|
||
</div>
|
||
<div>
|
||
<dt>可见范围</dt>
|
||
<dd>{isWithdrawn ? "仅固定历史版本" : "公开读取"}</dd>
|
||
</div>
|
||
</dl>
|
||
<code className={s.machineAddress}>{doc.id}</code>
|
||
{canEdit && !isWithdrawn && (
|
||
<div className={s.authorActions}>
|
||
<Link
|
||
className={s.secondaryButton}
|
||
href={"/write?id=" + doc.id}
|
||
>
|
||
<Pencil size={13} />
|
||
编辑文档
|
||
</Link>
|
||
<button
|
||
type="button"
|
||
className={s.quietButton}
|
||
onClick={() => dialog.current?.showModal()}
|
||
>
|
||
<Trash2 size={13} />
|
||
撤下文档
|
||
</button>
|
||
</div>
|
||
)}
|
||
</section>
|
||
<section className={`${s.sideCard} ${s.tocCard}`}>
|
||
<h3>本篇目录</h3>
|
||
<nav className={s.toc} aria-label="本篇目录">
|
||
{sectionLinks.map(([id, label]) => (
|
||
<a key={id} href={"#" + id} onClick={() => setTab("overview")}>
|
||
{label}
|
||
</a>
|
||
))}
|
||
</nav>
|
||
</section>
|
||
<section className={s.sideCard}>
|
||
<h3>
|
||
<BookOpen size={15} /> 采用这一篇
|
||
</h3>
|
||
<p>
|
||
把固定版本的读取地址交给 Agent。关联文档是候选帮助,无需一起加载。
|
||
</p>
|
||
<code className={s.machineAddress}>{api}</code>
|
||
<CopyButton
|
||
label="复制读取地址"
|
||
value={() => window.location.origin + api}
|
||
/>
|
||
</section>
|
||
{!!backlinks.length && (
|
||
<section className={s.sideCard}>
|
||
<h3>被这些文档引用</h3>
|
||
<div className={s.sideLinks}>
|
||
{backlinks.map((b) => (
|
||
<Link
|
||
key={b.id}
|
||
href={
|
||
(b.source === "community" ? "/documents/" : "/guide/") +
|
||
b.id +
|
||
(b.revision ? "?revision=" + b.revision : "")
|
||
}
|
||
>
|
||
{b.title}
|
||
<ArrowUpRight size={11} />
|
||
</Link>
|
||
))}
|
||
</div>
|
||
</section>
|
||
)}
|
||
</aside>
|
||
</div>
|
||
<dialog ref={dialog} className={s.modal} aria-labelledby="withdraw-title">
|
||
<div className={s.modalBody}>
|
||
<h2 id="withdraw-title">撤下这篇文档?</h2>
|
||
<p>
|
||
它将不再出现在目录中。已发布的固定历史版本会保留,已有引用仍然可以访问。
|
||
</p>
|
||
{error && <Notice tone="error">{error}</Notice>}
|
||
</div>
|
||
<div className={s.modalActions}>
|
||
<button
|
||
type="button"
|
||
disabled={busy}
|
||
className={s.secondaryButton}
|
||
onClick={() => dialog.current?.close()}
|
||
>
|
||
取消
|
||
</button>
|
||
<button
|
||
type="button"
|
||
disabled={busy}
|
||
className={s.dangerButton}
|
||
onClick={() => void withdraw()}
|
||
>
|
||
{busy ? "正在撤下…" : "确认撤下"}
|
||
</button>
|
||
</div>
|
||
</dialog>
|
||
</GuidanceShell>
|
||
);
|
||
}
|