diff --git a/frontend/app/admin/appearance/AppearanceAdmin.tsx b/frontend/app/admin/appearance/AppearanceAdmin.tsx index 8c39c01..94e62c2 100644 --- a/frontend/app/admin/appearance/AppearanceAdmin.tsx +++ b/frontend/app/admin/appearance/AppearanceAdmin.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import { Palette, @@ -24,7 +24,6 @@ import { AdminSettingsGroup, AdminSettingsPage, AdminSettingsRow, - AdminSettingsWork, AdminStepper, AdminSwitch, } from "@/components/admin"; @@ -57,21 +56,6 @@ import { type SiteBgConfig, } from "@/lib/bg"; -type SectionId = "theme" | "background" | "code" | "motion"; - -const NAV: { id: SectionId; label: string }[] = [ - { id: "theme", label: "主题色" }, - { id: "background", label: "背景图" }, - { id: "code", label: "代码块" }, - { id: "motion", label: "界面动画" }, -]; - -function parseSection(hash: string): SectionId { - const id = hash.replace(/^#/, ""); - if (id === "background" || id === "code" || id === "motion" || id === "theme") return id; - return "theme"; -} - function InstantNote({ saving }: { saving?: boolean }) { return {saving ? "正在保存…" : "修改后立即生效"}; } @@ -85,7 +69,7 @@ function bgEqual(a: SiteBgConfig, b: SiteBgConfig): boolean { ); } -// 后台「外观设置」:半屏导航;主题色/背景图草稿本地预览,保存后才写库并广播。 +// 后台「外观设置」:单页分区;主题色/背景图草稿本地预览,保存后才写库并广播。 export default function AppearanceAdmin({ initial }: { initial: PublicSettings }) { const router = useRouter(); const initialAccent = initial.accent || ""; @@ -128,10 +112,6 @@ export default function AppearanceAdmin({ initial }: { initial: PublicSettings } const savedBgRef = useRef(savedBg); savedBgRef.current = savedBg; - const [active, setActive] = useState("theme"); - const [pendingSection, setPendingSection] = useState(null); - const navRefs = useRef<(HTMLAnchorElement | null)[]>([]); - const normalized = useMemo(() => normalizeHex(draft), [draft]); const pair = useMemo( () => (normalized ? deriveAccentPair(normalized) : null), @@ -147,8 +127,6 @@ export default function AppearanceAdmin({ initial }: { initial: PublicSettings } adminFile != null || siteMediaId != null || adminMediaId != null; - const tabDirty = - (active === "theme" && themeDirty) || (active === "background" && bgDirty); const leave = useUnsavedGuard(themeDirty || bgDirty); useEffect(() => { @@ -172,53 +150,6 @@ export default function AppearanceAdmin({ initial }: { initial: PublicSettings } }; }, []); - useEffect(() => { - const apply = () => setActive(parseSection(window.location.hash)); - apply(); - if (!window.location.hash) { - history.replaceState(null, "", `${window.location.pathname}${window.location.search}#theme`); - } - window.addEventListener("hashchange", apply); - return () => window.removeEventListener("hashchange", apply); - }, []); - - const goSection = (id: SectionId) => { - if (id === active) return; - setActive(id); - history.replaceState(null, "", `${window.location.pathname}${window.location.search}#${id}`); - document.getElementById("main")?.scrollTo({ top: 0 }); - }; - - const selectTab = (id: SectionId) => { - if (id === active) return; - if (tabDirty) { - setPendingSection(id); - return; - } - goSection(id); - }; - - const onNavKeyDown = (e: KeyboardEvent, i: number) => { - if ( - e.key !== "ArrowDown" && - e.key !== "ArrowUp" && - e.key !== "Home" && - e.key !== "End" && - e.key !== "ArrowRight" && - e.key !== "ArrowLeft" - ) { - return; - } - e.preventDefault(); - let next = i; - if (e.key === "ArrowDown" || e.key === "ArrowRight") next = (i + 1) % NAV.length; - else if (e.key === "ArrowUp" || e.key === "ArrowLeft") next = (i + NAV.length - 1) % NAV.length; - else if (e.key === "Home") next = 0; - else next = NAV.length - 1; - selectTab(NAV[next].id); - navRefs.current[next]?.focus(); - }; - const handleSaveTheme = async () => { if (!normalized || savingTheme || themeInvalid) return; setSavingTheme(true); @@ -470,8 +401,6 @@ export default function AppearanceAdmin({ initial }: { initial: PublicSettings } } }; - }; - const renderBgRail = ( surface: BgSurface, title: string, @@ -496,7 +425,7 @@ export default function AppearanceAdmin({ initial }: { initial: PublicSettings }

{desc}

{ - if (pendingSection) { - const next = pendingSection; - setPendingSection(null); - if (themeDirty) discardTheme(); - if (bgDirty) discardBg(); - goSection(next); - return; - } - leave.confirmLeave(); - }; - - const nav = ( - - ); - return ( - -
- {active === "theme" && ( - <> - + void handleSaveTheme()} + onDiscard={discardTheme} + saveDisabled={!normalized || savingTheme || !themeDirty || themeInvalid} + error={themeError} + > + + + } + > + +
+ {ACCENT_PRESETS.map((p) => { + const selected = normalized === p.light; + const isDefault = p.light === DEFAULT_ACCENT; + return ( + - ); - })} -
-
- - void handleSaveTheme()} - onDiscard={discardTheme} - saveDisabled={!normalized || savingTheme || !themeDirty || themeInvalid} - error={themeError} - > - - - } - > - - 主题色 - - } - hintBelow="保存前仅本页预览,不会推送给其他访客。" - error={themeInvalid ? "颜色格式无效,请输入 6 位 HEX 值(如 3B5BDB)" : undefined} - > -
- -
- # - { - 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 值" - /> -
- {pair && ( -
- - - 浅色 {pair.light} - - - - 暗色 {pair.dark} - -
- )} -
- {pair?.adjusted && ( -

- 该颜色与白底对比度不足,浅色模式已自动校正为 {pair.light}(暗色模式同步派生{" "} - {pair.dark}) -

- )} - {preset && preset.light === DEFAULT_ACCENT && ( -

当前为内置默认色,保存时会清除自定义配置

- )} -
- - -
-
- - - e.preventDefault()} - > - 文字链接示例 - -
-
- 未选中 - - 选中标签 - - - 导航选中态 - -
-
-
-

板块目录

-
- - - - 选中的板块 -
-
-
-
-
-
- - )} - - {active === "background" && ( - void handleSaveBg()} - onDiscard={discardBg} - error={bgError} - /> - } - > - - 前台 / 后台 - - } - hintBelow="选择图片与铺放方式仅在本页预览;点保存后才写入并广播。" - wide - > -
- {renderBgRail( - "site", - "前台", - "访客看到的论坛页面,含首页、帖子与个人主页。", - Monitor, - siteFileRef - )} - {renderBgRail( - "admin", - "后台", - "管理面板专用。与前台互不影响,可配更克制的图案。", - LayoutDashboard, - adminFileRef - )} -
-
-
- )} - - {active === "code" && ( - - - 自动折叠 - - } - > -
- void toggleCodeFold()} - label="代码块自动折叠" - /> - -
-
- {codeAutoFold ? ( - -
- - -
-
- ) : null} -
- )} + + + {p.name} + + + ); + })} +
+ - {active === "motion" && ( - - - 总开关 - - } + + 自定义 + + } + hintBelow="取色器或 HEX;浅色对比不足时会自动校正。" + error={themeInvalid ? "颜色格式无效,请输入 6 位 HEX(如 3B5BDB)" : undefined} + > +
+ +
+ # + { + 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 值" + /> +
+ {pair && ( +
+ + + 浅色 {pair.light} + + + + 暗色 {pair.dark} + +
+ )} +
+ {pair?.adjusted && ( +

+ 该颜色与白底对比度不足,浅色已校正为 {pair.light}(暗色派生 {pair.dark}) +

+ )} + {preset && preset.light === DEFAULT_ACCENT && ( +

当前为内置默认色,保存时会清除自定义配置

+ )} +
+ + +
+
+ + + e.preventDefault()} + > + 文字链接示例 + +
+
+ 未选中 + + 选中标签 + + + 导航选中态 + +
+
+
+

板块目录

+
+ + + + 选中的板块 +
+
+
+
+
+
+ + void handleSaveBg()} + onDiscard={discardBg} + error={bgError} + /> + } + > + + 前台 / 后台 + + } + hintBelow="可从本机上传或媒体库选用;铺放方式随草稿一起保存。" + wide + > +
+ {renderBgRail( + "site", + "前台", + "访客看到的论坛页面,含首页、帖子与个人主页。", + Monitor, + siteFileRef + )} + {renderBgRail( + "admin", + "后台", + "管理面板专用。与前台互不影响,可配更克制的图案。", + LayoutDashboard, + adminFileRef + )} +
+
+
+ + + + 代码自动折叠 + + } + hintBelow="超过阈值行数的 Markdown 代码块默认折叠,读者可展开。" + > +
+ void toggleCodeFold()} + label="代码块自动折叠" + /> + +
+
+ {codeAutoFold ? ( + +
+ + +
+
+ ) : null} + + + 界面动画 + + } + hintBelow="总开关关闭后全站即时减少动态效果。" + > +
+ void toggleMotionField("ui_animations", !uiAnimations)} + label="界面动画总开关" + /> + +
+
+ {uiAnimations ? ( + <> +
void toggleMotionField("ui_animations", !uiAnimations)} - label="界面动画总开关" + onChange={() => void toggleMotionField("anim_code_fold", !animCodeFold)} + label="代码折叠动画" />
- {uiAnimations ? ( - <> - -
- void toggleMotionField("anim_code_fold", !animCodeFold)} - label="代码折叠动画" - /> - -
-
- +
+ void toggleMotionField("anim_smooth_scroll", !animSmoothScroll)} label="平滑滚动" - hintBelow="目录跳转、回顶、新内容提示等 scroll 行为。" - > -
- - void toggleMotionField("anim_smooth_scroll", !animSmoothScroll) - } - label="平滑滚动" - /> - -
- - + +
+
+ +
+ void toggleMotionField("anim_chrome", !animChrome)} label="装饰入场" - hintBelow="列表入场、Toast、点赞弹出、在线脉冲等装饰性动画。" - > -
- void toggleMotionField("anim_chrome", !animChrome)} - label="装饰入场" - /> - -
- - - ) : null} - - )} -
-
+ /> + +
+ + + ) : null} + + { - setPendingSection(null); - leave.cancelLeave(); - }} + onConfirm={leave.confirmLeave} + onCancel={leave.cancelLeave} /> { const canAudit = isOwner(currentUserRole); const auditBtn = canAudit ? ( - - - + + 内容档案 + ) : null; if (u.role === ROLES.OWNER) { return ( -
+
{auditBtn} - - - + + + 主页 +
); } if (isSelf) { return ( -
+
{auditBtn} + + + 主页 + +
{auditBtn} - - - - + + 主页 + +
); }; @@ -971,41 +978,7 @@ export default function UsersAdmin({ {u.last_login_at ? ` · ${formatRelative(u.last_login_at)}` : ""}

-
- {isOwner(currentUserRole) && ( - - 内容档案 - - )} - {(isSelf || u.role === ROLES.OWNER) && ( - - 主页 - - )} - {!isSelf && u.role !== ROLES.OWNER && ( - <> - - 主页 - - - - )} -
+
{renderActions(u, isSelf)}
); diff --git a/frontend/app/globals.css b/frontend/app/globals.css index fa084b9..2112121 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -474,8 +474,8 @@ html.j13-has-admin-bg[data-j13-chrome="admin"] body::after { display: flex; align-items: center; gap: 6px; - padding: 3px 4px 3px 3px; - border-radius: 12px; + padding: 2px 3px 2px 2px; + border-radius: 10px; border: 1px solid var(--line); background: var(--panel); color: var(--ink); @@ -499,11 +499,11 @@ html.j13-has-admin-bg[data-j13-chrome="admin"] body::after { flex: 1; min-width: 0; display: flex; - align-items: flex-start; - gap: 10px; - padding: 6px 4px 6px 7px; + align-items: center; + gap: 8px; + padding: 5px 4px 5px 6px; border: 0; - border-radius: 9px; + border-radius: 8px; background: transparent; color: inherit; text-align: left; @@ -519,9 +519,9 @@ html.j13-has-admin-bg[data-j13-chrome="admin"] body::after { outline-offset: 1px; } .j13-compose-typeopt-ico { - width: 32px; - height: 32px; - border-radius: 9px; + width: 28px; + height: 28px; + border-radius: 8px; display: inline-flex; align-items: center; justify-content: center; @@ -540,24 +540,26 @@ html.j13-has-admin-bg[data-j13-chrome="admin"] body::after { .j13-compose-typeopt-copy { min-width: 0; flex: 1; - padding-top: 1px; } .j13-compose-typeopt-label { display: block; - font-size: 13.5px; + font-size: 13px; font-weight: 700; - line-height: 1.3; + line-height: 1.25; color: var(--ink); letter-spacing: -0.01em; } .j13-compose-typeopt-hint { display: block; - margin-top: 2px; - font-size: 12px; + margin-top: 1px; + font-size: 11.5px; font-weight: 500; - line-height: 1.4; + line-height: 1.3; color: var(--ink-3); } +.j13-compose-typebar.is-compact { + gap: 4px; +} .j13-compose-typeopt.is-on .j13-compose-typeopt-hint.is-status { color: color-mix(in srgb, var(--accent) 58%, var(--ink-2)); font-variant-numeric: tabular-nums; @@ -1742,14 +1744,22 @@ html.j13-has-admin-bg[data-j13-chrome="admin"] body::after { opacity: 0.65; cursor: not-allowed; } -.j13-board-trigger.is-empty { - border-color: color-mix(in srgb, var(--accent) 48%, var(--line)); - background: var(--accent-soft); +/* 未选:沿用中性边框;仅校验失败后用固定警示红 */ +.j13-board-trigger.is-invalid { + border-color: #dc2626; + background: color-mix(in srgb, #dc2626 8%, var(--panel)); } -.j13-board-trigger.is-empty .j13-board-trigger-hint { - color: var(--accent); +.j13-board-trigger.is-invalid .j13-board-trigger-hint { + color: #dc2626; 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 { min-width: 0; flex: 1; diff --git a/frontend/components/BoardPicker.tsx b/frontend/components/BoardPicker.tsx index 4d2ee72..e476599 100644 --- a/frontend/components/BoardPicker.tsx +++ b/frontend/components/BoardPicker.tsx @@ -10,6 +10,8 @@ type BoardPickerProps = { onChange: (id: number) => void; disabled?: boolean; loading?: boolean; + /** 提交校验失败后再为 true,用于红色错误态 */ + invalid?: boolean; id?: string; }; @@ -23,6 +25,7 @@ export default function BoardPicker({ onChange, disabled, loading, + invalid = false, id, }: BoardPickerProps) { const listId = useId(); @@ -92,6 +95,7 @@ export default function BoardPicker({ }; const empty = !loading && !selected; + const showError = empty && invalid; const label = loading ? "加载板块…" : selected @@ -109,12 +113,12 @@ export default function BoardPicker({ {isOn && needsSetup ? ( @@ -1432,9 +1600,13 @@ export default function PostComposer(props: PostComposerProps) { {isCreate ? "写新帖" : "编辑帖子"}

- {selectedBoardName || (isCreate && !boardsReady ? "加载板块…" : "选择板块")} - {" · "} - {typeLabel} + {draftBanner + ? `有未恢复的本地草稿${ + draftBanner.updatedAt + ? ` · ${new Date(draftBanner.updatedAt).toLocaleString("zh-CN")}` + : "" + }` + : typeLabel}

@@ -1462,7 +1634,7 @@ export default function PostComposer(props: PostComposerProps) { diff --git a/frontend/components/RealtimeProvider.tsx b/frontend/components/RealtimeProvider.tsx index c315fe3..f135a1c 100644 --- a/frontend/components/RealtimeProvider.tsx +++ b/frontend/components/RealtimeProvider.tsx @@ -9,7 +9,8 @@ export default function RealtimeProvider({ userId }: { userId: number | null }) useEffect(() => { if (userId) { realtime.connect(); - return () => realtime.disconnect(); + // 用 release 而非立刻 disconnect:避开 Strict Mode / HMR 瞬时卸载打断握手 + return () => realtime.release(); } realtime.disconnect(); }, [userId]); diff --git a/frontend/lib/realtime.ts b/frontend/lib/realtime.ts index c727916..6636dd9 100644 --- a/frontend/lib/realtime.ts +++ b/frontend/lib/realtime.ts @@ -168,6 +168,8 @@ class RealtimeClient { private retryCount = 0; private reconnectTimer: ReturnType | null = null; private heartbeatTimer: ReturnType | null = null; + // Strict Mode / HMR 会瞬时卸载→重挂;延迟断开,避免握手未完成就被 close 刷控制台 + private releaseTimer: ReturnType | null = null; private readonly handlers = new Map>(); private readonly presenceHandlers = new Set>(); private onlineIds = new Set(); @@ -177,6 +179,7 @@ class RealtimeClient { /** 建立连接(幂等);登录后调用 */ connect(): void { if (typeof window === "undefined") return; + this.clearReleaseTimer(); this.wanted = true; if (this.state === "open" || this.state === "connecting") return; this.clearReconnectTimer(); @@ -185,8 +188,21 @@ class RealtimeClient { 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 { + this.clearReleaseTimer(); this.wanted = false; window.removeEventListener("online", this.handleNetworkBack); document.removeEventListener("visibilitychange", this.handleVisible); @@ -194,7 +210,15 @@ class RealtimeClient { this.clearHeartbeat(); if (this.ws) { 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.state = "idle"; @@ -329,6 +353,13 @@ class RealtimeClient { } } + private clearReleaseTimer(): void { + if (this.releaseTimer) { + clearTimeout(this.releaseTimer); + this.releaseTimer = null; + } + } + private clearHeartbeat(): void { if (this.heartbeatTimer) { clearInterval(this.heartbeatTimer);