新增: - 书库导入导出(library_import/library_export)及测试 - 图片变体生成(image_variants)与响应式图片(responsiveImage) - 书籍搜索(bookSearch)+ BookSearch/LibrarySearchGrid 组件 - 小组件运行时(widgetRuntime)与静态检查(widgetLint) - 上传缓存中间件(upload_cache)与字体 CSS 提取脚本 其它: - 后端 handlers/services 全量调整 - 前端页面、组件、库函数与配置更新
998 lines
38 KiB
TypeScript
998 lines
38 KiB
TypeScript
"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<string, LucideIcon> = {
|
||
"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<Side, string> = { 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<SidebarConfig>(() => initialConfig || defaultSidebarConfig());
|
||
const [widgets, setWidgets] = useState<SidebarWidgetRecord[]>(initialWidgets);
|
||
const [busy, setBusy] = useState(false);
|
||
const [activeSection, setActiveSection] = useState<SectionId>("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<number | null>(null);
|
||
const [draftName, setDraftName] = useState("");
|
||
const [mixed, setMixed] = useState("");
|
||
const [deleteId, setDeleteId] = useState<number | null>(null);
|
||
// 保存时静态扫描结果(error 禁止保存并列在此处;warn 弹二次确认框)
|
||
const [lintIssues, setLintIssues] = useState<WidgetLintIssue[] | null>(null);
|
||
const [warnConfirmOpen, setWarnConfirmOpen] = useState(false);
|
||
const pendingCodeRef = useRef<WidgetDraftCode | null>(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<Record<SectionId, HTMLElement | null>>({ 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 <p className="meta px-1 py-6">{initError}</p>;
|
||
|
||
// 各范围块数(含左右两侧合计,用于导航徽章)
|
||
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 = (
|
||
<nav className="admin-settings-nav" aria-label="侧栏管理分区">
|
||
{navItems.map((s) => {
|
||
const Icon = s.icon;
|
||
return (
|
||
<button
|
||
key={s.id}
|
||
type="button"
|
||
onClick={() => scrollTo(s.id)}
|
||
aria-current={activeSection === s.id ? "page" : undefined}
|
||
className="admin-settings-nav-link gap-2"
|
||
>
|
||
<Icon size={15} aria-hidden />
|
||
<span className="flex-1 truncate text-left">{s.label}</span>
|
||
{s.count > 0 && (
|
||
<span className="shrink-0 rounded-full px-1.5 py-0.5 text-[10.5px] font-semibold tabular-nums"
|
||
style={{ background: "var(--panel-2)", color: "var(--ink-3)" }}>
|
||
{s.count}
|
||
</span>
|
||
)}
|
||
</button>
|
||
);
|
||
})}
|
||
</nav>
|
||
);
|
||
|
||
// 页头与浮动保存条共用的未保存提示 + 操作按钮
|
||
const dirtyDot = (
|
||
<span
|
||
className="inline-block w-2 h-2 rounded-full animate-pulse shrink-0"
|
||
style={{ background: "var(--gold)" }}
|
||
aria-hidden
|
||
/>
|
||
);
|
||
const saveControls = dirty ? (
|
||
<div className="flex items-center gap-2">
|
||
<span className="meta flex items-center gap-1.5 text-[12px]">
|
||
{dirtyDot}
|
||
布局有未保存的修改
|
||
</span>
|
||
<button type="button" className="btn btn-line btn-sm" disabled={busy} onClick={discardChanges}>
|
||
撤销修改
|
||
</button>
|
||
<button type="button" className="btn btn-primary btn-sm" disabled={busy} onClick={() => void saveConfig()}>
|
||
{busy ? "保存中…" : "保存布局"}
|
||
</button>
|
||
</div>
|
||
) : undefined;
|
||
|
||
return (
|
||
<AdminSettingsPage>
|
||
<AdminPageHeader
|
||
icon={PanelLeft}
|
||
title="侧栏管理"
|
||
description="配置各页面左右侧栏的显示、顺序与位置,管理自定义 HTML 工具"
|
||
actions={saveControls}
|
||
/>
|
||
|
||
<AdminSettingsWork nav={leftNav}>
|
||
<>
|
||
<div className="space-y-5 pb-4">
|
||
{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 (
|
||
<section
|
||
key={scope}
|
||
id={`sec-${scope}`}
|
||
data-section={scope}
|
||
ref={(el) => { sectionRefs.current[scope] = el; }}
|
||
className="panel"
|
||
>
|
||
<SectionHead
|
||
icon={<Icon size={16} />}
|
||
title={`${SIDEBAR_SCOPE_LABELS[scope]}侧栏`}
|
||
desc={desc}
|
||
extra={
|
||
<button type="button" className="btn btn-line btn-sm" onClick={() => resetScope(scope)}>
|
||
<RotateCcw size={13} /> 恢复默认
|
||
</button>
|
||
}
|
||
/>
|
||
<div className={`grid grid-cols-1 gap-5 p-4 sm:p-5 ${sides.length === 2 ? "lg:grid-cols-2" : ""}`}>
|
||
{sides.map((side) => (
|
||
<RailEditor
|
||
key={side}
|
||
scope={scope}
|
||
side={side}
|
||
list={side === "left" ? left : right}
|
||
usedKeys={usedKeys}
|
||
targetFull={(side === "left" ? right : left).length >= SIDEBAR_MAX_BLOCKS_PER_SIDE}
|
||
widgets={widgets}
|
||
onChange={(next) => setSide(scope, side, next)}
|
||
onSendAcross={(i) => moveAcross(scope, side, i)}
|
||
/>
|
||
))}
|
||
</div>
|
||
</section>
|
||
);
|
||
})}
|
||
|
||
{/* 自定义工具 */}
|
||
<section
|
||
id="sec-widgets"
|
||
data-section="widgets"
|
||
ref={(el) => { sectionRefs.current.widgets = el; }}
|
||
className="panel overflow-hidden"
|
||
>
|
||
<SectionHead
|
||
icon={<Code2 size={16} />}
|
||
title="自定义工具"
|
||
desc="HTML + CSS + JS 代码块,在独立 Shadow DOM 内渲染执行(不做消毒)"
|
||
extra={<button type="button" className="btn btn-primary btn-sm" onClick={openCreate}><Plus size={14} /> 新增工具</button>}
|
||
/>
|
||
<div className="p-4 sm:p-5">
|
||
{widgets.length === 0 ? (
|
||
<AdminEmpty icon={<Blocks size={22} />} title="还没有自定义工具" description="创建后可在上方各页面的侧栏中添加使用" />
|
||
) : (
|
||
<ul className="flex flex-col gap-2.5">
|
||
{widgets.map((w) => (
|
||
<li key={w.id} className="j13-ads-row !py-2">
|
||
<span
|
||
className="shrink-0 w-8 h-8 rounded-lg flex items-center justify-center"
|
||
style={{ background: w.enabled ? "var(--gold-soft)" : "var(--panel-2)", color: w.enabled ? "var(--gold)" : "var(--ink-3)" }}
|
||
aria-hidden
|
||
>
|
||
<Code2 size={14} />
|
||
</span>
|
||
<div className="min-w-0 flex-1">
|
||
<p className="truncate text-[13.5px] font-medium" style={{ color: "var(--ink)" }}>
|
||
{w.name}
|
||
{!w.enabled && (
|
||
<span className="ml-2 rounded-full px-1.5 py-0.5 text-[10.5px] font-semibold"
|
||
style={{ background: "var(--panel-2)", color: "var(--ink-3)" }}>已停用</span>
|
||
)}
|
||
</p>
|
||
<p className="meta mt-0.5 text-[11.5px]">
|
||
ID #{w.id} · HTML {w.html.length} / CSS {w.css.length} / JS {w.js.length} 字符
|
||
</p>
|
||
</div>
|
||
<label className="flex shrink-0 items-center gap-1.5 text-[12.5px]" style={{ color: "var(--ink-2)" }}>
|
||
<AdminSwitch label={`启用 ${w.name}`} checked={w.enabled} onChange={() => void toggleWidget(w)} />
|
||
启用
|
||
</label>
|
||
<button type="button" className="btn btn-line btn-sm shrink-0" onClick={() => openEdit(w)}>
|
||
<Pencil size={13} /> 编辑
|
||
</button>
|
||
<button type="button" className="btn btn-line btn-sm shrink-0" onClick={() => setDeleteId(w.id)} aria-label={`删除 ${w.name}`}>
|
||
<Trash2 size={13} />
|
||
</button>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
</div>
|
||
</section>
|
||
</div>
|
||
|
||
{/* 浮动保存条:吸在内容列底部居中,滚动全程可见,不再贴屏幕角落 */}
|
||
{dirty && (
|
||
<div className="sticky bottom-4 z-30 flex justify-center pointer-events-none">
|
||
<div
|
||
className="pointer-events-auto flex items-center gap-3 rounded-full px-4 py-2"
|
||
style={{
|
||
background: "color-mix(in srgb, var(--panel) 92%, transparent)",
|
||
backdropFilter: "blur(12px)",
|
||
border: "1px solid var(--line)",
|
||
boxShadow: "var(--shadow-lg)",
|
||
}}
|
||
>
|
||
<span className="meta flex items-center gap-1.5 text-[12px]">
|
||
{dirtyDot}
|
||
布局有未保存的修改
|
||
</span>
|
||
<button type="button" className="btn btn-line btn-sm" disabled={busy} onClick={discardChanges}>
|
||
撤销修改
|
||
</button>
|
||
<button type="button" className="btn btn-primary btn-sm" disabled={busy} onClick={() => void saveConfig()}>
|
||
{busy ? "保存中…" : "保存布局"}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</>
|
||
</AdminSettingsWork>
|
||
|
||
{/* 工具编辑弹层 */}
|
||
<Modal
|
||
open={editorOpen}
|
||
title={editingId == null ? "新增自定义工具" : "编辑自定义工具"}
|
||
onClose={() => { if (!busy) setEditorOpen(false); }}
|
||
busy={busy}
|
||
maxWidthClass="max-w-2xl"
|
||
footer={
|
||
<>
|
||
<button type="button" className="btn btn-line btn-sm" disabled={busy} onClick={() => setEditorOpen(false)}>取消</button>
|
||
<button type="button" className="btn btn-primary btn-sm" disabled={busy || !draftOk} onClick={() => submitWidget()}>
|
||
{busy ? "保存中…" : editingId == null ? "创建" : "保存"}
|
||
</button>
|
||
</>
|
||
}
|
||
>
|
||
<div className="space-y-3">
|
||
<label className="block">
|
||
<span className="meta mb-1 block text-[12px]">名称(管理列表与侧栏引用中显示)</span>
|
||
<input className="field w-full" value={draftName} maxLength={40} onChange={(e) => setDraftName(e.target.value)} placeholder="如:节假日横幅" />
|
||
</label>
|
||
<label className="block">
|
||
<span className="mb-1 flex items-center justify-between gap-2">
|
||
<span className="meta text-[12px]">代码一框写完(<style> / <script> 段提交时自动拆分)</span>
|
||
<span className="meta shrink-0 text-[11.5px] tabular-nums">{draftBytes} / {Math.floor(WIDGET_CODE_MAX_BYTES / 1024)} KB</span>
|
||
</span>
|
||
<textarea
|
||
className="field w-full min-h-[240px] font-mono text-[12.5px]"
|
||
value={mixed}
|
||
onChange={(e) => {
|
||
setMixed(e.target.value);
|
||
setLintIssues(null);
|
||
}}
|
||
placeholder={`<div class="my-banner">节日快乐!</div>\n\n<style>\n .my-banner { border-radius: 12px; }\n</style>\n\n<script>\n // 脚本参数:(root, shadow, toolId, registerCleanup)\n // 元素查找一律从 root 出发:root.querySelector(".my-banner")\n</script>`}
|
||
/>
|
||
</label>
|
||
{lintIssues && lintIssues.length > 0 && <WidgetLintPanel issues={lintIssues} />}
|
||
<details className="rounded-xl border px-3 py-2" style={{ borderColor: "var(--line)" }}>
|
||
<summary className="meta cursor-pointer select-none text-[12px]">开放能力速查:脚本参数 / window.J13 / 主题变量</summary>
|
||
<div className="mt-2 space-y-1.5 text-[12px] leading-relaxed" style={{ color: "var(--ink-2)" }}>
|
||
<p>脚本接收参数 <code>(root, shadow, toolId, registerCleanup)</code>:元素查找一律 <code>root.querySelector(...)</code>,禁止 <code>document</code> 全局查找(同页多个同款工具会互相取错)</p>
|
||
<p>定时器 / 监听器请通过 <code>registerCleanup(fn)</code> 注册清理,离开页面时自动执行(如 <code>clearInterval</code>)</p>
|
||
<p>样式仅在工具内生效,直接写类名即可;禁止 <code>body</code> / <code>html</code> / <code>*</code> 全局选择器与 <code>position: fixed</code>,变量请定义在 <code>.tool-root</code> 上</p>
|
||
<p><code>J13.user</code>:当前登录用户(未登录为 null)</p>
|
||
<p><code>J13.theme.get()</code> 返回 "light" | "dark";<code> J13.theme.onChange(fn)</code> 监听明暗切换</p>
|
||
<p><code>J13.getOverview()</code>:站点公开统计(Promise,users / posts / online 等)</p>
|
||
<p>CSS 变量自动跟随明暗主题与后台自定义主题色:<code>var(--ink) var(--accent) var(--panel)…</code></p>
|
||
</div>
|
||
</details>
|
||
<p className="meta text-[11.5px]">
|
||
工具运行在独立 Shadow DOM 内,样式不外泄;前台原样渲染执行(不做消毒),自行把控安全;外部脚本库需内联引入。
|
||
</p>
|
||
</div>
|
||
</Modal>
|
||
|
||
{/* 仅 warn 级问题时的二次确认 */}
|
||
<ConfirmDialog
|
||
open={warnConfirmOpen}
|
||
title="代码存在风险提醒"
|
||
message={`检测到 ${lintIssues?.filter((i) => i.level === "warn").length ?? 0} 条提醒(不阻止保存,明细见编辑器列表)。确认了解风险并保存?`}
|
||
confirmLabel="仍要保存"
|
||
busy={busy}
|
||
onConfirm={confirmWarnSubmit}
|
||
onCancel={cancelWarnSubmit}
|
||
/>
|
||
|
||
{/* 删除工具确认 */}
|
||
<ConfirmDialog
|
||
open={deleteId != null}
|
||
title="删除这个自定义工具?"
|
||
message="删除后各侧栏中的引用将被同步清理,且不可恢复。"
|
||
confirmLabel="删除"
|
||
danger
|
||
busy={busy}
|
||
onConfirm={() => void deleteWidget()}
|
||
onCancel={() => setDeleteId(null)}
|
||
/>
|
||
|
||
{/* 未保存离开确认 */}
|
||
<ConfirmDialog
|
||
open={leaveOpen}
|
||
title="有未保存的布局修改"
|
||
message="离开页面将丢失未保存的侧栏布局修改。"
|
||
confirmLabel="离开"
|
||
onConfirm={confirmLeave}
|
||
onCancel={cancelLeave}
|
||
/>
|
||
</AdminSettingsPage>
|
||
);
|
||
}
|
||
|
||
/* ─── 工具代码静态扫描结果面板 ─── */
|
||
const LINT_FIELD_LABEL: Record<WidgetLintIssue["field"], string> = {
|
||
js: "JS",
|
||
css: "CSS",
|
||
html: "HTML",
|
||
};
|
||
|
||
function WidgetLintPanel({ issues }: { issues: WidgetLintIssue[] }) {
|
||
const errors = issues.filter((i) => i.level === "error");
|
||
const warns = issues.filter((i) => i.level === "warn");
|
||
return (
|
||
<div className="space-y-2">
|
||
{errors.length > 0 && (
|
||
<div
|
||
className="rounded-xl border p-2.5"
|
||
style={{
|
||
borderColor: "color-mix(in srgb, var(--danger) 40%, var(--line))",
|
||
background: "var(--danger-soft)",
|
||
}}
|
||
>
|
||
<p className="mb-1.5 flex items-center gap-1.5 text-[12px] font-semibold" style={{ color: "var(--danger)" }}>
|
||
<AlertTriangle size={13} aria-hidden />
|
||
{errors.length} 个错误,修复后才能保存
|
||
</p>
|
||
<ul className="space-y-1">
|
||
{errors.map((issue, idx) => (
|
||
<LintIssueRow key={`e-${idx}`} issue={issue} />
|
||
))}
|
||
</ul>
|
||
</div>
|
||
)}
|
||
{warns.length > 0 && (
|
||
<div
|
||
className="rounded-xl border p-2.5"
|
||
style={{
|
||
borderColor: "color-mix(in srgb, var(--gold) 40%, var(--line))",
|
||
background: "var(--gold-soft)",
|
||
}}
|
||
>
|
||
<p className="mb-1.5 flex items-center gap-1.5 text-[12px] font-semibold" style={{ color: "var(--gold)" }}>
|
||
<Info size={13} aria-hidden />
|
||
{warns.length} 个提醒,确认风险后仍可保存
|
||
</p>
|
||
<ul className="space-y-1">
|
||
{warns.map((issue, idx) => (
|
||
<LintIssueRow key={`w-${idx}`} issue={issue} />
|
||
))}
|
||
</ul>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function LintIssueRow({ issue }: { issue: WidgetLintIssue }) {
|
||
const tone = issue.level === "error" ? "var(--danger)" : "var(--gold)";
|
||
return (
|
||
<li className="flex gap-1.5 text-[11.5px] leading-relaxed">
|
||
<span
|
||
className="mt-px shrink-0 rounded px-1 py-px font-mono text-[10.5px] font-semibold tabular-nums"
|
||
style={{ color: tone, background: "color-mix(in srgb, currentColor 10%, transparent)" }}
|
||
>
|
||
{LINT_FIELD_LABEL[issue.field]} {issue.line}:{issue.column}
|
||
</span>
|
||
<span style={{ color: "var(--ink-2)" }}>{issue.message}</span>
|
||
</li>
|
||
);
|
||
}
|
||
|
||
/* ─── 分区卡片头 ─── */
|
||
function SectionHead({
|
||
icon,
|
||
title,
|
||
desc,
|
||
extra,
|
||
}: {
|
||
icon: React.ReactNode;
|
||
title: string;
|
||
desc?: string;
|
||
extra?: React.ReactNode;
|
||
}) {
|
||
return (
|
||
<div className="flex flex-wrap items-center justify-between gap-3 px-4 py-3.5 sm:px-5" style={{ borderBottom: "1px solid var(--line)" }}>
|
||
<div className="flex items-center gap-2.5 min-w-0">
|
||
<span className="w-8 h-8 rounded-lg flex items-center justify-center shrink-0" style={{ background: "var(--accent-soft)", color: "var(--accent)" }} aria-hidden>{icon}</span>
|
||
<span className="min-w-0 leading-tight">
|
||
<span className="block text-[15px] font-semibold" style={{ color: "var(--ink)" }}>{title}</span>
|
||
{desc ? <span className="meta mt-0.5 block text-[12px] truncate">{desc}</span> : null}
|
||
</span>
|
||
</div>
|
||
{extra ? <div className="shrink-0">{extra}</div> : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ─── 单侧栏块列表编辑器 ─── */
|
||
function RailEditor({
|
||
scope,
|
||
side,
|
||
list,
|
||
usedKeys,
|
||
targetFull,
|
||
widgets,
|
||
onChange,
|
||
onSendAcross,
|
||
}: {
|
||
scope: SidebarScope;
|
||
side: Side;
|
||
list: SidebarBlockRef[];
|
||
usedKeys: Set<string>;
|
||
targetFull: boolean;
|
||
widgets: SidebarWidgetRecord[];
|
||
onChange: (next: SidebarBlockRef[]) => void;
|
||
onSendAcross: (index: number) => void;
|
||
}) {
|
||
const candidates = useMemo(() => {
|
||
const meta = SIDEBAR_WIDGET_META[scope];
|
||
const builtin: { ref: SidebarBlockRef; label: string; custom: boolean; iconKey?: string; widget?: SidebarWidgetRecord }[] = [];
|
||
for (const key of Object.keys(meta)) {
|
||
if (!usedKeys.has(`k:${key}`)) builtin.push({ ref: { key }, label: meta[key], custom: false, iconKey: SIDEBAR_BLOCK_ICON[key] });
|
||
}
|
||
const customWidgets: typeof builtin = [];
|
||
for (const w of widgets) {
|
||
if (!usedKeys.has(`w:${w.id}`)) customWidgets.push({ ref: { widget: w.id }, label: w.name, custom: true, widget: w });
|
||
}
|
||
return { builtin, customWidgets };
|
||
}, [scope, usedKeys, widgets]);
|
||
|
||
const full = list.length >= SIDEBAR_MAX_BLOCKS_PER_SIDE;
|
||
|
||
const move = (index: number, delta: number) => {
|
||
const next = [...list];
|
||
const target = index + delta;
|
||
if (target < 0 || target >= next.length) return;
|
||
[next[index], next[target]] = [next[target], next[index]];
|
||
onChange(next);
|
||
};
|
||
|
||
return (
|
||
<div>
|
||
<div className="mb-2.5 flex items-center justify-between gap-2">
|
||
<p className="text-[13.5px] font-medium" style={{ color: "var(--ink)" }}>
|
||
{SIDE_LABEL[side]}
|
||
<span className="meta ml-2 text-[11.5px] font-normal tabular-nums">{list.length} / {SIDEBAR_MAX_BLOCKS_PER_SIDE}</span>
|
||
</p>
|
||
</div>
|
||
|
||
{list.length === 0 ? (
|
||
<div
|
||
className="rounded-xl border border-dashed px-4 py-6 text-center"
|
||
style={{ borderColor: "var(--line)", color: "var(--ink-3)" }}
|
||
>
|
||
<Blocks size={18} className="mx-auto mb-2" aria-hidden style={{ color: "var(--ink-3)" }} />
|
||
<p className="text-[12.5px]">该侧栏为空——前台不渲染这一列</p>
|
||
<p className="meta mt-1 text-[11px]">从下方选择块添加</p>
|
||
</div>
|
||
) : (
|
||
<ul className="flex flex-col gap-1.5">
|
||
{list.map((ref, i) => {
|
||
const info = sidebarBlockLabel(ref, widgets.map((w) => ({ id: w.id, name: w.name })));
|
||
const w = ref.widget ? widgets.find((x) => x.id === ref.widget) : null;
|
||
const disabled = !!(w && !w.enabled);
|
||
const Icon = ref.key ? keyIcon(ref.key) : undefined;
|
||
return (
|
||
<li
|
||
key={`${refKey(ref)}-${i}`}
|
||
className={`j13-ads-row !py-1.5 !px-2.5 ${disabled ? "opacity-50" : ""}`}
|
||
>
|
||
{/* 类型图标圆点:内置蓝 / 自定义金 */}
|
||
<span
|
||
className="shrink-0 w-6 h-6 rounded-md flex items-center justify-center"
|
||
style={{
|
||
background: info?.custom ? "var(--gold-soft)" : "var(--accent-soft)",
|
||
color: info?.custom ? "var(--gold)" : "var(--accent)",
|
||
}}
|
||
aria-hidden
|
||
>
|
||
{Icon ? <Icon size={12} /> : info?.custom ? <Code2 size={12} /> : <Blocks size={12} />}
|
||
</span>
|
||
{/* 序号 */}
|
||
<span className="meta w-5 shrink-0 text-center text-[11px] tabular-nums">{i + 1}</span>
|
||
{/* 块信息 */}
|
||
<span className="min-w-0 flex-1">
|
||
<span className="block truncate text-[13px] font-medium" style={{ color: "var(--ink)" }}>
|
||
{info ? info.label : "未知模块"}
|
||
</span>
|
||
<span className="meta text-[11px]">
|
||
{info?.custom ? "自定义工具" : "内置模块"}
|
||
{disabled ? " · 已停用(前台不显示)" : ""}
|
||
</span>
|
||
</span>
|
||
{/* 操作按钮组 */}
|
||
<div className="flex shrink-0 items-center gap-0.5">
|
||
<button
|
||
type="button"
|
||
className="btn btn-line btn-sm !px-1.5 !py-1"
|
||
disabled={i === 0}
|
||
onClick={() => move(i, -1)}
|
||
aria-label="上移"
|
||
>
|
||
<ArrowUp size={13} />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="btn btn-line btn-sm !px-1.5 !py-1"
|
||
disabled={i === list.length - 1}
|
||
onClick={() => move(i, 1)}
|
||
aria-label="下移"
|
||
>
|
||
<ArrowDown size={13} />
|
||
</button>
|
||
{scope !== "board" && (
|
||
<button
|
||
type="button"
|
||
className="btn btn-line btn-sm !px-1.5 !py-1"
|
||
disabled={targetFull}
|
||
onClick={() => onSendAcross(i)}
|
||
title={side === "left" ? "移到右栏末尾" : "移到左栏末尾"}
|
||
aria-label={side === "left" ? "移到右栏" : "移到左栏"}
|
||
>
|
||
{side === "left" ? <ArrowRight size={13} /> : <ArrowLeft size={13} />}
|
||
</button>
|
||
)}
|
||
<button
|
||
type="button"
|
||
className="btn btn-line btn-sm !px-1.5 !py-1"
|
||
onClick={() => onChange(list.filter((_, j) => j !== i))}
|
||
aria-label="从侧栏移除"
|
||
>
|
||
<Trash2 size={13} />
|
||
</button>
|
||
</div>
|
||
</li>
|
||
);
|
||
})}
|
||
</ul>
|
||
)}
|
||
|
||
{/* 添加块:自定义下拉浮层(不依赖原生 select/option,完全跟随主题变量) */}
|
||
<AddBlockPicker
|
||
candidates={candidates}
|
||
full={full}
|
||
onPick={(ref) => onChange([...list, ref])}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ─── 添加块下拉浮层 ─── */
|
||
type CandidateItem = { ref: SidebarBlockRef; label: string; custom: boolean; iconKey?: string; widget?: SidebarWidgetRecord };
|
||
|
||
function AddBlockPicker({
|
||
candidates,
|
||
full,
|
||
onPick,
|
||
}: {
|
||
candidates: { builtin: CandidateItem[]; customWidgets: CandidateItem[] };
|
||
full: boolean;
|
||
onPick: (ref: SidebarBlockRef) => void;
|
||
}) {
|
||
const [open, setOpen] = useState(false);
|
||
const ref = useRef<HTMLDivElement>(null);
|
||
|
||
useEffect(() => {
|
||
if (!open) return;
|
||
const onDown = (e: MouseEvent) => {
|
||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||
};
|
||
const onEsc = (e: KeyboardEvent) => {
|
||
if (e.key === "Escape") setOpen(false);
|
||
};
|
||
document.addEventListener("mousedown", onDown);
|
||
document.addEventListener("keydown", onEsc);
|
||
return () => {
|
||
document.removeEventListener("mousedown", onDown);
|
||
document.removeEventListener("keydown", onEsc);
|
||
};
|
||
}, [open]);
|
||
|
||
const hasAny = candidates.builtin.length + candidates.customWidgets.length > 0;
|
||
|
||
return (
|
||
<div ref={ref} className="relative mt-3">
|
||
<button
|
||
type="button"
|
||
className="field flex items-center justify-between gap-2"
|
||
disabled={full || (!hasAny && !full)}
|
||
onClick={() => setOpen((o) => !o)}
|
||
aria-haspopup="listbox"
|
||
aria-expanded={open}
|
||
>
|
||
<span className="truncate text-[13px]" style={{ color: full || !hasAny ? "var(--ink-3)" : "var(--ink-3)" }}>
|
||
{full ? "已达上限" : hasAny ? "选择要添加的模块…" : "没有更多可用的块"}
|
||
</span>
|
||
<ChevronDown
|
||
size={15}
|
||
className={`shrink-0 transition-transform ${open ? "rotate-180" : ""}`}
|
||
style={{ color: "var(--ink-3)" }}
|
||
aria-hidden
|
||
/>
|
||
</button>
|
||
|
||
{open && !full && hasAny && (
|
||
<div
|
||
className="absolute z-50 mt-1 w-full overflow-y-auto rounded-xl border shadow-lg"
|
||
style={{
|
||
background: "var(--panel)",
|
||
borderColor: "var(--line)",
|
||
maxHeight: "320px",
|
||
}}
|
||
role="listbox"
|
||
>
|
||
{candidates.builtin.length > 0 && (
|
||
<AddGroup title="内置模块" items={candidates.builtin} onPick={(r) => { onPick(r); setOpen(false); }} />
|
||
)}
|
||
{candidates.customWidgets.length > 0 && (
|
||
<AddGroup title="自定义工具" items={candidates.customWidgets} onPick={(r) => { onPick(r); setOpen(false); }} />
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function AddGroup({
|
||
title,
|
||
items,
|
||
onPick,
|
||
}: {
|
||
title: string;
|
||
items: CandidateItem[];
|
||
onPick: (ref: SidebarBlockRef) => void;
|
||
}) {
|
||
return (
|
||
<div role="group" aria-label={title}>
|
||
<p
|
||
className="sticky top-0 px-3 py-1.5 text-[11px] font-semibold uppercase tracking-wide"
|
||
style={{ background: "var(--panel)", color: "var(--ink-3)", borderBottom: "1px solid var(--line)" }}
|
||
>
|
||
{title}
|
||
</p>
|
||
<ul className="p-1">
|
||
{items.map((c) => {
|
||
const Icon = c.iconKey ? ICON_MAP[c.iconKey] : undefined;
|
||
const disabled = c.widget ? !c.widget.enabled : false;
|
||
return (
|
||
<li key={refKey(c.ref)}>
|
||
<button
|
||
type="button"
|
||
role="option"
|
||
aria-selected={false}
|
||
disabled={disabled}
|
||
onClick={() => onPick(c.ref)}
|
||
className="flex w-full items-center gap-2.5 rounded-lg px-2.5 py-2 text-left transition-colors hover:bg-[var(--panel-2)] focus-visible:bg-[var(--panel-2)] focus:outline-none"
|
||
style={{ opacity: disabled ? 0.5 : 1 }}
|
||
>
|
||
<span
|
||
className="shrink-0 w-6 h-6 rounded-md flex items-center justify-center"
|
||
style={{
|
||
background: c.custom ? "var(--gold-soft)" : "var(--accent-soft)",
|
||
color: c.custom ? "var(--gold)" : "var(--accent)",
|
||
}}
|
||
aria-hidden
|
||
>
|
||
{Icon ? <Icon size={12} /> : <Code2 size={12} />}
|
||
</span>
|
||
<span className="min-w-0 flex-1">
|
||
<span className="block truncate text-[13px] font-medium" style={{ color: "var(--ink)" }}>
|
||
{c.label}
|
||
</span>
|
||
{disabled && (
|
||
<span className="meta text-[11px]">已停用(需先在工具库启用)</span>
|
||
)}
|
||
</span>
|
||
<Plus size={13} className="shrink-0" style={{ color: "var(--ink-3)" }} aria-hidden />
|
||
</button>
|
||
</li>
|
||
);
|
||
})}
|
||
</ul>
|
||
</div>
|
||
);
|
||
}
|