Files
jiang13-bbs/frontend/app/admin/appearance/AppearanceAdmin.tsx

1035 lines
37 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, type KeyboardEvent } from "react";
import { useRouter } from "next/navigation";
import {
Palette,
Check,
Code2,
Loader2,
Save,
RotateCcw,
Pipette,
Sparkles,
ImageIcon,
Monitor,
LayoutDashboard,
Trash2,
Upload,
Images,
} from "lucide-react";
import {
AdminPageHeader,
AdminSettingsActions,
AdminSettingsGroup,
AdminSettingsPage,
AdminSettingsRow,
AdminSettingsWork,
AdminStepper,
AdminSwitch,
} from "@/components/admin";
import BgModePicker from "./BgModePicker";
import BgMediaPicker from "./BgMediaPicker";
import ConfirmDialog from "@/components/ConfirmDialog";
import {
apiBackgroundFromMedia,
apiUpdateSiteSettings,
apiUploadSiteBackground,
type PublicSettings,
} from "@/lib/api";
import { toast } from "@/lib/toast";
import useUnsavedGuard from "@/hooks/useUnsavedGuard";
import {
ACCENT_PRESETS,
DEFAULT_ACCENT,
applyAccentTheme,
deriveAccentPair,
findPreset,
normalizeHex,
} from "@/lib/theme";
import {
BG_PREVIEW_ASPECT,
applySiteBackground,
bgConfigFromSettings,
previewBoxStyle,
type BgMode,
type BgSurface,
type SiteBgConfig,
} from "@/lib/bg";
type SectionId = "theme" | "background" | "code" | "motion";
const NAV: { id: SectionId; label: string }[] = [
{ id: "theme", label: "主题色" },
{ id: "background", label: "背景图" },
{ id: "code", label: "代码块" },
{ id: "motion", label: "界面动画" },
];
function parseSection(hash: string): SectionId {
const id = hash.replace(/^#/, "");
if (id === "background" || id === "code" || id === "motion" || id === "theme") return id;
return "theme";
}
function InstantNote({ saving }: { saving?: boolean }) {
return <span className="admin-settings-instant">{saving ? "正在保存…" : "修改后立即生效"}</span>;
}
function bgEqual(a: SiteBgConfig, b: SiteBgConfig): boolean {
return (
a.bg_site_url === b.bg_site_url &&
a.bg_site_mode === b.bg_site_mode &&
a.bg_admin_url === b.bg_admin_url &&
a.bg_admin_mode === b.bg_admin_mode
);
}
// 后台「外观设置」:半屏导航;主题色/背景图草稿本地预览,保存后才写库并广播。
export default function AppearanceAdmin({ initial }: { initial: PublicSettings }) {
const router = useRouter();
const initialAccent = initial.accent || "";
const [savedHex, setSavedHex] = useState(initialAccent);
const [draft, setDraft] = useState(initialAccent || DEFAULT_ACCENT);
const [codeAutoFold, setCodeAutoFold] = useState(initial.code_block_auto_fold !== false);
const [foldLines, setFoldLines] = useState(
Number.isFinite(initial.code_block_fold_lines) ? initial.code_block_fold_lines : 5
);
const [togglingFold, setTogglingFold] = useState(false);
const [savingFoldLines, setSavingFoldLines] = useState(false);
const [uiAnimations, setUiAnimations] = useState(initial.ui_animations !== false);
const [animCodeFold, setAnimCodeFold] = useState(initial.anim_code_fold !== false);
const [animSmoothScroll, setAnimSmoothScroll] = useState(
initial.anim_smooth_scroll !== false
);
const [animChrome, setAnimChrome] = useState(initial.anim_chrome !== false);
const [togglingMotion, setTogglingMotion] = useState(false);
const [savingTheme, setSavingTheme] = useState(false);
const [themeError, setThemeError] = useState("");
const [savedBg, setSavedBg] = useState<SiteBgConfig>(() => bgConfigFromSettings(initial));
const [bgDraft, setBgDraft] = useState<SiteBgConfig>(() => bgConfigFromSettings(initial));
const [siteFile, setSiteFile] = useState<File | null>(null);
const [adminFile, setAdminFile] = useState<File | null>(null);
const [siteBlob, setSiteBlob] = useState("");
const [adminBlob, setAdminBlob] = useState("");
const [siteMediaId, setSiteMediaId] = useState<number | null>(null);
const [adminMediaId, setAdminMediaId] = useState<number | null>(null);
const [siteMediaUrl, setSiteMediaUrl] = useState("");
const [adminMediaUrl, setAdminMediaUrl] = useState("");
const [bgPicker, setBgPicker] = useState<BgSurface | null>(null);
const [savingBg, setSavingBg] = useState(false);
const [bgError, setBgError] = useState("");
const siteFileRef = useRef<HTMLInputElement>(null);
const adminFileRef = useRef<HTMLInputElement>(null);
const blobsRef = useRef<string[]>([]);
const savedRef = useRef(savedHex);
savedRef.current = savedHex;
const savedBgRef = useRef(savedBg);
savedBgRef.current = savedBg;
const [active, setActive] = useState<SectionId>("theme");
const [pendingSection, setPendingSection] = useState<SectionId | null>(null);
const navRefs = useRef<(HTMLAnchorElement | null)[]>([]);
const normalized = useMemo(() => normalizeHex(draft), [draft]);
const pair = useMemo(
() => (normalized ? deriveAccentPair(normalized) : null),
[normalized]
);
const preset = useMemo(() => (normalized ? findPreset(normalized) : null), [normalized]);
const submitHex = normalized && normalized !== DEFAULT_ACCENT ? normalized : "";
const themeDirty = (savedHex || DEFAULT_ACCENT) !== (normalized || "");
const themeInvalid = draft.trim() !== "" && !normalized;
const bgDirty =
!bgEqual(bgDraft, savedBg) ||
siteFile != null ||
adminFile != null ||
siteMediaId != null ||
adminMediaId != null;
const tabDirty =
(active === "theme" && themeDirty) || (active === "background" && bgDirty);
const leave = useUnsavedGuard(themeDirty || bgDirty);
useEffect(() => {
const held = blobsRef;
return () => {
held.current.forEach((u) => URL.revokeObjectURL(u));
};
}, []);
useEffect(() => {
if (normalized) applyAccentTheme(normalized);
return () => {
applyAccentTheme(savedRef.current || null);
};
}, [normalized]);
// 离开页时恢复已保存背景,避免草稿残留在本标签
useEffect(() => {
return () => {
applySiteBackground(savedBgRef.current);
};
}, []);
useEffect(() => {
const apply = () => setActive(parseSection(window.location.hash));
apply();
if (!window.location.hash) {
history.replaceState(null, "", `${window.location.pathname}${window.location.search}#theme`);
}
window.addEventListener("hashchange", apply);
return () => window.removeEventListener("hashchange", apply);
}, []);
const goSection = (id: SectionId) => {
if (id === active) return;
setActive(id);
history.replaceState(null, "", `${window.location.pathname}${window.location.search}#${id}`);
document.getElementById("main")?.scrollTo({ top: 0 });
};
const selectTab = (id: SectionId) => {
if (id === active) return;
if (tabDirty) {
setPendingSection(id);
return;
}
goSection(id);
};
const onNavKeyDown = (e: KeyboardEvent<HTMLAnchorElement>, i: number) => {
if (
e.key !== "ArrowDown" &&
e.key !== "ArrowUp" &&
e.key !== "Home" &&
e.key !== "End" &&
e.key !== "ArrowRight" &&
e.key !== "ArrowLeft"
) {
return;
}
e.preventDefault();
let next = i;
if (e.key === "ArrowDown" || e.key === "ArrowRight") next = (i + 1) % NAV.length;
else if (e.key === "ArrowUp" || e.key === "ArrowLeft") next = (i + NAV.length - 1) % NAV.length;
else if (e.key === "Home") next = 0;
else next = NAV.length - 1;
selectTab(NAV[next].id);
navRefs.current[next]?.focus();
};
const handleSaveTheme = async () => {
if (!normalized || savingTheme || themeInvalid) return;
setSavingTheme(true);
setThemeError("");
try {
const res = await apiUpdateSiteSettings({ accent: submitHex });
const next = (res.accent || "").toLowerCase();
setSavedHex(next);
applyAccentTheme(next || null);
router.refresh();
toast(next ? "主题色已保存并对全站生效" : "已恢复默认主题色");
} catch (e) {
const msg = e instanceof Error ? e.message : "保存失败";
setThemeError(msg);
toast(msg, "error");
applyAccentTheme(savedRef.current || null);
} finally {
setSavingTheme(false);
}
};
const discardTheme = () => {
setDraft(savedHex || DEFAULT_ACCENT);
setThemeError("");
applyAccentTheme(savedHex || null);
};
const revokeBlob = (url: string) => {
if (!url) return;
URL.revokeObjectURL(url);
blobsRef.current = blobsRef.current.filter((u) => u !== url);
};
const clearPending = (surface: BgSurface) => {
setBgError("");
if (surface === "site") {
setSiteFile(null);
setSiteBlob((prev) => {
revokeBlob(prev);
return "";
});
setSiteMediaId(null);
setSiteMediaUrl("");
} else {
setAdminFile(null);
setAdminBlob((prev) => {
revokeBlob(prev);
return "";
});
setAdminMediaId(null);
setAdminMediaUrl("");
}
};
const setPendingFile = (surface: BgSurface, file: File | null) => {
setBgError("");
if (!file) {
clearPending(surface);
return;
}
const blob = URL.createObjectURL(file);
blobsRef.current.push(blob);
if (surface === "site") {
setSiteFile(file);
setSiteBlob((prev) => {
revokeBlob(prev);
return blob;
});
setSiteMediaId(null);
setSiteMediaUrl("");
} else {
setAdminFile(file);
setAdminBlob((prev) => {
revokeBlob(prev);
return blob;
});
setAdminMediaId(null);
setAdminMediaUrl("");
}
};
const setPendingMedia = (surface: BgSurface, mediaId: number, mediaUrl: string) => {
setBgError("");
if (surface === "site") {
setSiteFile(null);
setSiteBlob((prev) => {
revokeBlob(prev);
return "";
});
setSiteMediaId(mediaId);
setSiteMediaUrl(mediaUrl);
} else {
setAdminFile(null);
setAdminBlob((prev) => {
revokeBlob(prev);
return "";
});
setAdminMediaId(mediaId);
setAdminMediaUrl(mediaUrl);
}
};
const discardBg = () => {
setBgDraft(savedBg);
clearPending("site");
clearPending("admin");
setBgError("");
};
const resolveSurfaceUrl = async (
surface: BgSurface,
committed: string,
file: File | null,
mediaId: number | null
): Promise<string> => {
if (file) return apiUploadSiteBackground(surface, file);
if (mediaId != null) return apiBackgroundFromMedia(surface, mediaId);
return committed;
};
const handleSaveBg = async () => {
if (savingBg || !bgDirty) return;
setSavingBg(true);
setBgError("");
try {
const siteUrl = await resolveSurfaceUrl("site", bgDraft.bg_site_url, siteFile, siteMediaId);
const adminUrl = await resolveSurfaceUrl(
"admin",
bgDraft.bg_admin_url,
adminFile,
adminMediaId
);
const res = await apiUpdateSiteSettings({
bg_site_url: siteUrl,
bg_site_mode: bgDraft.bg_site_mode,
bg_admin_url: adminUrl,
bg_admin_mode: bgDraft.bg_admin_mode,
});
const next = bgConfigFromSettings(res);
setSavedBg(next);
setBgDraft(next);
clearPending("site");
clearPending("admin");
applySiteBackground(next);
router.refresh();
toast("背景图已保存并对全站生效", "ok");
} catch (e) {
const msg = e instanceof Error ? e.message : "保存失败";
setBgError(msg);
toast(msg, "error");
} finally {
setSavingBg(false);
}
};
const onPickBg = (surface: BgSurface, file: File | undefined) => {
if (!file || savingBg) return;
if (!/^image\/(jpeg|png|webp)$/i.test(file.type) && !/\.(jpe?g|png|webp)$/i.test(file.name)) {
toast("仅支持 JPEG / PNG / WebP", "error");
return;
}
if (file.size > 8 * 1024 * 1024) {
toast("背景图不能超过 8MB", "error");
return;
}
setPendingFile(surface, file);
};
const onChangeBgMode = (surface: BgSurface, mode: BgMode) => {
const key = surface === "site" ? "bg_site_mode" : "bg_admin_mode";
setBgDraft((prev) => ({ ...prev, [key]: mode }));
};
const onClearBg = (surface: BgSurface) => {
const key = surface === "site" ? "bg_site_url" : "bg_admin_url";
setBgDraft((prev) => ({ ...prev, [key]: "" }));
clearPending(surface);
};
const previewUrl = (surface: BgSurface) => {
if (surface === "site") return siteBlob || siteMediaUrl || bgDraft.bg_site_url;
return adminBlob || adminMediaUrl || bgDraft.bg_admin_url;
};
const toggleCodeFold = async () => {
if (togglingFold) return;
const next = !codeAutoFold;
setTogglingFold(true);
try {
const res = await apiUpdateSiteSettings({ code_block_auto_fold: next });
setCodeAutoFold(res.code_block_auto_fold);
toast(
res.code_block_auto_fold
? `已开启代码块自动折叠(超过 ${res.code_block_fold_lines} 行)`
: "已关闭代码块自动折叠",
"ok"
);
} catch (e) {
toast(e instanceof Error ? e.message : "保存失败");
} finally {
setTogglingFold(false);
}
};
const saveFoldLines = async () => {
if (savingFoldLines) return;
const raw = Math.round(Number(foldLines));
const n = Number.isFinite(raw) ? Math.max(0, Math.min(100, raw)) : 5;
setSavingFoldLines(true);
try {
const res = await apiUpdateSiteSettings({ code_block_fold_lines: n });
setFoldLines(res.code_block_fold_lines);
toast(
res.code_block_fold_lines === 0
? "折叠预览已设为 0 行(无预览)"
: `折叠阈值已设为超过 ${res.code_block_fold_lines} 行`,
"ok"
);
} catch (e) {
toast(e instanceof Error ? e.message : "保存失败");
} finally {
setSavingFoldLines(false);
}
};
const toggleMotionField = async (
key: "ui_animations" | "anim_code_fold" | "anim_smooth_scroll" | "anim_chrome",
next: boolean
) => {
if (togglingMotion) return;
setTogglingMotion(true);
try {
const res = await apiUpdateSiteSettings({ [key]: next });
setUiAnimations(res.ui_animations);
setAnimCodeFold(res.anim_code_fold);
setAnimSmoothScroll(res.anim_smooth_scroll);
setAnimChrome(res.anim_chrome);
const labels: Record<typeof key, [string, string]> = {
ui_animations: ["已开启界面动画", "已关闭界面动画(全站减少动态效果)"],
anim_code_fold: ["已开启代码折叠动画", "已关闭代码折叠动画"],
anim_smooth_scroll: ["已开启平滑滚动", "已关闭平滑滚动"],
anim_chrome: ["已开启装饰入场动画", "已关闭装饰入场动画"],
};
toast(next ? labels[key][0] : labels[key][1], "ok");
} catch (e) {
toast(e instanceof Error ? e.message : "保存失败");
} finally {
setTogglingMotion(false);
}
};
};
const renderBgRail = (
surface: BgSurface,
title: string,
desc: string,
Icon: typeof Monitor,
fileRef: typeof siteFileRef
) => {
const url = previewUrl(surface);
const mode = surface === "site" ? bgDraft.bg_site_mode : bgDraft.bg_admin_mode;
const busy = savingBg;
const hasCommitted = surface === "site" ? !!bgDraft.bg_site_url : !!bgDraft.bg_admin_url;
const hasPending =
surface === "site"
? siteFile != null || siteMediaId != null
: adminFile != null || adminMediaId != null;
return (
<div className="flex flex-col gap-3 min-w-0">
<div className="min-w-0">
<h3 className="text-[14px] font-medium flex items-center gap-1.5" style={{ color: "var(--ink)" }}>
<Icon size={15} /> {title}
</h3>
<p className="meta text-[12.5px] mt-0.5 leading-relaxed">{desc}</p>
</div>
<div
className="relative w-full max-w-[480px] rounded-[var(--r-sm)] border overflow-hidden"
style={{
aspectRatio: BG_PREVIEW_ASPECT,
borderColor: "var(--line)",
...previewBoxStyle(url, mode),
}}
role="img"
aria-label={`${title}背景预览,按 16:9 常见显示器比例`}
title="按 16:9(1920×1080)显示器比例预览"
>
{!url && (
<span
className="absolute inset-0 flex items-center justify-center text-[12.5px]"
style={{ color: "var(--ink-3)" }}
>
默认纹饰画布
</span>
)}
<span
className="absolute bottom-1.5 right-1.5 text-[10.5px] leading-none px-1.5 py-1 rounded-[4px] pointer-events-none"
aria-hidden
style={{
background: "color-mix(in srgb, var(--panel) 88%, transparent)",
color: "var(--ink-3)",
border: "1px solid var(--line)",
}}
>
16:9
</span>
</div>
<input
ref={fileRef}
type="file"
accept="image/jpeg,image/png,image/webp,.jpg,.jpeg,.png,.webp"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
e.target.value = "";
onPickBg(surface, f);
}}
/>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
className="btn btn-line btn-sm"
disabled={busy}
onClick={() => fileRef.current?.click()}
>
{busy ? <Loader2 size={14} className="animate-spin" /> : <Upload size={14} />}
{hasCommitted || hasPending ? "更换图片" : "选择图片"}
</button>
<button
type="button"
className="btn btn-line btn-sm"
disabled={busy}
onClick={() => setBgPicker(surface)}
>
<Images size={14} /> 媒体库
</button>
{hasCommitted || hasPending ? (
<button
type="button"
className="btn btn-line btn-sm"
disabled={busy}
onClick={() => onClearBg(surface)}
>
<Trash2 size={14} /> 恢复默认
</button>
) : null}
</div>
<div>
<p className="field-label mb-2">铺放方式</p>
<BgModePicker
value={mode}
disabled={busy}
ariaLabel={`${title}铺放方式`}
onChange={(next) => onChangeBgMode(surface, next)}
/>
</div>
</div>
);
};
const leaveOpen = leave.leaveOpen || pendingSection != null;
const confirmLeave = () => {
if (pendingSection) {
const next = pendingSection;
setPendingSection(null);
if (themeDirty) discardTheme();
if (bgDirty) discardBg();
goSection(next);
return;
}
leave.confirmLeave();
};
const nav = (
<nav className="admin-settings-nav" aria-label="外观设置分类">
{NAV.map((item, i) => (
<a
key={item.id}
ref={(el) => {
navRefs.current[i] = el;
}}
href={`#${item.id}`}
className="admin-settings-nav-link"
aria-current={active === item.id ? "page" : undefined}
onClick={(e) => {
e.preventDefault();
selectTab(item.id);
}}
onKeyDown={(e) => onNavKeyDown(e, i)}
>
{item.label}
</a>
))}
</nav>
);
return (
<AdminSettingsPage>
<AdminPageHeader
icon={Palette}
title="外观设置"
description="主题色与背景图需保存后全站生效;代码折叠与动画开关改完立即写入"
/>
<AdminSettingsWork nav={nav}>
<div id={`appearance-panel-${active}`} className="admin-settings-stack">
{active === "theme" && (
<>
<AdminSettingsGroup
title="预设主题色"
description="浅/暗两套色板均已调校,保证文字对比度达标。修改后本页实时预览,保存后全站生效。"
>
<div className="grid grid-cols-3 sm:grid-cols-6 gap-3 px-1 pb-1">
{ACCENT_PRESETS.map((p) => {
const selected = normalized === p.light;
const isDefault = p.light === DEFAULT_ACCENT;
return (
<button
key={p.key}
type="button"
onClick={() => {
setThemeError("");
setDraft(p.light);
}}
className="group flex flex-col items-center gap-2 py-3 rounded-[var(--r-sm)] border transition-colors"
style={{
borderColor: selected ? "var(--accent)" : "var(--line)",
background: selected ? "var(--accent-soft)" : "var(--panel)",
cursor: "pointer",
}}
aria-pressed={selected}
data-tip={isDefault ? `${p.name}(默认)` : p.name}
>
<span className="relative flex items-center justify-center">
<span className="w-9 h-9 rounded-full overflow-hidden grid grid-rows-2 border-2 border-[var(--panel)] shadow-sm">
<span style={{ background: p.light }} />
<span style={{ background: p.dark }} />
</span>
{selected && (
<span
className="absolute -bottom-1 -right-1 min-w-[18px] min-h-[18px] w-[18px] h-[18px] rounded-full flex items-center justify-center"
style={{ background: "var(--accent)", color: "var(--accent-on)" }}
>
<Check size={11} strokeWidth={3} />
</span>
)}
</span>
<span
className="text-[12px] leading-none"
style={{ color: selected ? "var(--accent)" : "var(--ink-2)" }}
>
{p.name}
</span>
</button>
);
})}
</div>
</AdminSettingsGroup>
<AdminSettingsGroup
title="自定义颜色"
description="取色器选色或输入 HEX;浅色下对比度过低时会自动校正。"
footer={
<AdminSettingsActions
dirty={themeDirty}
saving={savingTheme}
saveLabel="保存并全站生效"
onSave={() => void handleSaveTheme()}
onDiscard={discardTheme}
saveDisabled={!normalized || savingTheme || !themeDirty || themeInvalid}
error={themeError}
>
<button
type="button"
className="btn btn-line btn-sm"
disabled={savingTheme}
onClick={() => {
setThemeError("");
setDraft(DEFAULT_ACCENT);
}}
>
<RotateCcw size={14} /> 恢复默认
</button>
</AdminSettingsActions>
}
>
<AdminSettingsRow
label={
<span className="inline-flex items-center gap-1.5">
<Pipette size={15} /> 主题色
</span>
}
hintBelow="保存前仅本页预览,不会推送给其他访客。"
error={themeInvalid ? "颜色格式无效,请输入 6 位 HEX 值(如 3B5BDB)" : undefined}
>
<div className="flex flex-wrap items-center gap-3">
<label
className="relative w-11 h-11 rounded-[var(--r-sm)] overflow-hidden border cursor-pointer shrink-0"
style={{ borderColor: "var(--line-2)" }}
data-tip="打开取色器"
>
<input
type="color"
className="absolute inset-0 w-[200%] h-[200%] -translate-x-1/4 -translate-y-1/4 cursor-pointer border-0 p-0 bg-transparent"
value={normalized || DEFAULT_ACCENT}
onChange={(e) => {
setThemeError("");
setDraft(e.target.value);
}}
aria-label="自定义主题色取色器"
/>
</label>
<div
className={`admin-field-shell px-3 h-11 font-mono text-[14px] w-40${themeInvalid ? " is-invalid" : ""}`}
>
<span style={{ color: "var(--ink-3)" }}>#</span>
<input
value={draft.startsWith("#") ? draft.slice(1) : draft}
onChange={(e) => {
setThemeError("");
setDraft("#" + e.target.value.replace(/[^0-9a-fA-F#]/g, ""));
}}
className="admin-field-input flex-1 min-w-0 px-0 py-0 ml-1 uppercase"
maxLength={7}
spellCheck={false}
aria-label="主题色 HEX 值"
/>
</div>
{pair && (
<div className="flex items-center gap-2 text-[12px]" style={{ color: "var(--ink-3)" }}>
<span className="inline-flex items-center gap-1.5">
<span
className="w-3.5 h-3.5 rounded-full border"
style={{ background: pair.light, borderColor: "var(--line)" }}
/>
浅色 {pair.light}
</span>
<span className="inline-flex items-center gap-1.5">
<span
className="w-3.5 h-3.5 rounded-full border"
style={{ background: pair.dark, borderColor: "var(--line)" }}
/>
暗色 {pair.dark}
</span>
</div>
)}
</div>
{pair?.adjusted && (
<p className="mt-2.5 text-[12.5px]" style={{ color: "var(--gold)" }}>
该颜色与白底对比度不足,浅色模式已自动校正为 {pair.light}(暗色模式同步派生{" "}
{pair.dark})
</p>
)}
{preset && preset.light === DEFAULT_ACCENT && (
<p className="mt-2.5 text-[12.5px] meta">当前为内置默认色,保存时会清除自定义配置</p>
)}
</AdminSettingsRow>
<AdminSettingsRow label="实时预览" wide>
<div
className="rounded-[var(--r-sm)] border p-4 space-y-4"
style={{ borderColor: "var(--line)", background: "var(--bg)" }}
>
<div className="flex flex-wrap items-center gap-2.5">
<button type="button" className="btn btn-primary btn-sm">
<Save size={14} /> 主按钮
</button>
<button type="button" className="btn btn-line btn-sm">
次要按钮
</button>
<a
className="text-[13px]"
style={{ color: "var(--accent)" }}
href="#preview"
onClick={(e) => e.preventDefault()}
>
文字链接示例
</a>
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="admin-seg">未选中</span>
<span className="admin-seg" aria-selected="true">
选中标签
</span>
<span
className="inline-flex items-center rounded-full px-3.5 py-2 text-[13.5px]"
style={{ background: "var(--nav-active-bg)", color: "var(--nav-active-ink)" }}
>
导航选中态
</span>
</div>
<div className="max-w-xs">
<section className="j13-module">
<p className="j13-module-title mb-2.5">板块目录</p>
<div className="j13-bcard" aria-current="page">
<span
className="j13-bcard-ico"
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
>
<Palette size={16} />
</span>
<span className="j13-bcard-name flex-1">选中的板块</span>
</div>
</section>
</div>
</div>
</AdminSettingsRow>
</AdminSettingsGroup>
</>
)}
{active === "background" && (
<AdminSettingsGroup
title="站点背景图"
description="前台与后台可分别配置 JPEG / PNG / WebP(≤8MB)。预览按 16:9。未保存不会推送给其他访客。"
footer={
<AdminSettingsActions
dirty={bgDirty}
saving={savingBg}
saveLabel="保存并全站生效"
onSave={() => void handleSaveBg()}
onDiscard={discardBg}
error={bgError}
/>
}
>
<AdminSettingsRow
label={
<span className="inline-flex items-center gap-1.5">
<ImageIcon size={15} /> 前台 / 后台
</span>
}
hintBelow="选择图片与铺放方式仅在本页预览;点保存后才写入并广播。"
wide
>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{renderBgRail(
"site",
"前台",
"访客看到的论坛页面,含首页、帖子与个人主页。",
Monitor,
siteFileRef
)}
{renderBgRail(
"admin",
"后台",
"管理面板专用。与前台互不影响,可配更克制的图案。",
LayoutDashboard,
adminFileRef
)}
</div>
</AdminSettingsRow>
</AdminSettingsGroup>
)}
{active === "code" && (
<AdminSettingsGroup
title="代码块自动折叠"
description="超过阈值行数的 Markdown 代码块默认折叠,读者可点「展开全部」。"
>
<AdminSettingsRow
label={
<span className="inline-flex items-center gap-1.5">
<Code2 size={15} /> 自动折叠
</span>
}
>
<div className="flex items-center gap-2">
<AdminSwitch
checked={codeAutoFold}
disabled={togglingFold}
onChange={() => void toggleCodeFold()}
label="代码块自动折叠"
/>
<InstantNote saving={togglingFold} />
</div>
</AdminSettingsRow>
{codeAutoFold ? (
<AdminSettingsRow
label="折叠行数阈值"
htmlFor="fold-lines"
hintBelow="超过该值时折叠;折叠后约露出同样行数。设为 0 表示无预览。范围 0–100,默认 5。"
>
<div className="flex flex-wrap items-center gap-2.5">
<AdminStepper
id="fold-lines"
value={foldLines}
onChange={setFoldLines}
min={0}
max={100}
unit="行"
ariaLabel="折叠行数阈值"
disabled={savingFoldLines}
/>
<button
type="button"
className="btn btn-line btn-sm"
disabled={savingFoldLines}
onClick={() => void saveFoldLines()}
>
{savingFoldLines ? (
<Loader2 size={14} className="animate-spin" />
) : (
<Save size={14} />
)}
保存
</button>
</div>
</AdminSettingsRow>
) : null}
</AdminSettingsGroup>
)}
{active === "motion" && (
<AdminSettingsGroup
title="界面动画"
description="总开关关闭后全站即时切换。系统「减少动态效果」始终优先。"
>
<AdminSettingsRow
label={
<span className="inline-flex items-center gap-1.5">
<Sparkles size={15} /> 总开关
</span>
}
>
<div className="flex items-center gap-2">
<AdminSwitch
checked={uiAnimations}
disabled={togglingMotion}
onChange={() => void toggleMotionField("ui_animations", !uiAnimations)}
label="界面动画总开关"
/>
<InstantNote saving={togglingMotion} />
</div>
</AdminSettingsRow>
{uiAnimations ? (
<>
<AdminSettingsRow
label="代码折叠动画"
hintBelow="代码块展开/收起的高度过渡与跟滚。"
>
<div className="flex items-center gap-2">
<AdminSwitch
checked={animCodeFold}
disabled={togglingMotion}
onChange={() => void toggleMotionField("anim_code_fold", !animCodeFold)}
label="代码折叠动画"
/>
<InstantNote saving={togglingMotion} />
</div>
</AdminSettingsRow>
<AdminSettingsRow
label="平滑滚动"
hintBelow="目录跳转、回顶、新内容提示等 scroll 行为。"
>
<div className="flex items-center gap-2">
<AdminSwitch
checked={animSmoothScroll}
disabled={togglingMotion}
onChange={() =>
void toggleMotionField("anim_smooth_scroll", !animSmoothScroll)
}
label="平滑滚动"
/>
<InstantNote saving={togglingMotion} />
</div>
</AdminSettingsRow>
<AdminSettingsRow
label="装饰入场"
hintBelow="列表入场、Toast、点赞弹出、在线脉冲等装饰性动画。"
>
<div className="flex items-center gap-2">
<AdminSwitch
checked={animChrome}
disabled={togglingMotion}
onChange={() => void toggleMotionField("anim_chrome", !animChrome)}
label="装饰入场"
/>
<InstantNote saving={togglingMotion} />
</div>
</AdminSettingsRow>
</>
) : null}
</AdminSettingsGroup>
)}
</div>
</AdminSettingsWork>
<ConfirmDialog
open={leaveOpen}
title="有未保存的修改"
message="离开后当前分类的草稿将丢弃,确定继续?"
confirmLabel="丢弃并离开"
cancelLabel="继续编辑"
danger
onConfirm={confirmLeave}
onCancel={() => {
setPendingSection(null);
leave.cancelLeave();
}}
/>
<BgMediaPicker
open={bgPicker != null}
surface={bgPicker || "site"}
onClose={() => setBgPicker(null)}
onPick={(item) => {
if (!bgPicker) return;
setPendingMedia(bgPicker, item.id, item.url);
}}
/>
</AdminSettingsPage>
);
}