feat: 外观支持自定义 CSS/JS,并升级发帖工作台与积分存量看板

后台可注入全站 CSS/JS(SSR 生效,CSS 可热换);发帖/编辑改为锁一屏工作室;经济看板增加存量健康度;主题令牌与弹层误关一并收紧。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-18 04:16:32 +08:00
parent fa713bfc3f
commit 5f7193042f
41 changed files with 2297 additions and 873 deletions

View File

@@ -91,13 +91,14 @@ export default function BoardPicker({
}
};
const empty = !loading && !selected;
const label = loading
? "加载板块…"
: selected
? selected.name
: boards.length === 0
? "暂无可用板块"
: "选择板块";
: "请选择板块";
const colorIdx = selected
? boardColorIndex(selected, selectedIdx)
@@ -108,19 +109,28 @@ export default function BoardPicker({
<button
id={id}
type="button"
className="j13-board-trigger"
className={`j13-board-trigger${empty ? " is-empty" : ""}`}
disabled={disabled || loading || boards.length === 0}
aria-haspopup="listbox"
aria-expanded={open}
aria-controls={listId}
aria-invalid={empty || undefined}
onClick={() => setOpen((v) => !v)}
onKeyDown={onTriggerKeyDown}
>
<span className={`j13-board-trigger-ico av-${colorIdx}`} aria-hidden>
<span
className={`j13-board-trigger-ico${selected ? ` av-${colorIdx}` : " is-placeholder"}`}
aria-hidden
>
{(selected?.name || "板").slice(0, 1)}
</span>
<span className="flex-1 min-w-0 truncate font-semibold text-[14px]">
{label}
<span className="j13-board-trigger-copy">
<span className="j13-board-trigger-name">{label}</span>
{empty ? (
<span className="j13-board-trigger-hint">发布前必须选择</span>
) : loading ? (
<span className="j13-board-trigger-hint">正在加载</span>
) : null}
</span>
<ChevronDown
size={16}

View File

@@ -101,11 +101,7 @@ export default function ComposeAttachments({
const accept = buildAcceptAttr(limits.exts, limits.extLimit);
const hintLine = limits.extLimit
? `单文件 ≤${limits.maxMB}MB,最多 ${limits.maxCount} 个;允许 ${
limits.exts.length > 8
? limits.exts.slice(0, 8).map((e) => `.${e}`).join(" ") + "…"
: limits.exts.map((e) => `.${e}`).join(" ") || "(列表为空)"
}`
? `单文件 ≤${limits.maxMB}MB,最多 ${limits.maxCount} 个`
: `未限制类型,单文件 ≤${limits.maxMB}MB,最多 ${limits.maxCount} 个`;
/** 仅加入待传列表,不立刻上传 */

View File

@@ -3,7 +3,7 @@
import Modal from "@/components/Modal";
/**
* 站点风格确认框:基于 Modal,仅取消/关闭可关,避免点遮罩误关。
* 站点风格确认框:基于 Modal。点遮罩不关(需明确取消/关闭);ESC / 取消 / 关闭可关。
* danger 主按钮使用 btn-danger,其余用 btn-primary。
*/
export default function ConfirmDialog({

View File

@@ -199,7 +199,7 @@ export default function Header({
className="sticky top-0 z-50 border-b"
style={{
borderColor: "var(--line)",
background: "color-mix(in srgb, var(--bg) 82%, transparent)",
background: "color-mix(in srgb, var(--panel) 86%, transparent)",
backdropFilter: "blur(14px)",
WebkitBackdropFilter: "blur(14px)",
}}
@@ -426,7 +426,7 @@ function NavPill({
className="inline-flex items-center gap-1.5 px-3.5 py-2 rounded-full text-[15px] font-semibold transition-colors"
style={
active
? { background: "var(--accent-soft)", color: "var(--accent)" }
? { background: "transparent", color: "var(--accent)" }
: { color: "var(--ink)" }
}
>
@@ -451,7 +451,7 @@ function MobileLink({
className="px-3 py-3 rounded-2xl text-[16px] font-semibold transition-colors"
style={{
color: active ? "var(--accent)" : "var(--ink)",
background: active ? "var(--accent-soft)" : "transparent",
background: "transparent",
}}
>
{children}
@@ -484,7 +484,7 @@ function UserMenu({ user, onLogout }: { user: User; onLogout: () => void }) {
}, [open]);
const itemCls =
"w-full flex items-center gap-2.5 px-4 py-2.5 text-[13.5px] transition-colors hover:bg-[var(--accent-soft)] focus-visible:bg-[var(--accent-soft)]";
"w-full flex items-center gap-2.5 px-4 py-2.5 text-[13.5px] transition-colors hover:bg-[var(--panel-2)] focus-visible:bg-[var(--panel-2)]";
return (
<div className="relative hidden lg:block" ref={menuRef}>

View File

@@ -28,7 +28,6 @@ import {
Pencil,
Maximize2,
Minimize2,
Columns2,
Lock,
ChevronDown,
Upload,
@@ -61,7 +60,7 @@ type MarkdownEditorProps = {
allowReplyHide?: boolean;
};
type EditorMode = "split" | "write" | "preview";
type EditorMode = "write" | "preview";
type WrapOpts = {
before: string;
@@ -126,7 +125,7 @@ export default function MarkdownEditor({
const taRef = useRef<HTMLTextAreaElement>(null);
const fileRef = useRef<HTMLInputElement>(null);
const toolbarRef = useRef<HTMLDivElement>(null);
const [mode, setMode] = useState<EditorMode>("split");
const [mode, setMode] = useState<EditorMode>("write");
const deferredValue = useDeferredValue(value);
const [uploading, setUploading] = useState(false);
const [fullscreen, setFullscreen] = useState(false);
@@ -940,15 +939,6 @@ export default function MarkdownEditor({
>
<Pencil size={12} /> 编写
</button>
<button
type="button"
role="tab"
aria-selected={mode === "split"}
className={mode === "split" ? "is-on" : ""}
onClick={() => setMode("split")}
>
<Columns2 size={12} /> 双栏
</button>
<button
type="button"
role="tab"

View File

@@ -42,7 +42,7 @@ export default function MobilePostBar({
<button
type="button"
onClick={scrollToComments}
className="w-11 h-11 flex items-center justify-center rounded-full transition-colors hover:bg-[var(--accent-soft)] focus-visible:bg-[var(--accent-soft)]"
className="w-11 h-11 flex items-center justify-center rounded-full transition-colors hover:bg-[var(--panel-2)] focus-visible:bg-[var(--panel-2)]"
style={{ color: "var(--ink-2)" }}
aria-label="跳到评论框"
>

View File

@@ -1,6 +1,6 @@
"use client";
import { useEffect, type ReactNode } from "react";
import { useEffect, useRef, type PointerEvent, type ReactNode } from "react";
import { X } from "lucide-react";
export type ModalProps = {
@@ -16,7 +16,11 @@ export type ModalProps = {
maxWidthClass?: string;
/** 面板额外 class(如 max-h / flex) */
panelClassName?: string;
/** 点遮罩关闭;表单/确认默认 false 防误关 */
/**
* 点遮罩关闭。按下与抬起都必须落在遮罩上才会关:
* 框内拖选后在遮罩松手、或在遮罩按下后拖进弹层松手,都不会关。
* 确认/扣费等仍建议关此开关。
*/
dismissOnOverlay?: boolean;
/** busy 时禁用关闭与 ESC */
busy?: boolean;
@@ -28,7 +32,7 @@ export type ModalProps = {
/** 自定义标题区(如带图标);提供时覆盖默认 title 行,仍渲染关闭钮除非 hideClose */
header?: ReactNode;
hideClose?: boolean;
/** 是否响应 ESC;封禁告知等不可关场景设 false */
/** 是否响应 ESC;默认 true(按键非误触)。仅封禁告知等必须点确认的场景设 false */
closeOnEscape?: boolean;
/** 内容区额外 class */
bodyClassName?: string;
@@ -36,7 +40,8 @@ export type ModalProps = {
/**
* 全站弹层壳:统一遮罩、面板、标题关闭钮与 ESC。
* 主 CTA 请用 btn-primary;危险操作用 btn-danger;取消用 btn-line。
* 默认:点遮罩不关;开启 dismissOnOverlay 时须按下+抬起都在遮罩上。
* ESC / 关闭钮 / 页脚按钮可关。主 CTA 用 btn-primary,危险操作用 btn-danger。
*/
export default function Modal({
open,
@@ -57,6 +62,12 @@ export default function Modal({
closeOnEscape = true,
bodyClassName = "",
}: ModalProps) {
const overlayPressedRef = useRef(false);
useEffect(() => {
overlayPressedRef.current = false;
}, [open]);
useEffect(() => {
if (!open || !closeOnEscape) return;
const onKey = (e: KeyboardEvent) => {
@@ -71,13 +82,33 @@ export default function Modal({
const label =
ariaLabel || (typeof title === "string" ? title : undefined);
const onOverlayPointerDown = (e: PointerEvent<HTMLDivElement>) => {
overlayPressedRef.current =
e.button === 0 && e.target === e.currentTarget;
};
const onOverlayPointerUp = (e: PointerEvent<HTMLDivElement>) => {
const pressedOnOverlay = overlayPressedRef.current;
overlayPressedRef.current = false;
if (!dismissOnOverlay || busy) return;
if (e.button !== 0) return;
// 用 pointerup 的真实落点,不用 click:mousedown 在遮罩、mouseup 在面板时
// click 会合成到共同祖先(遮罩)上,造成拖进弹层仍关闭。
if (!pressedOnOverlay || e.target !== e.currentTarget) return;
onClose();
};
const onOverlayPointerCancel = () => {
overlayPressedRef.current = false;
};
return (
<div
className={`fixed inset-0 ${zIndexClass} flex items-center justify-center p-4`}
style={{ background: "rgba(0,0,0,0.55)" }}
onClick={() => {
if (dismissOnOverlay && !busy) onClose();
}}
onPointerDown={onOverlayPointerDown}
onPointerUp={onOverlayPointerUp}
onPointerCancel={onOverlayPointerCancel}
role={role}
aria-modal="true"
aria-label={label}
@@ -136,7 +167,7 @@ function CloseButton({
onClick={onClick}
disabled={disabled}
aria-label="关闭"
className="w-7 h-7 shrink-0 flex items-center justify-center rounded-full hover:bg-[var(--accent-soft)] transition-colors disabled:opacity-50"
className="w-7 h-7 shrink-0 flex items-center justify-center rounded-full hover:bg-[var(--panel-2)] transition-colors disabled:opacity-50"
style={{ color: "var(--ink-3)" }}
>
<X size={15} />

View File

@@ -223,7 +223,7 @@ export default function NotificationBell({ initialUnread = 0 }: { initialUnread?
<button
key={n.id}
onClick={() => handleClickNotification(n)}
className={`w-full text-left px-4 py-3.5 flex items-start gap-3 transition-colors hover:bg-[var(--accent-soft)] focus-visible:bg-[var(--accent-soft)] ${
className={`w-full text-left px-4 py-3.5 flex items-start gap-3 transition-colors hover:bg-[var(--panel-2)] focus-visible:bg-[var(--panel-2)] ${
n.is_read ? "" : "bg-[var(--panel-2)]"
}`}
style={{

View File

@@ -44,7 +44,7 @@ export default function PinnedPosts({
<Link
key={p.id}
href={`/post/${p.id}`}
className="group relative flex items-center gap-3 sm:gap-3.5 px-4 sm:px-5 py-3.5 transition-colors hover:bg-[var(--accent-soft)] focus-visible:bg-[var(--accent-soft)] j13-fade-up"
className="group relative flex items-center gap-3 sm:gap-3.5 px-4 sm:px-5 py-3.5 transition-colors hover:bg-[var(--panel-2)] focus-visible:bg-[var(--panel-2)] j13-fade-up"
style={{
...(i > 0 ? { borderTop: "1px solid var(--line)" } : {}),
animationDelay: `${i * 35}ms`,

View File

@@ -137,7 +137,7 @@ export default function PostActions({
if (!canManage) return null;
const itemCls =
"w-full flex items-center gap-2.5 px-4 py-2.5 text-[13px] transition-colors hover:bg-[var(--accent-soft)] focus-visible:bg-[var(--accent-soft)]";
"w-full flex items-center gap-2.5 px-4 py-2.5 text-[13px] transition-colors hover:bg-[var(--panel-2)] focus-visible:bg-[var(--panel-2)]";
return (
<div className="relative" ref={menuRef}>

File diff suppressed because it is too large Load Diff

View File

@@ -45,7 +45,7 @@ export default async function PostRow({
return (
<div
className={`group relative flex items-center px-4 sm:px-5 border-t cursor-pointer transition-colors hover:bg-[var(--accent-soft)] focus-within:bg-[var(--accent-soft)] j13-fade-up ${
className={`group relative flex items-center px-4 sm:px-5 border-t cursor-pointer transition-colors hover:bg-[var(--panel-2)] focus-within:bg-[var(--panel-2)] j13-fade-up ${
compact ? "gap-2.5 sm:gap-3" : "gap-3.5 sm:gap-4"
}`}
style={{

View File

@@ -17,6 +17,11 @@ function pathIsChat(pathname: string | null) {
return pathname === "/chat" || pathname.startsWith("/chat/");
}
function pathIsComposeStudio(pathname: string | null) {
if (!pathname) return false;
return pathname === "/compose" || /^\/post\/[^/]+\/edit\/?$/.test(pathname);
}
/** 帖子详情(非 edit)移动端有底栏,回顶需抬高 */
function pathIsPostDetail(pathname: string | null) {
if (!pathname) return false;
@@ -75,8 +80,8 @@ export default function SiteChrome({
return <>{children}</>;
}
// 聊天页锁视口、主滚动在对话区,窗口回顶无意义
const showBackToTop = !pathIsChat(pathname);
// 聊天 / 发帖工作台锁视口,窗口回顶无意义
const showBackToTop = !pathIsChat(pathname) && !pathIsComposeStudio(pathname);
return (
<>

View File

@@ -130,7 +130,7 @@ export default function StaffUserMenu({
};
const itemCls =
"w-full flex items-center gap-2.5 px-4 py-2.5 text-[13px] transition-colors hover:bg-[var(--accent-soft)] focus-visible:bg-[var(--accent-soft)]";
"w-full flex items-center gap-2.5 px-4 py-2.5 text-[13px] transition-colors hover:bg-[var(--panel-2)] focus-visible:bg-[var(--panel-2)]";
return (
<div className="relative" ref={menuRef}>

View File

@@ -4,39 +4,52 @@ import { useEffect, useRef } from "react";
import { usePathname } from "next/navigation";
import { apiGetSettings } from "@/lib/api";
import { applyAccentTheme } from "@/lib/theme";
import { applyCustomCSS } from "@/lib/customCode";
import { realtime, RT_SETTINGS_CHANGED, type RtSettingsData } from "@/lib/realtime";
import {
VISIBLE_RECONCILE_GAP_MS,
onDocumentVisible,
} from "@/lib/visibilityReconcile";
// 站点主题色同步器:
// 主题色由管理员在后台修改后,其他用户已打开的标签页不会因软导航重新执行根布局
// SSR,本组件通过 WS 推送 + 标签页重新可见时探测公开设置,发现变化即热替换
// accent CSS 变量(复用 lib/theme 的运行时 style 节点),无需 F5 即可换肤。
// 焦点/可见性事件可能短时间连续触发,限制实际请求间隔
// 站点外观同步器:
// 主题色 / 自定义 CSS 由管理员在后台修改后,其他用户已打开的标签页不会因软导航重新执行根布局
// SSR,本组件通过 WS 推送 + 标签页重新可见时探测公开设置,发现变化即热替换。
// 自定义 JS 只在整页加载时执行,不在此热替换(避免重复绑定)。
const MIN_FETCH_GAP_MS = VISIBLE_RECONCILE_GAP_MS;
export default function ThemeSync({ initialAccent }: { initialAccent: string }) {
export default function ThemeSync({
initialAccent,
initialCustomCSS,
}: {
initialAccent: string;
initialCustomCSS: string;
}) {
const pathname = usePathname();
// 当前已应用的已保存色(空串=内置默认);与 SSR 注入的 style 节点保持一致
const currentRef = useRef((initialAccent || "").trim().toLowerCase());
const cssRef = useRef((initialCustomCSS || "").trim());
const lastFetchAtRef = useRef(0);
const inFlightRef = useRef(false);
useEffect(() => {
// 外观设置页存在"未保存的本地实时预览",该页面内暂停同步,
// 避免 HTTP 校准覆盖管理员正在预览的颜色;离开页面后 effect 重跑立即对齐
// 外观设置页存在未保存的本地实时预览,暂停同步避免覆盖草稿
if (pathname?.startsWith("/admin/appearance")) return;
let cancelled = false;
const apply = (next: string) => {
const applyAccent = (next: string) => {
if (cancelled || next === currentRef.current) return;
currentRef.current = next;
applyAccentTheme(next || null);
};
const applyCSS = (next: string) => {
if (cancelled) return;
const text = (next || "").trim();
if (text === cssRef.current) return;
cssRef.current = text;
applyCustomCSS(text);
};
const sync = async () => {
if (inFlightRef.current) return;
const now = Date.now();
@@ -44,25 +57,24 @@ export default function ThemeSync({ initialAccent }: { initialAccent: string })
lastFetchAtRef.current = now;
inFlightRef.current = true;
try {
// no-store:必须拿到管理员刚保存的最新值,不能走浏览器缓存
const { accent } = await apiGetSettings();
const settings = await apiGetSettings();
if (cancelled) return;
apply((accent || "").trim().toLowerCase());
applyAccent((settings.accent || "").trim().toLowerCase());
applyCSS(settings.custom_css || "");
} catch {
// 主题同步失败静默处理,下次回前台再试,不打扰用户
// 同步失败静默,下次回前台再试
} finally {
inFlightRef.current = false;
}
};
// WebSocket 推送优先:管理员保存即换肤;回前台 HTTP 校准兜底
const offPush = realtime.on<RtSettingsData>(RT_SETTINGS_CHANGED, (data) => {
if (data) apply((data.accent || "").trim().toLowerCase());
if (!data) return;
applyAccent((data.accent || "").trim().toLowerCase());
if (typeof data.custom_css === "string") applyCSS(data.custom_css);
});
// 仅 visibility:window focus 与 visibility 常连发,统一走限频即可
const offVisible = onDocumentVisible(() => void sync(), MIN_FETCH_GAP_MS);
// 离开外观页等 pathname 变化时立刻对齐一次(受 MIN_FETCH_GAP 限频)
void sync();
return () => {

View File

@@ -54,7 +54,7 @@ export default function AdminSearchField({
type="button"
onClick={clear}
aria-label="清除搜索"
className="w-7 h-7 inline-flex items-center justify-center rounded-full shrink-0 transition-colors hover:bg-[var(--accent-soft)]"
className="w-7 h-7 inline-flex items-center justify-center rounded-full shrink-0 transition-colors hover:bg-[var(--panel-2)]"
style={{ color: "var(--ink-3)" }}
>
<X size={15} />

View File

@@ -263,7 +263,7 @@ function AdminUserMenu({ user, onLogout }: { user: User; onLogout: () => void })
}, [open]);
const itemCls =
"w-full flex items-center gap-2.5 px-4 py-2.5 text-[13.5px] transition-colors hover:bg-[var(--accent-soft)] focus-visible:bg-[var(--accent-soft)]";
"w-full flex items-center gap-2.5 px-4 py-2.5 text-[13.5px] transition-colors hover:bg-[var(--panel-2)] focus-visible:bg-[var(--panel-2)]";
return (
<div className="relative" ref={menuRef}>

View File

@@ -45,14 +45,16 @@ export function AdminChartCard({
hint,
children,
actions,
className = "",
}: {
title: string;
hint?: string;
children: React.ReactNode;
actions?: React.ReactNode;
className?: string;
}) {
return (
<div className="panel p-4 sm:p-5 flex flex-col min-h-[260px]">
<div className={`panel p-4 sm:p-5 flex flex-col ${className}`.trim()}>
<div className="flex items-start justify-between gap-2 mb-3">
<div className="min-w-0">
<h3 className="text-[14.5px] font-semibold" style={{ color: "var(--ink)" }}>
@@ -66,7 +68,7 @@ export function AdminChartCard({
</div>
{/* 捕获阶段拦截 mousedown:冒泡阶段太晚,浏览器已开始 SVG 多柱选区黑框 */}
<div
className="flex-1 min-h-[180px] w-full overflow-hidden select-none [&_*]:select-none [&_svg]:outline-none [&_.recharts-surface]:outline-none [&_.recharts-tooltip-cursor]:hidden"
className="w-full overflow-hidden select-none [&_*]:select-none [&_svg]:outline-none [&_.recharts-surface]:outline-none [&_.recharts-tooltip-cursor]:hidden"
onMouseDownCapture={(e) => e.preventDefault()}
>
{children}
@@ -144,15 +146,17 @@ export function SingleAreaChart({
dataKey,
name,
color = "var(--accent)",
height = 200,
}: {
data: { date: string; [k: string]: string | number }[];
dataKey: string;
name: string;
color?: string;
height?: number;
}) {
const rows = data.map((d) => ({ ...d, label: shortDate(String(d.date)) }));
return (
<ResponsiveContainer width="100%" height={200}>
<ResponsiveContainer width="100%" height={height}>
<AreaChart data={rows} margin={{ top: 12, right: 12, left: 0, bottom: 0 }} accessibilityLayer={false}>
<CartesianGrid stroke="var(--line)" strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="label" tick={{ fill: "var(--ink-3)", fontSize: 11 }} axisLine={false} tickLine={false} />
@@ -183,8 +187,10 @@ export function NamedBarChart({
layout?: "vertical" | "horizontal";
}) {
if (layout === "horizontal") {
// 固定像素高度,避免 overflow 滚动条 ↔ ResponsiveContainer 互相触发闪烁
const chartH = Math.max(180, Math.min(320, data.length * 28));
return (
<ResponsiveContainer width="100%" height={Math.max(180, data.length * 28)}>
<ResponsiveContainer width="100%" height={chartH}>
<BarChart data={data} layout="vertical" margin={{ top: 8, right: 12, left: 8, bottom: 4 }} accessibilityLayer={false}>
<CartesianGrid stroke="var(--line)" strokeDasharray="3 3" horizontal={false} />
<XAxis type="number" tick={{ fill: "var(--ink-3)", fontSize: 11 }} axisLine={false} allowDecimals={false} />

View File

@@ -296,7 +296,7 @@ function NewUsers({ users }: { users: NewUser[] }) {
<li key={u.id}>
<Link
href={`/u/${u.id}`}
className="flex flex-col items-center gap-1.5 rounded-lg px-1 py-1 transition-colors hover:bg-[var(--accent-soft)] focus-visible:bg-[var(--accent-soft)]"
className="flex flex-col items-center gap-1.5 rounded-lg px-1 py-1 transition-colors hover:bg-[var(--panel-2)] focus-visible:bg-[var(--panel-2)]"
>
<Avatar name={name} src={u.avatar} size={38} />
<span