902 lines
32 KiB
TypeScript
902 lines
32 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useMemo, useRef, useState } 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,
|
||
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";
|
||
|
||
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 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 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);
|
||
};
|
||
}, []);
|
||
|
||
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 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>
|
||
);
|
||
};
|
||
|
||
return (
|
||
<AdminSettingsPage>
|
||
<AdminPageHeader
|
||
icon={Palette}
|
||
title="外观设置"
|
||
description="主题色与背景图需保存后全站生效;代码折叠与动画开关改完立即写入"
|
||
/>
|
||
|
||
<div className="admin-settings-stack">
|
||
<AdminSettingsGroup
|
||
title="主题色"
|
||
description="本页实时预览,点保存后才推送给全站访客。"
|
||
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="预设"
|
||
hintBelow="浅/暗两套色板均已调校,保证文字对比度达标。"
|
||
wide
|
||
>
|
||
<div className="grid grid-cols-3 sm:grid-cols-6 gap-3">
|
||
{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>
|
||
</AdminSettingsRow>
|
||
|
||
<AdminSettingsRow
|
||
label={
|
||
<span className="inline-flex items-center gap-1.5">
|
||
<Pipette size={15} /> 自定义
|
||
</span>
|
||
}
|
||
hintBelow="取色器或 HEX;浅色对比不足时会自动校正。"
|
||
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>
|
||
|
||
<AdminSettingsGroup
|
||
title="站点背景图"
|
||
description="前台与后台可分别配置 JPEG / PNG / WebP(≤8MB)。本页预览,保存后才全站生效。"
|
||
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>
|
||
|
||
<AdminSettingsGroup
|
||
title="代码块与动画"
|
||
description="开关改完立即写入。系统「减少动态效果」始终优先于站点动画。"
|
||
>
|
||
<AdminSettingsRow
|
||
label={
|
||
<span className="inline-flex items-center gap-1.5">
|
||
<Code2 size={15} /> 代码自动折叠
|
||
</span>
|
||
}
|
||
hintBelow="超过阈值行数的 Markdown 代码块默认折叠,读者可展开。"
|
||
>
|
||
<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}
|
||
|
||
<AdminSettingsRow
|
||
label={
|
||
<span className="inline-flex items-center gap-1.5">
|
||
<Sparkles size={15} /> 界面动画
|
||
</span>
|
||
}
|
||
hintBelow="总开关关闭后全站即时减少动态效果。"
|
||
>
|
||
<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>
|
||
|
||
<ConfirmDialog
|
||
open={leave.leaveOpen}
|
||
title="有未保存的修改"
|
||
message="离开后主题色或背景图的草稿将丢弃,确定继续?"
|
||
confirmLabel="丢弃并离开"
|
||
cancelLabel="继续编辑"
|
||
danger
|
||
onConfirm={leave.confirmLeave}
|
||
onCancel={leave.cancelLeave}
|
||
/>
|
||
<BgMediaPicker
|
||
open={bgPicker != null}
|
||
surface={bgPicker || "site"}
|
||
onClose={() => setBgPicker(null)}
|
||
onPick={(item) => {
|
||
if (!bgPicker) return;
|
||
setPendingMedia(bgPicker, item.id, item.url);
|
||
}}
|
||
/>
|
||
</AdminSettingsPage>
|
||
);
|
||
}
|