"use client"; import { ArrowDown, ArrowUp, BookOpen, ExternalLink, Home, Plus, Trash2, Trophy } from "lucide-react"; import { useEffect, useState } from "react"; import { AdminSettingsActions, AdminSettingsGroup, AdminSwitch } from "@/components/admin"; import { apiUpdateSiteSettings, type PublicSettings } from "@/lib/api"; import { HEADER_NAV_BUILTIN_KEYS, HEADER_NAV_BUILTIN_LABELS, HEADER_NAV_LIMITS, isHeaderNavExternal, type HeaderNavBuiltinKey, type HeaderNavItem, } from "@/lib/headerNav"; import { footerURLAllowed } from "@/lib/brand"; import { toast } from "@/lib/toast"; const BUILTIN_ICONS: Record = { home: , leaderboard: , library: , }; /** 内置项目标页说明(实际地址随伪静态风格变化,不允许改地址) */ const BUILTIN_HINTS: Record = { home: "站点首页 /", leaderboard: "排行榜页(地址随伪静态风格变化)", library: "书库页(地址随伪静态风格变化)", }; type BuiltinSwitches = Record; function clone(items: HeaderNavItem[]): HeaderNavItem[] { return items.map((item) => ({ ...item })); } function switchesOf(items: HeaderNavItem[]): BuiltinSwitches { const present = new Set( items.filter((it): it is Extract => it.type === "builtin").map((it) => it.key), ); return { home: present.has("home"), leaderboard: present.has("leaderboard"), library: present.has("library"), }; } /** 完整行列表:已启用项按配置顺序在前,未启用的内置项按默认顺序追加在后 */ function rowsOf(items: HeaderNavItem[]): HeaderNavItem[] { const present = new Set( items.filter((it): it is Extract => it.type === "builtin").map((it) => it.key), ); const hidden = HEADER_NAV_BUILTIN_KEYS.filter((key) => !present.has(key)).map((key) => ({ type: "builtin" as const, key, })); return [...clone(items), ...hidden]; } /** * 桌面端 Header 品牌名右侧的主导航:排序、开关显隐内置项、增删自定义链接,整体替换保存。 * 与移动端底栏面板同款交互:操作按钮居右,内置项开关隐藏(隐藏项保留排序位置)。 */ export default function HeaderNavPanel({ initial, onSaved, onDirtyChange, }: { initial: PublicSettings; onSaved?: (next: PublicSettings) => void; onDirtyChange?: (dirty: boolean) => void; }) { const [rows, setRows] = useState(() => rowsOf(initial.header_nav)); const [on, setOn] = useState(() => switchesOf(initial.header_nav)); const [savedPayload, setSavedPayload] = useState(() => clone(initial.header_nav)); const [saving, setSaving] = useState(false); const [error, setError] = useState(""); // 新增自定义行挂载后自动聚焦名称输入框 const [focusIdx, setFocusIdx] = useState(null); // 提交负载 = 自定义链接 + 开关打开的内置项,按行顺序 const payload: HeaderNavItem[] = rows.filter((item) => item.type === "custom" || on[item.key]); const dirty = JSON.stringify(payload) !== JSON.stringify(savedPayload); const atCap = payload.length >= HEADER_NAV_LIMITS.items; useEffect(() => { onDirtyChange?.(dirty); }, [dirty, onDirtyChange]); useEffect(() => { if (focusIdx !== null) setFocusIdx(null); }, [focusIdx]); const move = (i: number, dir: -1 | 1) => { setError(""); setRows((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 removeAt = (i: number) => { setError(""); setRows((prev) => prev.filter((_, idx) => idx !== i)); }; const addCustom = () => { if (atCap) return; setError(""); setFocusIdx(rows.length); setRows((prev) => [...prev, { type: "custom", label: "", url: "", new_tab: false }]); }; const updateCustom = (i: number, patch: Partial>) => { setError(""); setRows((prev) => prev.map((item, idx) => (idx === i && item.type === "custom" ? { ...item, ...patch } : item))); }; const discard = () => { setRows(rowsOf(savedPayload)); setOn(switchesOf(savedPayload)); setError(""); }; const save = async () => { if (saving || !dirty) return; for (const item of payload) { if (item.type !== "custom") continue; if (!item.label.trim()) { setError("自定义菜单名称不能为空"); toast("自定义菜单名称不能为空"); return; } if (!footerURLAllowed(item.url)) { setError(`地址无效:${item.url || "(空)"}`); toast(`地址无效:${item.url || "(空)"}`); return; } } const body: HeaderNavItem[] = payload.map((item) => item.type === "custom" ? { type: "custom", label: item.label.trim(), url: item.url.trim(), new_tab: item.new_tab } : { type: "builtin", key: item.key }, ); setSaving(true); try { const res = await apiUpdateSiteSettings({ header_nav: body }); setSavedPayload(clone(res.header_nav)); setRows(rowsOf(res.header_nav)); setOn(switchesOf(res.header_nav)); onSaved?.(res); toast("已保存,顶部导航已更新", "ok"); } catch (e) { const msg = e instanceof Error ? e.message : "保存失败"; setError(msg); toast(msg); } finally { setSaving(false); } }; return ( void save()} onDiscard={discard} /> } >
{rows.map((item, i) => { const enabled = item.type === "custom" || on[item.key]; return (
{item.type === "builtin" ? ( <> {BUILTIN_ICONS[item.key]} {HEADER_NAV_BUILTIN_LABELS[item.key]} {BUILTIN_HINTS[item.key]} ) : ( <>
updateCustom(i, { label: e.target.value })} placeholder="菜单名称" aria-label={`第 ${i + 1} 个自定义菜单名称`} autoFocus={focusIdx === i} />
{ const v = e.target.value; const wasExt = isHeaderNavExternal(item.url); const nowExt = isHeaderNavExternal(v); updateCustom(i, { url: v, new_tab: !wasExt && nowExt ? true : item.new_tab }); }} placeholder="https://… 或 /p/about" aria-label={`第 ${i + 1} 个自定义菜单地址`} />
)}
{item.type === "builtin" ? ( { setError(""); setOn((prev) => ({ ...prev, [item.key]: !prev[item.key] })); }} label={`顶部导航显示${HEADER_NAV_BUILTIN_LABELS[item.key]}`} /> ) : ( )}
); })}
  • 内置菜单(首页/排行榜/书库)用开关控制显隐,地址由系统按伪静态风格自动生成;隐藏项保留排序位置。
  • 自定义链接支持站内路径(如 /p/about、/announcements)或 http(s) 外链;名称最多 16 字。
); }