Files
jiang13-bbs/frontend/app/admin/appearance/AppearanceAdmin.tsx
freefire d784b0ea7a feat: 交付官方 Docker 运行时,外观改背景图并下线自定义 CSS/JS
站点/后台分轨背景与用户列表排序一并落地;生产 CORS 改走 SITE_URL,健康检查带版本号。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-18 05:27:33 +08:00

731 lines
27 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 { useRouter } from "next/navigation";
import {
Palette,
Check,
Code2,
Loader2,
Save,
Undo2,
RotateCcw,
Pipette,
Sparkles,
ImageIcon,
Monitor,
LayoutDashboard,
Trash2,
Upload,
} from "lucide-react";
import {
AdminPageHeader,
AdminSection,
AdminSettingRow,
AdminStepper,
AdminSwitch,
AdminSegmented,
} from "@/components/admin";
import {
apiUpdateSiteSettings,
apiUploadSiteBackground,
type PublicSettings,
} from "@/lib/api";
import { toast } from "@/lib/toast";
import {
ACCENT_PRESETS,
DEFAULT_ACCENT,
applyAccentTheme,
deriveAccentPair,
findPreset,
normalizeHex,
} from "@/lib/theme";
import {
BG_MODE_OPTIONS,
applySiteBackground,
bgConfigFromSettings,
normalizeBgMode,
previewBoxStyle,
type BgMode,
type BgSurface,
type SiteBgConfig,
} from "@/lib/bg";
// 后台「外观设置」:主题色、代码块折叠与界面动画。
// 主题色修改时立即对整页实时预览(仅本地),保存后才写库并对所有用户生效。
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 [saving, setSaving] = useState(false);
const [bg, setBg] = useState<SiteBgConfig>(() => bgConfigFromSettings(initial));
const [bgBusy, setBgBusy] = useState<BgSurface | null>(null);
const siteFileRef = useRef<HTMLInputElement>(null);
const adminFileRef = useRef<HTMLInputElement>(null);
const savedRef = useRef(savedHex);
savedRef.current = savedHex;
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 dirty = (savedHex || DEFAULT_ACCENT) !== (normalized || "");
const invalid = draft.trim() !== "" && !normalized;
useEffect(() => {
if (normalized) applyAccentTheme(normalized);
return () => {
applyAccentTheme(savedRef.current || null);
};
}, [normalized]);
const handleSave = async () => {
if (!normalized || saving) return;
setSaving(true);
try {
const res = await apiUpdateSiteSettings({ accent: submitHex });
const next = (res.accent || "").toLowerCase();
setSavedHex(next);
applyAccentTheme(next || null);
router.refresh();
toast(next ? "主题色已保存并对全站生效" : "已恢复默认主题色");
} catch (e) {
toast(e instanceof Error ? e.message : "保存失败", "error");
applyAccentTheme(savedRef.current || null);
} finally {
setSaving(false);
}
};
const discard = () => {
setDraft(savedHex || DEFAULT_ACCENT);
applyAccentTheme(savedHex || null);
};
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 commitBg = (next: SiteBgConfig) => {
setBg(next);
applySiteBackground(next);
};
const saveBgPatch = async (patch: Partial<SiteBgConfig>, toastOk: string) => {
const res = await apiUpdateSiteSettings(patch);
commitBg(bgConfigFromSettings(res));
toast(toastOk, "ok");
};
const onPickBg = async (surface: BgSurface, file: File | undefined) => {
if (!file || bgBusy) 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;
}
setBgBusy(surface);
try {
const pendingMode = surface === "site" ? bg.bg_site_mode : bg.bg_admin_mode;
let res = await apiUploadSiteBackground(surface, file);
let next = bgConfigFromSettings(res);
const savedMode = surface === "site" ? next.bg_site_mode : next.bg_admin_mode;
if (pendingMode !== savedMode) {
const modeKey = surface === "site" ? "bg_site_mode" : "bg_admin_mode";
res = await apiUpdateSiteSettings({ [modeKey]: pendingMode });
next = bgConfigFromSettings(res);
}
commitBg(next);
toast(surface === "site" ? "前台背景已更新" : "后台背景已更新", "ok");
} catch (e) {
toast(e instanceof Error ? e.message : "上传失败", "error");
} finally {
setBgBusy(null);
}
};
const onChangeBgMode = async (surface: BgSurface, mode: BgMode) => {
if (bgBusy) return;
const key = surface === "site" ? "bg_site_mode" : "bg_admin_mode";
const url = surface === "site" ? bg.bg_site_url : bg.bg_admin_url;
if (!url) {
commitBg({ ...bg, [key]: mode });
return;
}
setBgBusy(surface);
try {
await saveBgPatch(
{ [key]: mode },
surface === "site" ? "前台铺放模式已保存" : "后台铺放模式已保存"
);
} catch (e) {
toast(e instanceof Error ? e.message : "保存失败", "error");
} finally {
setBgBusy(null);
}
};
const onClearBg = async (surface: BgSurface) => {
if (bgBusy) return;
const key = surface === "site" ? "bg_site_url" : "bg_admin_url";
setBgBusy(surface);
try {
await saveBgPatch(
{ [key]: "" },
surface === "site" ? "已恢复前台默认画布" : "已恢复后台默认画布"
);
} catch (e) {
toast(e instanceof Error ? e.message : "保存失败", "error");
} finally {
setBgBusy(null);
}
};
const renderBgRail = (
surface: BgSurface,
title: string,
desc: string,
Icon: typeof Monitor,
fileRef: typeof siteFileRef
) => {
const url = surface === "site" ? bg.bg_site_url : bg.bg_admin_url;
const mode = surface === "site" ? bg.bg_site_mode : bg.bg_admin_mode;
const busy = bgBusy === surface;
return (
<div className="flex flex-col gap-3 min-w-0">
<div className="flex items-start justify-between gap-2">
<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>
<div
className="relative h-36 rounded-[var(--r-sm)] border overflow-hidden"
style={{
borderColor: "var(--line)",
...previewBoxStyle(url, mode),
}}
>
{!url && (
<span
className="absolute inset-0 flex items-center justify-center text-[12.5px]"
style={{ color: "var(--ink-3)" }}
>
默认纹饰画布
</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 = "";
void 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} />}
{url ? "更换图片" : "上传图片"}
</button>
{url ? (
<button
type="button"
className="btn btn-line btn-sm"
disabled={busy}
onClick={() => void onClearBg(surface)}
>
<Trash2 size={14} /> 恢复默认
</button>
) : null}
</div>
<div>
<p className="field-label mb-2">铺放模式</p>
<AdminSegmented
size="sm"
role="group"
ariaLabel={`${title}铺放模式`}
value={mode}
onChange={(key) => void onChangeBgMode(surface, normalizeBgMode(key))}
options={BG_MODE_OPTIONS.map((o) => ({
key: o.key,
label: o.label,
disabled: busy,
}))}
/>
<p className="meta text-[12px] mt-1.5">
{BG_MODE_OPTIONS.find((o) => o.key === mode)?.hint}
</p>
</div>
</div>
);
};
return (
<div>
<AdminPageHeader
icon={Palette}
title="外观设置"
description="配置站点主题色、背景图、代码块折叠与界面动画"
/>
<div className="grid grid-cols-1 xl:grid-cols-3 gap-4 mb-6">
{/* 主题色列 */}
<div className="flex flex-col gap-4 xl:col-span-2">
<AdminSection
title="预设主题色"
description="浅/暗两套色板均已调校,保证文字对比度达标"
>
<div className="grid grid-cols-3 sm:grid-cols-6 gap-3">
{ACCENT_PRESETS.map((p) => {
const active = normalized === p.light;
const isDefault = p.light === DEFAULT_ACCENT;
return (
<button
key={p.key}
type="button"
onClick={() => setDraft(p.light)}
className="group flex flex-col items-center gap-2 py-3 rounded-[var(--r-sm)] border transition-colors"
style={{
borderColor: active ? "var(--accent)" : "var(--line)",
background: active ? "var(--accent-soft)" : "var(--panel)",
cursor: "pointer",
}}
aria-pressed={active}
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>
{active && (
<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: active ? "var(--accent)" : "var(--ink-2)" }}
>
{p.name}
</span>
</button>
);
})}
</div>
</AdminSection>
<AdminSection
title={
<span className="inline-flex items-center gap-1.5">
<Pipette size={15} /> 自定义颜色
</span>
}
description="取色器选色或输入 HEX 值;浅色下对比度过低时会自动校正"
>
<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) => setDraft(e.target.value)}
aria-label="自定义主题色取色器"
/>
</label>
<div className="flex flex-col gap-1">
<div
className={`admin-field-shell px-3 h-11 font-mono text-[14px] w-40${invalid ? " is-invalid" : ""}`}
>
<span style={{ color: "var(--ink-3)" }}>#</span>
<input
value={draft.startsWith("#") ? draft.slice(1) : draft}
onChange={(e) => 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>
</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>
{invalid && (
<p className="mt-2.5 text-[12.5px]" style={{ color: "var(--danger)" }}>
颜色格式无效,请输入 6 位 HEX 值(如 3B5BDB)
</p>
)}
{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>
)}
</AdminSection>
<AdminSection title="实时预览">
<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>
</AdminSection>
</div>
{/* 代码折叠 + 动画列 */}
<div className="flex flex-col gap-4">
<AdminSettingRow
title={
<>
<Code2 size={15} /> 代码块自动折叠
</>
}
description="超过阈值行数的 Markdown 代码块默认折叠,读者可点「展开全部」。"
control={
<AdminSwitch
checked={codeAutoFold}
disabled={togglingFold}
onChange={() => void toggleCodeFold()}
label="代码块自动折叠"
/>
}
>
{codeAutoFold ? (
<>
<label className="field-label" htmlFor="fold-lines">
折叠行数阈值
</label>
<p className="meta text-[12.5px] mb-2.5 leading-relaxed">
超过该值时折叠;折叠后约露出同样行数。设为 0 表示无预览。范围 0–100,默认 5。
</p>
<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>
</>
) : null}
</AdminSettingRow>
<AdminSettingRow
title={
<>
<Sparkles size={15} /> 界面动画
</>
}
description="总开关关闭后全站即时切换。系统「减少动态效果」始终优先。"
control={
<AdminSwitch
checked={uiAnimations}
disabled={togglingMotion}
onChange={() => void toggleMotionField("ui_animations", !uiAnimations)}
label="界面动画总开关"
/>
}
>
{uiAnimations ? (
<div className="space-y-4">
<div className="flex items-start justify-between gap-4 flex-wrap">
<div className="min-w-0 flex-1">
<p className="text-[13.5px] font-medium" style={{ color: "var(--ink)" }}>
代码折叠动画
</p>
<p className="meta text-[12.5px] mt-0.5 leading-relaxed">
代码块展开/收起的高度过渡与跟滚。
</p>
</div>
<AdminSwitch
checked={animCodeFold}
disabled={togglingMotion}
onChange={() => void toggleMotionField("anim_code_fold", !animCodeFold)}
label="代码折叠动画"
/>
</div>
<div className="flex items-start justify-between gap-4 flex-wrap">
<div className="min-w-0 flex-1">
<p className="text-[13.5px] font-medium" style={{ color: "var(--ink)" }}>
平滑滚动
</p>
<p className="meta text-[12.5px] mt-0.5 leading-relaxed">
目录跳转、回顶、新内容提示等 scroll 行为。
</p>
</div>
<AdminSwitch
checked={animSmoothScroll}
disabled={togglingMotion}
onChange={() =>
void toggleMotionField("anim_smooth_scroll", !animSmoothScroll)
}
label="平滑滚动"
/>
</div>
<div className="flex items-start justify-between gap-4 flex-wrap">
<div className="min-w-0 flex-1">
<p className="text-[13.5px] font-medium" style={{ color: "var(--ink)" }}>
装饰入场
</p>
<p className="meta text-[12.5px] mt-0.5 leading-relaxed">
列表入场、Toast、点赞弹出、在线脉冲等装饰性动画。
</p>
</div>
<AdminSwitch
checked={animChrome}
disabled={togglingMotion}
onChange={() => void toggleMotionField("anim_chrome", !animChrome)}
label="装饰入场"
/>
</div>
</div>
) : null}
</AdminSettingRow>
</div>
</div>
<AdminSection
className="mb-6"
title={
<span className="inline-flex items-center gap-1.5">
<ImageIcon size={15} /> 站点背景图
</span>
}
description="前台与后台可分别上传 JPEG / PNG / WebP(≤8MB)。未配置时使用默认纹饰画布。"
>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{renderBgRail(
"site",
"前台",
"访客看到的论坛页面,含首页、帖子与个人主页。",
Monitor,
siteFileRef
)}
{renderBgRail(
"admin",
"后台",
"管理面板专用。与前台互不影响,可配更克制的图案。",
LayoutDashboard,
adminFileRef
)}
</div>
</AdminSection>
<div className="flex items-center justify-between gap-3 sticky bottom-5 panel !p-3.5 pl-5 z-10">
<p className="text-[12.5px] meta hidden sm:block">
{dirty ? "有未保存的改动,整页正处于实时预览状态" : "设置已是保存状态"}
</p>
<div className="flex items-center gap-2.5 ml-auto">
{dirty && (
<button type="button" className="btn btn-line btn-sm" onClick={discard}>
<Undo2 size={14} /> 撤销改动
</button>
)}
<button
type="button"
className="btn btn-line btn-sm"
onClick={() => {
setDraft(DEFAULT_ACCENT);
}}
>
<RotateCcw size={14} /> 恢复默认
</button>
<button
type="button"
className="btn btn-primary btn-sm"
onClick={handleSave}
disabled={!normalized || saving || !dirty}
>
{saving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
保存并全站生效
</button>
</div>
</div>
</div>
);
}