"use client"; import { useEffect, useMemo, useRef, useState } from "react"; import { AlertTriangle, ArrowDown, ArrowLeft, ArrowRight, ArrowUp, BarChart3, Blocks, ChevronDown, CircleDollarSign, Code2, FileText, Flame, Handshake, Home, Info, Layers, LayoutGrid, Link2, List, Megaphone, PanelLeft, Pencil, PenLine, Plus, RotateCcw, ShieldCheck, Trash2, User, UserPlus, Users, type LucideIcon, } from "lucide-react"; import { apiAdminCreateSidebarWidget, apiAdminDeleteSidebarWidget, apiAdminGetSidebar, apiAdminSaveSidebarConfig, apiAdminUpdateSidebarWidget, type SidebarBlockRef, type SidebarConfig, type SidebarScope, type SidebarWidgetRecord, } from "@/lib/api"; import { SIDEBAR_BLOCK_ICON, SIDEBAR_MAX_BLOCKS_PER_SIDE, SIDEBAR_SCOPE_LABELS, SIDEBAR_WIDGET_META, defaultSidebarConfig, sidebarBlockLabel, } from "@/lib/sidebarMeta"; import { AdminEmpty, AdminPageHeader, AdminSettingsPage, AdminSettingsWork, AdminSwitch, } from "@/components/admin"; import ConfirmDialog from "@/components/ConfirmDialog"; import Modal from "@/components/Modal"; import useUnsavedGuard from "@/hooks/useUnsavedGuard"; import { toast } from "@/lib/toast"; import { mergeWidgetCode, parseMixedWidgetCode } from "@/lib/widgetCode"; import { lintWidgetCode, type WidgetLintIssue } from "@/lib/widgetLint"; type Side = "left" | "right"; type SectionId = SidebarScope | "widgets"; const refKey = (r: SidebarBlockRef) => (r.widget ? `w:${r.widget}` : `k:${r.key}`); // lucide icon 映射(SIDEBAR_BLOCK_ICON 是字符串 key,这里解析成组件) const ICON_MAP: Record = { "layout-grid": LayoutGrid, "bar-chart-3": BarChart3, megaphone: Megaphone, "circle-dollar-sign": CircleDollarSign, handshake: Handshake, flame: Flame, "user-plus": UserPlus, users: Users, link2: Link2, info: Info, "shield-check": ShieldCheck, "file-text": FileText, layers: Layers, user: User, "pen-line": PenLine, list: List, }; function keyIcon(key: string): LucideIcon | undefined { return ICON_MAP[SIDEBAR_BLOCK_ICON[key] ?? ""]; } const SIDES: { scope: SidebarScope; icon: LucideIcon; desc: string; sides: Side[] }[] = [ { scope: "home", icon: Home, desc: "首页全站态(含板块页左栏):左右两栏均可增删排序", sides: ["left", "right"] }, { scope: "board", icon: Layers, desc: "板块页右栏;板块页左栏跟随「首页侧栏」配置", sides: ["right"] }, { scope: "post", icon: FileText, desc: "帖子详情页:左右两栏(窄屏不渲染侧栏)", sides: ["left", "right"] }, ]; const SIDE_LABEL: Record = { left: "左栏", right: "右栏" }; const WIDGET_CODE_MAX_BYTES = 64 * 1024; // 提交给后端的工具草稿(混写框拆分后的三字段 + 名称) type WidgetDraftCode = { name: string; html: string; css: string; js: string }; export default function SidebarAdmin({ initialConfig, initialWidgets, initError, }: { initialConfig: SidebarConfig | null; initialWidgets: SidebarWidgetRecord[]; initError: string; }) { const [config, setConfig] = useState(() => initialConfig || defaultSidebarConfig()); const [widgets, setWidgets] = useState(initialWidgets); const [busy, setBusy] = useState(false); const [activeSection, setActiveSection] = useState("home"); const savedJsonRef = useRef(JSON.stringify(initialConfig || defaultSidebarConfig())); const dirty = JSON.stringify(config) !== savedJsonRef.current; const { leaveOpen, confirmLeave, cancelLeave } = useUnsavedGuard(dirty && !busy); // 工具编辑弹层 const [editorOpen, setEditorOpen] = useState(false); const [editingId, setEditingId] = useState(null); const [draftName, setDraftName] = useState(""); const [mixed, setMixed] = useState(""); const [deleteId, setDeleteId] = useState(null); // 保存时静态扫描结果(error 禁止保存并列在此处;warn 弹二次确认框) const [lintIssues, setLintIssues] = useState(null); const [warnConfirmOpen, setWarnConfirmOpen] = useState(false); const pendingCodeRef = useRef(null); const draftBytes = useMemo(() => new TextEncoder().encode(mixed).length, [mixed]); const draftOk = draftName.trim().length > 0 && draftBytes <= WIDGET_CODE_MAX_BYTES; const reloadAll = async () => { const res = await apiAdminGetSidebar(); setConfig(res.config); setWidgets(res.widgets); savedJsonRef.current = JSON.stringify(res.config); }; /* ---------- 布局配置 ---------- */ const setSide = (scope: SidebarScope, side: Side, next: SidebarBlockRef[]) => { setConfig((c) => { const cur = c.scopes[scope] ?? {}; return { ...c, scopes: { ...c.scopes, [scope]: { ...cur, [side]: next } } }; }); }; const moveAcross = (scope: SidebarScope, from: Side, index: number) => { const to: Side = from === "left" ? "right" : "left"; setConfig((c) => { const cur = c.scopes[scope] ?? {}; const fromList = [...(cur[from] ?? [])]; const [item] = fromList.splice(index, 1); const toList = cur[to] ?? []; if (!item || toList.length >= SIDEBAR_MAX_BLOCKS_PER_SIDE) return c; return { ...c, scopes: { ...c.scopes, [scope]: { ...cur, [from]: fromList, [to]: [...toList, item] } } }; }); }; const resetScope = (scope: SidebarScope) => { const def = defaultSidebarConfig().scopes[scope] ?? {}; setConfig((c) => ({ ...c, scopes: { ...c.scopes, [scope]: { left: def.left ?? [], right: def.right ?? [] } }, })); toast("已重置为默认布局,记得保存", "ok"); }; const saveConfig = async () => { setBusy(true); try { const res = await apiAdminSaveSidebarConfig(config); setConfig(res.config); savedJsonRef.current = JSON.stringify(res.config); toast("侧栏布局已保存", "ok"); } catch (e) { toast(e instanceof Error ? e.message : "保存失败", "error"); } finally { setBusy(false); } }; // 放弃未保存修改:回到最近一次保存的布局 const discardChanges = () => { if (busy) return; try { setConfig(JSON.parse(savedJsonRef.current)); } catch { /* 忽略:快照损坏时保持现状 */ } }; /* ---------- 自定义工具 ---------- */ const openCreate = () => { setEditingId(null); setDraftName(""); setMixed(""); setLintIssues(null); setWarnConfirmOpen(false); pendingCodeRef.current = null; setEditorOpen(true); }; const openEdit = (w: SidebarWidgetRecord) => { setEditingId(w.id); setDraftName(w.name); setMixed(mergeWidgetCode({ html: w.html, css: w.css, js: w.js })); setLintIssues(null); setWarnConfirmOpen(false); pendingCodeRef.current = null; setEditorOpen(true); }; const doSubmitWidget = async (code: WidgetDraftCode) => { setBusy(true); try { if (editingId == null) { await apiAdminCreateSidebarWidget(code); toast("工具已创建", "ok"); } else { const w = widgets.find((x) => x.id === editingId); await apiAdminUpdateSidebarWidget(editingId, { ...code, enabled: w?.enabled ?? true }); toast("工具已保存", "ok"); } setEditorOpen(false); setLintIssues(null); setWarnConfirmOpen(false); pendingCodeRef.current = null; await reloadAll(); } catch (e) { toast(e instanceof Error ? e.message : "保存失败", "error"); } finally { setBusy(false); } }; // 保存按钮:先静态扫描——有 error 禁止保存并列出;只有 warn 弹确认框;无问题直接保存 const submitWidget = () => { if (!draftOk || busy) return; const code: WidgetDraftCode = { name: draftName.trim(), ...parseMixedWidgetCode(mixed) }; const issues = lintWidgetCode(code); setLintIssues(issues.length ? issues : null); if (issues.some((i) => i.level === "error")) return; const warns = issues.filter((i) => i.level === "warn"); if (warns.length > 0) { pendingCodeRef.current = code; setWarnConfirmOpen(true); return; } void doSubmitWidget(code); }; const confirmWarnSubmit = () => { const code = pendingCodeRef.current; pendingCodeRef.current = null; setWarnConfirmOpen(false); if (code) void doSubmitWidget(code); }; const cancelWarnSubmit = () => { pendingCodeRef.current = null; setWarnConfirmOpen(false); }; const toggleWidget = async (w: SidebarWidgetRecord) => { setBusy(true); try { await apiAdminUpdateSidebarWidget(w.id, { name: w.name, html: w.html, css: w.css, js: w.js, enabled: !w.enabled }); toast(w.enabled ? "已停用" : "已启用", "ok"); await reloadAll(); } catch (e) { toast(e instanceof Error ? e.message : "操作失败", "error"); } finally { setBusy(false); } }; const deleteWidget = async () => { if (deleteId == null) return; setBusy(true); try { await apiAdminDeleteSidebarWidget(deleteId); toast("工具已删除,侧栏引用已同步清理", "ok"); setDeleteId(null); await reloadAll(); } catch (e) { toast(e instanceof Error ? e.message : "删除失败", "error"); } finally { setBusy(false); } }; /* ---------- 分区滚动导航 ---------- */ const sectionRefs = useRef>({ home: null, board: null, post: null, widgets: null }); useEffect(() => { const els = Object.values(sectionRefs.current).filter((el): el is HTMLElement => !!el); if (els.length === 0) return; const observer = new IntersectionObserver( (entries) => { const visible = entries.filter((e) => e.isIntersecting).sort((a, b) => b.intersectionRatio - a.intersectionRatio); if (visible[0]) setActiveSection((visible[0].target as HTMLElement).dataset.section as SectionId); }, { rootMargin: "-30% 0px -55% 0px", threshold: [0, 0.25, 0.5, 1] } ); els.forEach((el) => observer.observe(el)); return () => observer.disconnect(); }, []); const scrollTo = (id: SectionId) => sectionRefs.current[id]?.scrollIntoView({ behavior: "smooth", block: "start" }); if (initError && !initialConfig) return

{initError}

; // 各范围块数(含左右两侧合计,用于导航徽章) const scopeCount = (s: SidebarScope) => { const sc = config.scopes[s] ?? {}; return (sc.left ?? []).length + (sc.right ?? []).length; }; const builtinCount = widgets.filter((w) => w.enabled).length; const navItems: { id: SectionId; label: string; icon: LucideIcon; count: number }[] = [ ...SIDES.map((s) => ({ id: s.scope as SectionId, label: `${SIDEBAR_SCOPE_LABELS[s.scope]}侧栏`, icon: s.icon, count: scopeCount(s.scope) })), { id: "widgets", label: "自定义工具", icon: Code2, count: builtinCount }, ]; const leftNav = ( ); // 页头与浮动保存条共用的未保存提示 + 操作按钮 const dirtyDot = ( ); const saveControls = dirty ? (
{dirtyDot} 布局有未保存的修改
) : undefined; return ( <>
{SIDES.map(({ scope, icon, desc, sides }) => { const sc = config.scopes[scope] ?? {}; const left = sc.left ?? []; const right = sc.right ?? []; const usedKeys = new Set([...left, ...right].map(refKey)); const Icon = icon; return (
{ sectionRefs.current[scope] = el; }} className="panel" > } title={`${SIDEBAR_SCOPE_LABELS[scope]}侧栏`} desc={desc} extra={ } />
{sides.map((side) => ( = SIDEBAR_MAX_BLOCKS_PER_SIDE} widgets={widgets} onChange={(next) => setSide(scope, side, next)} onSendAcross={(i) => moveAcross(scope, side, i)} /> ))}
); })} {/* 自定义工具 */}
{ sectionRefs.current.widgets = el; }} className="panel overflow-hidden" > } title="自定义工具" desc="HTML + CSS + JS 代码块,在独立 Shadow DOM 内渲染执行(不做消毒)" extra={} />
{widgets.length === 0 ? ( } title="还没有自定义工具" description="创建后可在上方各页面的侧栏中添加使用" /> ) : (
    {widgets.map((w) => (
  • {w.name} {!w.enabled && ( 已停用 )}

    ID #{w.id} · HTML {w.html.length} / CSS {w.css.length} / JS {w.js.length} 字符

  • ))}
)}
{/* 浮动保存条:吸在内容列底部居中,滚动全程可见,不再贴屏幕角落 */} {dirty && (
{dirtyDot} 布局有未保存的修改
)}
{/* 工具编辑弹层 */} { if (!busy) setEditorOpen(false); }} busy={busy} maxWidthClass="max-w-2xl" footer={ <> } >