外观设置改为单页分区;发帖草稿与板块校验优化,WS 延迟释放避开 Strict Mode 误断。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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,15 +516,40 @@ 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">
|
|
||||||
{active === "theme" && (
|
|
||||||
<>
|
|
||||||
<AdminSettingsGroup
|
<AdminSettingsGroup
|
||||||
title="预设主题色"
|
title="主题色"
|
||||||
description="浅/暗两套色板均已调校,保证文字对比度达标。修改后本页实时预览,保存后全站生效。"
|
description="本页实时预览,点保存后才推送给全站访客。"
|
||||||
|
footer={
|
||||||
|
<AdminSettingsActions
|
||||||
|
dirty={themeDirty}
|
||||||
|
saving={savingTheme}
|
||||||
|
saveLabel="保存主题色"
|
||||||
|
onSave={() => void handleSaveTheme()}
|
||||||
|
onDiscard={discardTheme}
|
||||||
|
saveDisabled={!normalized || savingTheme || !themeDirty || themeInvalid}
|
||||||
|
error={themeError}
|
||||||
>
|
>
|
||||||
<div className="grid grid-cols-3 sm:grid-cols-6 gap-3 px-1 pb-1">
|
<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) => {
|
{ACCENT_PRESETS.map((p) => {
|
||||||
const selected = normalized === p.light;
|
const selected = normalized === p.light;
|
||||||
const isDefault = p.light === DEFAULT_ACCENT;
|
const isDefault = p.light === DEFAULT_ACCENT;
|
||||||
@@ -676,43 +594,16 @@ export default function AppearanceAdmin({ initial }: { initial: PublicSettings }
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</AdminSettingsGroup>
|
</AdminSettingsRow>
|
||||||
|
|
||||||
<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
|
<AdminSettingsRow
|
||||||
label={
|
label={
|
||||||
<span className="inline-flex items-center gap-1.5">
|
<span className="inline-flex items-center gap-1.5">
|
||||||
<Pipette size={15} /> 主题色
|
<Pipette size={15} /> 自定义
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
hintBelow="保存前仅本页预览,不会推送给其他访客。"
|
hintBelow="取色器或 HEX;浅色对比不足时会自动校正。"
|
||||||
error={themeInvalid ? "颜色格式无效,请输入 6 位 HEX 值(如 3B5BDB)" : undefined}
|
error={themeInvalid ? "颜色格式无效,请输入 6 位 HEX(如 3B5BDB)" : undefined}
|
||||||
>
|
>
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<label
|
<label
|
||||||
@@ -768,8 +659,7 @@ export default function AppearanceAdmin({ initial }: { initial: PublicSettings }
|
|||||||
</div>
|
</div>
|
||||||
{pair?.adjusted && (
|
{pair?.adjusted && (
|
||||||
<p className="mt-2.5 text-[12.5px]" style={{ color: "var(--gold)" }}>
|
<p className="mt-2.5 text-[12.5px]" style={{ color: "var(--gold)" }}>
|
||||||
该颜色与白底对比度不足,浅色模式已自动校正为 {pair.light}(暗色模式同步派生{" "}
|
该颜色与白底对比度不足,浅色已校正为 {pair.light}(暗色派生 {pair.dark})
|
||||||
{pair.dark})
|
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{preset && preset.light === DEFAULT_ACCENT && (
|
{preset && preset.light === DEFAULT_ACCENT && (
|
||||||
@@ -777,7 +667,7 @@ export default function AppearanceAdmin({ initial }: { initial: PublicSettings }
|
|||||||
)}
|
)}
|
||||||
</AdminSettingsRow>
|
</AdminSettingsRow>
|
||||||
|
|
||||||
<AdminSettingsRow label="实时预览" wide>
|
<AdminSettingsRow label="预览" wide>
|
||||||
<div
|
<div
|
||||||
className="rounded-[var(--r-sm)] border p-4 space-y-4"
|
className="rounded-[var(--r-sm)] border p-4 space-y-4"
|
||||||
style={{ borderColor: "var(--line)", background: "var(--bg)" }}
|
style={{ borderColor: "var(--line)", background: "var(--bg)" }}
|
||||||
@@ -827,18 +717,15 @@ export default function AppearanceAdmin({ initial }: { initial: PublicSettings }
|
|||||||
</div>
|
</div>
|
||||||
</AdminSettingsRow>
|
</AdminSettingsRow>
|
||||||
</AdminSettingsGroup>
|
</AdminSettingsGroup>
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{active === "background" && (
|
|
||||||
<AdminSettingsGroup
|
<AdminSettingsGroup
|
||||||
title="站点背景图"
|
title="站点背景图"
|
||||||
description="前台与后台可分别配置 JPEG / PNG / WebP(≤8MB)。预览按 16:9。未保存不会推送给其他访客。"
|
description="前台与后台可分别配置 JPEG / PNG / WebP(≤8MB)。本页预览,保存后才全站生效。"
|
||||||
footer={
|
footer={
|
||||||
<AdminSettingsActions
|
<AdminSettingsActions
|
||||||
dirty={bgDirty}
|
dirty={bgDirty}
|
||||||
saving={savingBg}
|
saving={savingBg}
|
||||||
saveLabel="保存并全站生效"
|
saveLabel="保存背景图"
|
||||||
onSave={() => void handleSaveBg()}
|
onSave={() => void handleSaveBg()}
|
||||||
onDiscard={discardBg}
|
onDiscard={discardBg}
|
||||||
error={bgError}
|
error={bgError}
|
||||||
@@ -851,7 +738,7 @@ export default function AppearanceAdmin({ initial }: { initial: PublicSettings }
|
|||||||
<ImageIcon size={15} /> 前台 / 后台
|
<ImageIcon size={15} /> 前台 / 后台
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
hintBelow="选择图片与铺放方式仅在本页预览;点保存后才写入并广播。"
|
hintBelow="可从本机上传或媒体库选用;铺放方式随草稿一起保存。"
|
||||||
wide
|
wide
|
||||||
>
|
>
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
@@ -872,19 +759,18 @@ export default function AppearanceAdmin({ initial }: { initial: PublicSettings }
|
|||||||
</div>
|
</div>
|
||||||
</AdminSettingsRow>
|
</AdminSettingsRow>
|
||||||
</AdminSettingsGroup>
|
</AdminSettingsGroup>
|
||||||
)}
|
|
||||||
|
|
||||||
{active === "code" && (
|
|
||||||
<AdminSettingsGroup
|
<AdminSettingsGroup
|
||||||
title="代码块自动折叠"
|
title="代码块与动画"
|
||||||
description="超过阈值行数的 Markdown 代码块默认折叠,读者可点「展开全部」。"
|
description="开关改完立即写入。系统「减少动态效果」始终优先于站点动画。"
|
||||||
>
|
>
|
||||||
<AdminSettingsRow
|
<AdminSettingsRow
|
||||||
label={
|
label={
|
||||||
<span className="inline-flex items-center gap-1.5">
|
<span className="inline-flex items-center gap-1.5">
|
||||||
<Code2 size={15} /> 自动折叠
|
<Code2 size={15} /> 代码自动折叠
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
|
hintBelow="超过阈值行数的 Markdown 代码块默认折叠,读者可展开。"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<AdminSwitch
|
<AdminSwitch
|
||||||
@@ -900,7 +786,7 @@ export default function AppearanceAdmin({ initial }: { initial: PublicSettings }
|
|||||||
<AdminSettingsRow
|
<AdminSettingsRow
|
||||||
label="折叠行数阈值"
|
label="折叠行数阈值"
|
||||||
htmlFor="fold-lines"
|
htmlFor="fold-lines"
|
||||||
hintBelow="超过该值时折叠;折叠后约露出同样行数。设为 0 表示无预览。范围 0–100,默认 5。"
|
hintBelow="超过该值时折叠;折叠后约露出同样行数。0 表示无预览。范围 0–100,默认 5。"
|
||||||
>
|
>
|
||||||
<div className="flex flex-wrap items-center gap-2.5">
|
<div className="flex flex-wrap items-center gap-2.5">
|
||||||
<AdminStepper
|
<AdminStepper
|
||||||
@@ -924,25 +810,19 @@ export default function AppearanceAdmin({ initial }: { initial: PublicSettings }
|
|||||||
) : (
|
) : (
|
||||||
<Save size={14} />
|
<Save size={14} />
|
||||||
)}
|
)}
|
||||||
保存
|
保存阈值
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</AdminSettingsRow>
|
</AdminSettingsRow>
|
||||||
) : null}
|
) : null}
|
||||||
</AdminSettingsGroup>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{active === "motion" && (
|
|
||||||
<AdminSettingsGroup
|
|
||||||
title="界面动画"
|
|
||||||
description="总开关关闭后全站即时切换。系统「减少动态效果」始终优先。"
|
|
||||||
>
|
|
||||||
<AdminSettingsRow
|
<AdminSettingsRow
|
||||||
label={
|
label={
|
||||||
<span className="inline-flex items-center gap-1.5">
|
<span className="inline-flex items-center gap-1.5">
|
||||||
<Sparkles size={15} /> 总开关
|
<Sparkles size={15} /> 界面动画
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
|
hintBelow="总开关关闭后全站即时减少动态效果。"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<AdminSwitch
|
<AdminSwitch
|
||||||
@@ -956,10 +836,7 @@ export default function AppearanceAdmin({ initial }: { initial: PublicSettings }
|
|||||||
</AdminSettingsRow>
|
</AdminSettingsRow>
|
||||||
{uiAnimations ? (
|
{uiAnimations ? (
|
||||||
<>
|
<>
|
||||||
<AdminSettingsRow
|
<AdminSettingsRow label="代码折叠动画" hintBelow="代码块展开/收起的高度过渡与跟滚。">
|
||||||
label="代码折叠动画"
|
|
||||||
hintBelow="代码块展开/收起的高度过渡与跟滚。"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<AdminSwitch
|
<AdminSwitch
|
||||||
checked={animCodeFold}
|
checked={animCodeFold}
|
||||||
@@ -970,17 +847,12 @@ export default function AppearanceAdmin({ initial }: { initial: PublicSettings }
|
|||||||
<InstantNote saving={togglingMotion} />
|
<InstantNote saving={togglingMotion} />
|
||||||
</div>
|
</div>
|
||||||
</AdminSettingsRow>
|
</AdminSettingsRow>
|
||||||
<AdminSettingsRow
|
<AdminSettingsRow label="平滑滚动" hintBelow="目录跳转、回顶、新内容提示等 scroll 行为。">
|
||||||
label="平滑滚动"
|
|
||||||
hintBelow="目录跳转、回顶、新内容提示等 scroll 行为。"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<AdminSwitch
|
<AdminSwitch
|
||||||
checked={animSmoothScroll}
|
checked={animSmoothScroll}
|
||||||
disabled={togglingMotion}
|
disabled={togglingMotion}
|
||||||
onChange={() =>
|
onChange={() => void toggleMotionField("anim_smooth_scroll", !animSmoothScroll)}
|
||||||
void toggleMotionField("anim_smooth_scroll", !animSmoothScroll)
|
|
||||||
}
|
|
||||||
label="平滑滚动"
|
label="平滑滚动"
|
||||||
/>
|
/>
|
||||||
<InstantNote saving={togglingMotion} />
|
<InstantNote saving={togglingMotion} />
|
||||||
@@ -1003,22 +875,17 @@ export default function AppearanceAdmin({ initial }: { initial: PublicSettings }
|
|||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</AdminSettingsGroup>
|
</AdminSettingsGroup>
|
||||||
)}
|
|
||||||
</div>
|
</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}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 ? (
|
||||||
<>
|
<>
|
||||||
|
{!hideComposeNav && (
|
||||||
<Link href="/compose" className="btn btn-primary btn-sm hidden lg:inline-flex">
|
<Link href="/compose" className="btn btn-primary btn-sm hidden lg:inline-flex">
|
||||||
<PenSquare size={14} /> 发帖
|
<PenSquare size={14} /> 发帖
|
||||||
</Link>
|
</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">
|
||||||
|
|||||||
@@ -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,8 +936,6 @@ export default function PostComposer(props: PostComposerProps) {
|
|||||||
? "请填写标题"
|
? "请填写标题"
|
||||||
: !content.trim()
|
: !content.trim()
|
||||||
? "请填写正文"
|
? "请填写正文"
|
||||||
: isCreate && (!boardsReady || boardId <= 0)
|
|
||||||
? "请选择板块"
|
|
||||||
: postType === "poll" &&
|
: postType === "poll" &&
|
||||||
pollOptions.map((o) => o.trim()).filter(Boolean).length < 2
|
pollOptions.map((o) => o.trim()).filter(Boolean).length < 2
|
||||||
? "请至少填写 2 个投票选项"
|
? "请至少填写 2 个投票选项"
|
||||||
@@ -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>
|
||||||
|
{hintText ? (
|
||||||
<span
|
<span
|
||||||
className={`j13-compose-typeopt-hint${
|
className={`j13-compose-typeopt-hint${
|
||||||
status ? (status.warn ? " is-warn" : " is-status") : ""
|
status ? (status.warn ? " is-warn" : " is-status") : ""
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{status ? status.text : t.hint}
|
{hintText}
|
||||||
</span>
|
</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>
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
// 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.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);
|
||||||
|
|||||||
Reference in New Issue
Block a user