feat: 交付官方 Docker 运行时,外观改背景图并下线自定义 CSS/JS
站点/后台分轨背景与用户列表排序一并落地;生产 CORS 改走 SITE_URL,健康检查带版本号。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 ?? ""}
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
/* 帖子移动端底栏上方抬高,避免遮挡 */
|
||||
|
||||
9
frontend/app/healthz/route.ts
Normal file
9
frontend/app/healthz/route.ts
Normal 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" },
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user