Files
superwork/vscode/src/codeReader/fixtures/legalDocPage.tsx.txt
T

286 lines
11 KiB
Plaintext

"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import {
ChevronLeft,
FileText,
Printer,
ShieldCheck,
Sparkles,
} from "lucide-react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { useSiteConfig } from "@/lib/site-config";
interface LegalDocPageProps {
/** terms = 用户服务协议; privacy = 隐私政策 */
doc: "terms" | "privacy";
}
const DOC_TITLE: Record<LegalDocPageProps["doc"], string> = {
terms: "用户服务协议",
privacy: "隐私政策",
};
/** 由 ## 标题文本生成稳定锚点 id; 中文可直接作为 HTML id, querySelector 用属性选择器规避转义. */
const slugify = (raw: string): string => `sec-${raw.trim().replace(/\s+/g, "-")}`;
/** 递归取 ReactMarkdown 传入的 heading children 的纯文本, 用于生成与 TOC 一致的 id. */
const nodeText = (node: React.ReactNode): string => {
if (typeof node === "string" || typeof node === "number") return String(node);
if (Array.isArray(node)) return node.map(nodeText).join("");
if (node && typeof node === "object" && "props" in node) {
return nodeText((node as React.ReactElement<{ children?: React.ReactNode }>).props.children);
}
return "";
};
/** 从 markdown 抽取全部 ## 二级标题, 作为左侧目录数据源 (顺序即正文顺序). */
const extractHeadings = (md: string): { id: string; title: string }[] => {
const out: { id: string; title: string }[] = [];
for (const line of md.split("\n")) {
const m = /^##\s+(.+?)\s*$/.exec(line);
if (m) out.push({ id: slugify(m[1]), title: m[1] });
}
return out;
};
/**
* 协议正文页面 (服务协议 / 隐私政策共享).
*
* 正文为 Markdown, 由运营在 admin 站点配置页维护, 经 sys_config(site 组)下发,
* 这里通过 `useSiteConfig()` 读取 —— 不再硬编码文案。左侧目录从正文的 ## 标题自动生成。
*/
export const LegalDocPage = ({ doc }: LegalDocPageProps) => {
const cfg = useSiteConfig();
const router = useRouter();
const markdown = doc === "terms" ? cfg.termsContent : cfg.privacyContent;
const headings = useMemo(() => extractHeadings(markdown), [markdown]);
const scrollRef = useRef<HTMLDivElement>(null);
const [activeSection, setActiveSection] = useState<string>(
headings[0]?.id ?? "",
);
// doc 切换时渲染期重置高亮 (React 官方 in-render reset 模式, 避免 effect 内 setState 级联渲染).
const [prevDoc, setPrevDoc] = useState(doc);
if (doc !== prevDoc) {
setPrevDoc(doc);
setActiveSection(headings[0]?.id ?? "");
}
// 切换文档时正文滚回顶部 (DOM 副作用).
useEffect(() => {
scrollRef.current?.scrollTo({ top: 0 });
}, [doc]);
// 滚动时按 heading.top 计算当前高亮章节.
useEffect(() => {
const container = scrollRef.current;
if (!container) return;
const handleScroll = () => {
const hs = Array.from(container.querySelectorAll<HTMLElement>("h2[id]"));
// 尾部章节比视口短时 heading 永远到不了激活线, 滚到底就直接高亮最后一章.
const atBottom =
container.scrollTop + container.clientHeight >=
container.scrollHeight - 1;
let currentId = hs[0]?.id ?? "";
if (atBottom) {
currentId = hs[hs.length - 1]?.id ?? currentId;
} else {
for (const h of hs) {
if (h.getBoundingClientRect().top <= 150) currentId = h.id;
}
}
setActiveSection(currentId);
};
container.addEventListener("scroll", handleScroll, { passive: true });
return () => container.removeEventListener("scroll", handleScroll);
}, [doc, headings]);
const handleSectionClick = (id: string) => {
const container = scrollRef.current;
const target = container?.querySelector<HTMLElement>(`[id="${id}"]`);
if (container && target) {
container.scrollTo({ top: target.offsetTop - 40, behavior: "smooth" });
}
};
const handlePrint = () => {
if (typeof window !== "undefined") window.print();
};
const handleBack = () => {
if (typeof window !== "undefined" && window.history.length > 1) {
window.history.back();
return;
}
router.push("/home");
};
return (
<div className="flex flex-col h-screen w-full bg-surface text-foreground font-sans antialiased overflow-hidden">
{/* 顶部导航栏: 返回 + 文档切换 + 打印 */}
<header className="h-[60px] md:h-[72px] shrink-0 border-b border-border flex items-center justify-between px-6 md:px-10 bg-surface/80 backdrop-blur-md z-50">
<div className="flex items-center gap-6">
<button
type="button"
onClick={handleBack}
aria-label="返回"
className="flex items-center justify-center w-8 h-8 rounded-full hover:bg-background text-foreground transition-colors -ml-2"
>
<ChevronLeft size={20} />
</button>
<div className="hidden md:flex items-center gap-2">
<div className="w-8 h-8 bg-primary rounded-lg flex items-center justify-center shadow-sm">
<Sparkles size={16} className="text-primary-foreground" />
</div>
<span className="font-semibold tracking-tight">法律与隐私</span>
</div>
</div>
{/* 双文档切换 (Apple Segmented Control) */}
<div className="flex bg-background p-1 rounded-full w-fit">
<Link
href="/terms"
className={`flex items-center gap-1.5 px-4 md:px-6 py-1.5 rounded-full text-[13px] md:text-[14px] font-semibold transition-all duration-300 ${
doc === "terms"
? "bg-surface text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
}`}
>
<FileText size={16} />
<span>服务协议</span>
</Link>
<Link
href="/privacy"
className={`flex items-center gap-1.5 px-4 md:px-6 py-1.5 rounded-full text-[13px] md:text-[14px] font-semibold transition-all duration-300 ${
doc === "privacy"
? "bg-surface text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
}`}
>
<ShieldCheck size={16} />
<span>隐私政策</span>
</Link>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={handlePrint}
title="打印"
aria-label="打印"
className="w-10 h-10 rounded-full hover:bg-background flex items-center justify-center text-muted-foreground hover:text-foreground transition-colors"
>
<Printer size={18} />
</button>
</div>
</header>
{/* 主体阅读区 (双栏布局) */}
<div className="flex-1 flex overflow-hidden bg-surface max-w-[1200px] mx-auto w-full">
{/* 左侧 TOC, 仅 lg 显示 */}
<aside className="hidden lg:block w-[280px] h-full shrink-0 border-r border-border py-10 pr-8 overflow-y-auto legal-scroll">
<h3 className="text-[12px] font-bold text-muted-foreground tracking-widest uppercase mb-6 pl-4">
目录
</h3>
<nav className="flex flex-col relative">
<div className="absolute left-[3px] top-2 bottom-2 w-px bg-border -z-10" />
{headings.map((h) => {
const isActive = activeSection === h.id;
return (
<div key={h.id} className="relative flex items-center mb-1">
<div
className={`absolute left-[2px] w-[3px] h-4 rounded-full transition-colors duration-300 ${
isActive ? "bg-primary" : "bg-transparent"
}`}
/>
<button
type="button"
onClick={() => handleSectionClick(h.id)}
className={`w-full text-left pl-6 py-2.5 rounded-lg text-[14px] transition-colors ${
isActive
? "font-semibold text-foreground bg-background"
: "font-medium text-muted-foreground hover:text-foreground hover:bg-background/50"
}`}
>
{h.title}
</button>
</div>
);
})}
</nav>
</aside>
{/* 右侧正文 */}
<main
ref={scrollRef}
className="flex-1 h-full overflow-y-auto px-6 md:px-16 lg:px-20 py-12 scroll-smooth legal-scroll"
>
<div className="max-w-[720px] pb-32">
<div className="mb-12 border-b border-border pb-10">
<h1 className="text-[36px] md:text-[44px] font-bold tracking-tight text-foreground leading-tight">
{DOC_TITLE[doc]}
</h1>
</div>
{markdown.trim() ? (
<div className="markdown-body text-[15px] leading-loose">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
h2: ({ children }) => (
<h2
id={slugify(nodeText(children))}
className="text-[20px] font-bold text-foreground mt-12 mb-4 tracking-tight scroll-mt-8"
>
{children}
</h2>
),
a: ({ href, children }) => (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
{children}
</a>
),
}}
>
{markdown}
</ReactMarkdown>
</div>
) : (
<p className="text-[14px] text-muted-foreground">
{DOC_TITLE[doc]}内容尚未配置, 请在管理后台“站点配置”中填写。
</p>
)}
</div>
</main>
</div>
{/* 打印态与滚动条样式 */}
<style
dangerouslySetInnerHTML={{
__html: `
@media print {
aside, header { display: none !important; }
main { overflow: visible !important; padding: 0 !important; height: auto !important; }
}
.legal-scroll::-webkit-scrollbar { width: 6px; }
.legal-scroll::-webkit-scrollbar-track { background: transparent; }
.legal-scroll::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 10px; }
.legal-scroll::-webkit-scrollbar-thumb:hover { background: var(--muted-foreground); }
`,
}}
/>
</div>
);
};