"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 = { 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(null); const [activeSection, setActiveSection] = useState( 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("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(`[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 (
{/* 顶部导航栏: 返回 + 文档切换 + 打印 */}
法律与隐私
{/* 双文档切换 (Apple Segmented Control) */}
服务协议 隐私政策
{/* 主体阅读区 (双栏布局) */}
{/* 左侧 TOC, 仅 lg 显示 */} {/* 右侧正文 */}

{DOC_TITLE[doc]}

{markdown.trim() ? (
(

{children}

), a: ({ href, children }) => ( {children} ), }} > {markdown}
) : (

{DOC_TITLE[doc]}内容尚未配置, 请在管理后台“站点配置”中填写。

)}
{/* 打印态与滚动条样式 */}