"use client"; import { ArrowDown, ArrowUp, ExternalLink, Plus, Trash2 } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { AdminSettingsActions, AdminSettingsGroup } from "@/components/admin"; import { apiUpdateSiteSettings, type PublicSettings } from "@/lib/api"; import { BRAND_LIMITS, FOOTER_LINKS_ALIGNS, footerURLAllowed, isExternalFooterURL, type FooterLink, type FooterLinksAlign, } from "@/lib/brand"; import { toast } from "@/lib/toast"; function clone(links: FooterLink[]): FooterLink[] { return links.map((l) => ({ ...l })); } function zoneOf(links: FooterLink[], align: FooterLinksAlign): FooterLink[] { return links.filter((l) => (l.align || "right") === align && l.label.trim() && l.url.trim()); } export default function FooterLinksPanel({ initial, onSaved, onDirtyChange, }: { initial: PublicSettings; onSaved?: (next: PublicSettings) => void; onDirtyChange?: (dirty: boolean) => void; }) { const [links, setLinks] = useState(() => clone(initial.footer_links)); const [savedLinks, setSavedLinks] = useState(() => clone(initial.footer_links)); const [saving, setSaving] = useState(false); const [error, setError] = useState(""); const saveGen = useRef(0); const dirty = JSON.stringify(links) !== JSON.stringify(savedLinks); const left = zoneOf(links, "left"); const center = zoneOf(links, "center"); const right = zoneOf(links, "right"); const hasPreview = left.length + center.length + right.length > 0; useEffect(() => { onDirtyChange?.(dirty); }, [dirty, onDirtyChange]); const update = (i: number, patch: Partial) => { setError(""); setLinks((prev) => prev.map((item, idx) => (idx === i ? { ...item, ...patch } : item))); }; const discard = () => { setLinks(clone(savedLinks)); setError(""); }; const save = async () => { if (saving || !dirty) return; for (const item of links) { if (!item.label.trim()) { setError("链接文案不能为空"); toast("链接文案不能为空"); return; } if (!footerURLAllowed(item.url)) { setError(`地址无效:${item.url || "(空)"}`); toast(`地址无效:${item.url || "(空)"}`); return; } } setError(""); const snapLinks = clone(links).map((item) => ({ label: item.label.trim(), url: item.url.trim(), new_tab: item.new_tab, align: item.align || "right", })); const ticket = ++saveGen.current; setSaving(true); try { const res = await apiUpdateSiteSettings({ footer_links: snapLinks, }); if (ticket !== saveGen.current) return; setSavedLinks(clone(res.footer_links)); setLinks((prev) => (JSON.stringify(prev) === JSON.stringify(links) ? clone(res.footer_links) : prev)); onSaved?.(res); toast("已保存", "ok"); } catch (e) { if (ticket !== saveGen.current) return; const msg = e instanceof Error ? e.message : "保存失败"; setError(msg); toast(msg); } finally { if (ticket === saveGen.current) setSaving(false); } }; const move = (i: number, dir: -1 | 1) => { setLinks((prev) => { const j = i + dir; if (j < 0 || j >= prev.length) return prev; const next = [...prev]; const [row] = next.splice(i, 1); next.splice(j, 0, row); return next; }); }; const renderPreviewLinks = (items: FooterLink[]) => items.map((item) => ( {item.label.trim()} {item.new_tab ? : null} )); return ( void save()} onDiscard={discard} /> } >
© {new Date().getFullYear()} {initial.site_name} {left.length > 0 ? : null}
{center.length > 0 ? ( ) : null}
{right.length > 0 ? ( ) : !hasPreview ? ( 暂无链接 ) : null}
# 文案 地址 位置 打开方式 操作
{links.length === 0 ? (

暂无链接,可添加备案号、用户协议等

) : ( links.map((item, i) => (
{i + 1}
update(i, { label: e.target.value })} placeholder="如:京 ICP 备…" aria-label={`第 ${i + 1} 条文案`} />
{ const v = e.target.value; const wasExt = isExternalFooterURL(item.url); const nowExt = isExternalFooterURL(v); update(i, { url: v, new_tab: !wasExt && nowExt ? true : item.new_tab }); }} placeholder="https://… 或 /about" aria-label={`第 ${i + 1} 条地址`} />
{FOOTER_LINKS_ALIGNS.map((opt) => ( ))}
)) )}
); }