外观设置改为单页分区;发帖草稿与板块校验优化,WS 延迟释放避开 Strict Mode 误断。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-20 11:27:39 +08:00
parent 68e90ff6ff
commit 1f2e4b2a33
8 changed files with 684 additions and 618 deletions

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { import {
Palette, Palette,
@@ -24,7 +24,6 @@ import {
AdminSettingsGroup, AdminSettingsGroup,
AdminSettingsPage, AdminSettingsPage,
AdminSettingsRow, AdminSettingsRow,
AdminSettingsWork,
AdminStepper, AdminStepper,
AdminSwitch, AdminSwitch,
} from "@/components/admin"; } from "@/components/admin";
@@ -57,21 +56,6 @@ import {
type SiteBgConfig, type SiteBgConfig,
} from "@/lib/bg"; } 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 }) { function InstantNote({ saving }: { saving?: boolean }) {
return <span className="admin-settings-instant">{saving ? "正在保存…" : "修改后立即生效"}</span>; 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 }) { export default function AppearanceAdmin({ initial }: { initial: PublicSettings }) {
const router = useRouter(); const router = useRouter();
const initialAccent = initial.accent || ""; const initialAccent = initial.accent || "";
@@ -128,10 +112,6 @@ export default function AppearanceAdmin({ initial }: { initial: PublicSettings }
const savedBgRef = useRef(savedBg); const savedBgRef = useRef(savedBg);
savedBgRef.current = 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 normalized = useMemo(() => normalizeHex(draft), [draft]);
const pair = useMemo( const pair = useMemo(
() => (normalized ? deriveAccentPair(normalized) : null), () => (normalized ? deriveAccentPair(normalized) : null),
@@ -147,8 +127,6 @@ export default function AppearanceAdmin({ initial }: { initial: PublicSettings }
adminFile != null || adminFile != null ||
siteMediaId != null || siteMediaId != null ||
adminMediaId != null; adminMediaId != null;
const tabDirty =
(active === "theme" && themeDirty) || (active === "background" && bgDirty);
const leave = useUnsavedGuard(themeDirty || bgDirty); const leave = useUnsavedGuard(themeDirty || bgDirty);
useEffect(() => { 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 () => { const handleSaveTheme = async () => {
if (!normalized || savingTheme || themeInvalid) return; if (!normalized || savingTheme || themeInvalid) return;
setSavingTheme(true); setSavingTheme(true);
@@ -470,8 +401,6 @@ export default function AppearanceAdmin({ initial }: { initial: PublicSettings }
} }
}; };
};
const renderBgRail = ( const renderBgRail = (
surface: BgSurface, surface: BgSurface,
title: string, 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> <p className="meta text-[12.5px] mt-0.5 leading-relaxed">{desc}</p>
</div> </div>
<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={{ style={{
aspectRatio: BG_PREVIEW_ASPECT, aspectRatio: BG_PREVIEW_ASPECT,
borderColor: "var(--line)", 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 ( return (
<AdminSettingsPage> <AdminSettingsPage>
<AdminPageHeader <AdminPageHeader
@@ -623,402 +516,376 @@ export default function AppearanceAdmin({ initial }: { initial: PublicSettings }
description="主题色与背景图需保存后全站生效;代码折叠与动画开关改完立即写入" description="主题色与背景图需保存后全站生效;代码折叠与动画开关改完立即写入"
/> />
<AdminSettingsWork nav={nav}> <div className="admin-settings-stack">
<div id={`appearance-panel-${active}`} className="admin-settings-stack"> <AdminSettingsGroup
{active === "theme" && ( title="主题色"
<> description="本页实时预览,点保存后才推送给全站访客。"
<AdminSettingsGroup footer={
title="预设主题色" <AdminSettingsActions
description="浅/暗两套色板均已调校,保证文字对比度达标。修改后本页实时预览,保存后全站生效。" 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"> <RotateCcw size={14} /> 恢复默认
{ACCENT_PRESETS.map((p) => { </button>
const selected = normalized === p.light; </AdminSettingsActions>
const isDefault = p.light === DEFAULT_ACCENT; }
return ( >
<button <AdminSettingsRow
key={p.key} label="预设"
type="button" hintBelow="浅/暗两套色板均已调校,保证文字对比度达标。"
onClick={() => { wide
setThemeError(""); >
setDraft(p.light); <div className="grid grid-cols-3 sm:grid-cols-6 gap-3">
}} {ACCENT_PRESETS.map((p) => {
className="group flex flex-col items-center gap-2 py-3 rounded-[var(--r-sm)] border transition-colors" const selected = normalized === p.light;
style={{ const isDefault = p.light === DEFAULT_ACCENT;
borderColor: selected ? "var(--accent)" : "var(--line)", return (
background: selected ? "var(--accent-soft)" : "var(--panel)", <button
cursor: "pointer", key={p.key}
}} type="button"
aria-pressed={selected} onClick={() => {
data-tip={isDefault ? `${p.name}(默认)` : p.name} setThemeError("");
> setDraft(p.light);
<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"> className="group flex flex-col items-center gap-2 py-3 rounded-[var(--r-sm)] border transition-colors"
<span style={{ background: p.light }} /> style={{
<span style={{ background: p.dark }} /> borderColor: selected ? "var(--accent)" : "var(--line)",
</span> background: selected ? "var(--accent-soft)" : "var(--panel)",
{selected && ( cursor: "pointer",
<span }}
className="absolute -bottom-1 -right-1 min-w-[18px] min-h-[18px] w-[18px] h-[18px] rounded-full flex items-center justify-center" aria-pressed={selected}
style={{ background: "var(--accent)", color: "var(--accent-on)" }} data-tip={isDefault ? `${p.name}(默认)` : p.name}
> >
<Check size={11} strokeWidth={3} /> <span className="relative flex items-center justify-center">
</span> <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> <span style={{ background: p.dark }} />
</span>
{selected && (
<span <span
className="text-[12px] leading-none" className="absolute -bottom-1 -right-1 min-w-[18px] min-h-[18px] w-[18px] h-[18px] rounded-full flex items-center justify-center"
style={{ color: selected ? "var(--accent)" : "var(--ink-2)" }} style={{ background: "var(--accent)", color: "var(--accent-on)" }}
> >
{p.name} <Check size={11} strokeWidth={3} />
</span> </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} />
)} )}
保存 </span>
</button> <span
</div> className="text-[12px] leading-none"
</AdminSettingsRow> style={{ color: selected ? "var(--accent)" : "var(--ink-2)" }}
) : null} >
</AdminSettingsGroup> {p.name}
)} </span>
</button>
);
})}
</div>
</AdminSettingsRow>
{active === "motion" && ( <AdminSettingsRow
<AdminSettingsGroup label={
title="界面动画" <span className="inline-flex items-center gap-1.5">
description="总开关关闭后全站即时切换。系统「减少动态效果」始终优先。" <Pipette size={15} /> 自定义
> </span>
<AdminSettingsRow }
label={ hintBelow="取色器或 HEX;浅色对比不足时会自动校正。"
<span className="inline-flex items-center gap-1.5"> error={themeInvalid ? "颜色格式无效,请输入 6 位 HEX(如 3B5BDB)" : undefined}
<Sparkles size={15} /> 总开关 >
</span> <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"> <div className="flex items-center gap-2">
<AdminSwitch <AdminSwitch
checked={uiAnimations} checked={animCodeFold}
disabled={togglingMotion} disabled={togglingMotion}
onChange={() => void toggleMotionField("ui_animations", !uiAnimations)} onChange={() => void toggleMotionField("anim_code_fold", !animCodeFold)}
label="界面动画总开关" label="代码折叠动画"
/> />
<InstantNote saving={togglingMotion} /> <InstantNote saving={togglingMotion} />
</div> </div>
</AdminSettingsRow> </AdminSettingsRow>
{uiAnimations ? ( <AdminSettingsRow label="平滑滚动" hintBelow="目录跳转、回顶、新内容提示等 scroll 行为。">
<> <div className="flex items-center gap-2">
<AdminSettingsRow <AdminSwitch
label="代码折叠动画" checked={animSmoothScroll}
hintBelow="代码块展开/收起的高度过渡与跟滚。" disabled={togglingMotion}
> onChange={() => void toggleMotionField("anim_smooth_scroll", !animSmoothScroll)}
<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="平滑滚动" label="平滑滚动"
hintBelow="目录跳转、回顶、新内容提示等 scroll 行为。" />
> <InstantNote saving={togglingMotion} />
<div className="flex items-center gap-2"> </div>
<AdminSwitch </AdminSettingsRow>
checked={animSmoothScroll} <AdminSettingsRow
disabled={togglingMotion} label="装饰入场"
onChange={() => hintBelow="列表入场、Toast、点赞弹出、在线脉冲等装饰性动画。"
void toggleMotionField("anim_smooth_scroll", !animSmoothScroll) >
} <div className="flex items-center gap-2">
label="平滑滚动" <AdminSwitch
/> checked={animChrome}
<InstantNote saving={togglingMotion} /> disabled={togglingMotion}
</div> onChange={() => void toggleMotionField("anim_chrome", !animChrome)}
</AdminSettingsRow>
<AdminSettingsRow
label="装饰入场" label="装饰入场"
hintBelow="列表入场、Toast、点赞弹出、在线脉冲等装饰性动画。" />
> <InstantNote saving={togglingMotion} />
<div className="flex items-center gap-2"> </div>
<AdminSwitch </AdminSettingsRow>
checked={animChrome} </>
disabled={togglingMotion} ) : null}
onChange={() => void toggleMotionField("anim_chrome", !animChrome)} </AdminSettingsGroup>
label="装饰入场" </div>
/>
<InstantNote saving={togglingMotion} />
</div>
</AdminSettingsRow>
</>
) : null}
</AdminSettingsGroup>
)}
</div>
</AdminSettingsWork>
<ConfirmDialog <ConfirmDialog
open={leaveOpen} open={leave.leaveOpen}
title="有未保存的修改" title="有未保存的修改"
message="离开后当前分类的草稿将丢弃,确定继续?" message="离开后主题色或背景图的草稿将丢弃,确定继续?"
confirmLabel="丢弃并离开" confirmLabel="丢弃并离开"
cancelLabel="继续编辑" cancelLabel="继续编辑"
danger danger
onConfirm={confirmLeave} onConfirm={leave.confirmLeave}
onCancel={() => { onCancel={leave.cancelLeave}
setPendingSection(null);
leave.cancelLeave();
}}
/> />
<BgMediaPicker <BgMediaPicker
open={bgPicker != null} open={bgPicker != null}

View File

@@ -41,7 +41,6 @@ import Modal from "@/components/Modal";
import RoleAccountModal from "@/components/RoleAccountModal"; import RoleAccountModal from "@/components/RoleAccountModal";
import { import {
AdminEmpty, AdminEmpty,
AdminIconButton,
AdminPageHeader, AdminPageHeader,
AdminPager, AdminPager,
AdminSearchField, AdminSearchField,
@@ -523,7 +522,8 @@ export default function UsersAdmin({
frags.push("100px"); frags.push("100px");
break; break;
case "actions": case "actions":
frags.push("minmax(148px,1.8fr)"); // 带文案按钮:档案 / 主页 / 角色与账号
frags.push("minmax(268px,2.2fr)");
break; break;
} }
} }
@@ -665,30 +665,35 @@ export default function UsersAdmin({
const renderActions = (u: AdminUser, isSelf: boolean) => { const renderActions = (u: AdminUser, isSelf: boolean) => {
const canAudit = isOwner(currentUserRole); const canAudit = isOwner(currentUserRole);
const auditBtn = canAudit ? ( const auditBtn = canAudit ? (
<AdminIconButton <Link
href={`/admin/users/${u.id}`} href={`/admin/users/${u.id}`}
tip="内容档案" className="btn btn-line btn-sm gap-1"
ariaLabel={`查看 ${u.username} 的内容档案`} aria-label={`查看 ${u.username} 的内容档案`}
tone="accent"
> >
<FolderSearch size={15} /> <FolderSearch size={13} />
</AdminIconButton> 内容档案
</Link>
) : null; ) : null;
if (u.role === ROLES.OWNER) { if (u.role === ROLES.OWNER) {
return ( return (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1.5 flex-wrap">
{auditBtn} {auditBtn}
<AdminIconButton href={`/u/${u.id}`} tip="查看个人主页" ariaLabel={`查看 ${u.nickname} 的个人主页`}> <Link href={`/u/${u.id}`} className="btn btn-line btn-sm gap-1" aria-label={`查看 ${u.nickname} 的个人主页`}>
<ExternalLink size={15} /> <ExternalLink size={13} />
</AdminIconButton> 主页
</Link>
</div> </div>
); );
} }
if (isSelf) { if (isSelf) {
return ( return (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1.5 flex-wrap">
{auditBtn} {auditBtn}
<Link href={`/u/${u.id}`} className="btn btn-line btn-sm gap-1" aria-label={`查看 ${u.nickname} 的个人主页`}>
<ExternalLink size={13} />
主页
</Link>
<span <span
className="inline-flex items-center rounded-full px-2.5 py-1 text-[11px] whitespace-nowrap" 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)" }} style={{ background: "var(--panel-2)", color: "var(--ink-3)" }}
@@ -699,26 +704,28 @@ export default function UsersAdmin({
); );
} }
const busy = actingId === u.id; 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 ( return (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1.5 flex-wrap">
{auditBtn} {auditBtn}
<AdminIconButton <Link href={`/u/${u.id}`} className="btn btn-line btn-sm gap-1" aria-label={`查看 ${u.nickname} 的个人主页`}>
href={`/u/${u.id}`} <ExternalLink size={13} />
tip="查看个人主页" 主页
ariaLabel={`查看 ${u.nickname} 的个人主页`} </Link>
> <button
<ExternalLink size={15} /> type="button"
</AdminIconButton>
<AdminIconButton
tip="角色与账号"
ariaLabel={`设置 ${u.username} 的角色与账号状态`}
tone={roleTone}
disabled={busy} disabled={busy}
onClick={() => setRoleTarget(u)} onClick={() => setRoleTarget(u)}
className="btn btn-line btn-sm gap-1"
style={roleStyle}
aria-label={`设置 ${u.username} 的角色与账号状态`}
> >
<ShieldCheck size={15} /> <ShieldCheck size={13} />
</AdminIconButton> 角色与账号
</button>
</div> </div>
); );
}; };
@@ -971,41 +978,7 @@ export default function UsersAdmin({
{u.last_login_at ? ` · ${formatRelative(u.last_login_at)}` : ""} {u.last_login_at ? ` · ${formatRelative(u.last_login_at)}` : ""}
</button> </button>
</p> </p>
<div className="flex items-center gap-2 mt-3 flex-wrap"> <div className="mt-3">{renderActions(u, isSelf)}</div>
{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> </div>
</div> </div>
); );

View File

@@ -474,8 +474,8 @@ html.j13-has-admin-bg[data-j13-chrome="admin"] body::after {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
padding: 3px 4px 3px 3px; padding: 2px 3px 2px 2px;
border-radius: 12px; border-radius: 10px;
border: 1px solid var(--line); border: 1px solid var(--line);
background: var(--panel); background: var(--panel);
color: var(--ink); color: var(--ink);
@@ -499,11 +499,11 @@ html.j13-has-admin-bg[data-j13-chrome="admin"] body::after {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
display: flex; display: flex;
align-items: flex-start; align-items: center;
gap: 10px; gap: 8px;
padding: 6px 4px 6px 7px; padding: 5px 4px 5px 6px;
border: 0; border: 0;
border-radius: 9px; border-radius: 8px;
background: transparent; background: transparent;
color: inherit; color: inherit;
text-align: left; text-align: left;
@@ -519,9 +519,9 @@ html.j13-has-admin-bg[data-j13-chrome="admin"] body::after {
outline-offset: 1px; outline-offset: 1px;
} }
.j13-compose-typeopt-ico { .j13-compose-typeopt-ico {
width: 32px; width: 28px;
height: 32px; height: 28px;
border-radius: 9px; border-radius: 8px;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -540,24 +540,26 @@ html.j13-has-admin-bg[data-j13-chrome="admin"] body::after {
.j13-compose-typeopt-copy { .j13-compose-typeopt-copy {
min-width: 0; min-width: 0;
flex: 1; flex: 1;
padding-top: 1px;
} }
.j13-compose-typeopt-label { .j13-compose-typeopt-label {
display: block; display: block;
font-size: 13.5px; font-size: 13px;
font-weight: 700; font-weight: 700;
line-height: 1.3; line-height: 1.25;
color: var(--ink); color: var(--ink);
letter-spacing: -0.01em; letter-spacing: -0.01em;
} }
.j13-compose-typeopt-hint { .j13-compose-typeopt-hint {
display: block; display: block;
margin-top: 2px; margin-top: 1px;
font-size: 12px; font-size: 11.5px;
font-weight: 500; font-weight: 500;
line-height: 1.4; line-height: 1.3;
color: var(--ink-3); color: var(--ink-3);
} }
.j13-compose-typebar.is-compact {
gap: 4px;
}
.j13-compose-typeopt.is-on .j13-compose-typeopt-hint.is-status { .j13-compose-typeopt.is-on .j13-compose-typeopt-hint.is-status {
color: color-mix(in srgb, var(--accent) 58%, var(--ink-2)); color: color-mix(in srgb, var(--accent) 58%, var(--ink-2));
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
@@ -1742,14 +1744,22 @@ html.j13-has-admin-bg[data-j13-chrome="admin"] body::after {
opacity: 0.65; opacity: 0.65;
cursor: not-allowed; cursor: not-allowed;
} }
.j13-board-trigger.is-empty { /* 未选:沿用中性边框;仅校验失败后用固定警示红 */
border-color: color-mix(in srgb, var(--accent) 48%, var(--line)); .j13-board-trigger.is-invalid {
background: var(--accent-soft); border-color: #dc2626;
background: color-mix(in srgb, #dc2626 8%, var(--panel));
} }
.j13-board-trigger.is-empty .j13-board-trigger-hint { .j13-board-trigger.is-invalid .j13-board-trigger-hint {
color: var(--accent); color: #dc2626;
font-weight: 650; font-weight: 650;
} }
html.dark .j13-board-trigger.is-invalid {
border-color: #f87171;
background: color-mix(in srgb, #f87171 12%, var(--panel));
}
html.dark .j13-board-trigger.is-invalid .j13-board-trigger-hint {
color: #f87171;
}
.j13-board-trigger-copy { .j13-board-trigger-copy {
min-width: 0; min-width: 0;
flex: 1; flex: 1;

View File

@@ -10,6 +10,8 @@ type BoardPickerProps = {
onChange: (id: number) => void; onChange: (id: number) => void;
disabled?: boolean; disabled?: boolean;
loading?: boolean; loading?: boolean;
/** 提交校验失败后再为 true,用于红色错误态 */
invalid?: boolean;
id?: string; id?: string;
}; };
@@ -23,6 +25,7 @@ export default function BoardPicker({
onChange, onChange,
disabled, disabled,
loading, loading,
invalid = false,
id, id,
}: BoardPickerProps) { }: BoardPickerProps) {
const listId = useId(); const listId = useId();
@@ -92,6 +95,7 @@ export default function BoardPicker({
}; };
const empty = !loading && !selected; const empty = !loading && !selected;
const showError = empty && invalid;
const label = loading const label = loading
? "加载板块…" ? "加载板块…"
: selected : selected
@@ -109,12 +113,12 @@ export default function BoardPicker({
<button <button
id={id} id={id}
type="button" type="button"
className={`j13-board-trigger${empty ? " is-empty" : ""}`} className={`j13-board-trigger${showError ? " is-invalid" : ""}`}
disabled={disabled || loading || boards.length === 0} disabled={disabled || loading || boards.length === 0}
aria-haspopup="listbox" aria-haspopup="listbox"
aria-expanded={open} aria-expanded={open}
aria-controls={listId} aria-controls={listId}
aria-invalid={empty || undefined} aria-invalid={showError || undefined}
onClick={() => setOpen((v) => !v)} onClick={() => setOpen((v) => !v)}
onKeyDown={onTriggerKeyDown} onKeyDown={onTriggerKeyDown}
> >
@@ -126,7 +130,9 @@ export default function BoardPicker({
</span> </span>
<span className="j13-board-trigger-copy"> <span className="j13-board-trigger-copy">
<span className="j13-board-trigger-name">{label}</span> <span className="j13-board-trigger-name">{label}</span>
{empty ? ( {showError ? (
<span className="j13-board-trigger-hint">请选择板块后再发布</span>
) : empty ? (
<span className="j13-board-trigger-hint">发布前必须选择</span> <span className="j13-board-trigger-hint">发布前必须选择</span>
) : loading ? ( ) : loading ? (
<span className="j13-board-trigger-hint">正在加载</span> <span className="j13-board-trigger-hint">正在加载</span>

View File

@@ -65,6 +65,10 @@ export default function Header({
const router = useRouter(); const router = useRouter();
const pathname = usePathname(); const pathname = usePathname();
const allowMessages = useAllowMessages(); const allowMessages = useAllowMessages();
const hideComposeNav =
pathname === "/compose" ||
(pathname?.startsWith("/compose/") ?? false) ||
/^\/post\/[^/]+\/edit\/?$/.test(pathname || "");
const [menuOpen, setMenuOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false);
// 登录态以 SSR 直出为初始值,F5 无切换闪动;router.refresh() 后随 props 校正 // 登录态以 SSR 直出为初始值,F5 无切换闪动;router.refresh() 后随 props 校正
const [user, setUser] = useState<User | null>(initialUser); const [user, setUser] = useState<User | null>(initialUser);
@@ -318,9 +322,11 @@ export default function Header({
{user && <NotificationBell initialUnread={unread} />} {user && <NotificationBell initialUnread={unread} />}
{user ? ( {user ? (
<> <>
<Link href="/compose" className="btn btn-primary btn-sm hidden lg:inline-flex"> {!hideComposeNav && (
<PenSquare size={14} /> 发帖 <Link href="/compose" className="btn btn-primary btn-sm hidden lg:inline-flex">
</Link> <PenSquare size={14} /> 发帖
</Link>
)}
<UserMenu user={user} onLogout={handleLogout} /> <UserMenu user={user} onLogout={handleLogout} />
</> </>
) : ( ) : (
@@ -368,7 +374,7 @@ export default function Header({
</form> </form>
<div className="flex flex-col"> <div className="flex flex-col">
<MobileLink href="/" active={pathname === "/"}>首页</MobileLink> <MobileLink href="/" active={pathname === "/"}>首页</MobileLink>
{user && <MobileLink href="/compose">发帖</MobileLink>} {user && !hideComposeNav && <MobileLink href="/compose">发帖</MobileLink>}
{user && allowMessages && ( {user && allowMessages && (
<MobileLink href="/chat" active={pathname?.startsWith("/chat") ?? false}> <MobileLink href="/chat" active={pathname?.startsWith("/chat") ?? false}>
<span className="inline-flex items-center gap-2"> <span className="inline-flex items-center gap-2">

View File

@@ -31,6 +31,7 @@ import DeadlinePicker, {
defaultCustomLocal, defaultCustomLocal,
localInputToRFC3339, localInputToRFC3339,
formatLocalPreview, formatLocalPreview,
toLocalInputValue,
} from "@/components/DeadlinePicker"; } from "@/components/DeadlinePicker";
import Modal from "@/components/Modal"; import Modal from "@/components/Modal";
import { realtime, RT_SETTINGS_CHANGED, type RtSettingsData } from "@/lib/realtime"; import { realtime, RT_SETTINGS_CHANGED, type RtSettingsData } from "@/lib/realtime";
@@ -49,13 +50,28 @@ const TYPE_ICON: Record<PostTypeValue, LucideIcon> = {
lottery: Gift, lottery: Gift,
}; };
type TypeFormSnapshot = {
pollOptions: string[];
pollMulti: boolean;
pollAnonymous: boolean;
pollExpireDays: number;
bountyPoints: number | "";
bountyExpireDays: number;
lotteryPrizes: { name: string; count: number | "" }[];
lotteryExpireDays: number;
deadlineMode: DeadlineMode;
customEndsLocal: string;
};
type ComposeDraft = { type ComposeDraft = {
title: string; title: string;
content: string; content: string;
tags: string; tags: string;
boardId: number; boardId: number;
postType?: string; postType?: string;
/** @deprecated 旧草稿仅有 JSON 字符串;新草稿用 typeForm */
typeMeta?: string; typeMeta?: string;
typeForm?: TypeFormSnapshot;
updatedAt: number; updatedAt: number;
}; };
@@ -74,6 +90,73 @@ function serializeTags(tags: string[]): string {
return tags.join(", "); return tags.join(", ");
} }
/** 从旧版 typeMeta JSON 尽量还原类型表单(不完整则用默认) */
function typeFormFromMeta(
postType: PostTypeValue,
raw: string | undefined
): Partial<TypeFormSnapshot> {
if (!raw?.trim()) return {};
try {
const m = JSON.parse(raw) as Record<string, unknown>;
const out: Partial<TypeFormSnapshot> = {};
const endsAt = typeof m.ends_at === "string" ? m.ends_at : "";
const expireDays = Number(m.expire_days);
if (endsAt) {
const t = Date.parse(endsAt);
if (Number.isFinite(t)) {
out.deadlineMode = "custom";
out.customEndsLocal = toLocalInputValue(new Date(t));
}
} else if (Number.isFinite(expireDays) && expireDays > 0) {
out.deadlineMode = "preset";
}
if (postType === "poll") {
if (Array.isArray(m.options)) {
const opts = m.options.map((o) => String(o ?? ""));
out.pollOptions = opts.length >= 2 ? opts : ["", ""];
}
out.pollMulti = !!m.multi;
out.pollAnonymous = m.anonymous !== false;
if ((POLL_EXPIRE_DAYS as readonly number[]).includes(expireDays)) {
out.pollExpireDays = expireDays;
}
} else if (postType === "bounty") {
const p = Number(m.points);
out.bountyPoints = p > 0 ? Math.floor(p) : "";
if ((BOUNTY_EXPIRE_DAYS as readonly number[]).includes(expireDays)) {
out.bountyExpireDays = expireDays;
}
} else if (postType === "lottery") {
const prizes = Array.isArray(m.prizes)
? (m.prizes as { name?: string; count?: number }[]).map((p) => ({
name: String(p.name || "").slice(0, 32),
count:
typeof p.count === "number" && p.count >= 1
? Math.floor(p.count)
: ("" as const),
}))
: [];
out.lotteryPrizes =
prizes.length > 0 ? prizes : [{ name: "", count: "" as const }];
if ((LOTTERY_EXPIRE_DAYS as readonly number[]).includes(expireDays)) {
out.lotteryExpireDays = expireDays;
}
}
return out;
} catch {
return {};
}
}
function draftHasContent(d: ComposeDraft): boolean {
if (d.title.trim() || d.content.trim() || d.tags.trim()) return true;
if (d.boardId > 0) return true;
const pt = normalizePostType(d.postType);
if (pt !== "discussion") return true;
if (d.typeForm || (d.typeMeta && d.typeMeta.trim())) return true;
return false;
}
function mapPostError(msg: string): string { function mapPostError(msg: string): string {
if (!msg) return "操作失败"; if (!msg) return "操作失败";
const cooldown = msg.match(/新用户注册\s*(\d+)\s*小时后才能发帖/); const cooldown = msg.match(/新用户注册\s*(\d+)\s*小时后才能发帖/);
@@ -154,6 +237,8 @@ export default function PostComposer(props: PostComposerProps) {
const [boardId, setBoardId] = useState( const [boardId, setBoardId] = useState(
isCreate ? props.initialBoardId || 0 : props.boardId || 0 isCreate ? props.initialBoardId || 0 : props.boardId || 0
); );
/** 仅提交校验失败后为 true,选好板块后清除 */
const [boardInvalid, setBoardInvalid] = useState(false);
const [title, setTitle] = useState(isCreate ? "" : props.initialTitle); const [title, setTitle] = useState(isCreate ? "" : props.initialTitle);
const [content, setContent] = useState(isCreate ? "" : props.initialContent); const [content, setContent] = useState(isCreate ? "" : props.initialContent);
const [tagList, setTagList] = useState<string[]>( const [tagList, setTagList] = useState<string[]>(
@@ -379,10 +464,7 @@ export default function PostComposer(props: PostComposerProps) {
); );
} }
const draft = readDraft(); const draft = readDraft();
if ( if (draft && draftHasContent(draft)) {
draft &&
(draft.title.trim() || draft.content.trim() || draft.tags.trim())
) {
setDraftBanner(draft); setDraftBanner(draft);
skipDraftSave.current = true; skipDraftSave.current = true;
} else { } else {
@@ -425,7 +507,14 @@ export default function PostComposer(props: PostComposerProps) {
if (!isCreate || skipDraftSave.current) return; if (!isCreate || skipDraftSave.current) return;
if (draftTimer.current) clearTimeout(draftTimer.current); if (draftTimer.current) clearTimeout(draftTimer.current);
draftTimer.current = setTimeout(() => { draftTimer.current = setTimeout(() => {
if (!title.trim() && !content.trim() && tagList.length === 0) { const emptyCore =
!title.trim() && !content.trim() && tagList.length === 0 && boardId <= 0;
const emptyType =
postType === "discussion" &&
pollOptions.every((o) => !o.trim()) &&
bountyPoints === "" &&
lotteryPrizes.every((p) => !p.name.trim() && p.count === "");
if (emptyCore && emptyType) {
clearDraft(); clearDraft();
return; return;
} }
@@ -435,7 +524,18 @@ export default function PostComposer(props: PostComposerProps) {
tags: serializeTags(tagList), tags: serializeTags(tagList),
boardId, boardId,
postType, postType,
typeMeta, typeForm: {
pollOptions,
pollMulti,
pollAnonymous,
pollExpireDays,
bountyPoints,
bountyExpireDays,
lotteryPrizes,
lotteryExpireDays,
deadlineMode,
customEndsLocal,
},
updatedAt: Date.now(), updatedAt: Date.now(),
}); });
}, DRAFT_DEBOUNCE_MS); }, DRAFT_DEBOUNCE_MS);
@@ -449,7 +549,16 @@ export default function PostComposer(props: PostComposerProps) {
tagList, tagList,
boardId, boardId,
postType, postType,
typeMeta, pollOptions,
pollMulti,
pollAnonymous,
pollExpireDays,
bountyPoints,
bountyExpireDays,
lotteryPrizes,
lotteryExpireDays,
deadlineMode,
customEndsLocal,
]); ]);
const restoreDraft = () => { const restoreDraft = () => {
@@ -457,9 +566,59 @@ export default function PostComposer(props: PostComposerProps) {
setTitle(draftBanner.title || ""); setTitle(draftBanner.title || "");
setContent(draftBanner.content || ""); setContent(draftBanner.content || "");
setTagList(parseTags(draftBanner.tags || "")); setTagList(parseTags(draftBanner.tags || ""));
if (draftBanner.boardId) setBoardId(draftBanner.boardId); if (draftBanner.boardId) {
if (draftBanner.postType) setPostType(normalizePostType(draftBanner.postType)); setBoardId(draftBanner.boardId);
setBoardInvalid(false);
}
const nextType = normalizePostType(draftBanner.postType);
setPostType(nextType);
const fromMeta = typeFormFromMeta(nextType, draftBanner.typeMeta);
const snap: Partial<TypeFormSnapshot> = {
...fromMeta,
...(draftBanner.typeForm || {}),
};
if (snap.pollOptions && snap.pollOptions.length >= 2) {
setPollOptions(snap.pollOptions);
}
if (typeof snap.pollMulti === "boolean") setPollMulti(snap.pollMulti);
if (typeof snap.pollAnonymous === "boolean") {
setPollAnonymous(snap.pollAnonymous);
}
if (
typeof snap.pollExpireDays === "number" &&
(POLL_EXPIRE_DAYS as readonly number[]).includes(snap.pollExpireDays)
) {
setPollExpireDays(snap.pollExpireDays);
}
if (snap.bountyPoints === "" || typeof snap.bountyPoints === "number") {
setBountyPoints(snap.bountyPoints);
}
if (
typeof snap.bountyExpireDays === "number" &&
(BOUNTY_EXPIRE_DAYS as readonly number[]).includes(snap.bountyExpireDays)
) {
setBountyExpireDays(snap.bountyExpireDays);
}
if (snap.lotteryPrizes && snap.lotteryPrizes.length > 0) {
setLotteryPrizes(snap.lotteryPrizes);
}
if (
typeof snap.lotteryExpireDays === "number" &&
(LOTTERY_EXPIRE_DAYS as readonly number[]).includes(snap.lotteryExpireDays)
) {
setLotteryExpireDays(snap.lotteryExpireDays);
}
if (snap.deadlineMode === "custom" || snap.deadlineMode === "preset") {
setDeadlineMode(snap.deadlineMode);
}
if (snap.customEndsLocal) setCustomEndsLocal(snap.customEndsLocal);
// 编辑态用的 typeMeta 字符串一并同步,避免后续误用空串
if (draftBanner.typeMeta) setTypeMeta(draftBanner.typeMeta); if (draftBanner.typeMeta) setTypeMeta(draftBanner.typeMeta);
else if (draftBanner.typeForm) setTypeMeta("");
setDraftBanner(null); setDraftBanner(null);
skipDraftSave.current = false; skipDraftSave.current = false;
toast("已恢复草稿", "ok"); toast("已恢复草稿", "ok");
@@ -586,7 +745,7 @@ export default function PostComposer(props: PostComposerProps) {
!fileBusy && !fileBusy &&
title.trim().length > 0 && title.trim().length > 0 &&
content.trim().length > 0 && content.trim().length > 0 &&
(isCreate ? boardsReady && boardId > 0 : true) && (isCreate ? boardsReady : true) &&
(postType !== "poll" || (postType !== "poll" ||
pollOptions.map((o) => o.trim()).filter(Boolean).length >= 2) && pollOptions.map((o) => o.trim()).filter(Boolean).length >= 2) &&
(postType !== "bounty" || (postType !== "bounty" ||
@@ -689,7 +848,11 @@ export default function PostComposer(props: PostComposerProps) {
} }
if (isCreate && (!boardsReady || boardId <= 0)) { if (isCreate && (!boardsReady || boardId <= 0)) {
if (isNarrow) setRailOpen(true); if (isNarrow) setRailOpen(true);
setBoardInvalid(true);
reportSubmitError("请选择板块"); reportSubmitError("请选择板块");
window.setTimeout(() => {
document.getElementById("compose-board")?.focus();
}, isNarrow ? 80 : 0);
return; return;
} }
if ( if (
@@ -764,9 +927,6 @@ export default function PostComposer(props: PostComposerProps) {
: isCreate : isCreate
? "发布" ? "发布"
: "保存"; : "保存";
const selectedBoardName = isCreate
? boards.find((b) => b.id === boardId)?.name
: props.boardName;
const typeLabel = POST_TYPES.find((t) => t.value === postType)?.label || "讨论"; const typeLabel = POST_TYPES.find((t) => t.value === postType)?.label || "讨论";
const blockReason = loading const blockReason = loading
? "" ? ""
@@ -776,19 +936,17 @@ export default function PostComposer(props: PostComposerProps) {
? "请填写标题" ? "请填写标题"
: !content.trim() : !content.trim()
? "请填写正文" ? "请填写正文"
: isCreate && (!boardsReady || boardId <= 0) : postType === "poll" &&
? "请选择板块" pollOptions.map((o) => o.trim()).filter(Boolean).length < 2
: postType === "poll" && ? "请至少填写 2 个投票选项"
pollOptions.map((o) => o.trim()).filter(Boolean).length < 2 : postType === "bounty" &&
? "请至少填写 2 个投票选项" (typeof bountyPoints !== "number" || bountyPoints < 1)
: postType === "bounty" && ? "请填写悬赏积分"
(typeof bountyPoints !== "number" || bountyPoints < 1) : bountyShort
? "请填写悬赏积分" ? "积分不足"
: bountyShort : postType === "lottery" && !lotteryPrizesReady
? "积分不足" ? "请填写奖品名称与数量"
: postType === "lottery" && !lotteryPrizesReady : "";
? "请填写奖品名称与数量"
: "";
const railHidden = isNarrow && !railOpen; const railHidden = isNarrow && !railOpen;
const typeSetupKind = const typeSetupKind =
postType === "poll" || postType === "bounty" || postType === "lottery"; postType === "poll" || postType === "bounty" || postType === "lottery";
@@ -1284,16 +1442,19 @@ export default function PostComposer(props: PostComposerProps) {
<section className="j13-compose-rail-block" aria-label="板块"> <section className="j13-compose-rail-block" aria-label="板块">
<label className="j13-compose-section-title" htmlFor="compose-board"> <label className="j13-compose-section-title" htmlFor="compose-board">
板块 板块
{isCreate ? <span className="j13-compose-req">必选</span> : null}
</label> </label>
{isCreate ? ( {isCreate ? (
<BoardPicker <BoardPicker
id="compose-board" id="compose-board"
boards={boards} boards={boards}
value={boardId} value={boardId}
onChange={setBoardId} onChange={(id) => {
setBoardId(id);
setBoardInvalid(false);
}}
disabled={loading} disabled={loading}
loading={!boardsReady} loading={!boardsReady}
invalid={boardInvalid}
/> />
) : ( ) : (
<BoardBadge <BoardBadge
@@ -1305,7 +1466,7 @@ export default function PostComposer(props: PostComposerProps) {
<section className="j13-compose-rail-block" aria-label="基本信息"> <section className="j13-compose-rail-block" aria-label="基本信息">
<h2 className="j13-compose-section-title">帖子类型</h2> <h2 className="j13-compose-section-title">帖子类型</h2>
<div className="j13-compose-typebar" role="radiogroup" aria-label="帖子类型"> <div className="j13-compose-typebar is-compact" role="radiogroup" aria-label="帖子类型">
{POST_TYPES.map((t) => { {POST_TYPES.map((t) => {
const Icon = TYPE_ICON[t.value]; const Icon = TYPE_ICON[t.value];
const isOn = postType === t.value; const isOn = postType === t.value;
@@ -1313,6 +1474,7 @@ export default function PostComposer(props: PostComposerProps) {
const status = isOn ? typeChipStatus : null; const status = isOn ? typeChipStatus : null;
const needsSetup = const needsSetup =
t.value === "poll" || t.value === "bounty" || t.value === "lottery"; t.value === "poll" || t.value === "bounty" || t.value === "lottery";
const hintText = status ? status.text : isOn ? t.hint : "";
return ( return (
<div <div
key={t.value} key={t.value}
@@ -1327,22 +1489,28 @@ export default function PostComposer(props: PostComposerProps) {
className="j13-compose-typeopt-hit" className="j13-compose-typeopt-hit"
disabled={locked} disabled={locked}
data-tip={ data-tip={
!isCreate && t.value !== postType ? "编辑时不可更改类型" : undefined !isCreate && t.value !== postType
? "编辑时不可更改类型"
: !isOn
? t.hint
: undefined
} }
onClick={() => onPickType(t.value)} onClick={() => onPickType(t.value)}
> >
<span className="j13-compose-typeopt-ico" aria-hidden> <span className="j13-compose-typeopt-ico" aria-hidden>
<Icon size={16} strokeWidth={2.2} /> <Icon size={15} strokeWidth={2.2} />
</span> </span>
<span className="j13-compose-typeopt-copy"> <span className="j13-compose-typeopt-copy">
<span className="j13-compose-typeopt-label">{t.label}</span> <span className="j13-compose-typeopt-label">{t.label}</span>
<span {hintText ? (
className={`j13-compose-typeopt-hint${ <span
status ? (status.warn ? " is-warn" : " is-status") : "" className={`j13-compose-typeopt-hint${
}`} status ? (status.warn ? " is-warn" : " is-status") : ""
> }`}
{status ? status.text : t.hint} >
</span> {hintText}
</span>
) : null}
</span> </span>
</button> </button>
{isOn && needsSetup ? ( {isOn && needsSetup ? (
@@ -1432,9 +1600,13 @@ export default function PostComposer(props: PostComposerProps) {
{isCreate ? "写新帖" : "编辑帖子"} {isCreate ? "写新帖" : "编辑帖子"}
</h1> </h1>
<p className="j13-compose-toolbar-sub"> <p className="j13-compose-toolbar-sub">
{selectedBoardName || (isCreate && !boardsReady ? "加载板块…" : "选择板块")} {draftBanner
{" · "} ? `有未恢复的本地草稿${
{typeLabel} draftBanner.updatedAt
? ` · ${new Date(draftBanner.updatedAt).toLocaleString("zh-CN")}`
: ""
}`
: typeLabel}
</p> </p>
</div> </div>
</div> </div>
@@ -1462,7 +1634,7 @@ export default function PostComposer(props: PostComposerProps) {
<button <button
type="submit" type="submit"
disabled={!canSubmit} disabled={!canSubmit}
className="btn btn-primary btn-sm min-w-[4.75rem]" className="btn btn-primary min-w-[5.5rem] h-9 px-4 text-[13.5px] font-semibold"
> >
{submitLabel} {submitLabel}
</button> </button>

View File

@@ -9,7 +9,8 @@ export default function RealtimeProvider({ userId }: { userId: number | null })
useEffect(() => { useEffect(() => {
if (userId) { if (userId) {
realtime.connect(); realtime.connect();
return () => realtime.disconnect(); // 用 release 而非立刻 disconnect:避开 Strict Mode / HMR 瞬时卸载打断握手
return () => realtime.release();
} }
realtime.disconnect(); realtime.disconnect();
}, [userId]); }, [userId]);

View File

@@ -168,6 +168,8 @@ class RealtimeClient {
private retryCount = 0; private retryCount = 0;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null; private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private heartbeatTimer: ReturnType<typeof setInterval> | null = null; private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
// Strict Mode / HMR 会瞬时卸载→重挂;延迟断开,避免握手未完成就被 close 刷控制台
private releaseTimer: ReturnType<typeof setTimeout> | null = null;
private readonly handlers = new Map<string, Set<RtHandler>>(); private readonly handlers = new Map<string, Set<RtHandler>>();
private readonly presenceHandlers = new Set<RtHandler<RtPresenceData>>(); private readonly presenceHandlers = new Set<RtHandler<RtPresenceData>>();
private onlineIds = new Set<number>(); private onlineIds = new Set<number>();
@@ -177,6 +179,7 @@ class RealtimeClient {
/** 建立连接(幂等);登录后调用 */ /** 建立连接(幂等);登录后调用 */
connect(): void { connect(): void {
if (typeof window === "undefined") return; if (typeof window === "undefined") return;
this.clearReleaseTimer();
this.wanted = true; this.wanted = true;
if (this.state === "open" || this.state === "connecting") return; if (this.state === "open" || this.state === "connecting") return;
this.clearReconnectTimer(); this.clearReconnectTimer();
@@ -185,8 +188,21 @@ class RealtimeClient {
document.addEventListener("visibilitychange", this.handleVisible); document.addEventListener("visibilitychange", this.handleVisible);
} }
/**
* 组件卸载时调用:短暂延迟后再真正断开。
* 若 Strict Mode / HMR 随即重新 connect,会取消本次断开,保留握手中的连接。
*/
release(delayMs = 120): void {
this.clearReleaseTimer();
this.releaseTimer = setTimeout(() => {
this.releaseTimer = null;
this.disconnect();
}, delayMs);
}
/** 主动断开(登出/卸载);不再自动重连 */ /** 主动断开(登出/卸载);不再自动重连 */
disconnect(): void { disconnect(): void {
this.clearReleaseTimer();
this.wanted = false; this.wanted = false;
window.removeEventListener("online", this.handleNetworkBack); window.removeEventListener("online", this.handleNetworkBack);
document.removeEventListener("visibilitychange", this.handleVisible); document.removeEventListener("visibilitychange", this.handleVisible);
@@ -194,7 +210,15 @@ class RealtimeClient {
this.clearHeartbeat(); this.clearHeartbeat();
if (this.ws) { if (this.ws) {
this.ws.onclose = null; this.ws.onclose = null;
this.ws.close(1000, "client logout"); // CONNECTING 时直接 close 会触发浏览器警告;改等 open 后再关
if (this.ws.readyState === WebSocket.CONNECTING) {
const pending = this.ws;
pending.onopen = () => {
pending.close(1000, "client logout");
};
} else {
this.ws.close(1000, "client logout");
}
this.ws = null; this.ws = null;
} }
this.state = "idle"; this.state = "idle";
@@ -329,6 +353,13 @@ class RealtimeClient {
} }
} }
private clearReleaseTimer(): void {
if (this.releaseTimer) {
clearTimeout(this.releaseTimer);
this.releaseTimer = null;
}
}
private clearHeartbeat(): void { private clearHeartbeat(): void {
if (this.heartbeatTimer) { if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer); clearInterval(this.heartbeatTimer);