外观设置改为单页分区;发帖草稿与板块校验优化,WS 延迟释放避开 Strict Mode 误断。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
Palette,
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
AdminSettingsGroup,
|
||||
AdminSettingsPage,
|
||||
AdminSettingsRow,
|
||||
AdminSettingsWork,
|
||||
AdminStepper,
|
||||
AdminSwitch,
|
||||
} from "@/components/admin";
|
||||
@@ -57,21 +56,6 @@ import {
|
||||
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>;
|
||||
}
|
||||
@@ -85,7 +69,7 @@ function bgEqual(a: SiteBgConfig, b: SiteBgConfig): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
// 后台「外观设置」:半屏导航;主题色/背景图草稿本地预览,保存后才写库并广播。
|
||||
// 后台「外观设置」:单页分区;主题色/背景图草稿本地预览,保存后才写库并广播。
|
||||
export default function AppearanceAdmin({ initial }: { initial: PublicSettings }) {
|
||||
const router = useRouter();
|
||||
const initialAccent = initial.accent || "";
|
||||
@@ -128,10 +112,6 @@ export default function AppearanceAdmin({ initial }: { initial: PublicSettings }
|
||||
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),
|
||||
@@ -147,8 +127,6 @@ export default function AppearanceAdmin({ initial }: { initial: PublicSettings }
|
||||
adminFile != null ||
|
||||
siteMediaId != null ||
|
||||
adminMediaId != null;
|
||||
const tabDirty =
|
||||
(active === "theme" && themeDirty) || (active === "background" && bgDirty);
|
||||
const leave = useUnsavedGuard(themeDirty || bgDirty);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -172,53 +150,6 @@ export default function AppearanceAdmin({ initial }: { initial: PublicSettings }
|
||||
};
|
||||
}, []);
|
||||
|
||||
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);
|
||||
@@ -470,8 +401,6 @@ export default function AppearanceAdmin({ initial }: { initial: PublicSettings }
|
||||
}
|
||||
};
|
||||
|
||||
};
|
||||
|
||||
const renderBgRail = (
|
||||
surface: BgSurface,
|
||||
title: string,
|
||||
@@ -496,7 +425,7 @@ export default function AppearanceAdmin({ initial }: { initial: PublicSettings }
|
||||
<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"
|
||||
className="relative w-full rounded-[var(--r-sm)] border overflow-hidden"
|
||||
style={{
|
||||
aspectRatio: BG_PREVIEW_ASPECT,
|
||||
borderColor: "var(--line)",
|
||||
@@ -579,42 +508,6 @@ export default function AppearanceAdmin({ initial }: { initial: PublicSettings }
|
||||
);
|
||||
};
|
||||
|
||||
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
|
||||
@@ -623,402 +516,376 @@ export default function AppearanceAdmin({ initial }: { initial: PublicSettings }
|
||||
description="主题色与背景图需保存后全站生效;代码折叠与动画开关改完立即写入"
|
||||
/>
|
||||
|
||||
<AdminSettingsWork nav={nav}>
|
||||
<div id={`appearance-panel-${active}`} className="admin-settings-stack">
|
||||
{active === "theme" && (
|
||||
<>
|
||||
<AdminSettingsGroup
|
||||
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);
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
<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="text-[12px] leading-none"
|
||||
style={{ color: selected ? "var(--accent)" : "var(--ink-2)" }}
|
||||
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)" }}
|
||||
>
|
||||
{p.name}
|
||||
<Check size={11} strokeWidth={3} />
|
||||
</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>
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
className="text-[12px] leading-none"
|
||||
style={{ color: selected ? "var(--accent)" : "var(--ink-2)" }}
|
||||
>
|
||||
{p.name}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</AdminSettingsRow>
|
||||
|
||||
{active === "motion" && (
|
||||
<AdminSettingsGroup
|
||||
title="界面动画"
|
||||
description="总开关关闭后全站即时切换。系统「减少动态效果」始终优先。"
|
||||
>
|
||||
<AdminSettingsRow
|
||||
label={
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Sparkles size={15} /> 总开关
|
||||
</span>
|
||||
}
|
||||
<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={uiAnimations}
|
||||
checked={animCodeFold}
|
||||
disabled={togglingMotion}
|
||||
onChange={() => void toggleMotionField("ui_animations", !uiAnimations)}
|
||||
label="界面动画总开关"
|
||||
onChange={() => void toggleMotionField("anim_code_fold", !animCodeFold)}
|
||||
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
|
||||
<AdminSettingsRow label="平滑滚动" hintBelow="目录跳转、回顶、新内容提示等 scroll 行为。">
|
||||
<div className="flex items-center gap-2">
|
||||
<AdminSwitch
|
||||
checked={animSmoothScroll}
|
||||
disabled={togglingMotion}
|
||||
onChange={() => void toggleMotionField("anim_smooth_scroll", !animSmoothScroll)}
|
||||
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
|
||||
/>
|
||||
<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="装饰入场"
|
||||
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>
|
||||
/>
|
||||
<InstantNote saving={togglingMotion} />
|
||||
</div>
|
||||
</AdminSettingsRow>
|
||||
</>
|
||||
) : null}
|
||||
</AdminSettingsGroup>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={leaveOpen}
|
||||
open={leave.leaveOpen}
|
||||
title="有未保存的修改"
|
||||
message="离开后当前分类的草稿将丢弃,确定继续?"
|
||||
message="离开后主题色或背景图的草稿将丢弃,确定继续?"
|
||||
confirmLabel="丢弃并离开"
|
||||
cancelLabel="继续编辑"
|
||||
danger
|
||||
onConfirm={confirmLeave}
|
||||
onCancel={() => {
|
||||
setPendingSection(null);
|
||||
leave.cancelLeave();
|
||||
}}
|
||||
onConfirm={leave.confirmLeave}
|
||||
onCancel={leave.cancelLeave}
|
||||
/>
|
||||
<BgMediaPicker
|
||||
open={bgPicker != null}
|
||||
|
||||
@@ -41,7 +41,6 @@ import Modal from "@/components/Modal";
|
||||
import RoleAccountModal from "@/components/RoleAccountModal";
|
||||
import {
|
||||
AdminEmpty,
|
||||
AdminIconButton,
|
||||
AdminPageHeader,
|
||||
AdminPager,
|
||||
AdminSearchField,
|
||||
@@ -523,7 +522,8 @@ export default function UsersAdmin({
|
||||
frags.push("100px");
|
||||
break;
|
||||
case "actions":
|
||||
frags.push("minmax(148px,1.8fr)");
|
||||
// 带文案按钮:档案 / 主页 / 角色与账号
|
||||
frags.push("minmax(268px,2.2fr)");
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -665,30 +665,35 @@ export default function UsersAdmin({
|
||||
const renderActions = (u: AdminUser, isSelf: boolean) => {
|
||||
const canAudit = isOwner(currentUserRole);
|
||||
const auditBtn = canAudit ? (
|
||||
<AdminIconButton
|
||||
<Link
|
||||
href={`/admin/users/${u.id}`}
|
||||
tip="内容档案"
|
||||
ariaLabel={`查看 ${u.username} 的内容档案`}
|
||||
tone="accent"
|
||||
className="btn btn-line btn-sm gap-1"
|
||||
aria-label={`查看 ${u.username} 的内容档案`}
|
||||
>
|
||||
<FolderSearch size={15} />
|
||||
</AdminIconButton>
|
||||
<FolderSearch size={13} />
|
||||
内容档案
|
||||
</Link>
|
||||
) : null;
|
||||
|
||||
if (u.role === ROLES.OWNER) {
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
{auditBtn}
|
||||
<AdminIconButton href={`/u/${u.id}`} tip="查看个人主页" ariaLabel={`查看 ${u.nickname} 的个人主页`}>
|
||||
<ExternalLink size={15} />
|
||||
</AdminIconButton>
|
||||
<Link href={`/u/${u.id}`} className="btn btn-line btn-sm gap-1" aria-label={`查看 ${u.nickname} 的个人主页`}>
|
||||
<ExternalLink size={13} />
|
||||
主页
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (isSelf) {
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
{auditBtn}
|
||||
<Link href={`/u/${u.id}`} className="btn btn-line btn-sm gap-1" aria-label={`查看 ${u.nickname} 的个人主页`}>
|
||||
<ExternalLink size={13} />
|
||||
主页
|
||||
</Link>
|
||||
<span
|
||||
className="inline-flex items-center rounded-full px-2.5 py-1 text-[11px] whitespace-nowrap"
|
||||
style={{ background: "var(--panel-2)", color: "var(--ink-3)" }}
|
||||
@@ -699,26 +704,28 @@ export default function UsersAdmin({
|
||||
);
|
||||
}
|
||||
const busy = actingId === u.id;
|
||||
const roleTone = u.banned ? "danger" : u.role !== ROLES.USER ? "accent" : "default";
|
||||
// 「角色与账号」用主题色突出主操作;封禁态改用危险色提示需处理
|
||||
const roleStyle = u.banned
|
||||
? { color: "var(--danger)", borderColor: "var(--danger)" }
|
||||
: { color: "var(--accent)", borderColor: "var(--accent)" };
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
{auditBtn}
|
||||
<AdminIconButton
|
||||
href={`/u/${u.id}`}
|
||||
tip="查看个人主页"
|
||||
ariaLabel={`查看 ${u.nickname} 的个人主页`}
|
||||
>
|
||||
<ExternalLink size={15} />
|
||||
</AdminIconButton>
|
||||
<AdminIconButton
|
||||
tip="角色与账号"
|
||||
ariaLabel={`设置 ${u.username} 的角色与账号状态`}
|
||||
tone={roleTone}
|
||||
<Link href={`/u/${u.id}`} className="btn btn-line btn-sm gap-1" aria-label={`查看 ${u.nickname} 的个人主页`}>
|
||||
<ExternalLink size={13} />
|
||||
主页
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => setRoleTarget(u)}
|
||||
className="btn btn-line btn-sm gap-1"
|
||||
style={roleStyle}
|
||||
aria-label={`设置 ${u.username} 的角色与账号状态`}
|
||||
>
|
||||
<ShieldCheck size={15} />
|
||||
</AdminIconButton>
|
||||
<ShieldCheck size={13} />
|
||||
角色与账号
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -971,41 +978,7 @@ export default function UsersAdmin({
|
||||
{u.last_login_at ? ` · ${formatRelative(u.last_login_at)}` : ""}
|
||||
</button>
|
||||
</p>
|
||||
<div className="flex items-center gap-2 mt-3 flex-wrap">
|
||||
{isOwner(currentUserRole) && (
|
||||
<Link href={`/admin/users/${u.id}`} className="btn btn-line btn-sm">
|
||||
<FolderSearch size={13} /> 内容档案
|
||||
</Link>
|
||||
)}
|
||||
{(isSelf || u.role === ROLES.OWNER) && (
|
||||
<Link href={`/u/${u.id}`} className="btn btn-line btn-sm">
|
||||
<ExternalLink size={13} /> 主页
|
||||
</Link>
|
||||
)}
|
||||
{!isSelf && u.role !== ROLES.OWNER && (
|
||||
<>
|
||||
<Link href={`/u/${u.id}`} className="btn btn-line btn-sm">
|
||||
<ExternalLink size={13} /> 主页
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
disabled={actingId === u.id}
|
||||
onClick={() => setRoleTarget(u)}
|
||||
className="btn btn-line btn-sm"
|
||||
style={
|
||||
u.banned
|
||||
? { color: "var(--danger)", borderColor: "var(--danger)" }
|
||||
: u.role !== ROLES.USER
|
||||
? { color: "var(--accent)", borderColor: "var(--accent)" }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<ShieldCheck size={13} />
|
||||
角色与账号
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-3">{renderActions(u, isSelf)}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user