496 lines
16 KiB
TypeScript
496 lines
16 KiB
TypeScript
import Link from "next/link";
|
||
import { useRouter } from "next/router";
|
||
import {
|
||
useCallback,
|
||
useEffect,
|
||
useRef,
|
||
useState,
|
||
type FormEvent,
|
||
} from "react";
|
||
import {
|
||
ArrowRight,
|
||
ArrowUpRight,
|
||
Bot,
|
||
ChevronRight,
|
||
FileText,
|
||
Plus,
|
||
Search,
|
||
X,
|
||
} from "lucide-react";
|
||
import GuidanceShell, { useGuidanceAccount } from "@/components/GuidanceShell";
|
||
import {
|
||
EmptyState,
|
||
KindBadge,
|
||
KindIcon,
|
||
Notice,
|
||
PageHeading,
|
||
kindNames,
|
||
Tabs,
|
||
} from "@/components/GuidanceUI";
|
||
import { guides, type Guidance } from "@/content/guides/catalog";
|
||
import s from "@/styles/guidance.module.css";
|
||
|
||
type Summary = {
|
||
id: string;
|
||
revision: number;
|
||
title: string;
|
||
summary: string;
|
||
kind: Guidance["kind"];
|
||
author?: string;
|
||
tags?: string[];
|
||
source: "community" | "example";
|
||
};
|
||
type Source = "all" | "published" | "examples";
|
||
const kinds = ["all", "guide", "context", "evidence"] as const;
|
||
const examples: Summary[] = guides.map((g) => ({ ...g, source: "example" }));
|
||
export default function DocumentDirectory({
|
||
communityOnly = false,
|
||
}: {
|
||
communityOnly?: boolean;
|
||
}) {
|
||
const router = useRouter();
|
||
const mine = communityOnly && router.query.scope === "mine";
|
||
return (
|
||
<GuidanceShell
|
||
title={mine ? "我的文档" : communityOnly ? "社区文档" : "探索文档"}
|
||
>
|
||
<DirectoryContent communityOnly={communityOnly} mine={mine} />
|
||
</GuidanceShell>
|
||
);
|
||
}
|
||
function DirectoryContent({
|
||
communityOnly,
|
||
mine,
|
||
}: {
|
||
communityOnly: boolean;
|
||
mine: boolean;
|
||
}) {
|
||
const router = useRouter(),
|
||
account = useGuidanceAccount();
|
||
const [published, setPublished] = useState<Summary[]>([]),
|
||
[loading, setLoading] = useState(true),
|
||
[error, setError] = useState("");
|
||
const [q, setQ] = useState(""),
|
||
[kind, setKind] = useState("all"),
|
||
[source, setSource] = useState<Source>(communityOnly ? "published" : "all"),
|
||
[sort, setSort] = useState("default");
|
||
const controller = useRef<AbortController | null>(null);
|
||
const refresh = useCallback(async () => {
|
||
controller.current?.abort();
|
||
const abort = new AbortController();
|
||
controller.current = abort;
|
||
setLoading(true);
|
||
setError("");
|
||
try {
|
||
const r = await fetch(
|
||
"/api/documents" +
|
||
(mine && account.user ? "?author=" + account.user.id : ""),
|
||
{ signal: abort.signal },
|
||
);
|
||
const b = await r.json();
|
||
if (!r.ok) throw Error(b.error || "目录暂时不可用");
|
||
if (!Array.isArray(b.documents)) throw Error("目录返回异常,请稍后重试");
|
||
setPublished(
|
||
b.documents.map((d: Omit<Summary, "source">) => ({
|
||
...d,
|
||
source: "community",
|
||
})),
|
||
);
|
||
} catch (e) {
|
||
if (!abort.signal.aborted)
|
||
setError(e instanceof Error ? e.message : "目录暂时不可用");
|
||
} finally {
|
||
if (!abort.signal.aborted) setLoading(false);
|
||
}
|
||
}, [mine, account.user?.id]);
|
||
useEffect(() => {
|
||
void refresh();
|
||
return () => controller.current?.abort();
|
||
}, [refresh]);
|
||
useEffect(() => {
|
||
if (!router.isReady) return;
|
||
setQ(typeof router.query.q === "string" ? router.query.q : "");
|
||
setKind(
|
||
typeof router.query.kind === "string" &&
|
||
kinds.some((k) => k === router.query.kind)
|
||
? router.query.kind
|
||
: "all",
|
||
);
|
||
setSource(
|
||
communityOnly
|
||
? "published"
|
||
: router.query.source === "published" ||
|
||
router.query.source === "examples"
|
||
? router.query.source
|
||
: "all",
|
||
);
|
||
setSort(router.query.sort === "title" ? "title" : "default");
|
||
}, [
|
||
router.isReady,
|
||
router.query.q,
|
||
router.query.kind,
|
||
router.query.source,
|
||
router.query.sort,
|
||
communityOnly,
|
||
]);
|
||
function remember(
|
||
next: { q?: string; kind?: string; source?: Source; sort?: string } = {},
|
||
) {
|
||
const values = { q, kind, source, sort, ...next },
|
||
query: Record<string, string> = {};
|
||
if (values.q) query.q = values.q;
|
||
if (values.kind !== "all") query.kind = values.kind;
|
||
if (!communityOnly && values.source !== "all") query.source = values.source;
|
||
if (values.sort !== "default") query.sort = values.sort;
|
||
if (mine) query.scope = "mine";
|
||
void router.push(
|
||
{ pathname: communityOnly ? "/documents" : "/", query },
|
||
undefined,
|
||
{ shallow: true, scroll: false },
|
||
);
|
||
}
|
||
function search(e: FormEvent) {
|
||
e.preventDefault();
|
||
remember();
|
||
}
|
||
function clear() {
|
||
setQ("");
|
||
setKind("all");
|
||
setSource(communityOnly ? "published" : "all");
|
||
setSort("default");
|
||
remember({
|
||
q: "",
|
||
kind: "all",
|
||
source: communityOnly ? "published" : "all",
|
||
sort: "default",
|
||
});
|
||
}
|
||
const all: Summary[] = communityOnly
|
||
? published
|
||
: [...published, ...examples];
|
||
const term = q.trim().toLocaleLowerCase();
|
||
const found = all.filter(
|
||
(d) =>
|
||
(!mine || d.author === account.user?.id) &&
|
||
(source === "all" ||
|
||
(source === "published" && d.source === "community") ||
|
||
(source === "examples" && d.source === "example")) &&
|
||
`${d.title} ${d.summary} ${d.id} ${d.tags?.join(" ") || ""}`
|
||
.toLocaleLowerCase()
|
||
.includes(term),
|
||
);
|
||
const documents = found.filter((d) => kind === "all" || d.kind === kind);
|
||
if (sort === "title")
|
||
documents.sort((a, b) => a.title.localeCompare(b.title, "zh-CN"));
|
||
const waiting = loading || (mine && account.loading),
|
||
filtered =
|
||
!!q ||
|
||
kind !== "all" ||
|
||
(!communityOnly && source !== "all") ||
|
||
sort !== "default";
|
||
const heading = mine ? "我的文档" : communityOnly ? "社区文档" : "探索文档";
|
||
return (
|
||
<>
|
||
<PageHeading
|
||
title={heading}
|
||
description={
|
||
mine
|
||
? "在这里管理你发布的文档,修改会创建新的固定版本。"
|
||
: communityOnly
|
||
? "来自真实作者的独立文档。找到有用的知识,从一篇开始阅读。"
|
||
: "为当前任务找到有用的指导,按需连接下一篇知识。"
|
||
}
|
||
eyebrow={
|
||
mine
|
||
? "YOUR KNOWLEDGE"
|
||
: communityOnly
|
||
? "COMMUNITY LIBRARY"
|
||
: "DISCOVER KNOWLEDGE"
|
||
}
|
||
>
|
||
<Link
|
||
className={s.secondaryButton}
|
||
href="/agent/index.md"
|
||
target="_blank"
|
||
>
|
||
<Bot size={15} />
|
||
Agent 入口
|
||
<ArrowUpRight size={13} />
|
||
</Link>
|
||
</PageHeading>
|
||
{!communityOnly && (
|
||
<>
|
||
<section className={s.introCard}>
|
||
<span className={s.introIcon}>
|
||
<Bot size={23} />
|
||
</span>
|
||
<div>
|
||
<h2>好用的知识,从不必整套加载。</h2>
|
||
<p>
|
||
每篇文档都有适用条件和固定版本。需要更多时,再沿着有用的链接继续阅读。
|
||
</p>
|
||
</div>
|
||
<div className={s.introDiagram} aria-hidden="true">
|
||
<span>
|
||
<FileText size={13} />
|
||
一篇文档
|
||
</span>
|
||
<ArrowRight size={15} />
|
||
<span>
|
||
<FileText size={13} />
|
||
下一页
|
||
</span>
|
||
</div>
|
||
<Link className={s.textLink} href="/protocol">
|
||
了解工作原理
|
||
<ArrowUpRight size={13} />
|
||
</Link>
|
||
</section>
|
||
<div className={s.topics}>
|
||
<span>试试这些任务</span>
|
||
{["代码审查", "规划", "验证"].map((t) => (
|
||
<button
|
||
type="button"
|
||
key={t}
|
||
onClick={() => {
|
||
setQ(t);
|
||
remember({ q: t });
|
||
}}
|
||
>
|
||
{t}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</>
|
||
)}
|
||
{mine && !account.loading && !account.user ? (
|
||
<section className={s.panel}>
|
||
{account.error && (
|
||
<div className={s.panelBody}>
|
||
<Notice tone="error">
|
||
{account.error}{" "}
|
||
<button type="button" onClick={() => void account.refresh()}>
|
||
重新检查
|
||
</button>
|
||
</Notice>
|
||
</div>
|
||
)}
|
||
<EmptyState
|
||
title="登录后,管理你的知识"
|
||
description="使用已验证的邮箱账户发布文档、更新版本,并随时回到这里继续编辑。"
|
||
>
|
||
<Link
|
||
className={s.primaryButton}
|
||
href="/account?next=%2Fdocuments%3Fscope%3Dmine"
|
||
>
|
||
登录 / 创建账户
|
||
<ArrowRight size={15} />
|
||
</Link>
|
||
</EmptyState>
|
||
</section>
|
||
) : (
|
||
<>
|
||
<section className={s.panel} aria-label="文档目录">
|
||
<Tabs
|
||
tabs={kinds.map((k) => ({
|
||
id: k,
|
||
label: k === "all" ? "全部文档" : kindNames[k],
|
||
count: found.filter((d) => k === "all" || d.kind === k).length,
|
||
}))}
|
||
value={kind}
|
||
onChange={(value) => {
|
||
setKind(value);
|
||
remember({ kind: value });
|
||
}}
|
||
/>
|
||
<div className={s.catalogToolbar}>
|
||
<form className={s.catalogSearch} role="search" onSubmit={search}>
|
||
<Search size={16} />
|
||
<input
|
||
aria-label="搜索文档"
|
||
placeholder="搜索标题、摘要或文档 ID…"
|
||
value={q}
|
||
onChange={(e) => setQ(e.target.value)}
|
||
/>
|
||
{q && (
|
||
<button
|
||
type="button"
|
||
aria-label="清空关键词"
|
||
onClick={() => {
|
||
setQ("");
|
||
remember({ q: "" });
|
||
}}
|
||
>
|
||
<X size={14} />
|
||
</button>
|
||
)}
|
||
<button type="submit" aria-label="提交搜索">
|
||
<ArrowRight size={15} />
|
||
</button>
|
||
</form>
|
||
<div className={s.catalogTools}>
|
||
{!communityOnly && (
|
||
<select
|
||
aria-label="文档来源"
|
||
value={source}
|
||
onChange={(e) => {
|
||
const value = e.target.value as Source;
|
||
setSource(value);
|
||
remember({ source: value });
|
||
}}
|
||
>
|
||
<option value="all">全部来源</option>
|
||
<option value="published">社区发布</option>
|
||
<option value="examples">协议样例</option>
|
||
</select>
|
||
)}
|
||
<select
|
||
aria-label="文档排序"
|
||
value={sort}
|
||
onChange={(e) => {
|
||
setSort(e.target.value);
|
||
remember({ sort: e.target.value });
|
||
}}
|
||
>
|
||
<option value="default">默认顺序</option>
|
||
<option value="title">按标题排序</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div className={s.catalogStatus}>
|
||
<p role="status">
|
||
{waiting
|
||
? "正在加载文档…"
|
||
: error
|
||
? `当前显示 ${documents.length} 篇,社区目录加载失败`
|
||
: `找到 ${documents.length} 篇文档`}
|
||
</p>
|
||
{filtered ? (
|
||
<button type="button" aria-label="清除所有筛选" onClick={clear}>
|
||
清除所有筛选
|
||
</button>
|
||
) : (
|
||
<span>最多展示最近 100 篇社区文档</span>
|
||
)}
|
||
</div>
|
||
{error && (
|
||
<div className={s.panelBody}>
|
||
<Notice tone="error">
|
||
{error}{" "}
|
||
<button
|
||
type="button"
|
||
className={s.textLink}
|
||
onClick={() => void refresh()}
|
||
>
|
||
重新加载
|
||
</button>
|
||
</Notice>
|
||
</div>
|
||
)}
|
||
<div className={s.tableHeader} aria-hidden="true">
|
||
<span>文档</span>
|
||
<span>类型</span>
|
||
<span>版本</span>
|
||
<span className={s.tableSource}>来源</span>
|
||
<span />
|
||
</div>
|
||
{waiting && (!documents.length || communityOnly) ? (
|
||
<div aria-label="正在加载文档" aria-busy="true">
|
||
{[0, 1, 2].map((i) => (
|
||
<div className={s.skeletonRow} key={i}>
|
||
<div className={s.skeletonBox} />
|
||
<div className={s.skeletonLines}>
|
||
<div className={s.skeletonLine} />
|
||
<div className={s.skeletonLine} />
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : (
|
||
documents.map((d) => (
|
||
<Link
|
||
href={`${d.source === "example" ? "/guide/" : "/documents/"}${d.id}?revision=${d.revision}`}
|
||
key={d.id}
|
||
className={s.documentRow}
|
||
>
|
||
<div className={s.documentPrimary}>
|
||
<span className={s.documentIcon}>
|
||
<KindIcon kind={d.kind} />
|
||
</span>
|
||
<div className={s.documentDetails}>
|
||
<h2 className={s.documentTitle}>{d.title}</h2>
|
||
<p className={s.documentSummary}>{d.summary}</p>
|
||
<p className={s.documentId} title={d.id}>
|
||
{d.id}
|
||
</p>
|
||
<div className={s.documentMobileMeta}>
|
||
<KindBadge kind={d.kind} />
|
||
<span>v{d.revision}</span>
|
||
<span>
|
||
{d.source === "example" ? "协议样例" : "社区发布"}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<span className={s.documentKind}>
|
||
<KindBadge kind={d.kind} />
|
||
</span>
|
||
<span className={s.documentVersion}>v{d.revision}</span>
|
||
<span className={s.documentSource}>
|
||
{d.source === "example" ? "协议样例" : "社区发布"}
|
||
</span>
|
||
<ChevronRight size={16} className={s.documentArrow} />
|
||
</Link>
|
||
))
|
||
)}
|
||
{!waiting && !documents.length && !error && (
|
||
<EmptyState
|
||
title={
|
||
filtered
|
||
? "没有匹配文档"
|
||
: mine
|
||
? "你的第一篇文档,从这里开始"
|
||
: "这里还没有社区文档"
|
||
}
|
||
description={
|
||
filtered
|
||
? "试试其他关键词或清除筛选,发现更多适合当前任务的知识。"
|
||
: mine
|
||
? "把你反复使用的方法写下来,让 Agent 在需要时找到它。"
|
||
: "你可以先阅读协议样例,也可以发布第一篇独立文档。"
|
||
}
|
||
>
|
||
{filtered ? (
|
||
<button
|
||
type="button"
|
||
className={s.secondaryButton}
|
||
onClick={clear}
|
||
>
|
||
重置筛选
|
||
</button>
|
||
) : (
|
||
<Link className={s.primaryButton} href="/write">
|
||
<Plus size={15} />
|
||
发布一篇文档
|
||
</Link>
|
||
)}
|
||
</EmptyState>
|
||
)}
|
||
</section>
|
||
<div className={s.catalogFootnote}>
|
||
<span>
|
||
{mine
|
||
? "只有你可以更新自己的文档,已发布的历史版本保持不变。"
|
||
: "社区文档与协议样例分别标识,采用前请确认适用条件与版本。"}
|
||
</span>
|
||
<Link href="/protocol">
|
||
了解读取规范
|
||
<ArrowUpRight size={12} />
|
||
</Link>
|
||
</div>
|
||
</>
|
||
)}
|
||
</>
|
||
);
|
||
}
|