286 lines
11 KiB
Plaintext
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>
|
|
);
|
|
};
|