feat: 交付官方 Docker 运行时,外观改背景图并下线自定义 CSS/JS

站点/后台分轨背景与用户列表排序一并落地;生产 CORS 改走 SITE_URL,健康检查带版本号。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-18 05:27:33 +08:00
parent 5f7193042f
commit d784b0ea7a
68 changed files with 2588 additions and 768 deletions

7
frontend/.dockerignore Normal file
View File

@@ -0,0 +1,7 @@
node_modules
.next
.env
.env.local
.env.*.local
*.md
coverage

View File

@@ -9,3 +9,6 @@ NEXT_PUBLIC_API_URL=http://localhost:3001
# 部署到 Cloudflare Workers 等边缘环境时【必须】显式配置为后端 HTTPS 地址,
# 边缘运行时不存在 localhost,留空会导致 SSR 登录态轮转直接放行失效。
# BACKEND_URL=https://your-backend.example.com
# 可选。官方 Docker 构建会写入与仓库根 VERSION 相同的值;本地 dev 可不设。
# NEXT_PUBLIC_APP_VERSION=0.1.0

View File

@@ -96,61 +96,63 @@ export default async function AboutIndexPage() {
<SiteDocShell
left={left}
right={right}
breadcrumb={
<SiteDocBreadcrumb
items={[
{ href: "/", label: "首页" },
{ label: "关于本站" },
]}
/>
}
mobileExtras={
announcements.length > 0 ? <SiteDocAnnoNav items={announcements} /> : null
}
>
<header className="mb-5 flex items-end justify-between gap-3">
<div>
<h1
className="text-[24px] sm:text-[28px] font-extrabold tracking-tight flex items-center gap-2"
style={{ color: "var(--ink)" }}
>
<Info size={26} style={{ color: "var(--accent)" }} aria-hidden />
关于本站
</h1>
<p className="meta mt-2">
{error
? "加载失败"
: pages.length > 0
? `共 ${pages.length} 篇站点文档`
: "站点说明与政策文档"}
</p>
</div>
{canManagePages ? (
<Link
href="/admin/pages"
className="meta text-[13px] shrink-0 mb-0.5 transition-colors hover:text-[var(--accent)]"
>
管理
</Link>
) : null}
</header>
<div className="panel overflow-hidden">
<header
className="px-5 sm:px-6 pt-5 pb-4"
style={{ borderBottom: "1px solid var(--line)" }}
>
<SiteDocBreadcrumb
className="mb-3"
items={[
{ href: "/", label: "首页" },
{ label: "关于本站" },
]}
/>
<div className="flex items-end justify-between gap-3">
<div>
<h1
className="text-[24px] sm:text-[28px] font-extrabold tracking-tight flex items-center gap-2"
style={{ color: "var(--ink)" }}
>
<Info size={26} style={{ color: "var(--accent)" }} aria-hidden />
关于本站
</h1>
<p className="meta mt-2">
{error
? "加载失败"
: pages.length > 0
? `共 ${pages.length} 篇站点文档`
: "站点说明与政策文档"}
</p>
</div>
{canManagePages ? (
<Link
href="/admin/pages"
className="meta text-[13px] shrink-0 mb-0.5 transition-colors hover:text-[var(--accent)]"
>
管理
</Link>
) : null}
</div>
</header>
{error ? (
<p className="alert-error">{error}</p>
) : pages.length === 0 ? (
<div className="panel p-10 text-center">
<p className="font-medium" style={{ color: "var(--ink)" }}>
暂无介绍
</p>
<p className="meta mt-1.5">有新文档时会显示在这里</p>
</div>
) : (
<div className="panel overflow-hidden">
{pages.map((p, i) => (
<PageRow key={p.id} p={p} showDivider={i > 0} />
))}
</div>
)}
{error ? (
<p className="alert-error m-5">{error}</p>
) : pages.length === 0 ? (
<div className="p-10 text-center">
<p className="font-medium" style={{ color: "var(--ink)" }}>
暂无介绍
</p>
<p className="meta mt-1.5">有新文档时会显示在这里</p>
</div>
) : (
pages.map((p, i) => <PageRow key={p.id} p={p} showDivider={i > 0} />)
)}
</div>
</SiteDocShell>
);
}

View File

@@ -12,7 +12,11 @@ import {
RotateCcw,
Pipette,
Sparkles,
FileCode2,
ImageIcon,
Monitor,
LayoutDashboard,
Trash2,
Upload,
} from "lucide-react";
import {
AdminPageHeader,
@@ -20,8 +24,13 @@ import {
AdminSettingRow,
AdminStepper,
AdminSwitch,
AdminSegmented,
} from "@/components/admin";
import { apiUpdateSiteSettings, type PublicSettings, type UpdateSiteSettingsBody } from "@/lib/api";
import {
apiUpdateSiteSettings,
apiUploadSiteBackground,
type PublicSettings,
} from "@/lib/api";
import { toast } from "@/lib/toast";
import {
ACCENT_PRESETS,
@@ -32,26 +41,23 @@ import {
normalizeHex,
} from "@/lib/theme";
import {
MAX_CUSTOM_CSS_CHARS,
MAX_CUSTOM_JS_CHARS,
SAMPLE_CUSTOM_CSS,
SAMPLE_CUSTOM_JS,
applyCustomCSS,
clipCustomSnippet,
} from "@/lib/customCode";
BG_MODE_OPTIONS,
applySiteBackground,
bgConfigFromSettings,
normalizeBgMode,
previewBoxStyle,
type BgMode,
type BgSurface,
type SiteBgConfig,
} from "@/lib/bg";
// 后台「外观设置」:主题色、自定义 CSS/JS、代码块折叠与界面动画。
// 主题色 / 自定义 CSS 修改时立即对整页实时预览(仅本地),保存后才写库并对所有用户生效。
// 自定义 JS 不在编辑时执行,保存后整页加载生效。
// 后台「外观设置」:主题色、代码块折叠与界面动画。
// 主题色修改时立即对整页实时预览(仅本地),保存后才写库并对所有用户生效。
export default function AppearanceAdmin({ initial }: { initial: PublicSettings }) {
const router = useRouter();
const initialAccent = initial.accent || "";
const [savedHex, setSavedHex] = useState(initialAccent);
const [draft, setDraft] = useState(initialAccent || DEFAULT_ACCENT);
const [savedCustomCSS, setSavedCustomCSS] = useState(initial.custom_css || "");
const [customCSS, setCustomCSS] = useState(initial.custom_css || SAMPLE_CUSTOM_CSS);
const [savedCustomJS, setSavedCustomJS] = useState(initial.custom_js || "");
const [customJS, setCustomJS] = useState(initial.custom_js || SAMPLE_CUSTOM_JS);
const [codeAutoFold, setCodeAutoFold] = useState(initial.code_block_auto_fold !== false);
const [foldLines, setFoldLines] = useState(
Number.isFinite(initial.code_block_fold_lines) ? initial.code_block_fold_lines : 5
@@ -66,10 +72,12 @@ export default function AppearanceAdmin({ initial }: { initial: PublicSettings }
const [animChrome, setAnimChrome] = useState(initial.anim_chrome !== false);
const [togglingMotion, setTogglingMotion] = useState(false);
const [saving, setSaving] = useState(false);
const [bg, setBg] = useState<SiteBgConfig>(() => bgConfigFromSettings(initial));
const [bgBusy, setBgBusy] = useState<BgSurface | null>(null);
const siteFileRef = useRef<HTMLInputElement>(null);
const adminFileRef = useRef<HTMLInputElement>(null);
const savedRef = useRef(savedHex);
savedRef.current = savedHex;
const savedCSSRef = useRef(savedCustomCSS);
savedCSSRef.current = savedCustomCSS;
const normalized = useMemo(() => normalizeHex(draft), [draft]);
const pair = useMemo(
@@ -78,12 +86,7 @@ export default function AppearanceAdmin({ initial }: { initial: PublicSettings }
);
const preset = useMemo(() => (normalized ? findPreset(normalized) : null), [normalized]);
const submitHex = normalized && normalized !== DEFAULT_ACCENT ? normalized : "";
const accentDirty = (savedHex || DEFAULT_ACCENT) !== (normalized || "");
const cssIsGuide = !savedCustomCSS && customCSS === SAMPLE_CUSTOM_CSS;
const jsIsGuide = !savedCustomJS && customJS === SAMPLE_CUSTOM_JS;
const cssDirty = customCSS !== savedCustomCSS && !cssIsGuide;
const jsDirty = customJS !== savedCustomJS && !jsIsGuide;
const dirty = accentDirty || cssDirty || jsDirty;
const dirty = (savedHex || DEFAULT_ACCENT) !== (normalized || "");
const invalid = draft.trim() !== "" && !normalized;
useEffect(() => {
@@ -93,35 +96,19 @@ export default function AppearanceAdmin({ initial }: { initial: PublicSettings }
};
}, [normalized]);
useEffect(() => {
applyCustomCSS(customCSS, true);
return () => {
applyCustomCSS(savedCSSRef.current, false);
};
}, [customCSS]);
const handleSave = async () => {
if (!normalized || saving) return;
const body: UpdateSiteSettingsBody = {};
if (accentDirty) body.accent = submitHex;
if (cssDirty) body.custom_css = customCSS;
if (jsDirty) body.custom_js = customJS;
if (Object.keys(body).length === 0) return;
setSaving(true);
try {
const res = await apiUpdateSiteSettings(body);
const res = await apiUpdateSiteSettings({ accent: submitHex });
const next = (res.accent || "").toLowerCase();
setSavedHex(next);
setSavedCustomCSS(res.custom_css || "");
setSavedCustomJS(res.custom_js || "");
applyAccentTheme(next || null);
applyCustomCSS(res.custom_css || "", false);
router.refresh();
toast(jsDirty ? "外观已保存;自定义 JS 将在整页刷新后执行" : "外观已保存并对全站生效");
toast(next ? "主题色已保存并对全站生效" : "已恢复默认主题色");
} catch (e) {
toast(e instanceof Error ? e.message : "保存失败", "error");
applyAccentTheme(savedRef.current || null);
applyCustomCSS(savedCSSRef.current, false);
} finally {
setSaving(false);
}
@@ -129,10 +116,7 @@ export default function AppearanceAdmin({ initial }: { initial: PublicSettings }
const discard = () => {
setDraft(savedHex || DEFAULT_ACCENT);
setCustomCSS(savedCustomCSS || SAMPLE_CUSTOM_CSS);
setCustomJS(savedCustomJS || SAMPLE_CUSTOM_JS);
applyAccentTheme(savedHex || null);
applyCustomCSS(savedCustomCSS, true);
};
const toggleCodeFold = async () => {
@@ -202,12 +186,180 @@ export default function AppearanceAdmin({ initial }: { initial: PublicSettings }
}
};
const commitBg = (next: SiteBgConfig) => {
setBg(next);
applySiteBackground(next);
};
const saveBgPatch = async (patch: Partial<SiteBgConfig>, toastOk: string) => {
const res = await apiUpdateSiteSettings(patch);
commitBg(bgConfigFromSettings(res));
toast(toastOk, "ok");
};
const onPickBg = async (surface: BgSurface, file: File | undefined) => {
if (!file || bgBusy) return;
if (!/^image\/(jpeg|png|webp)$/i.test(file.type) && !/\.(jpe?g|png|webp)$/i.test(file.name)) {
toast("仅支持 JPEG / PNG / WebP", "error");
return;
}
if (file.size > 8 * 1024 * 1024) {
toast("背景图不能超过 8MB", "error");
return;
}
setBgBusy(surface);
try {
const pendingMode = surface === "site" ? bg.bg_site_mode : bg.bg_admin_mode;
let res = await apiUploadSiteBackground(surface, file);
let next = bgConfigFromSettings(res);
const savedMode = surface === "site" ? next.bg_site_mode : next.bg_admin_mode;
if (pendingMode !== savedMode) {
const modeKey = surface === "site" ? "bg_site_mode" : "bg_admin_mode";
res = await apiUpdateSiteSettings({ [modeKey]: pendingMode });
next = bgConfigFromSettings(res);
}
commitBg(next);
toast(surface === "site" ? "前台背景已更新" : "后台背景已更新", "ok");
} catch (e) {
toast(e instanceof Error ? e.message : "上传失败", "error");
} finally {
setBgBusy(null);
}
};
const onChangeBgMode = async (surface: BgSurface, mode: BgMode) => {
if (bgBusy) return;
const key = surface === "site" ? "bg_site_mode" : "bg_admin_mode";
const url = surface === "site" ? bg.bg_site_url : bg.bg_admin_url;
if (!url) {
commitBg({ ...bg, [key]: mode });
return;
}
setBgBusy(surface);
try {
await saveBgPatch(
{ [key]: mode },
surface === "site" ? "前台铺放模式已保存" : "后台铺放模式已保存"
);
} catch (e) {
toast(e instanceof Error ? e.message : "保存失败", "error");
} finally {
setBgBusy(null);
}
};
const onClearBg = async (surface: BgSurface) => {
if (bgBusy) return;
const key = surface === "site" ? "bg_site_url" : "bg_admin_url";
setBgBusy(surface);
try {
await saveBgPatch(
{ [key]: "" },
surface === "site" ? "已恢复前台默认画布" : "已恢复后台默认画布"
);
} catch (e) {
toast(e instanceof Error ? e.message : "保存失败", "error");
} finally {
setBgBusy(null);
}
};
const renderBgRail = (
surface: BgSurface,
title: string,
desc: string,
Icon: typeof Monitor,
fileRef: typeof siteFileRef
) => {
const url = surface === "site" ? bg.bg_site_url : bg.bg_admin_url;
const mode = surface === "site" ? bg.bg_site_mode : bg.bg_admin_mode;
const busy = bgBusy === surface;
return (
<div className="flex flex-col gap-3 min-w-0">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<h3 className="text-[14px] font-medium flex items-center gap-1.5" style={{ color: "var(--ink)" }}>
<Icon size={15} /> {title}
</h3>
<p className="meta text-[12.5px] mt-0.5 leading-relaxed">{desc}</p>
</div>
</div>
<div
className="relative h-36 rounded-[var(--r-sm)] border overflow-hidden"
style={{
borderColor: "var(--line)",
...previewBoxStyle(url, mode),
}}
>
{!url && (
<span
className="absolute inset-0 flex items-center justify-center text-[12.5px]"
style={{ color: "var(--ink-3)" }}
>
默认纹饰画布
</span>
)}
</div>
<input
ref={fileRef}
type="file"
accept="image/jpeg,image/png,image/webp,.jpg,.jpeg,.png,.webp"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
e.target.value = "";
void onPickBg(surface, f);
}}
/>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
className="btn btn-line btn-sm"
disabled={busy}
onClick={() => fileRef.current?.click()}
>
{busy ? <Loader2 size={14} className="animate-spin" /> : <Upload size={14} />}
{url ? "更换图片" : "上传图片"}
</button>
{url ? (
<button
type="button"
className="btn btn-line btn-sm"
disabled={busy}
onClick={() => void onClearBg(surface)}
>
<Trash2 size={14} /> 恢复默认
</button>
) : null}
</div>
<div>
<p className="field-label mb-2">铺放模式</p>
<AdminSegmented
size="sm"
role="group"
ariaLabel={`${title}铺放模式`}
value={mode}
onChange={(key) => void onChangeBgMode(surface, normalizeBgMode(key))}
options={BG_MODE_OPTIONS.map((o) => ({
key: o.key,
label: o.label,
disabled: busy,
}))}
/>
<p className="meta text-[12px] mt-1.5">
{BG_MODE_OPTIONS.find((o) => o.key === mode)?.hint}
</p>
</div>
</div>
);
};
return (
<div>
<AdminPageHeader
icon={Palette}
title="外观设置"
description="配置站点主题色、自定义 CSS/JS、代码块折叠与界面动画"
description="配置站点主题色、背景图、代码块折叠与界面动画"
/>
<div className="grid grid-cols-1 xl:grid-cols-3 gap-4 mb-6">
@@ -370,7 +522,8 @@ export default function AppearanceAdmin({ initial }: { initial: PublicSettings }
</span>
</div>
<div className="max-w-xs">
<div className="panel p-3">
<section className="j13-module">
<p className="j13-module-title mb-2.5">板块目录</p>
<div className="j13-bcard" aria-current="page">
<span
className="j13-bcard-ico"
@@ -378,9 +531,9 @@ export default function AppearanceAdmin({ initial }: { initial: PublicSettings }
>
<Palette size={16} />
</span>
<span className="j13-bcard-name text-[13px] flex-1">选中的板块卡片</span>
<span className="j13-bcard-name flex-1">选中的板块</span>
</div>
</div>
</section>
</div>
</div>
</AdminSection>
@@ -516,81 +669,29 @@ export default function AppearanceAdmin({ initial }: { initial: PublicSettings }
</div>
<AdminSection
className="mb-6"
title={
<span className="inline-flex items-center gap-1.5">
<FileCode2 size={15} /> 自定义代码
<ImageIcon size={15} /> 站点背景图
</span>
}
description="注入全站 HTML。空配置时先显示注释示例(不会自动写入);保存后才对访客生效。勿包含 </style> 或 </script>。"
className="mb-6"
description="前台与后台可分别上传 JPEG / PNG / WebP(≤8MB)。未配置时使用默认纹饰画布。"
>
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
<div>
<div className="flex items-center justify-between gap-2 mb-1">
<label className="field-label !mb-0" htmlFor="custom-css">
自定义 CSS
</label>
{customCSS !== SAMPLE_CUSTOM_CSS && (
<button
type="button"
className="btn btn-line btn-sm !h-7 !px-2.5 !text-[12px]"
onClick={() => setCustomCSS(SAMPLE_CUSTOM_CSS)}
>
填入示例
</button>
)}
</div>
<p className="meta text-[12.5px] mb-2 leading-relaxed">
覆盖令牌或组件类。本页即时预览;去掉注释后的规则才会改变外观。上限{" "}
{MAX_CUSTOM_CSS_CHARS.toLocaleString()} 字。
</p>
<textarea
id="custom-css"
className="j13-compose-field w-full font-mono text-[12.5px] leading-relaxed"
style={{ minHeight: 280 }}
spellCheck={false}
value={customCSS}
onChange={(e) => setCustomCSS(clipCustomSnippet(e.target.value, MAX_CUSTOM_CSS_CHARS))}
aria-label="自定义 CSS"
/>
<p className="meta text-[11.5px] mt-1.5 tabular-nums">
{customCSS.length.toLocaleString()} / {MAX_CUSTOM_CSS_CHARS.toLocaleString()}
{cssIsGuide ? " · 示例未保存" : ""}
</p>
</div>
<div>
<div className="flex items-center justify-between gap-2 mb-1">
<label className="field-label !mb-0" htmlFor="custom-js">
自定义 JS
</label>
{customJS !== SAMPLE_CUSTOM_JS && (
<button
type="button"
className="btn btn-line btn-sm !h-7 !px-2.5 !text-[12px]"
onClick={() => setCustomJS(SAMPLE_CUSTOM_JS)}
>
填入示例
</button>
)}
</div>
<p className="meta text-[12.5px] mb-2 leading-relaxed">
仅站长/超管可写。不在编辑时运行,保存后需整页刷新。上限{" "}
{MAX_CUSTOM_JS_CHARS.toLocaleString()} 字。
</p>
<textarea
id="custom-js"
className="j13-compose-field w-full font-mono text-[12.5px] leading-relaxed"
style={{ minHeight: 280 }}
spellCheck={false}
value={customJS}
onChange={(e) => setCustomJS(clipCustomSnippet(e.target.value, MAX_CUSTOM_JS_CHARS))}
aria-label="自定义 JS"
/>
<p className="meta text-[11.5px] mt-1.5 tabular-nums">
{customJS.length.toLocaleString()} / {MAX_CUSTOM_JS_CHARS.toLocaleString()}
{jsIsGuide ? " · 示例未保存" : ""}
</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{renderBgRail(
"site",
"前台",
"访客看到的论坛页面,含首页、帖子与个人主页。",
Monitor,
siteFileRef
)}
{renderBgRail(
"admin",
"后台",
"管理面板专用。与前台互不影响,可配更克制的图案。",
LayoutDashboard,
adminFileRef
)}
</div>
</AdminSection>

View File

@@ -4,8 +4,12 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import {
ArrowUpDown,
ChevronDown,
ChevronLeft,
ChevronRight,
ChevronsUpDown,
ChevronUp,
Clock,
Columns3,
ExternalLink,
@@ -46,12 +50,14 @@ import {
} from "@/components/admin";
export type UserFilter = "all" | "staff" | "banned";
export type UserSortKey = "id" | "role" | "status" | "points" | "last_login" | "counts" | "created_at" | "online";
export type UserSortDir = "asc" | "desc";
const PAGE_SIZE = 20;
const COLS_STORAGE_KEY = "j13-admin-user-cols";
/** 默认始终显示的列 */
const CORE_COLS = ["member", "role", "status", "points", "last_login", "actions"] as const;
const CORE_COLS = ["id", "member", "role", "status", "points", "last_login", "actions"] as const;
/** 可选列(localStorage 开关) */
const OPTIONAL_COLS = ["online", "email", "counts", "created_at"] as const;
@@ -59,6 +65,9 @@ type CoreCol = (typeof CORE_COLS)[number];
type OptionalCol = (typeof OPTIONAL_COLS)[number];
type ColKey = CoreCol | OptionalCol;
/** 可服务端排序的列(与后端白名单一致) */
const SORTABLE_COLS = new Set<ColKey>(["id", "role", "status", "points", "last_login", "counts", "created_at", "online"]);
const OPTIONAL_LABELS: Record<OptionalCol, string> = {
online: "在线",
email: "邮箱",
@@ -96,6 +105,17 @@ const FILTER_TABS: { key: UserFilter; label: string }[] = [
{ key: "banned", label: "已封禁" },
];
const SORT_OPTIONS: { key: UserSortKey; label: string }[] = [
{ key: "id", label: "用户 ID" },
{ key: "role", label: "角色" },
{ key: "status", label: "状态" },
{ key: "points", label: "积分" },
{ key: "last_login", label: "上次登录" },
{ key: "counts", label: "内容" },
{ key: "created_at", label: "注册时间" },
{ key: "online", label: "在线" },
];
function filterToParams(filter: UserFilter): { role?: string; status?: string } {
if (filter === "staff") return { role: "staff" };
if (filter === "banned") return { status: "banned" };
@@ -178,7 +198,7 @@ function ColumnConfigPopover({
))}
</ul>
<p className="px-2 pt-1.5 pb-1 text-[11px] leading-relaxed" style={{ color: "var(--ink-3)" }}>
成员 / 角色 / 状态 / 积分 / 上次登录 / 操作固定显示
成员 / ID / 角色 / 状态 / 积分 / 上次登录 / 操作固定显示
</p>
</div>
)}
@@ -186,6 +206,89 @@ function ColumnConfigPopover({
);
}
function MobileSortPopover({
sort,
order,
onChange,
}: {
sort: UserSortKey;
order: UserSortDir;
onChange: (key: UserSortKey, dir: UserSortDir) => void;
}) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const current = SORT_OPTIONS.find((o) => o.key === sort);
useEffect(() => {
if (!open) return;
const onDown = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
};
document.addEventListener("mousedown", onDown);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onDown);
document.removeEventListener("keydown", onKey);
};
}, [open]);
return (
<div className="relative shrink-0 md:hidden" ref={ref}>
<button
type="button"
className="btn btn-line btn-sm"
aria-haspopup="listbox"
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
>
<ArrowUpDown size={14} />
{current?.label ?? "排序"}
{order === "asc" ? <ChevronUp size={13} /> : <ChevronDown size={13} />}
</button>
{open && (
<div
role="listbox"
aria-label="排序"
className="absolute right-0 top-full mt-2 z-40 w-48 p-2 j13-toast-in"
style={{
background: "var(--panel)",
border: "1px solid var(--line)",
borderRadius: "var(--r)",
boxShadow: "var(--shadow-lg)",
}}
>
<ul className="flex flex-col gap-0.5">
{SORT_OPTIONS.map((o) => {
const active = sort === o.key;
return (
<li key={o.key}>
<button
type="button"
role="option"
aria-selected={active}
className="w-full flex items-center justify-between gap-2 px-2 py-2 rounded-[var(--r-sm)] text-[13px] hover:bg-[var(--panel-2)]"
style={{ color: active ? "var(--accent)" : "var(--ink)" }}
onClick={() => {
onChange(o.key, active && order === "desc" ? "asc" : "desc");
setOpen(false);
}}
>
{o.label}
{active ? (order === "asc" ? <ChevronUp size={13} /> : <ChevronDown size={13} />) : null}
</button>
</li>
);
})}
</ul>
</div>
)}
</div>
);
}
export default function UsersAdmin({
initial,
initError,
@@ -195,7 +298,7 @@ export default function UsersAdmin({
}: {
initial: AdminUsersResponse | null;
initError: string;
initialQuery: { q: string; filter: UserFilter; page: number };
initialQuery: { q: string; filter: UserFilter; page: number; sort: UserSortKey; order: UserSortDir };
currentUserId: number;
currentUserRole: string;
}) {
@@ -216,6 +319,8 @@ export default function UsersAdmin({
const [filter, setFilter] = useState<UserFilter>(initialQuery.filter);
const [keyword, setKeyword] = useState(initialQuery.q);
const [appliedQ, setAppliedQ] = useState(initialQuery.q);
const [sort, setSort] = useState<UserSortKey>(initialQuery.sort);
const [order, setOrder] = useState<UserSortDir>(initialQuery.order);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(initError);
const [actingId, setActingId] = useState<number | null>(null);
@@ -270,7 +375,13 @@ export default function UsersAdmin({
return off;
}, []);
const load = useCallback(async (opts: { page: number; filter: UserFilter; q: string }) => {
const load = useCallback(async (opts: {
page: number;
filter: UserFilter;
q: string;
sort: UserSortKey;
order: UserSortDir;
}) => {
const seq = ++seqRef.current;
setLoading(true);
try {
@@ -278,6 +389,8 @@ export default function UsersAdmin({
page: opts.page,
size: PAGE_SIZE,
q: opts.q || undefined,
sort: opts.sort,
order: opts.order,
...filterToParams(opts.filter),
});
if (seq !== seqRef.current) return;
@@ -287,6 +400,8 @@ export default function UsersAdmin({
setFilter(opts.filter);
setPage(opts.page);
setAppliedQ(opts.q);
setSort(opts.sort);
setOrder(opts.order);
setError("");
} catch (e) {
if (seq !== seqRef.current) return;
@@ -301,14 +416,14 @@ export default function UsersAdmin({
debounceRef.current = setTimeout(() => {
const q = keyword.trim();
if (q !== appliedQ) {
load({ page: 1, filter, q });
load({ page: 1, filter, q, sort, order });
}
}, 400);
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [keyword]);
}, [keyword, filter, sort, order]);
useEffect(() => {
if (urlFirstRun.current) {
@@ -319,43 +434,50 @@ export default function UsersAdmin({
if (appliedQ) p.set("q", appliedQ);
if (filter !== "all") p.set("filter", filter);
if (page > 1) p.set("page", String(page));
if (sort !== "id") p.set("sort", sort);
if (order === "asc") p.set("order", "asc");
const qs = p.toString();
router.replace(`/admin/users${qs ? `?${qs}` : ""}`, { scroll: false });
}, [appliedQ, filter, page, router]);
}, [appliedQ, filter, page, sort, order, router]);
const submitSearch = () => {
const q = keyword.trim();
if (q === appliedQ) return;
load({ page: 1, filter, q });
load({ page: 1, filter, q, sort, order });
};
const clearSearch = () => {
setKeyword("");
if (appliedQ !== "" || page !== 1) {
load({ page: 1, filter, q: "" });
load({ page: 1, filter, q: "", sort, order });
}
};
const changeFilter = (f: UserFilter) => {
if (f === filter) return;
load({ page: 1, filter: f, q: appliedQ });
load({ page: 1, filter: f, q: appliedQ, sort, order });
};
const goPage = (p: number) => {
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
const next = Math.min(Math.max(1, p), totalPages);
if (next === page) return;
load({ page: next, filter, q: appliedQ });
load({ page: next, filter, q: appliedQ, sort, order });
};
const toggleSort = (key: UserSortKey) => {
const nextDir: UserSortDir = sort === key && order === "desc" ? "asc" : "desc";
load({ page: 1, filter, q: appliedQ, sort: key, order: sort === key ? nextDir : "desc" });
};
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
const tabCount = (f: UserFilter) =>
f === "all" ? summary.total : f === "staff" ? summary.admins : summary.banned;
const hasFilter = appliedQ !== "" || filter !== "all";
const reload = () => load({ page, filter, q: appliedQ });
const reload = () => load({ page, filter, q: appliedQ, sort, order });
const visibleCols = useMemo(() => {
const list: ColKey[] = ["member", "role", "status", "points"];
const list: ColKey[] = ["id", "member", "role", "status", "points"];
if (optCols.online) list.push("online");
if (optCols.email) list.push("email");
list.push("last_login");
@@ -370,11 +492,14 @@ export default function UsersAdmin({
const frags: string[] = [];
for (const c of visibleCols) {
switch (c) {
case "id":
frags.push("56px");
break;
case "member":
frags.push("minmax(0,1.35fr)");
frags.push("minmax(0,0.7fr)");
break;
case "role":
frags.push("minmax(96px,0.9fr)");
frags.push("minmax(92px,0.45fr)");
break;
case "status":
frags.push("72px");
@@ -386,7 +511,7 @@ export default function UsersAdmin({
frags.push("64px");
break;
case "email":
frags.push("minmax(120px,1fr)");
frags.push("minmax(0,0.55fr)");
break;
case "last_login":
frags.push("146px");
@@ -398,7 +523,7 @@ export default function UsersAdmin({
frags.push("100px");
break;
case "actions":
frags.push("136px");
frags.push("minmax(148px,1.8fr)");
break;
}
}
@@ -407,6 +532,8 @@ export default function UsersAdmin({
const colHeader = (c: ColKey): string => {
switch (c) {
case "id":
return "ID";
case "member":
return "成员";
case "role":
@@ -430,6 +557,37 @@ export default function UsersAdmin({
}
};
const renderColHeader = (c: ColKey) => {
const label = colHeader(c);
if (!SORTABLE_COLS.has(c)) {
return (
<span>{label}</span>
);
}
const key = c as UserSortKey;
const active = sort === key;
const ariaSort = active ? (order === "asc" ? "ascending" : "descending") : "none";
const Icon = active ? (order === "asc" ? ChevronUp : ChevronDown) : ChevronsUpDown;
return (
<div role="columnheader" aria-sort={ariaSort}>
<button
type="button"
onClick={() => toggleSort(key)}
className="-ml-1 inline-flex items-center gap-0.5 rounded-md px-1 py-0.5 uppercase tracking-wide transition-colors hover:bg-[var(--panel)]"
style={{ color: active ? "var(--accent)" : "var(--ink-3)" }}
aria-label={
active
? `${label},当前${order === "asc" ? "升序" : "降序"},点击切换`
: `按${label}排序`
}
>
{label}
<Icon size={13} className={active ? undefined : "opacity-45"} aria-hidden />
</button>
</div>
);
};
const renderStatus = (u: AdminUser) => {
if (u.banned) {
return (
@@ -519,7 +677,7 @@ export default function UsersAdmin({
if (u.role === ROLES.OWNER) {
return (
<div className="flex items-center gap-1 justify-end">
<div className="flex items-center gap-1">
{auditBtn}
<AdminIconButton href={`/u/${u.id}`} tip="查看个人主页" ariaLabel={`查看 ${u.nickname} 的个人主页`}>
<ExternalLink size={15} />
@@ -529,7 +687,7 @@ export default function UsersAdmin({
}
if (isSelf) {
return (
<div className="flex items-center gap-1 justify-end">
<div className="flex items-center gap-1">
{auditBtn}
<span
className="inline-flex items-center rounded-full px-2.5 py-1 text-[11px] whitespace-nowrap"
@@ -543,7 +701,7 @@ export default function UsersAdmin({
const busy = actingId === u.id;
const roleTone = u.banned ? "danger" : u.role !== ROLES.USER ? "accent" : "default";
return (
<div className="flex items-center gap-1 justify-end">
<div className="flex items-center gap-1">
{auditBtn}
<AdminIconButton
href={`/u/${u.id}`}
@@ -567,6 +725,12 @@ export default function UsersAdmin({
const renderCell = (c: ColKey, u: AdminUser, isSelf: boolean) => {
switch (c) {
case "id":
return (
<span className="text-[12.5px] tabular-nums" style={{ color: "var(--ink-3)" }}>
{u.id}
</span>
);
case "member":
return (
<div className="flex items-center gap-2.5 min-w-0">
@@ -622,12 +786,12 @@ export default function UsersAdmin({
</span>
);
case "actions":
return <div className="flex justify-end">{renderActions(u, isSelf)}</div>;
return <div className="flex">{renderActions(u, isSelf)}</div>;
}
};
return (
<div>
<div className="admin-users-page">
<AdminPageHeader
icon={Users}
title="用户管理"
@@ -668,73 +832,80 @@ export default function UsersAdmin({
/>
</div>
<div className="panel overflow-hidden">
<div className="admin-toolbar">
<div className="flex flex-col sm:flex-row gap-3 sm:items-center w-full">
<AdminSearchField
value={keyword}
onChange={setKeyword}
onSubmit={submitSearch}
onClear={clearSearch}
placeholder="搜索用户名、昵称或邮箱…"
ariaLabel="搜索用户"
/>
<div className="flex items-center gap-2 flex-wrap shrink-0">
<AdminSegmented<UserFilter>
role="group"
ariaLabel="用户筛选"
value={filter}
onChange={changeFilter}
options={FILTER_TABS.map((t) => ({
key: t.key,
label: t.label,
count: tabCount(t.key),
}))}
<div className="panel">
<div className="admin-users-stick">
<div className="admin-toolbar">
<div className="flex flex-col sm:flex-row gap-3 sm:items-center w-full">
<div className="flex items-center gap-2 flex-wrap shrink-0">
<AdminSegmented<UserFilter>
role="group"
ariaLabel="用户筛选"
value={filter}
onChange={changeFilter}
options={FILTER_TABS.map((t) => ({
key: t.key,
label: t.label,
count: tabCount(t.key),
}))}
/>
<MobileSortPopover
sort={sort}
order={order}
onChange={(key, dir) => load({ page: 1, filter, q: appliedQ, sort: key, order: dir })}
/>
<ColumnConfigPopover cols={optCols} onChange={persistCols} />
</div>
<AdminSearchField
value={keyword}
onChange={setKeyword}
onSubmit={submitSearch}
onClear={clearSearch}
placeholder="搜索用户名、昵称或邮箱…"
ariaLabel="搜索用户"
/>
<ColumnConfigPopover cols={optCols} onChange={persistCols} />
</div>
</div>
</div>
<div
className="px-4 sm:px-5 py-3.5 flex items-center justify-between gap-2 flex-wrap"
style={{ borderBottom: "1px solid var(--line)" }}
>
<span className="font-medium text-[15px]" style={{ color: "var(--ink)" }}>
{appliedQ ? (
<>
搜索“<span style={{ color: "var(--accent)" }}>{appliedQ}</span>”
</>
) : filter === "staff" ? (
"管理团队"
) : filter === "banned" ? (
"已封禁成员"
) : (
"全部成员"
)}
</span>
<span className="meta text-[12px] tabular-nums" style={{ color: "var(--ink-3)" }}>
共 {total} 人
</span>
</div>
{users.length > 0 && (
<div
className="hidden md:grid gap-3 items-center px-4 sm:px-5 py-3.5 text-[11.5px] uppercase tracking-wide"
style={{
...gridStyle,
color: "var(--ink-3)",
borderBottom: "1px solid var(--line)",
background: "var(--panel-2)",
}}
className="px-4 sm:px-5 py-2.5 flex items-center justify-between gap-2 flex-wrap"
style={{ borderBottom: "1px solid var(--line)" }}
>
{visibleCols.map((c) => (
<span key={c} className={c === "actions" ? "text-right" : undefined}>
{colHeader(c)}
</span>
))}
<span className="font-medium text-[14px]" style={{ color: "var(--ink)" }}>
{appliedQ ? (
<>
搜索“<span style={{ color: "var(--accent)" }}>{appliedQ}</span>”
</>
) : filter === "staff" ? (
"管理团队"
) : filter === "banned" ? (
"已封禁成员"
) : (
"全部成员"
)}
</span>
<span className="meta text-[12px] tabular-nums" style={{ color: "var(--ink-3)" }}>
共 {total} 人
</span>
</div>
)}
{users.length > 0 && (
<div
className="hidden md:grid gap-3 items-center px-4 sm:px-5 py-3 text-[11.5px] uppercase tracking-wide"
style={{
...gridStyle,
color: "var(--ink-3)",
borderBottom: "1px solid var(--line)",
background: "var(--panel-2)",
}}
>
{visibleCols.map((c) => (
<div key={c}>
{renderColHeader(c)}
</div>
))}
</div>
)}
</div>
<div aria-busy={loading}>
{error ? (
@@ -779,7 +950,7 @@ export default function UsersAdmin({
)}
</div>
<p className="text-[11.5px] truncate mt-0.5" style={{ color: "var(--ink-3)" }}>
@{u.username}
#{u.id} · @{u.username}
{optCols.email && u.email ? ` · ${u.email}` : ""}
</p>
</div>

View File

@@ -5,14 +5,20 @@ import { fetchAdminUsers, type AdminUsersResponse } from "@/lib/api";
import { getMeCached } from "@/lib/serverData";
import { isSuperOrOwner } from "@/lib/roles";
import Forbidden from "../Forbidden";
import UsersAdmin, { type UserFilter } from "./UsersAdmin";
import UsersAdmin, { type UserFilter, type UserSortKey } from "./UsersAdmin";
export const metadata: Metadata = {
title: "用户管理",
};
const SORT_KEYS = ["id", "role", "status", "points", "last_login", "counts", "created_at", "online"] as const;
function parseSort(raw?: string): UserSortKey {
return (SORT_KEYS as readonly string[]).includes(raw || "") ? (raw as UserSortKey) : "id";
}
interface UsersPageProps {
searchParams: Promise<{ q?: string; filter?: string; page?: string }>;
searchParams: Promise<{ q?: string; filter?: string; page?: string; sort?: string; order?: string }>;
}
// 筛选项映射为后端查询参数:管理团队走 role=staff,封禁走 status,其余不过滤
@@ -28,6 +34,8 @@ export default async function AdminUsersPage({ searchParams }: UsersPageProps) {
const q = sp.q?.trim() || "";
const filter: UserFilter = sp.filter === "staff" || sp.filter === "banned" ? sp.filter : "all";
const page = Math.max(1, parseInt(sp.page || "1", 10) || 1);
const sort = parseSort(sp.sort);
const order: "asc" | "desc" = sp.order === "asc" ? "asc" : "desc";
const cookie = authCookieHeader(await cookies());
const me = await getMeCached(cookie || undefined);
@@ -40,7 +48,7 @@ export default async function AdminUsersPage({ searchParams }: UsersPageProps) {
let initial: AdminUsersResponse | null = null;
let initError = "";
try {
initial = await fetchAdminUsers({ page, q, ...filterToQuery(filter) }, cookie || undefined);
initial = await fetchAdminUsers({ page, q, sort, order, ...filterToQuery(filter) }, cookie || undefined);
} catch (e) {
initError = e instanceof Error ? e.message : "获取用户列表失败";
}
@@ -49,7 +57,7 @@ export default async function AdminUsersPage({ searchParams }: UsersPageProps) {
<UsersAdmin
initial={initial}
initError={initError}
initialQuery={{ q, filter, page }}
initialQuery={{ q, filter, page, sort, order }}
currentUserId={me.user?.id ?? 0}
currentUserRole={me.user?.role ?? ""}
/>

View File

@@ -105,15 +105,6 @@ export default async function AnnouncementPage({ params }: PageProps) {
<SiteDocShell
left={left}
right={right}
breadcrumb={
<SiteDocBreadcrumb
items={[
{ href: "/", label: "首页" },
{ href: "/announcements", label: "站点公告" },
{ label: a.title },
]}
/>
}
mobileExtras={
<>
{toc.length > 0 ? <PostToc items={toc} /> : null}
@@ -123,6 +114,14 @@ export default async function AnnouncementPage({ params }: PageProps) {
}
>
<article className="panel p-6 sm:p-8">
<SiteDocBreadcrumb
className="is-ruled"
items={[
{ href: "/", label: "首页" },
{ href: "/announcements", label: "站点公告" },
{ label: a.title },
]}
/>
<SiteDocMasthead
kicker="站点公告"
title={a.title}

View File

@@ -140,48 +140,52 @@ export default async function AnnouncementsListPage({ searchParams }: PageProps)
<SiteDocShell
left={left}
right={right}
breadcrumb={
<SiteDocBreadcrumb
items={[
{ href: "/", label: "首页" },
{ label: "站点公告" },
]}
/>
}
mobileExtras={
<SiteDocPageNav pages={sidebarPages} title="关于本站" canManage={canManagePages} />
}
>
<header className="mb-5">
<h1
className="text-[24px] sm:text-[28px] font-extrabold tracking-tight flex items-center gap-2"
style={{ color: "var(--ink)" }}
<div className="panel overflow-hidden">
<header
className="px-5 sm:px-6 pt-5 pb-4"
style={{ borderBottom: "1px solid var(--line)" }}
>
<Megaphone size={26} style={{ color: "var(--accent)" }} aria-hidden />
站点公告
</h1>
<p className="meta mt-2">
{error ? "加载失败" : total > 0 ? `共 ${total} 条官方通知` : "站点通知与运营公告"}
</p>
</header>
{error ? (
<p className="alert-error">{error}</p>
) : announcements.length === 0 ? (
<div className="panel p-10 text-center">
<p className="font-medium" style={{ color: "var(--ink)" }}>
暂无公告
<SiteDocBreadcrumb
className="mb-3"
items={[
{ href: "/", label: "首页" },
{ label: "站点公告" },
]}
/>
<h1
className="text-[24px] sm:text-[28px] font-extrabold tracking-tight flex items-center gap-2"
style={{ color: "var(--ink)" }}
>
<Megaphone size={26} style={{ color: "var(--accent)" }} aria-hidden />
站点公告
</h1>
<p className="meta mt-2">
{error ? "加载失败" : total > 0 ? `共 ${total} 条官方通知` : "站点通知与运营公告"}
</p>
<p className="meta mt-1.5">有新消息时会显示在这里</p>
</div>
) : (
<div className="panel overflow-hidden">
{featured ? <FeaturedAnnouncement a={featured} /> : null}
{rest.map((a, i) => (
<AnnouncementRow key={a.id} a={a} showDivider={!!featured || i > 0} />
))}
</div>
)}
</header>
{error ? (
<p className="alert-error m-5">{error}</p>
) : announcements.length === 0 ? (
<div className="p-10 text-center">
<p className="font-medium" style={{ color: "var(--ink)" }}>
暂无公告
</p>
<p className="meta mt-1.5">有新消息时会显示在这里</p>
</div>
) : (
<>
{featured ? <FeaturedAnnouncement a={featured} /> : null}
{rest.map((a, i) => (
<AnnouncementRow key={a.id} a={a} showDivider={!!featured || i > 0} />
))}
</>
)}
</div>
{!error ? (
<Pagination total={total} page={page} size={size} basePath="/announcements" />

View File

@@ -6,8 +6,8 @@
============================================================ */
:root {
/* 浅色(默认)——中性暖灰底 + 纯白卡片浮起 */
--bg: #f7f7f5;
/* 浅色(默认)——画廊白底,卡片纯白浮起 */
--bg: #fafbfc;
--panel: #ffffff;
--panel-2: #f2f4f7;
--tint: #eceff3;
@@ -56,8 +56,11 @@
--stock-badge-low-bg: var(--tint);
--stock-badge-low-fg: var(--ink-3);
/* 工作台语义:选中用描边+文字色指示,底保持面板色,避免品牌色铺底 */
--nav-active-bg: var(--panel);
/* 工作台可点块悬停底:左栏、帖子行、右栏公告/热门/友链/关于本站、目录等共用。
改这一处即可全局换悬停色;选中态仍走 --nav-active-*。按钮/菜单等控件不要绑这个。 */
--item-hover-bg: var(--accent-soft);
/* 工作台语义:选中用主题色淡底 */
--nav-active-bg: var(--accent-soft);
--nav-active-ink: var(--accent);
--nav-active-line: rgba(59, 91, 219, 0.38);
--rank-1: #96620f;
@@ -68,9 +71,11 @@
--shadow: 0 2px 8px rgba(16, 24, 40, 0.05), 0 10px 28px rgba(16, 24, 40, 0.06);
--shadow-lg: 0 6px 16px rgba(16, 24, 40, 0.08), 0 20px 48px rgba(16, 24, 40, 0.12);
/* 页面画布:极淡品牌绿光晕 + SVG 噪点(色值集中于此) */
--bg-halo: rgba(7, 192, 95, 0.04);
--bg-noise-opacity: 0.03;
/* 页面画布:主题色柔光 + 发丝网格(纯 CSS,随 --accent 换肤) */
--bg-halo: color-mix(in srgb, var(--accent) 8%, transparent);
--bg-mesh: color-mix(in srgb, var(--ink) 3.2%, transparent);
--bg-grid-line: rgba(15, 23, 42, 0.05);
--bg-grid-size: 28px;
/* next/font 构建期自托管,变量由 layout.tsx 注入;
本地系统字体仅作网络字体分片就绪前的回退(PingFang/雅黑与 Noto 观感接近) */
@@ -96,8 +101,8 @@
}
.dark {
/* 近黑暖灰:面板逐层浮起,靛蓝提亮为长春花蓝 */
--bg: #101211;
/* 近黑冷灰:面板逐层浮起,靛蓝提亮为长春花蓝 */
--bg: #0c0d10;
--panel: #181b21;
--panel-2: #20242c;
--tint: #282d37;
@@ -144,8 +149,9 @@
--stock-badge-low-bg: var(--tint);
--stock-badge-low-fg: var(--ink-3);
/* 工作台语义:选中不铺品牌色底 */
--nav-active-bg: var(--panel);
--item-hover-bg: var(--accent-soft);
/* 工作台语义:选中用主题色淡底 */
--nav-active-bg: var(--accent-soft);
--nav-active-ink: #a9b8ff;
--nav-active-line: rgba(141, 163, 255, 0.38);
--rank-1: #e3bf74;
@@ -156,8 +162,10 @@
--shadow: 0 2px 8px rgba(0, 0, 0, 0.28), 0 12px 32px rgba(0, 0, 0, 0.22);
--shadow-lg: 0 6px 18px rgba(0, 0, 0, 0.35), 0 24px 56px rgba(0, 0, 0, 0.3);
--bg-halo: rgba(7, 192, 95, 0.03);
--bg-noise-opacity: 0.04;
--bg-halo: color-mix(in srgb, var(--accent) 14%, transparent);
--bg-mesh: color-mix(in srgb, var(--ink) 5%, transparent);
--bg-grid-line: rgba(238, 241, 246, 0.045);
--bg-grid-size: 28px;
color-scheme: dark;
}
@@ -165,6 +173,14 @@
html {
scrollbar-color: var(--line-2) transparent;
background-color: var(--bg); /* 橡皮筋 overscroll 与画布同色 */
--j13-bg-site-image: none;
--j13-bg-site-size: cover;
--j13-bg-site-repeat: no-repeat;
--j13-bg-site-position: center;
--j13-bg-admin-image: none;
--j13-bg-admin-size: cover;
--j13-bg-admin-repeat: no-repeat;
--j13-bg-admin-position: center;
}
body {
@@ -178,14 +194,17 @@ body {
isolation: isolate; /* 让 z-index:-1 的画布层停在内容之下、html 之上 */
}
/* 克制画布:fixed 伪元素(不用 background-attachment:fixed,避免移动端掉帧) */
/* 画布纹饰:固定层(不用 background-attachment:fixed,避免移动端掉帧)。
柔光 + 发丝网格均为静态 CSS,无 JS、无 SVG 滤镜、无持续动画。 */
body::before {
content: "";
position: fixed;
inset: 0;
z-index: -1;
pointer-events: none;
background: radial-gradient(ellipse 80% 50% at 50% -20%, var(--bg-halo), transparent 70%);
background:
radial-gradient(ellipse 78% 48% at 16% -14%, var(--bg-halo), transparent 62%),
radial-gradient(ellipse 52% 36% at 96% 0%, var(--bg-mesh), transparent 58%);
}
body::after {
@@ -194,9 +213,23 @@ body::after {
inset: 0;
z-index: -1;
pointer-events: none;
opacity: var(--bg-noise-opacity);
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 160 160' width='160' height='160'%3E%3Cfilter id='n' x='0' y='0' width='100%25' height='100%25'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.8' numOctaves='4' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
background-size: 160px 160px;
background-image:
repeating-linear-gradient(
to right,
var(--bg-grid-line) 0,
var(--bg-grid-line) 1px,
transparent 1px,
transparent var(--bg-grid-size)
),
repeating-linear-gradient(
to bottom,
var(--bg-grid-line) 0,
var(--bg-grid-line) 1px,
transparent 1px,
transparent var(--bg-grid-size)
);
-webkit-mask-image: radial-gradient(ellipse 92% 78% at 50% 16%, #000 12%, transparent 74%);
mask-image: radial-gradient(ellipse 92% 78% at 50% 16%, #000 12%, transparent 74%);
}
@media print {
@@ -213,6 +246,27 @@ body::after {
}
}
/* 自定义背景图:改写 canvas 的 ::before 固定层,关闭发丝网格。
前台 / 后台用 data-j13-chrome 分轨,软导航只改该属性即可切换。 */
html.j13-has-site-bg[data-j13-chrome="site"] body::before {
background-color: var(--bg);
background-image: var(--j13-bg-site-image);
background-size: var(--j13-bg-site-size);
background-repeat: var(--j13-bg-site-repeat);
background-position: var(--j13-bg-site-position);
}
html.j13-has-admin-bg[data-j13-chrome="admin"] body::before {
background-color: var(--bg);
background-image: var(--j13-bg-admin-image);
background-size: var(--j13-bg-admin-size);
background-repeat: var(--j13-bg-admin-repeat);
background-position: var(--j13-bg-admin-position);
}
html.j13-has-site-bg[data-j13-chrome="site"] body::after,
html.j13-has-admin-bg[data-j13-chrome="admin"] body::after {
display: none;
}
/* 全站悬停即显提示气泡(配合 TipHost + data-tip,替代原生 title 延迟) */
.j13-tip-bubble {
position: fixed;
@@ -3077,6 +3131,10 @@ body:has([data-admin-viewport]) {
.admin-canvas:has(.j13-compose-page) {
padding: 0;
}
/* 用户管理吸顶:顶内边距挪到 .admin-users-page,sticky 才能贴紧顶栏而不提前定住 */
.admin-canvas:has(.admin-users-stick) {
padding-top: 0;
}
.admin-canvas .j13-compose-page,
.admin-canvas .j13-compose-stage {
min-height: 100%;
@@ -3335,6 +3393,13 @@ a.j13-mention-token:hover {
inset: 0;
z-index: 1;
}
/* 标题本身在 overflow 里会被裁半圈;焦点画在铺满整行的 ::after 内侧 */
.stretch-link:focus-visible {
outline: none;
}
.stretch-link:focus-visible::after {
box-shadow: inset 0 0 0 2.5px var(--accent);
}
.above-link {
position: relative;
z-index: 2;
@@ -3740,6 +3805,28 @@ a.j13-mention-token:hover {
}
}
/* 用户管理:搜索栏 + 列头贴齐 admin-topbar(滚动容器是 .admin-canvas) */
.admin-users-page {
padding-top: 1.25rem;
}
@media (min-width: 640px) {
.admin-users-page {
padding-top: 1.5rem;
}
}
@media (min-width: 1024px) {
.admin-users-page {
padding-top: 1.75rem;
}
}
.admin-users-stick {
position: sticky;
top: 0;
z-index: 20;
background: var(--panel);
box-shadow: var(--shadow-sm);
}
/* 内容管理页:窄屏可整页滚;桌面高度由上方未分层的 canvas:has 规则锁定 */
.admin-content-page {
display: flex;
@@ -4419,7 +4506,7 @@ a.j13-mention-token:hover {
}
.j13-anno-item:hover,
.j13-anno-item:focus-visible {
background: var(--accent-soft);
background: var(--item-hover-bg);
border-color: color-mix(in srgb, var(--accent) 28%, var(--line));
}
.j13-anno-item:hover::before,
@@ -4761,13 +4848,18 @@ a.j13-mention-token:hover {
color: var(--accent);
transform: translateY(-1px);
}
/* 当前页:与全站选中态一致的主题色淡化底(而非实心填充) */
/* 当前页:主题色实心底,字色走 --accent-on 保证对比 */
.pager-btn.is-current,
.pager-btn.is-current:hover {
background: var(--nav-active-bg);
border-color: var(--nav-active-line);
color: var(--nav-active-ink);
background: var(--accent);
border-color: var(--accent);
color: var(--accent-on);
font-weight: 600;
transform: none;
}
.pager-btn.is-current:hover {
background: var(--accent-hi);
border-color: var(--accent-hi);
}
/* ----- 辅助 ----- */
@@ -4822,7 +4914,7 @@ a.j13-mention-token:hover {
.j13-tab:hover {
color: var(--ink);
border-color: var(--line-2);
background: var(--panel-2);
background: var(--item-hover-bg);
}
.j13-tab[aria-selected="true"] {
background: var(--nav-active-bg);
@@ -4848,7 +4940,7 @@ a.j13-mention-token:hover {
}
}
/* 侧栏导航项:无选中态的悬停/聚焦与榜单、公告一致,用主题色淡底 */
/* 侧栏导航项:无选中态的悬停/聚焦与榜单、公告一致,走 --item-hover-bg */
.j13-raillink {
display: flex;
align-items: center;
@@ -4863,7 +4955,7 @@ a.j13-mention-token:hover {
}
.j13-raillink:hover,
.j13-raillink:focus-visible {
background: var(--panel-2);
background: var(--item-hover-bg);
color: var(--ink);
}
.j13-raillink[aria-current="page"],
@@ -4875,8 +4967,17 @@ a.j13-mention-token:hover {
font-weight: 600;
}
/* 首页左栏「全部讨论 / 板块目录」卡片:与面包屑的纯文字位置指引区分,
承载讨论量等发现型信息,点击在工作台内筛选帖子流 */
/* 正文内嵌面包屑:无独立底块,靠所在面板保证对比 */
.j13-page-crumb {
color: var(--ink-3);
}
.j13-page-crumb.is-ruled {
padding-bottom: 14px;
margin-bottom: 20px;
border-bottom: 1px solid var(--line);
}
/* 板块导航行:默认独立卡片;落入 j13-module 后收成内嵌列表 */
.j13-bcard {
display: flex;
align-items: center;
@@ -4888,24 +4989,25 @@ a.j13-mention-token:hover {
border-radius: var(--r-sm);
transition: border-color 0.16s ease, background 0.16s ease;
}
.j13-bcard:hover {
border-color: var(--line-2);
background: var(--panel-2);
/* 悬停/选中必须叠在不透明 --panel 上:半透明 accent-soft 会把网格透出来 */
.j13-bcard:hover,
.j13-bcard:focus-visible {
border-color: var(--nav-active-line);
background: color-mix(in srgb, var(--accent) 10%, var(--panel));
}
.j13-bcard:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
/* 选中态:全站统一使用主题色淡化底(--nav-active-bg,随站点主题色联动),
与 raillink / chip / tab / 顶部导航 pill 的选中表达保持一致 */
.j13-bcard[aria-current="page"],
.j13-bcard[aria-current="page"]:hover {
.j13-bcard[aria-current="page"]:hover,
.j13-bcard[aria-current="page"]:focus-visible {
border-color: var(--nav-active-line);
background: var(--nav-active-bg);
background: color-mix(in srgb, var(--accent) 12%, var(--panel));
}
.j13-bcard-name {
color: var(--ink);
font-size: 14px;
font-size: 13.5px;
font-weight: 500;
}
.j13-bcard[aria-current="page"] .j13-bcard-name {
@@ -4922,6 +5024,49 @@ a.j13-mention-token:hover {
font-weight: 800;
flex-shrink: 0;
}
.j13-module .j13-bcard {
background: transparent;
border-color: transparent;
padding: 7px 8px;
border-radius: 10px;
}
.j13-module .j13-bcard:hover,
.j13-module .j13-bcard:focus-visible {
border-color: transparent;
background: color-mix(in srgb, var(--accent) 10%, var(--panel));
}
/* 滚动容器会把外扩 outline 裁成半圈:描边改画在条目内侧 */
.j13-module .j13-bcard:focus-visible {
outline: 2.5px solid var(--accent);
outline-offset: -2px;
}
.j13-module .j13-bcard[aria-current="page"],
.j13-module .j13-bcard[aria-current="page"]:hover,
.j13-module .j13-bcard[aria-current="page"]:focus-visible {
border-color: var(--nav-active-line);
background: color-mix(in srgb, var(--accent) 12%, var(--panel));
}
.j13-module .j13-bcard[aria-current="page"]:focus-visible {
outline: 2.5px solid var(--accent);
outline-offset: -2px;
}
.j13-module .j13-bcard-ico {
width: 30px;
height: 30px;
border-radius: 8px;
font-size: 12px;
}
.j13-board-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.j13-board-split {
height: 1px;
background: var(--line);
margin: 2px 4px;
flex-shrink: 0;
}
@media (prefers-reduced-motion: reduce) {
.j13-bcard {
transition: none;
@@ -4972,8 +5117,11 @@ a.j13-mention-token:hover {
}
.j13-about-item:hover,
.j13-about-item:focus-visible {
background: var(--panel);
border-color: var(--line-2);
background: var(--item-hover-bg);
border-color: var(--nav-active-line);
}
.j13-about-item:focus-visible {
outline-offset: -2px;
}
.j13-about-ico {
width: 30px;
@@ -5175,6 +5323,9 @@ a.j13-mention-token:hover {
font-weight: 500;
white-space: nowrap;
}
.j13-checkin-flow:focus-visible {
outline-offset: 1px;
}
/* 热门榜单 */
.j13-rank {
@@ -5186,10 +5337,13 @@ a.j13-mention-token:hover {
border-radius: 10px;
transition: background 0.16s ease;
}
/* 榜单行:无选中态的导航项,悬停/聚焦统一使用主题色淡化底 */
/* 榜单行:无选中态的导航项,悬停/聚焦走 --item-hover-bg */
.j13-rank:hover,
.j13-rank:focus-visible {
background: var(--accent-soft);
background: var(--item-hover-bg);
}
.j13-rank:focus-visible {
outline-offset: -2px;
}
.j13-rank-title {
display: block;
@@ -5263,9 +5417,9 @@ a.j13-mention-token:hover {
}
.j13-chip:hover,
.j13-chip:focus-visible {
background: var(--panel-2);
border-color: var(--line-2);
color: var(--ink);
background: var(--item-hover-bg);
border-color: color-mix(in srgb, var(--accent) 38%, var(--line));
color: var(--accent);
}
.j13-chip[aria-current="page"],
.j13-chip[aria-current="page"]:hover,
@@ -5274,6 +5428,14 @@ a.j13-mention-token:hover {
border-color: var(--nav-active-line);
color: var(--nav-active-ink);
}
.j13-chip:focus-visible {
outline-offset: -2px;
}
/* overflow:hidden 的列表面板:整行链接外描边会被圆角裁切 */
.panel.overflow-hidden > a:focus-visible {
outline-offset: -2px;
}
/* 帖子详情侧栏内的滚动承载区(目录 / 作者帖子列表):
配合栏容器的 max-height 独立滚动,细滚动条、不产生滚动链 */
@@ -5294,8 +5456,11 @@ a.j13-mention-token:hover {
.j13-rail-scroll::-webkit-scrollbar-track {
background: transparent;
}
.j13-rail-scroll :focus-visible {
outline-offset: -2px;
}
/* 文章目录项:悬停/聚焦与侧栏其他可点块统一为主题色淡底 */
/* 文章目录项:悬停/聚焦与侧栏其他可点块统一走 --item-hover-bg */
.j13-toc-link {
display: block;
padding: 6px 4px 6px 12px;
@@ -5313,7 +5478,7 @@ a.j13-mention-token:hover {
.j13-toc-link:hover,
.j13-toc-link:focus-visible {
color: var(--accent);
background: var(--panel-2);
background: var(--item-hover-bg);
}
.j13-toc-link.is-active,
.j13-toc-link.is-active:hover,
@@ -5823,20 +5988,20 @@ html.dark .prose-content pre[data-theme] span {
width: 2.75rem;
height: 2.75rem;
border-radius: 999px;
border: 1px solid var(--line-2);
background: color-mix(in srgb, var(--panel) 92%, transparent);
color: var(--ink-2);
box-shadow: var(--shadow-lg);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
border: 1px solid var(--accent);
background: var(--accent);
color: var(--accent-on);
box-shadow: 0 1px 2px rgba(16, 24, 40, 0.12),
0 4px 14px color-mix(in srgb, var(--accent) 32%, transparent);
cursor: pointer;
transition: color 0.15s ease, background 0.15s ease, border-color 0.15s ease;
transition: color 0.15s ease, background 0.15s ease, border-color 0.15s ease,
box-shadow 0.15s ease;
}
.j13-back-top:hover,
.j13-back-top:focus-visible {
color: var(--accent);
background: var(--accent-soft);
border-color: color-mix(in srgb, var(--accent) 28%, var(--line-2));
background: var(--accent-hi);
border-color: var(--accent-hi);
color: var(--accent-on);
outline: none;
}
/* 帖子移动端底栏上方抬高,避免遮挡 */

View File

@@ -0,0 +1,9 @@
// 官方运行时容器健康检查用,不走后端、不做 SSR。
export const dynamic = "force-dynamic";
export function GET() {
return new Response("ok", {
status: 200,
headers: { "cache-control": "no-store" },
});
}

View File

@@ -18,6 +18,7 @@ import type { User } from "@/lib/api";
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
import { TOKEN_COOKIE, authCookieHeader } from "@/lib/cookies";
import { accentStyleCSS } from "@/lib/theme";
import { bgConfigFromSettings, bgStyleCSS } from "@/lib/bg";
import { motionConfigFromSettings } from "@/lib/motion";
// 构建期下载并自托管字体:同源加载 + 自动 preload + fallback 度量校正,
@@ -83,12 +84,14 @@ export default async function RootLayout({
const settings = await getPublicSettingsCached();
const accent = settings.accent;
const accentCSS = accentStyleCSS(accent || "");
const customCSS = (settings.custom_css || "").trim();
const customJS = (settings.custom_js || "").trim();
const bgCfg = bgConfigFromSettings(settings);
const bgCSS = bgStyleCSS(bgCfg);
const motionCfg = motionConfigFromSettings(settings);
const motionHtmlClass = [
!motionCfg.ui_animations ? "j13-reduce-motion" : "",
motionCfg.ui_animations && !motionCfg.anim_chrome ? "j13-reduce-chrome" : "",
bgCfg.bg_site_url ? "j13-has-site-bg" : "",
bgCfg.bg_admin_url ? "j13-has-admin-bg" : "",
]
.filter(Boolean)
.join(" ");
@@ -108,6 +111,7 @@ export default async function RootLayout({
return (
<html
lang="zh-CN"
data-j13-chrome={isAdmin ? "admin" : "site"}
className={`${theme === "dark" ? "dark" : ""} ${motionHtmlClass} ${jakarta.variable} ${notoSC.variable} ${jetbrainsMono.variable}`.trim()}
>
<body className="min-h-screen flex flex-col">
@@ -119,11 +123,11 @@ export default async function RootLayout({
dangerouslySetInnerHTML={{ __html: accentCSS }}
/>
) : null}
{customCSS ? (
{bgCSS ? (
<style
id="j13-custom-css"
data-custom-css-source="ssr"
dangerouslySetInnerHTML={{ __html: customCSS }}
id="j13-bg-vars"
data-bg-source="ssr"
dangerouslySetInnerHTML={{ __html: bgCSS }}
/>
) : null}
<a href="#main" className="skip-link">
@@ -156,12 +160,9 @@ export default async function RootLayout({
<Toaster />
<TipHost />
<AccountGuard />
<ThemeSync initialAccent={accent || ""} initialCustomCSS={customCSS} />
<ThemeSync initialAccent={accent || ""} initialBg={bgCfg} />
<VisitBeacon />
<RealtimeProvider userId={user ? user.id : null} />
{customJS ? (
<script id="j13-custom-js" dangerouslySetInnerHTML={{ __html: customJS }} />
) : null}
</body>
</html>
);

View File

@@ -107,15 +107,6 @@ export default async function SitePagePublic({ params }: PageProps) {
<SiteDocShell
left={left}
right={right}
breadcrumb={
<SiteDocBreadcrumb
items={[
{ href: "/", label: "首页" },
{ href: "/about", label: "关于本站" },
{ label: p.title },
]}
/>
}
mobileExtras={
<>
{toc.length > 0 ? <PostToc items={toc} /> : null}
@@ -132,6 +123,14 @@ export default async function SitePagePublic({ params }: PageProps) {
}
>
<article className="panel p-6 sm:p-8">
<SiteDocBreadcrumb
className="is-ruled"
items={[
{ href: "/", label: "首页" },
{ href: "/about", label: "关于本站" },
{ label: p.title },
]}
/>
<SiteDocMasthead
kicker="官方文件"
title={p.title}

View File

@@ -172,20 +172,21 @@ export default async function HomePage({ searchParams }: HomePageProps) {
<WorkspaceNav boards={boards} user={user} />
{/* 中栏:tab 栏与帖子流为同一个整体卡片(左栏切板块只替换这一列) */}
<div className="flex-1 min-w-0 relative">
{activeBoard ? (
<div className="mt-4 lg:mt-0">
<SiteDocBreadcrumb
items={[
{ href: "/", label: "首页" },
{ label: activeBoard.name },
]}
/>
</div>
) : null}
<div className="flex-1 min-w-0">
<h1 className="sr-only">{heading}</h1>
<div className="panel overflow-hidden">
{activeBoard ? (
<div className="px-3 sm:px-4 pt-3">
<SiteDocBreadcrumb
className="is-ruled !mb-0"
items={[
{ href: "/", label: "首页" },
{ label: activeBoard.name },
]}
/>
</div>
) : null}
<div className="flex items-center justify-between gap-2 px-3 sm:px-4 pt-3 pb-2.5">
<FeedTabs current={sort} boardId={boardId} />
<span className="meta shrink-0 text-[12px] tabular-nums">

View File

@@ -581,21 +581,17 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
</aside>
{/* 中栏:正文主体 */}
<div className="min-w-0 relative">
<SiteDocBreadcrumb
items={[
{ href: "/", label: "首页" },
{ href: `/?board=${post.board.id}`, label: post.board.name },
]}
/>
<div className="min-w-0">
<article className="w-full">
<div className="panel p-6 sm:p-8">
<SiteDocBreadcrumb
className="is-ruled"
items={[
{ href: "/", label: "首页" },
{ href: `/?board=${post.board.id}`, label: post.board.name },
]}
/>
<header className="mb-7">
<div className="flex items-center gap-2 mb-4 flex-wrap">
<Link href={`/?board=${post.board.id}`} className="chip">
{post.board.name}
</Link>
</div>
<div className="flex items-start gap-2 flex-wrap mb-6">
{/* 仅置顶/推荐在标题左侧 */}
{post.pinned > 0 && <span className="badge-pin shrink-0 mt-2 sm:mt-3">置顶</span>}

View File

@@ -194,6 +194,9 @@ export default async function UserPage({ params, searchParams }: UserPageProps)
role: u.role,
board_ids: u.board_ids,
banned: u.banned,
avatar: u.avatar,
created_at: u.created_at,
points: data.stats.points,
}}
/>
{allowMessages ? <StartDMButton userId={data.user.id} /> : null}

View File

@@ -1,9 +1,12 @@
import pkg from "../package.json";
import FooterTiming from "./FooterTiming";
// 仅把明确发布过的版本视为"真实版本";脚手架默认号视为未设计,传空显示占位
function realVersion(v: string): string {
return !v || v === "0.0.0" || v === "0.1.0" ? "" : v;
// 官方镜像构建会写入 NEXT_PUBLIC_APP_VERSION(可与脚手架 0.1.0 相同)。
// 未注入时,脚手架默认号仍当未发布,页脚显示占位。
function realVersion(pkgVersion: string): string {
const injected = process.env.NEXT_PUBLIC_APP_VERSION?.trim();
if (injected) return injected;
return !pkgVersion || pkgVersion === "0.0.0" || pkgVersion === "0.1.0" ? "" : pkgVersion;
}
// 极简页脚:版权 + 实时性能指标(版本 / 页面加载 / 模板生成)
@@ -12,7 +15,13 @@ export default function Footer({ siteName = "姜十三社区" }: { siteName?: st
const brand = siteName.trim() || "姜十三社区";
return (
<footer className="mt-14" style={{ borderTop: "1px solid var(--line)" }}>
<footer
className="mt-14"
style={{
borderTop: "1px solid var(--line)",
background: "var(--panel)",
}}
>
<div className="max-w-[1440px] mx-auto px-4 sm:px-6 py-4 flex items-center justify-between gap-4">
<span className="meta text-[12px] whitespace-nowrap">© {year} {brand}</span>
<FooterTiming version={realVersion(pkg.version)} />

View File

@@ -199,9 +199,7 @@ export default function Header({
className="sticky top-0 z-50 border-b"
style={{
borderColor: "var(--line)",
background: "color-mix(in srgb, var(--panel) 86%, transparent)",
backdropFilter: "blur(14px)",
WebkitBackdropFilter: "blur(14px)",
background: "var(--panel)",
}}
>
<div className="max-w-[1440px] mx-auto px-4 sm:px-6 h-[var(--header-bar)] flex items-center gap-4 lg:gap-7">

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(--panel-2)] focus-visible:bg-[var(--panel-2)] 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(--item-hover-bg)] focus-visible:bg-[var(--item-hover-bg)] j13-fade-up"
style={{
...(i > 0 ? { borderTop: "1px solid var(--line)" } : {}),
animationDelay: `${i * 35}ms`,

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(--panel-2)] focus-within:bg-[var(--panel-2)] j13-fade-up ${
className={`group relative flex items-center px-4 sm:px-5 border-t cursor-pointer transition-colors hover:bg-[var(--item-hover-bg)] focus-within:bg-[var(--item-hover-bg)] j13-fade-up ${
compact ? "gap-2.5 sm:gap-3" : "gap-3.5 sm:gap-4"
}`}
style={{

View File

@@ -1,11 +1,15 @@
"use client";
import { useEffect, useState } from "react";
import { Ban, Loader2, RotateCcw, ShieldCheck, Users as UsersIcon } from "lucide-react";
import Link from "next/link";
import { Ban, ExternalLink, Loader2, RotateCcw, ShieldCheck, Users as UsersIcon } from "lucide-react";
import { apiAdminSetUserBan, apiAdminSetUserRole, type Board } from "@/lib/api";
import { ROLES, ROLE_META } from "@/lib/roles";
import { ROLES, ROLE_META, roleMeta } from "@/lib/roles";
import { formatDate } from "@/lib/format";
import { toast } from "@/lib/toast";
import Avatar from "@/components/Avatar";
import Modal from "@/components/Modal";
import RoleBadge from "@/components/RoleBadge";
/** 角色与账号弹窗所需的最小用户形状(后台列表项 / 公开资料均可) */
export interface RoleAccountTarget {
@@ -15,6 +19,10 @@ export interface RoleAccountTarget {
role: string;
board_ids?: number[];
banned: boolean;
avatar?: string;
email?: string;
points?: number;
created_at?: string;
}
interface RoleOption {
@@ -125,9 +133,7 @@ export default function RoleAccountModal({
if (willUnban) parts.push("已解封");
toast(parts.join(" · ") || "已保存", "ok");
onSaved({
id: target.id,
username: target.username,
nickname: target.nickname,
...target,
role: nextRole,
board_ids: nextBoards,
banned: nextBanned,
@@ -140,26 +146,20 @@ export default function RoleAccountModal({
};
const saveLabel = willBan ? "保存并封禁" : willUnban ? "保存并解封" : "保存";
const displayName = target.nickname || target.username;
const currentMeta = roleMeta(target.role);
return (
<Modal
open
title="角色与账号"
description={
<>
<span className="truncate block">
@{target.username}({target.nickname})
</span>
<span className="block mt-1">
角色决定后台入口与前台管理能力;封禁立即强制下线。站长内容档案仍走列表入口。
</span>
</>
}
header={<IdentityHeader target={target} displayName={displayName} currentMetaLabel={currentMeta.label} />}
onClose={onClose}
busy={acting}
maxWidthClass="max-w-[500px]"
panelClassName="p-6 max-h-[88vh] overflow-y-auto"
ariaLabel="角色与账号"
maxWidthClass="max-w-[540px]"
panelClassName="p-6 max-h-[88vh] flex flex-col overflow-hidden"
bodyClassName="overflow-y-auto flex-1 min-h-0 -mx-1 px-1"
ariaLabel={`设置 ${displayName} 的角色与账号`}
footer={
<>
<button type="button" className="btn btn-line" onClick={onClose} disabled={acting}>
@@ -177,7 +177,7 @@ export default function RoleAccountModal({
</>
}
>
<p className="mb-2 text-[12.5px]" style={{ color: "var(--ink)" }}>
<p className="mb-2.5 text-[12px] font-medium tracking-wide" style={{ color: "var(--ink-3)" }}>
管理角色
</p>
<div className="flex flex-col gap-2" role="radiogroup" aria-label="管理角色">
@@ -231,7 +231,7 @@ export default function RoleAccountModal({
{role === ROLES.BOARD_ADMIN && (
<div className="mt-4">
<p className="text-[12.5px] mb-2" style={{ color: "var(--ink)" }}>
<p className="text-[12px] font-medium tracking-wide mb-2" style={{ color: "var(--ink-3)" }}>
授权板块 <span style={{ color: "var(--danger)" }}>*</span>
</p>
{boards.length === 0 ? (
@@ -280,7 +280,7 @@ export default function RoleAccountModal({
)}
<div className="mt-5 pt-4" style={{ borderTop: "1px solid var(--line)" }}>
<p className="text-[12.5px] mb-2" style={{ color: "var(--ink)" }}>
<p className="text-[12px] font-medium tracking-wide mb-2.5" style={{ color: "var(--ink-3)" }}>
账号状态
</p>
<div className="grid grid-cols-2 gap-2" role="radiogroup" aria-label="账号状态">
@@ -289,17 +289,17 @@ export default function RoleAccountModal({
role="radio"
aria-checked={!banned}
onClick={() => setBanned(false)}
className="rounded-xl px-3 py-2.5 text-left transition-colors"
className="rounded-xl px-3.5 py-3 text-left transition-colors"
style={{
border: `1.5px solid ${!banned ? "var(--ok)" : "var(--line)"}`,
background: !banned ? "var(--ok-soft)" : "var(--panel-2)",
}}
>
<span
className="flex items-center gap-1.5 text-[13px] font-medium"
className="flex items-center gap-1.5 text-[13.5px] font-medium"
style={{ color: !banned ? "var(--ok)" : "var(--ink)" }}
>
<RotateCcw size={14} /> 正常
<RotateCcw size={15} /> 正常
</span>
<span className="block mt-1 text-[11.5px] leading-snug" style={{ color: "var(--ink-3)" }}>
可登录与互动
@@ -310,17 +310,17 @@ export default function RoleAccountModal({
role="radio"
aria-checked={banned}
onClick={() => setBanned(true)}
className="rounded-xl px-3 py-2.5 text-left transition-colors"
className="rounded-xl px-3.5 py-3 text-left transition-colors"
style={{
border: `1.5px solid ${banned ? "var(--danger)" : "var(--line)"}`,
background: banned ? "var(--danger-soft)" : "var(--panel-2)",
}}
>
<span
className="flex items-center gap-1.5 text-[13px] font-medium"
className="flex items-center gap-1.5 text-[13.5px] font-medium"
style={{ color: banned ? "var(--danger)" : "var(--ink)" }}
>
<Ban size={14} /> 封禁
<Ban size={15} /> 封禁
</span>
<span className="block mt-1 text-[11.5px] leading-snug" style={{ color: "var(--ink-3)" }}>
强制下线,禁止登录
@@ -328,7 +328,7 @@ export default function RoleAccountModal({
</button>
</div>
{willBan && (
<p className="mt-2 text-[12px] leading-relaxed" style={{ color: "var(--danger)" }}>
<p className="mt-2.5 text-[12px] leading-relaxed" style={{ color: "var(--danger)" }}>
封禁后立即强制下线,无法登录、发帖与回复;已发布内容保留。角色变更与封禁将一并保存。
</p>
)}
@@ -336,3 +336,89 @@ export default function RoleAccountModal({
</Modal>
);
}
function IdentityHeader({
target,
displayName,
currentMetaLabel,
}: {
target: RoleAccountTarget;
displayName: string;
currentMetaLabel: string;
}) {
const metaBits: string[] = [`ID ${target.id}`];
if (typeof target.points === "number") {
metaBits.push(`积分 ${target.points.toLocaleString("zh-CN")}`);
}
if (target.created_at) {
metaBits.push(`注册 ${formatDate(target.created_at)}`);
}
return (
<div>
<p
className="text-[11px] font-medium uppercase tracking-[0.14em] mb-3"
style={{ color: "var(--ink-3)" }}
>
角色与账号
</p>
<div
className="flex items-start gap-4 min-w-0 rounded-2xl p-3.5"
style={{ background: "var(--panel-2)", border: "1px solid var(--line)" }}
>
{/* 描边用 box-shadow,勿把实色垫在默认头像后面:.av-* 底是半透明,会透出垫色把字母对比打翻 */}
<span
className="inline-flex items-center justify-center shrink-0 rounded-full"
style={{
boxShadow: target.banned
? "0 0 0 3px var(--panel-2), 0 0 0 4.5px var(--danger)"
: "0 0 0 3px var(--panel-2), 0 0 0 4.5px color-mix(in srgb, var(--accent) 38%, transparent)",
}}
aria-hidden
>
<Avatar
name={displayName}
src={target.avatar || undefined}
size={68}
/>
</span>
<div className="min-w-0 flex-1 pt-0.5">
<p
className="text-[18px] sm:text-[20px] font-extrabold tracking-tight leading-tight truncate"
style={{ color: "var(--ink)" }}
>
{displayName}
</p>
<p className="mt-0.5 text-[13px] truncate" style={{ color: "var(--ink-2)" }}>
@{target.username}
{target.email ? ` · ${target.email}` : ""}
</p>
<div className="mt-2 flex items-center gap-1.5 flex-wrap">
<RoleBadge role={target.role} boardIds={target.board_ids} />
<span
className="inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium leading-[1.6]"
style={
target.banned
? { background: "var(--danger-soft)", color: "var(--danger)" }
: { background: "var(--ok-soft)", color: "var(--ok)" }
}
>
{target.banned ? "已封禁" : "正常"}
</span>
</div>
<p className="mt-2 text-[11.5px] tabular-nums leading-relaxed" style={{ color: "var(--ink-3)" }}>
{metaBits.join(" · ")}
<span className="sr-only">当前角色 {currentMetaLabel}</span>
</p>
<Link
href={`/u/${target.id}`}
className="mt-2.5 inline-flex items-center gap-1 text-[12.5px] font-medium hover:underline"
style={{ color: "var(--accent)" }}
>
查看个人主页 <ExternalLink size={12} aria-hidden />
</Link>
</div>
</div>
</div>
);
}

View File

@@ -76,6 +76,10 @@ export default function SiteChrome({
// 首屏跟 SSR;之后在渲染期直接读 pathname,软导航无 Header 闪动
const isAdmin = mounted ? pathIsAdmin(pathname) : initialIsAdmin;
useLayoutEffect(() => {
document.documentElement.dataset.j13Chrome = isAdmin ? "admin" : "site";
}, [isAdmin]);
if (isAdmin) {
return <>{children}</>;
}

View File

@@ -32,6 +32,9 @@ export interface StaffUserTarget {
board_ids?: number[];
/** 有权访客资料接口下发;未知时按未封禁处理 */
banned?: boolean;
avatar?: string;
created_at?: string;
points?: number;
}
/**
@@ -127,6 +130,9 @@ export default function StaffUserMenu({
role: target.role,
board_ids: target.board_ids,
banned,
avatar: target.avatar,
created_at: target.created_at,
points: target.points,
};
const itemCls =

View File

@@ -4,29 +4,33 @@ 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 {
applySiteBackground,
bgConfigFromSettings,
type SiteBgConfig,
type SiteBgInput,
} from "@/lib/bg";
import { realtime, RT_SETTINGS_CHANGED, type RtSettingsData } from "@/lib/realtime";
import {
VISIBLE_RECONCILE_GAP_MS,
onDocumentVisible,
} from "@/lib/visibilityReconcile";
// 站点外观同步器:
// 主题色 / 自定义 CSS 由管理员在后台修改后,其他用户已打开的标签页不会因软导航重新执行根布局
// SSR,本组件通过 WS 推送 + 标签页重新可见时探测公开设置,发现变化即热替换。
// 自定义 JS 只在整页加载时执行,不在此热替换(避免重复绑定)。
// 站点主题色 / 背景图同步器:
// 管理员保存后,其他已打开标签页不会因软导航重跑根布局 SSR。
// WS 推送 + 标签页重新可见时探测公开设置,热替换 accent 与背景。
const MIN_FETCH_GAP_MS = VISIBLE_RECONCILE_GAP_MS;
export default function ThemeSync({
initialAccent,
initialCustomCSS,
initialBg,
}: {
initialAccent: string;
initialCustomCSS: string;
initialBg: SiteBgConfig;
}) {
const pathname = usePathname();
const currentRef = useRef((initialAccent || "").trim().toLowerCase());
const cssRef = useRef((initialCustomCSS || "").trim());
const bgRef = useRef(initialBg);
const lastFetchAtRef = useRef(0);
const inFlightRef = useRef(false);
@@ -42,12 +46,11 @@ export default function ThemeSync({
applyAccentTheme(next || null);
};
const applyCSS = (next: string) => {
const applyBg = (raw: SiteBgInput) => {
if (cancelled) return;
const text = (next || "").trim();
if (text === cssRef.current) return;
cssRef.current = text;
applyCustomCSS(text);
const next = bgConfigFromSettings(raw);
bgRef.current = next;
applySiteBackground(next);
};
const sync = async () => {
@@ -57,10 +60,10 @@ export default function ThemeSync({
lastFetchAtRef.current = now;
inFlightRef.current = true;
try {
const settings = await apiGetSettings();
const s = await apiGetSettings();
if (cancelled) return;
applyAccent((settings.accent || "").trim().toLowerCase());
applyCSS(settings.custom_css || "");
applyAccent((s.accent || "").trim().toLowerCase());
applyBg(s);
} catch {
// 同步失败静默,下次回前台再试
} finally {
@@ -71,7 +74,7 @@ export default function ThemeSync({
const offPush = realtime.on<RtSettingsData>(RT_SETTINGS_CHANGED, (data) => {
if (!data) return;
applyAccent((data.accent || "").trim().toLowerCase());
if (typeof data.custom_css === "string") applyCSS(data.custom_css);
applyBg(data);
});
const offVisible = onDocumentVisible(() => void sync(), MIN_FETCH_GAP_MS);

View File

@@ -46,20 +46,27 @@ export function PulseCheckin({ initial }: { initial?: CheckinStatus | null }) {
<p className="text-[12.5px] leading-tight" style={{ color: "var(--ink)" }}>
每日签到
</p>
<p className="meta mt-0.5 truncate text-[11px]">
{guest
? "登录后每日签到领积分"
: checked
? `连签 ${status?.streak ?? 0} 天 · 余额 ${status?.total_points ?? 0}`
: `连签 ${status?.streak ?? 0} 天 · 今日 +${status?.today_points ?? 5}`}
{!guest && (
<p className="meta mt-0.5 flex min-w-0 items-center gap-1 text-[11px]">
<span className="min-w-0 truncate">
{guest
? "登录后每日签到领积分"
: checked
? `连签 ${status?.streak ?? 0} 天 · 余额 ${status?.total_points ?? 0}`
: `连签 ${status?.streak ?? 0} 天 · 今日 +${status?.today_points ?? 5}`}
</span>
{!guest ? (
<>
{" · "}
<Link href="/points" className="hover:text-[var(--accent)] transition-colors">
<span className="shrink-0" aria-hidden>
·
</span>
<Link
href="/points"
className="j13-checkin-flow shrink-0 rounded-sm px-0.5 -mx-0.5 hover:text-[var(--accent)] transition-colors"
>
流水
</Link>
</>
)}
) : null}
</p>
</div>
{guest ? (

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(--panel-2)] focus-visible:bg-[var(--panel-2)]"
className="flex flex-col items-center gap-1.5 rounded-lg px-1 py-1 transition-colors hover:bg-[var(--item-hover-bg)] focus-visible:bg-[var(--item-hover-bg)]"
>
<Avatar name={name} src={u.avatar} size={38} />
<span

View File

@@ -2,7 +2,7 @@
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { PenSquare, Home } from "lucide-react";
import { PenSquare, Home, LayoutGrid } from "lucide-react";
import type { BoardWithCount, User } from "@/lib/api";
// 首页壳内板块切换:仅替换中栏帖子流,不离开工作台
@@ -10,7 +10,7 @@ function boardHref(id: number) {
return `/?board=${id}`;
}
// 桌面左栏:板块导航
// 桌面左栏:整块模块,与右栏 j13-module 对齐
export function WorkspaceNav({ boards }: { boards: BoardWithCount[]; user?: User | null }) {
const params = useSearchParams();
const activeBoard = Number(params.get("board")) || 0;
@@ -18,57 +18,57 @@ export function WorkspaceNav({ boards }: { boards: BoardWithCount[]; user?: User
return (
// sticky 必须挂在 flex 子项上:父级 items-start 会把内层缩成内容高,失去粘滞行程
<nav
className="hidden lg:block w-56 xl:w-60 shrink-0 sticky top-[var(--rail-top)] max-h-[var(--rail-max-h)] j13-rail-scroll"
className="hidden lg:block w-56 xl:w-60 shrink-0 sticky top-[var(--rail-top)] max-h-[var(--rail-max-h)]"
aria-label="工作台导航"
>
<div className="space-y-5">
{/* 「全部讨论」是重置筛选的动作入口,与板块目录分区展示;卡片样式与下方板块一致 */}
<div className="space-y-0.5">
<Link href="/" className="j13-bcard" aria-current={activeBoard === 0 ? "page" : undefined}>
<section className="j13-module flex max-h-[var(--rail-max-h)] flex-col overflow-hidden">
<p className="j13-module-title shrink-0">
<LayoutGrid size={15} style={{ color: "var(--accent)" }} aria-hidden />
板块目录
{boards.length > 0 ? (
<span className="ml-auto meta text-[11.5px] font-medium tracking-normal tabular-nums">
{boards.length} 个
</span>
) : null}
</p>
<div className="j13-rail-scroll j13-board-list mt-2.5 min-h-0 flex-1">
<Link
href="/"
className="j13-bcard"
aria-current={activeBoard === 0 ? "page" : undefined}
>
<span
className="j13-bcard-ico"
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
aria-hidden="true"
>
<Home size={16} />
</span>
<span className="j13-bcard-name flex-1 truncate text-[14px]">
全部讨论
<Home size={15} />
</span>
<span className="j13-bcard-name flex-1 truncate">全部讨论</span>
</Link>
</div>
{/* 板块目录:纯分区标题;下方卡片仅在中栏筛选该板帖子流 */}
<div>
<div className="px-1 mb-2 flex items-center justify-between text-[11.5px] font-medium tracking-[0.08em] uppercase text-[var(--ink-3)]">
<span>板块目录</span>
{boards.length > 0 && (
<span className="tracking-normal tabular-nums">{boards.length} 个</span>
)}
</div>
<div className="space-y-2">
{boards.map((b, i) => (
<Link
key={b.id}
href={boardHref(b.id)}
className="j13-bcard"
aria-current={activeBoard === b.id ? "page" : undefined}
aria-label={`${b.name}板块,${b.post_count} 篇讨论`}
>
<span className={`av-${i % 8} j13-bcard-ico`} aria-hidden="true">
{b.name.slice(0, 1)}
</span>
<span className="min-w-0 flex-1 flex items-center justify-between gap-2">
<span className="j13-bcard-name truncate text-[14px]">
{b.name}
</span>
<span className="meta shrink-0 text-[11px] tabular-nums">{b.post_count} 篇</span>
</span>
</Link>
))}
</div>
{boards.length > 0 ? <div className="j13-board-split" role="separator" /> : null}
{boards.map((b, i) => (
<Link
key={b.id}
href={boardHref(b.id)}
className="j13-bcard"
aria-current={activeBoard === b.id ? "page" : undefined}
aria-label={`${b.name}板块,${b.post_count} 篇讨论`}
>
<span className={`av-${i % 8} j13-bcard-ico`} aria-hidden="true">
{b.name.slice(0, 1)}
</span>
<span className="min-w-0 flex-1 flex items-center justify-between gap-2">
<span className="j13-bcard-name truncate">{b.name}</span>
<span className="meta shrink-0 text-[11px] tabular-nums">{b.post_count} 篇</span>
</span>
</Link>
))}
</div>
</div>
</section>
</nav>
);
}

View File

@@ -3,11 +3,17 @@ import { ChevronRight } from "lucide-react";
export type Crumb = { href?: string; label: string };
/** 位置指引:桌面叠在中栏卡片上方的内边距里,不拉低卡片、不对齐到左栏 */
export default function SiteDocBreadcrumb({ items }: { items: Crumb[] }) {
/** 位置指引:嵌在正文面板顶部,不单独成块 */
export default function SiteDocBreadcrumb({
items,
className,
}: {
items: Crumb[];
className?: string;
}) {
return (
<nav
className="meta mb-2 flex items-center gap-1 flex-wrap text-[12.5px] leading-none lg:absolute lg:bottom-full lg:left-0 lg:right-0 lg:mb-0 lg:pb-2 lg:pl-1"
className={`j13-page-crumb meta flex items-center gap-1 flex-wrap text-[12.5px] leading-none ${className ?? ""}`}
aria-label="面包屑"
>
{items.map((item, i) => {

View File

@@ -14,7 +14,7 @@ export default function SiteDocShell({
left?: ReactNode;
right?: ReactNode;
children: ReactNode;
/** 中栏卡片上方的位置指引;桌面绝对定位,不占栅格高度 */
/** 中栏正文上方的位置指引(流式排布,不再绝对定位) */
breadcrumb?: ReactNode;
/** 窄屏下沉到正文之后的侧栏副本 */
mobileExtras?: ReactNode;
@@ -29,7 +29,7 @@ export default function SiteDocShell({
{left}
</aside>
<div className="min-w-0 relative">
<div className="min-w-0">
{breadcrumb}
{children}
</div>

View File

@@ -677,10 +677,14 @@ export interface PublicSettings {
attachment_max_count: number;
/** 正文插图上限 MB;缺省 5 */
image_max_mb: number;
/** 全站自定义 CSS;空串=无 */
custom_css: string;
/** 全站自定义 JS;空串=无(保存后整页加载生效) */
custom_js: string;
/** 前台背景图 URL;空=默认画布 */
bg_site_url: string;
/** 前台铺放模式 */
bg_site_mode: string;
/** 后台背景图 URL;空=默认画布 */
bg_admin_url: string;
/** 后台铺放模式 */
bg_admin_mode: string;
}
/** 与后端 DefaultAttachmentExts 保持同步 */
@@ -715,8 +719,10 @@ const DEFAULT_PUBLIC_SETTINGS: PublicSettings = {
attachment_max_mb: 20,
attachment_max_count: 10,
image_max_mb: 5,
custom_css: "",
custom_js: "",
bg_site_url: "",
bg_site_mode: "cover",
bg_admin_url: "",
bg_admin_mode: "cover",
};
function normalizeAttachmentExts(raw: unknown): string[] {
@@ -786,8 +792,10 @@ function normalizePublicSettings(data: Partial<PublicSettings> | null | undefine
attachment_max_mb: maxMB,
attachment_max_count: maxCount,
image_max_mb: imageMB,
custom_css: typeof data?.custom_css === "string" ? data.custom_css : "",
custom_js: typeof data?.custom_js === "string" ? data.custom_js : "",
bg_site_url: typeof data?.bg_site_url === "string" ? data.bg_site_url : "",
bg_site_mode: typeof data?.bg_site_mode === "string" ? data.bg_site_mode : "cover",
bg_admin_url: typeof data?.bg_admin_url === "string" ? data.bg_admin_url : "",
bg_admin_mode: typeof data?.bg_admin_mode === "string" ? data.bg_admin_mode : "cover",
};
}
@@ -1893,6 +1901,8 @@ export interface AdminUsersQuery {
q?: string;
role?: string;
status?: string;
sort?: string;
order?: "asc" | "desc";
}
function adminUsersParams(query: AdminUsersQuery): URLSearchParams {
@@ -1902,6 +1912,9 @@ function adminUsersParams(query: AdminUsersQuery): URLSearchParams {
if (query.q) params.set("q", query.q);
if (query.role) params.set("role", query.role);
if (query.status) params.set("status", query.status);
if (query.sort && query.sort !== "id") params.set("sort", query.sort);
if (query.order === "asc") params.set("order", "asc");
else if (query.sort && query.sort !== "id") params.set("order", query.order || "desc");
return params;
}
@@ -2144,8 +2157,10 @@ export type UpdateSiteSettingsBody = {
attachment_max_mb?: number;
attachment_max_count?: number;
image_max_mb?: number;
custom_css?: string;
custom_js?: string;
bg_site_url?: string;
bg_site_mode?: string;
bg_admin_url?: string;
bg_admin_mode?: string;
timeline_git_import?: string;
};
@@ -2161,6 +2176,24 @@ export async function apiUpdateSiteSettings(body: UpdateSiteSettingsBody): Promi
return normalizePublicSettings(data as Partial<PublicSettings>);
}
/** 上传站点背景并立即绑定前台或后台 */
export async function apiUploadSiteBackground(
surface: "site" | "admin",
file: File
): Promise<PublicSettings> {
const form = new FormData();
form.append("surface", surface);
form.append("file", file, file.name || "background.jpg");
const res = await fetchWithRefresh("/api/admin/upload/background", {
method: "POST",
headers: clientHeaders(),
body: form,
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "上传背景图失败");
return normalizePublicSettings(data as Partial<PublicSettings>);
}
// ===== 板块(客户端,供角色授权弹窗勾选) =====
export async function apiListBoards(): Promise<{ boards: Board[] }> {

173
frontend/lib/bg.ts Normal file
View File

@@ -0,0 +1,173 @@
// 站点背景图(前台 / 后台分轨):仅接受本站 /uploads/backgrounds/ 下的文件。
// 铺放模式映射到 CSS background-*,真正绘制在 body::before 固定层上,
// 避免 background-attachment:fixed 在移动端掉帧。
import type { CSSProperties } from "react";
export type BgSurface = "site" | "admin";
export type BgMode =
| "cover"
| "contain"
| "repeat"
| "repeat-x"
| "repeat-y"
| "stretch"
| "center";
export const DEFAULT_BG_MODE: BgMode = "cover";
export const BG_MODE_OPTIONS: { key: BgMode; label: string; hint: string }[] = [
{ key: "cover", label: "覆盖铺满", hint: "裁切以铺满视口" },
{ key: "contain", label: "完整显示", hint: "完整放入,两侧留底色" },
{ key: "stretch", label: "拉伸填满", hint: "变形拉伸至视口" },
{ key: "repeat", label: "平铺", hint: "按原尺寸重复铺满" },
{ key: "repeat-x", label: "横向平铺", hint: "只沿水平方向重复" },
{ key: "repeat-y", label: "纵向平铺", hint: "只沿垂直方向重复" },
{ key: "center", label: "居中原图", hint: "不缩放,居中显示" },
];
export interface SiteBgConfig {
bg_site_url: string;
bg_site_mode: BgMode;
bg_admin_url: string;
bg_admin_mode: BgMode;
}
export type SiteBgInput = {
bg_site_url?: string | null;
bg_site_mode?: string | null;
bg_admin_url?: string | null;
bg_admin_mode?: string | null;
};
const BG_URL_RE = /^\/uploads\/backgrounds\/[0-9a-f]{32}\.(jpg|jpeg|png|webp)$/;
const MODE_SET = new Set<BgMode>(BG_MODE_OPTIONS.map((o) => o.key));
const SSR_STYLE_ID = "j13-bg-vars";
const RUNTIME_STYLE_ID = "j13-bg-runtime";
export function normalizeBgURL(raw: string | null | undefined): string {
const u = (raw || "").trim().toLowerCase();
return BG_URL_RE.test(u) ? u : "";
}
export function normalizeBgMode(raw: string | null | undefined): BgMode {
const m = (raw || "").trim().toLowerCase() as BgMode;
return MODE_SET.has(m) ? m : DEFAULT_BG_MODE;
}
export function bgConfigFromSettings(s: SiteBgInput | null | undefined): SiteBgConfig {
return {
bg_site_url: normalizeBgURL(s?.bg_site_url),
bg_site_mode: normalizeBgMode(s?.bg_site_mode),
bg_admin_url: normalizeBgURL(s?.bg_admin_url),
bg_admin_mode: normalizeBgMode(s?.bg_admin_mode),
};
}
function modeCSS(mode: BgMode): { size: string; repeat: string; position: string } {
switch (mode) {
case "contain":
return { size: "contain", repeat: "no-repeat", position: "center" };
case "repeat":
return { size: "auto", repeat: "repeat", position: "0 0" };
case "repeat-x":
return { size: "auto", repeat: "repeat-x", position: "center top" };
case "repeat-y":
return { size: "auto", repeat: "repeat-y", position: "center top" };
case "stretch":
return { size: "100% 100%", repeat: "no-repeat", position: "center" };
case "center":
return { size: "auto", repeat: "no-repeat", position: "center" };
default:
return { size: "cover", repeat: "no-repeat", position: "center" };
}
}
function cssUrl(url: string): string {
// 已由 regex 锁死路径,仍去掉引号,避免意外打断 url("…")
return `url("${url.replace(/["'\\]/g, "")}")`;
}
function surfaceVars(prefix: "site" | "admin", url: string, mode: BgMode): string {
const m = modeCSS(mode);
const image = url ? cssUrl(url) : "none";
return [
`--j13-bg-${prefix}-image:${image};`,
`--j13-bg-${prefix}-size:${m.size};`,
`--j13-bg-${prefix}-repeat:${m.repeat};`,
`--j13-bg-${prefix}-position:${m.position};`,
].join("");
}
/** SSR 内联:仅在至少一侧配置了图时输出 */
export function bgStyleCSS(cfg: SiteBgConfig): string {
if (!cfg.bg_site_url && !cfg.bg_admin_url) return "";
return (
"html{" +
surfaceVars("site", cfg.bg_site_url, cfg.bg_site_mode) +
surfaceVars("admin", cfg.bg_admin_url, cfg.bg_admin_mode) +
"}"
);
}
function ensureRuntimeStyle(): HTMLStyleElement {
let el = document.getElementById(RUNTIME_STYLE_ID) as HTMLStyleElement | null;
if (el) return el;
el = document.createElement("style");
el.id = RUNTIME_STYLE_ID;
el.setAttribute("data-bg-source", "runtime");
document.body.appendChild(el);
return el;
}
function sameConfig(a: SiteBgConfig, b: SiteBgConfig): boolean {
return (
a.bg_site_url === b.bg_site_url &&
a.bg_site_mode === b.bg_site_mode &&
a.bg_admin_url === b.bg_admin_url &&
a.bg_admin_mode === b.bg_admin_mode
);
}
let lastApplied: SiteBgConfig | null = null;
/** 客户端热替换背景;禁止改写 SSR 节点 */
export function applySiteBackground(input: SiteBgInput | null): void {
if (typeof document === "undefined") return;
const cfg = bgConfigFromSettings(input);
if (lastApplied && sameConfig(lastApplied, cfg)) return;
lastApplied = cfg;
const html = document.documentElement;
html.classList.toggle("j13-has-site-bg", !!cfg.bg_site_url);
html.classList.toggle("j13-has-admin-bg", !!cfg.bg_admin_url);
const css = bgStyleCSS(cfg);
if (!css) {
const ssr = document.getElementById(SSR_STYLE_ID);
if (ssr && ssr.textContent && ssr.textContent.trim()) {
ensureRuntimeStyle().textContent =
"html{" +
surfaceVars("site", "", DEFAULT_BG_MODE) +
surfaceVars("admin", "", DEFAULT_BG_MODE) +
"}";
} else {
document.getElementById(RUNTIME_STYLE_ID)?.remove();
}
return;
}
ensureRuntimeStyle().textContent = css;
}
export function previewBoxStyle(url: string, mode: BgMode): CSSProperties {
const m = modeCSS(mode);
return {
backgroundColor: "var(--bg)",
backgroundImage: url ? cssUrl(url) : "none",
backgroundSize: m.size,
backgroundRepeat: m.repeat,
backgroundPosition: m.position,
};
}

View File

@@ -1,100 +0,0 @@
// 全站自定义 CSS:SSR <style id="j13-custom-css"> + 客户端 #j13-custom-css-runtime。
// 禁止对 SSR 节点 remove/改写;预览时 disable SSR,由 runtime 作为唯一源。
export const MAX_CUSTOM_CSS_CHARS = 48000;
export const MAX_CUSTOM_JS_CHARS = 48000;
/** 空配置时展示的注释模板:保存前不生效,全部注释故误保存也无副作用 */
export const SAMPLE_CUSTOM_CSS = `/* 姜十三 · 自定义 CSS 示例
* 覆盖 globals.css 令牌或组件类。保存后对全站访客生效。
* 勿写入 </style>
*
* 常用令牌:
* --bg / --panel / --ink / --accent
* --bg-halo 顶部光晕色(建议 ≤ 0.04)
* --bg-noise-opacity 噪点层透明度
* --r / --shadow-sm 卡片圆角与阴影
*/
/* 改底色与光晕(浅 / 暗成对改)
:root {
--bg: #f7f7f5;
--bg-halo: rgba(7, 192, 95, 0.04);
--bg-noise-opacity: 0.03;
--panel: #ffffff;
--r: 12px;
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.05);
}
.dark {
--bg: #101211;
--bg-halo: rgba(7, 192, 95, 0.03);
--bg-noise-opacity: 0.04;
}
*/
/* 关掉光晕和噪点
body::before,
body::after {
display: none;
}
*/
/* 隐藏页脚
footer { display: none; }
*/
`;
export const SAMPLE_CUSTOM_JS = `// 姜十三 · 自定义 JS 示例
// 仅整页加载执行一次(F5 / 首次进入),站内软导航不会重跑。
// 勿写入 </script>
//
// 给 <html> 打标记,便于 CSS 写 html[data-custom] .panel { ... }
// document.documentElement.dataset.custom = "1";
//
// 等 DOM 就绪后再改节点
// document.addEventListener("DOMContentLoaded", function () {
// // document.querySelector("footer")?.remove();
// });
`;
const SSR_STYLE_ID = "j13-custom-css";
const RUNTIME_STYLE_ID = "j13-custom-css-runtime";
export function clipCustomSnippet(raw: string, max: number): string {
const s = (raw || "").replace(/\u0000/g, "");
if (s.length <= max) return s;
return [...s].slice(0, max).join("");
}
function ensureRuntimeStyle(): HTMLStyleElement {
let el = document.getElementById(RUNTIME_STYLE_ID) as HTMLStyleElement | null;
if (el) return el;
el = document.createElement("style");
el.id = RUNTIME_STYLE_ID;
el.setAttribute("data-custom-css-source", "runtime");
document.body.appendChild(el);
return el;
}
function removeRuntimeStyle(): void {
document.getElementById(RUNTIME_STYLE_ID)?.remove();
}
function ssrStyle(): HTMLStyleElement | null {
return document.getElementById(SSR_STYLE_ID) as HTMLStyleElement | null;
}
/** 将自定义 CSS 应用到当前文档。preview=true 时忽略 SSR 节点(外观页草稿)。 */
export function applyCustomCSS(css: string, preview = false): void {
if (typeof document === "undefined") return;
const text = clipCustomSnippet(css, MAX_CUSTOM_CSS_CHARS).trim();
const ssr = ssrStyle();
if (ssr) ssr.disabled = preview;
if (!text) {
removeRuntimeStyle();
if (!preview && ssr) ssr.disabled = false;
return;
}
ensureRuntimeStyle().textContent = text;
}

View File

@@ -12,9 +12,13 @@ export type HideSegment =
body: string;
};
const HIDE_KINDS = new Set<HideKind>(["login", "reply", "points", "password"]);
const HIDE_KINDS = new Set<string>(["login", "reply", "points", "password"]);
const HIDE_OPEN_RE = /^\[hide(?:\s+(.+))?\]$/i;
function isHideKind(s: string): s is HideKind {
return HIDE_KINDS.has(s);
}
function isClose(line: string): boolean {
return line.trim().toLowerCase() === "[/hide]";
}
@@ -40,8 +44,8 @@ function parseOpen(trimmed: string): {
kind = head.slice(0, eq);
arg = head.slice(eq + 1);
}
kind = kind.toLowerCase() as HideKind;
if (!HIDE_KINDS.has(kind)) return null;
kind = kind.toLowerCase();
if (!isHideKind(kind)) return null;
let idx = 1;
let points = 0;

View File

@@ -59,8 +59,10 @@ export interface RtSettingsData {
attachment_max_mb?: number;
attachment_max_count?: number;
image_max_mb?: number;
custom_css?: string;
custom_js?: string;
bg_site_url?: string;
bg_site_mode?: string;
bg_admin_url?: string;
bg_admin_mode?: string;
}
// 群聊新消息:data 为落库的 ChatMessage(含 sender)

View File

@@ -157,7 +157,7 @@ function clampForLight(hex: string): string {
function deriveDark(hex: string): string {
const [h, s, lRaw] = rgbToHsl(hexToRgb(hex));
let l = Math.max(lRaw, 0.62);
const surfaces = ["#181b21", "#101211", "#10162b"];
const surfaces = ["#181b21", "#0c0d10", "#10162b"];
let cur = hex;
for (let i = 0; i < 120; i++) {
cur = rgbToHex(hslToRgb([h, s, l]));
@@ -207,7 +207,7 @@ function buildAccentCSS(input: string): string {
`--accent-hi:${hi};`,
`--accent-soft:${rgba(light, 0.1)};`,
`--accent-on:#ffffff;`,
`--nav-active-bg:var(--panel);`,
`--nav-active-bg:${rgba(light, 0.1)};`,
`--nav-active-ink:${light};`,
`--nav-active-line:${rgba(light, 0.38)};`,
`}`,
@@ -216,7 +216,7 @@ function buildAccentCSS(input: string): string {
`--accent-hi:${darkHi};`,
`--accent-soft:${rgba(dark, 0.16)};`,
`--accent-on:#10162b;`,
`--nav-active-bg:var(--panel);`,
`--nav-active-bg:${rgba(dark, 0.14)};`,
`--nav-active-ink:${darkHi};`,
`--nav-active-line:${rgba(dark, 0.38)};`,
`}`,

View File

@@ -183,6 +183,6 @@ export async function middleware(req: NextRequest) {
export const config = {
// 仅拦截页面与 RSC 请求;/api 由客户端 fetchWithRefresh 处理,静态资源放行
matcher: [
"/((?!api/|_next/static/|_next/image/|favicon.ico|robots.txt|sitemap.xml|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico|txt|woff2?)$).*)",
"/((?!api/|healthz|_next/static/|_next/image/|favicon.ico|robots.txt|sitemap.xml|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico|txt|woff2?)$).*)",
],
};

View File

@@ -5,6 +5,8 @@ const API_TARGET =
process.env.BACKEND_URL || process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001";
const nextConfig: NextConfig = {
// 官方 Docker 运行时:最小产物,不含完整 node_modules。next dev 忽略此项。
output: "standalone",
images: {
minimumCacheTTL: 14400, // 4 小时
remotePatterns: [

0
frontend/public/.gitkeep Normal file
View File