Files
jiang13-bbs/frontend/app/admin/sidebar/SidebarAdmin.tsx
freefire 7b327bc8cc feat: 站点访问统计与可配置侧边栏组件
- 新增访问统计:visitstats 服务与查询、visit_capture 中间件、爬虫/来源识别、管理端 analytics 页面
- 新增侧边栏:sidebar 服务与 handler、管理端 sidebar 配置页、前端侧边栏组件
- 新增 widget 嵌入代码生成(widgetCode)与 widgetApi
- 遥测逻辑迁移至 handler 层,删除 service/telemetry
- env 示例补充 Docker 部署可信代理 IP 说明
2026-09-24 18:53:45 +08:00

825 lines
31 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import {
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";
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;
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);
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 openCreate = () => {
setEditingId(null);
setDraftName("");
setMixed("");
setEditorOpen(true);
};
const openEdit = (w: SidebarWidgetRecord) => {
setEditingId(w.id);
setDraftName(w.name);
setMixed(mergeWidgetCode({ html: w.html, css: w.css, js: w.js }));
setEditorOpen(true);
};
const submitWidget = async () => {
if (!draftOk) return;
setBusy(true);
try {
const code = { name: draftName.trim(), ...parseMixedWidgetCode(mixed) };
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);
await reloadAll();
} catch (e) {
toast(e instanceof Error ? e.message : "保存失败", "error");
} finally {
setBusy(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>
);
return (
<AdminSettingsPage>
<AdminPageHeader
icon={PanelLeft}
title="侧栏管理"
description="配置各页面左右侧栏的显示、顺序与位置,管理自定义 HTML 工具"
/>
<AdminSettingsWork nav={leftNav}>
<div className="space-y-5 pb-24">
{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 代码块,前台原样渲染执行(不做消毒)"
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>
</AdminSettingsWork>
{/* 吸底保存栏 */}
{dirty && (
<div
className="fixed bottom-0 left-0 right-0 z-40 flex items-center justify-between gap-3 border-t px-4 py-3 sm:px-6"
style={{
background: "color-mix(in srgb, var(--panel) 92%, transparent)",
backdropFilter: "blur(12px)",
borderColor: "var(--line)",
}}
>
<p className="meta text-[12px]">
<span className="inline-block w-2 h-2 rounded-full mr-1.5" style={{ background: "var(--gold)" }} aria-hidden />
布局有未保存的修改
</p>
<button type="button" className="btn btn-primary" disabled={busy} onClick={() => void saveConfig()}>
{busy ? "保存中…" : "保存布局"}
</button>
</div>
)}
{/* 工具编辑弹层 */}
<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={() => void 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]">代码一框写完(&lt;style&gt; / &lt;script&gt; 段提交时自动拆分)</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)}
placeholder={`<div class="my-banner">节日快乐!</div>\n\n<style>\n .my-banner { border-radius: 12px; }\n</style>\n\n<script>\n // 页面加载后在工具位置执行\n</script>`}
/>
</label>
<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>J13.user</code>:当前登录用户(未登录为 null)</p>
<p><code>J13.theme.get()</code> 返回 &quot;light&quot; | &quot;dark&quot;;<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]">
CSS 自动限定在本板块内、不污染其他区域;前台原样渲染执行(不做消毒),自行把控安全;外部脚本库需内联引入。
</p>
</div>
</Modal>
{/* 删除工具确认 */}
<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>
);
}
/* ─── 分区卡片头 ─── */
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>
);
}