feat(admin): 完善站点设置、内容回收站与后台菜单分组

新增站点设置与内容管理(含已删除恢复/清除),用户页改用独有运营指标,并按概览/内容/成员与经济/站点重组侧栏。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-16 05:56:27 +08:00
parent be47b38370
commit dd7bd010b8
25 changed files with 2193 additions and 323 deletions

View File

@@ -0,0 +1,179 @@
"use client";
import { useState } from "react";
import {
Globe,
Loader2,
Save,
UserPlus,
} from "lucide-react";
import {
apiUpdateSiteSettings,
type PublicSettings,
} from "@/lib/api";
import { toast } from "@/lib/toast";
function Switch({
checked,
disabled,
onChange,
label,
}: {
checked: boolean;
disabled?: boolean;
onChange: () => void;
label: string;
}) {
return (
<button
type="button"
role="switch"
aria-checked={checked}
aria-label={label}
disabled={disabled}
onClick={onChange}
className="shrink-0 relative h-7 w-12 rounded-full transition-colors disabled:opacity-60"
style={{ background: checked ? "var(--accent)" : "var(--line-2)" }}
>
<span
className="absolute top-0.5 left-0.5 h-6 w-6 rounded-full bg-[var(--panel)] shadow transition-transform"
style={{ transform: checked ? "translateX(1.25rem)" : "translateX(0)" }}
/>
</button>
);
}
export default function SettingsAdmin({ initial }: { initial: PublicSettings }) {
const [siteName, setSiteName] = useState(initial.site_name);
const [siteDescription, setSiteDescription] = useState(initial.site_description);
const [allowRegister, setAllowRegister] = useState(initial.allow_register);
const [savedName, setSavedName] = useState(initial.site_name);
const [savedDesc, setSavedDesc] = useState(initial.site_description);
const [saving, setSaving] = useState(false);
const [togglingReg, setTogglingReg] = useState(false);
const dirty =
siteName.trim() !== savedName || siteDescription.trim() !== savedDesc;
const saveIdentity = async () => {
if (saving) return;
const name = siteName.trim();
if (!name) {
toast("站点名称不能为空");
return;
}
setSaving(true);
try {
const res = await apiUpdateSiteSettings({
site_name: name,
site_description: siteDescription.trim(),
});
setSiteName(res.site_name);
setSiteDescription(res.site_description);
setSavedName(res.site_name);
setSavedDesc(res.site_description);
toast("站点信息已保存", "ok");
} catch (e) {
toast(e instanceof Error ? e.message : "保存失败");
} finally {
setSaving(false);
}
};
const toggleRegister = async () => {
if (togglingReg) return;
const next = !allowRegister;
setTogglingReg(true);
try {
const res = await apiUpdateSiteSettings({ allow_register: next });
setAllowRegister(res.allow_register);
toast(res.allow_register ? "已开放注册" : "已关闭注册", "ok");
} catch (e) {
toast(e instanceof Error ? e.message : "保存失败");
} finally {
setTogglingReg(false);
}
};
return (
<div className="max-w-3xl">
<div className="mb-5">
<h1
className="text-[22px] sm:text-[26px] font-extrabold tracking-tight flex items-center gap-2.5"
style={{ color: "var(--ink)" }}
>
<span
className="w-9 h-9 rounded-xl flex items-center justify-center"
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
>
<Globe size={18} />
</span>
站点设置
</h1>
<p className="meta mt-1.5 text-[12.5px]">
配置站点名称、简介与注册开关;主题色请到「外观」页调整
</p>
</div>
<section className="j13-module p-5 sm:p-6 mb-5">
<h2 className="text-[15px] font-semibold mb-1" style={{ color: "var(--ink)" }}>
基本信息
</h2>
<p className="meta text-[12.5px] mb-4">用于页眉品牌、页脚版权与 SEO 元信息</p>
<label className="field-label" htmlFor="site-name">
站点名称
</label>
<input
id="site-name"
className="field mb-4"
maxLength={32}
value={siteName}
onChange={(e) => setSiteName(e.target.value)}
placeholder="姜十三论坛"
/>
<label className="field-label" htmlFor="site-desc">
站点简介
</label>
<textarea
id="site-desc"
className="field min-h-[96px] mb-4"
maxLength={160}
value={siteDescription}
onChange={(e) => setSiteDescription(e.target.value)}
placeholder="一句话介绍社区定位…"
/>
<div className="flex justify-end">
<button
type="button"
className="btn btn-primary btn-sm"
disabled={!dirty || saving}
onClick={() => void saveIdentity()}
>
{saving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
保存基本信息
</button>
</div>
</section>
<section className="j13-module p-5 sm:p-6 mb-5 flex items-start justify-between gap-4 flex-wrap">
<div className="min-w-0 flex-1">
<h2 className="text-[15px] font-semibold flex items-center gap-1.5" style={{ color: "var(--ink)" }}>
<UserPlus size={15} /> 开放注册
</h2>
<p className="meta text-[12.5px] mt-1 leading-relaxed">
关闭后,游客将无法提交注册;已有账号仍可登录。适用于内测或维护期。
</p>
</div>
<Switch
checked={allowRegister}
disabled={togglingReg}
onChange={() => void toggleRegister()}
label="开放注册"
/>
</section>
</div>
);
}

View File

@@ -0,0 +1,21 @@
import type { Metadata } from "next";
import { cookies } from "next/headers";
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
import { authCookieHeader } from "@/lib/cookies";
import { isSuperOrOwner } from "@/lib/roles";
import Forbidden from "../Forbidden";
import SettingsAdmin from "./SettingsAdmin";
export const metadata: Metadata = {
title: "站点设置",
};
export default async function AdminSettingsPage() {
const cookie = authCookieHeader(await cookies());
const me = await getMeCached(cookie || undefined);
if (!me.user || !isSuperOrOwner(me.user.role)) {
return <Forbidden text="站点设置仅超级管理员和站长可操作。" />;
}
const settings = await getPublicSettingsCached();
return <SettingsAdmin initial={settings} />;
}