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

@@ -13,6 +13,8 @@ import {
ArrowLeft,
ClipboardCheck,
Coins,
Globe,
FolderOpen,
} from "lucide-react";
import type { User } from "@/lib/api";
import { apiAdminPendingCounts } from "@/lib/api";
@@ -32,45 +34,80 @@ interface NavItem {
label: string;
icon: LucideIcon;
exact?: boolean;
/** 按 /me 角色过滤可见性;最终权限以后端路由守卫为准 */
visible: (u: RoleUserLike) => boolean;
/** 是否携带待审核角标 */
pendingBadge?: boolean;
}
// 后台导航项:exact 为精确匹配(仪表盘 /admin),其余按路径前缀匹配
const NAV_ITEMS: NavItem[] = [
{ href: "/admin", label: "仪表盘", icon: LayoutDashboard, exact: true, visible: () => true },
interface NavGroup {
title: string;
items: NavItem[];
}
// 分组导航:概览 → 内容运营 → 成员经济 → 站点配置,便于后期在组内扩展
const NAV_GROUPS: NavGroup[] = [
{
href: "/admin/moderation",
label: "内容审核",
icon: ClipboardCheck,
visible: (u) => canModerateAny(u),
pendingBadge: true,
title: "概览",
items: [
{ href: "/admin", label: "仪表盘", icon: LayoutDashboard, exact: true, visible: () => true },
],
},
{
href: "/admin/economy",
label: "积分看板",
icon: Coins,
visible: (u) => isAdminOrAbove(u.role),
title: "内容",
items: [
{
href: "/admin/moderation",
label: "内容审核",
icon: ClipboardCheck,
visible: (u) => canModerateAny(u),
pendingBadge: true,
},
{
href: "/admin/content",
label: "内容管理",
icon: FolderOpen,
visible: (u) => canModerateAny(u),
},
{
href: "/admin/announcements",
label: "公告管理",
icon: Megaphone,
visible: (u) => isAdminOrAbove(u.role),
},
],
},
{
href: "/admin/announcements",
label: "公告管理",
icon: Megaphone,
visible: (u) => isAdminOrAbove(u.role),
title: "成员与经济",
items: [
{
href: "/admin/users",
label: "用户权限",
icon: Users,
visible: (u) => isSuperOrOwner(u.role),
},
{
href: "/admin/economy",
label: "积分看板",
icon: Coins,
visible: (u) => isAdminOrAbove(u.role),
},
],
},
{
href: "/admin/users",
label: "用户权限",
icon: Users,
visible: (u) => isSuperOrOwner(u.role),
},
{
href: "/admin/appearance",
label: "外观设置",
icon: Palette,
visible: (u) => isSuperOrOwner(u.role),
title: "站点",
items: [
{
href: "/admin/settings",
label: "站点设置",
icon: Globe,
visible: (u) => isSuperOrOwner(u.role),
},
{
href: "/admin/appearance",
label: "外观",
icon: Palette,
visible: (u) => isSuperOrOwner(u.role),
},
],
},
];
@@ -78,7 +115,6 @@ function isActive(pathname: string, href: string, exact?: boolean) {
return exact ? pathname === href : pathname === href || pathname.startsWith(`${href}/`);
}
/** 待审核数量:首屏 + WS 推送 + 回前台校准(无定时轮询) */
function usePendingCounts(enabled: boolean) {
const [counts, setCounts] = useState({ posts: 0, comments: 0 });
useEffect(() => {
@@ -116,19 +152,24 @@ function PendingBadge({ count }: { count: number }) {
);
}
/**
* 桌面端后台侧边栏:品牌头 + 分组导航 + 返回站点 + 身份卡。
* 选中态复用全站导航语言(--nav-active-bg 淡底 + accent 文字),
* 与 j13-bcard / 顶部 pill 保持一致。
*/
function visibleGroups(user: RoleUserLike) {
return NAV_GROUPS.map((g) => ({
...g,
items: g.items.filter((i) => i.visible(user)),
})).filter((g) => g.items.length > 0);
}
function flatItems(user: RoleUserLike) {
return visibleGroups(user).flatMap((g) => g.items);
}
export function AdminSidebar({ user }: { user: User }) {
const pathname = usePathname();
const items = NAV_ITEMS.filter((i) => i.visible(user));
const pendingTotal = usePendingCounts(items.some((i) => i.pendingBadge));
const groups = visibleGroups(user);
const pendingTotal = usePendingCounts(flatItems(user).some((i) => i.pendingBadge));
return (
<div className="panel p-3">
{/* 品牌头 */}
<div className="flex items-center gap-2.5 px-2 pt-1.5 pb-3.5" style={{ borderBottom: "1px solid var(--line)" }}>
<span
className="w-9 h-9 rounded-xl flex items-center justify-center shrink-0"
@@ -146,35 +187,39 @@ export function AdminSidebar({ user }: { user: User }) {
</span>
</div>
{/* 导航 */}
<nav className="flex flex-col gap-1 pt-3 pb-2" aria-label="后台导航">
<p className="field-label px-2 pb-1.5" style={{ marginBottom: 0 }}>
站点管理
</p>
{items.map((item) => {
const active = isActive(pathname, item.href, item.exact);
const Icon = item.icon;
return (
<Link
key={item.href}
href={item.href}
aria-current={active ? "page" : undefined}
className="flex items-center gap-2.5 px-3 py-2.5 rounded-xl text-[13.5px] font-medium transition-colors"
style={
active
? { background: "var(--nav-active-bg)", color: "var(--nav-active-ink)" }
: { color: "var(--ink-2)" }
}
>
<Icon size={16} className="shrink-0" style={{ color: active ? "var(--nav-active-ink)" : "var(--ink-3)" }} />
{item.label}
{item.pendingBadge && <PendingBadge count={pendingTotal} />}
</Link>
);
})}
<nav className="flex flex-col gap-3 pt-3 pb-2" aria-label="后台导航">
{groups.map((group) => (
<div key={group.title}>
<p className="field-label px-2 pb-1.5" style={{ marginBottom: 0 }}>
{group.title}
</p>
<div className="flex flex-col gap-1">
{group.items.map((item) => {
const active = isActive(pathname, item.href, item.exact);
const Icon = item.icon;
return (
<Link
key={item.href}
href={item.href}
aria-current={active ? "page" : undefined}
className="flex items-center gap-2.5 px-3 py-2.5 rounded-xl text-[13.5px] font-medium transition-colors"
style={
active
? { background: "var(--nav-active-bg)", color: "var(--nav-active-ink)" }
: { color: "var(--ink-2)" }
}
>
<Icon size={16} className="shrink-0" style={{ color: active ? "var(--nav-active-ink)" : "var(--ink-3)" }} />
{item.label}
{item.pendingBadge && <PendingBadge count={pendingTotal} />}
</Link>
);
})}
</div>
</div>
))}
</nav>
{/* 返回前台 */}
<div className="pt-2" style={{ borderTop: "1px solid var(--line)" }}>
<Link
href="/"
@@ -186,7 +231,6 @@ export function AdminSidebar({ user }: { user: User }) {
</Link>
</div>
{/* 身份卡:按当前角色显示对应徽章(站长为皇冠标识) */}
<div className="flex items-center gap-2.5 mt-2 px-2.5 py-2.5 rounded-xl" style={{ background: "var(--panel-2)" }}>
<Avatar name={user.nickname || user.username} src={user.avatar || undefined} size={32} />
<div className="min-w-0 leading-tight">
@@ -205,13 +249,9 @@ export function AdminSidebar({ user }: { user: User }) {
);
}
/**
* 移动端后台导航:横向滚动 pill,吸附在全站 Header 下方,
* 窄屏下保持与桌面侧栏同等的切换能力且不占垂直空间。
*/
export function AdminNavMobile({ user }: { user: User }) {
const pathname = usePathname();
const items = NAV_ITEMS.filter((i) => i.visible(user));
const items = flatItems(user);
const pendingTotal = usePendingCounts(items.some((i) => i.pendingBadge));
return (

View File

@@ -11,7 +11,6 @@ import {
X,
ExternalLink,
Megaphone,
FileText,
} from "lucide-react";
import {
apiAdminListAnnouncements,
@@ -24,6 +23,7 @@ import {
} from "@/lib/api";
import { formatDate } from "@/lib/format";
import { toast } from "@/lib/toast";
import ConfirmDialog from "@/components/ConfirmDialog";
const COLORS: { value: AnnouncementColor; label: string }[] = [
{ value: "blue", label: "蓝色" },
@@ -52,6 +52,7 @@ export default function AnnouncementAdmin() {
const [form, setForm] = useState<FormState | null>(null);
const [saving, setSaving] = useState(false);
const [deletingId, setDeletingId] = useState<number | null>(null);
const [deleteTarget, setDeleteTarget] = useState<Announcement | null>(null);
const load = useCallback(async () => {
setLoading(true);
@@ -113,7 +114,6 @@ export default function AnnouncementAdmin() {
}
async function remove(a: Announcement) {
if (!window.confirm(`确定删除公告「${a.title}」吗?此操作不可撤销。`)) return;
setDeletingId(a.id);
try {
await apiAdminDeleteAnnouncement(a.id);
@@ -124,6 +124,7 @@ export default function AnnouncementAdmin() {
toast(e instanceof Error ? e.message : "删除失败");
} finally {
setDeletingId(null);
setDeleteTarget(null);
}
}
@@ -150,10 +151,10 @@ export default function AnnouncementAdmin() {
)}
</div>
<div className="grid grid-cols-1 lg:grid-cols-[400px_minmax(0,1fr)] gap-6 items-start">
{/* 编辑/新建表单 */}
{form ? (
<div className="panel p-5 lg:sticky lg:top-24">
<div className="grid grid-cols-1 gap-6 items-start">
{/* 编辑/新建表单:仅在新建或编辑时展开,空闲不占位 */}
{form && (
<div className="panel p-5">
<p className="font-semibold text-[15px] mb-4" style={{ color: "var(--ink)" }}>
{form.id == null ? "新建公告" : "编辑公告"}
</p>
@@ -257,26 +258,6 @@ export default function AnnouncementAdmin() {
</button>
</div>
</div>
) : (
<div className="panel p-6 text-center lg:sticky lg:top-24">
<span
className="inline-flex w-12 h-12 rounded-2xl items-center justify-center mb-3"
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
>
<FileText size={22} />
</span>
<p className="text-[14.5px]" style={{ color: "var(--ink)" }}>
公告编辑器
</p>
<p className="meta mt-1.5 text-[12.5px] leading-relaxed">
点击右上「新建公告」发布公告,
<br />
或在右侧列表中选择已有公告进行编辑。
</p>
<button onClick={startCreate} className="btn btn-primary mt-4">
<Plus size={15} /> 新建公告
</button>
</div>
)}
{/* 公告列表 */}
@@ -350,7 +331,7 @@ export default function AnnouncementAdmin() {
<Pencil size={15} />
</button>
<button
onClick={() => remove(a)}
onClick={() => setDeleteTarget(a)}
disabled={deletingId === a.id}
className="w-8 h-8 inline-flex items-center justify-center rounded-full transition-colors hover:bg-[var(--danger-soft)] disabled:opacity-50"
style={{ color: "var(--ink-3)" }}
@@ -367,6 +348,19 @@ export default function AnnouncementAdmin() {
)}
</div>
</div>
<ConfirmDialog
open={!!deleteTarget}
title="删除公告"
message={deleteTarget ? `确定删除公告「${deleteTarget.title}」吗?此操作不可撤销。` : ""}
danger
busy={deletingId != null}
confirmLabel="删除"
onCancel={() => deletingId == null && setDeleteTarget(null)}
onConfirm={() => {
if (deleteTarget) void remove(deleteTarget);
}}
/>
</div>
);
}

View File

@@ -0,0 +1,696 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import Link from "next/link";
import {
ChevronLeft,
ChevronRight,
FileText,
FolderOpen,
Loader2,
MessagesSquare,
RotateCcw,
Search,
Trash2,
} from "lucide-react";
import {
apiAdminContentCounts,
apiAdminContentPosts,
apiAdminContentComments,
apiAdminSoftDeletePost,
apiAdminRestorePost,
apiAdminPurgePost,
apiAdminSoftDeleteComment,
apiAdminRestoreComment,
apiAdminPurgeComment,
apiUpdateSiteSettings,
type AdminContentComment,
type AdminContentCounts,
type AdminContentPost,
type AdminContentStatus,
type PublicSettings,
} from "@/lib/api";
import { formatRelative } from "@/lib/format";
import { toast } from "@/lib/toast";
import Avatar from "@/components/Avatar";
import ConfirmDialog from "@/components/ConfirmDialog";
const PAGE_SIZE = 15;
type Kind = "posts" | "comments";
const STATUS_TABS: { key: AdminContentStatus; label: string }[] = [
{ key: "live", label: "全部" },
{ key: "published", label: "已发布" },
{ key: "pending", label: "待审" },
{ key: "rejected", label: "已拒绝" },
{ key: "deleted", label: "已删除" },
];
function statusLabel(status: string, deleted: boolean) {
if (deleted) return "已删除";
if (status === "pending") return "待审";
if (status === "rejected") return "已拒绝";
if (status === "published") return "已发布";
return status;
}
function excerpt(md: string, max = 120): string {
const text = md
.replace(/```[\s\S]*?```/g, " ")
.replace(/[#>*_`~\-]/g, "")
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
.replace(/\s+/g, " ")
.trim();
return text.length > max ? `${text.slice(0, max)}…` : text;
}
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 ContentAdmin({
initialPosts,
initialPostsTotal,
initialCounts,
initError,
canConfigureSettings,
initialSettings,
}: {
initialPosts: AdminContentPost[];
initialPostsTotal: number;
initialCounts: AdminContentCounts;
initError: string;
canConfigureSettings: boolean;
initialSettings: PublicSettings;
}) {
const [kind, setKind] = useState<Kind>("posts");
const [status, setStatus] = useState<AdminContentStatus>("live");
const [counts, setCounts] = useState(initialCounts);
const [posts, setPosts] = useState(initialPosts);
const [postsTotal, setPostsTotal] = useState(initialPostsTotal);
const [postsPage, setPostsPage] = useState(1);
const [comments, setComments] = useState<AdminContentComment[]>([]);
const [commentsTotal, setCommentsTotal] = useState(0);
const [commentsPage, setCommentsPage] = useState(1);
const [keyword, setKeyword] = useState("");
const [appliedQ, setAppliedQ] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState(initError);
const [actingKey, setActingKey] = useState<string | null>(null);
const [trustOn, setTrustOn] = useState(initialSettings.trust_reviewed_publish);
const [cooldown, setCooldown] = useState(initialSettings.post_cooldown_hours);
const [savingPolicy, setSavingPolicy] = useState(false);
const [confirm, setConfirm] = useState<{
title: string;
message: string;
danger?: boolean;
run: () => Promise<void>;
} | null>(null);
const [confirmBusy, setConfirmBusy] = useState(false);
const refreshCounts = useCallback(() => {
apiAdminContentCounts().then(setCounts).catch(() => {});
}, []);
const loadPosts = useCallback(async (page: number, st: AdminContentStatus, q: string) => {
setLoading(true);
try {
const res = await apiAdminContentPosts(page, st, q);
setPosts(res.posts);
setPostsTotal(res.total);
setPostsPage(page);
setError("");
} catch (e) {
setError(e instanceof Error ? e.message : "获取帖子失败");
} finally {
setLoading(false);
}
}, []);
const loadComments = useCallback(async (page: number, st: AdminContentStatus, q: string) => {
setLoading(true);
try {
const res = await apiAdminContentComments(page, st, q);
setComments(res.comments);
setCommentsTotal(res.total);
setCommentsPage(page);
setError("");
} catch (e) {
setError(e instanceof Error ? e.message : "获取评论失败");
} finally {
setLoading(false);
}
}, []);
const reload = (page = 1) => {
if (kind === "posts") void loadPosts(page, status, appliedQ);
else void loadComments(page, status, appliedQ);
refreshCounts();
};
useEffect(() => {
refreshCounts();
}, [refreshCounts]);
const switchKind = (k: Kind) => {
if (k === kind) return;
setKind(k);
setError("");
if (k === "posts") void loadPosts(1, status, appliedQ);
else void loadComments(1, status, appliedQ);
};
const switchStatus = (st: AdminContentStatus) => {
if (st === status) return;
setStatus(st);
setError("");
if (kind === "posts") void loadPosts(1, st, appliedQ);
else void loadComments(1, st, appliedQ);
};
const applySearch = (e: React.FormEvent) => {
e.preventDefault();
const q = keyword.trim();
setAppliedQ(q);
if (kind === "posts") void loadPosts(1, status, q);
else void loadComments(1, status, q);
};
const runAction = (key: string, fn: () => Promise<void>, okMsg: string) => {
setActingKey(key);
fn()
.then(() => {
toast(okMsg, "ok");
reload(kind === "posts" ? postsPage : commentsPage);
})
.catch((e) => toast(e instanceof Error ? e.message : "操作失败"))
.finally(() => setActingKey(null));
};
const askConfirm = (
title: string,
message: string,
run: () => Promise<void>,
danger = false
) => setConfirm({ title, message, run, danger });
const toggleTrust = async () => {
if (!canConfigureSettings || savingPolicy) return;
setSavingPolicy(true);
try {
const res = await apiUpdateSiteSettings({ trust_reviewed_publish: !trustOn });
setTrustOn(res.trust_reviewed_publish);
toast(res.trust_reviewed_publish ? "已开启过审免审" : "已关闭过审免审", "ok");
} catch (e) {
toast(e instanceof Error ? e.message : "保存失败");
} finally {
setSavingPolicy(false);
}
};
const saveCooldown = async () => {
if (!canConfigureSettings || savingPolicy) return;
const n = Math.max(0, Math.min(168, Math.round(Number(cooldown)) || 0));
setSavingPolicy(true);
try {
const res = await apiUpdateSiteSettings({ post_cooldown_hours: n });
setCooldown(res.post_cooldown_hours);
toast(
res.post_cooldown_hours === 0
? "已关闭新用户发帖冷静期"
: `冷静期已设为 ${res.post_cooldown_hours} 小时`,
"ok"
);
} catch (e) {
toast(e instanceof Error ? e.message : "保存失败");
} finally {
setSavingPolicy(false);
}
};
const total = kind === "posts" ? postsTotal : commentsTotal;
const page = kind === "posts" ? postsPage : commentsPage;
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
const statusCount = (st: AdminContentStatus) => {
if (kind === "posts") {
if (st === "live") return counts.posts_live;
if (st === "pending") return counts.posts_pending;
if (st === "rejected") return counts.posts_rejected;
if (st === "deleted") return counts.posts_deleted;
return null;
}
if (st === "live") return counts.comments_live;
if (st === "pending") return counts.comments_pending;
if (st === "rejected") return counts.comments_rejected;
if (st === "deleted") return counts.comments_deleted;
return null;
};
return (
<div>
<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)" }}
>
<FolderOpen size={18} />
</span>
内容管理
</h1>
<p className="meta mt-1.5 text-[12.5px]">
浏览与处理帖子/评论,含回收站恢复与彻底删除;策略开关仅超管/站长可改
</p>
</div>
{canConfigureSettings && (
<div className="grid gap-3 mb-5">
<section className="panel p-4 sm:p-5 flex items-start justify-between gap-4 flex-wrap">
<div className="min-w-0 flex-1">
<h2 className="text-[14.5px] font-semibold" style={{ color: "var(--ink)" }}>
已过审用户后续免审
</h2>
<p className="meta mt-1 text-[12.5px] leading-relaxed">
开启后,已有过审内容的用户后续发帖与评论将直接公开。默认开启。
</p>
</div>
<Switch
checked={trustOn}
disabled={savingPolicy}
onChange={() => void toggleTrust()}
label="已过审用户后续免审"
/>
</section>
<section className="panel p-4 sm:p-5">
<h2 className="text-[14.5px] font-semibold" style={{ color: "var(--ink)" }}>
新用户发帖冷静期
</h2>
<p className="meta mt-1 text-[12.5px] leading-relaxed mb-3">
注册后满该小时数才能发帖;设为 0 表示关闭。上限 168 小时(7 天)。
</p>
<div className="flex flex-wrap items-center gap-2.5">
<input
type="number"
min={0}
max={168}
className="field w-28"
value={cooldown}
onChange={(e) => setCooldown(Number(e.target.value))}
aria-label="冷静期小时数"
/>
<span className="meta text-[12.5px]">小时</span>
<button
type="button"
className="btn btn-line btn-sm"
disabled={savingPolicy}
onClick={() => void saveCooldown()}
>
保存
</button>
</div>
</section>
</div>
)}
<div className="flex items-center gap-1.5 mb-3 flex-wrap" role="tablist" aria-label="内容类型">
{(
[
{ key: "posts" as Kind, label: "帖子", icon: <FileText size={14} />, count: counts.posts_live },
{
key: "comments" as Kind,
label: "评论",
icon: <MessagesSquare size={14} />,
count: counts.comments_live,
},
]
).map((t) => {
const active = kind === t.key;
return (
<button
key={t.key}
type="button"
role="tab"
aria-selected={active}
onClick={() => switchKind(t.key)}
className="inline-flex items-center gap-1.5 px-3.5 py-2 rounded-full text-[13px] transition-colors"
style={
active
? { background: "var(--nav-active-bg)", color: "var(--nav-active-ink)" }
: { color: "var(--ink-2)", background: "var(--panel-2)" }
}
>
{t.icon}
{t.label}
<span className="text-[11px] tabular-nums" style={{ color: active ? "var(--nav-active-ink)" : "var(--ink-3)" }}>
{t.count}
</span>
</button>
);
})}
</div>
<div className="flex items-center gap-1.5 mb-4 flex-wrap" role="tablist" aria-label="状态筛选">
{STATUS_TABS.map((t) => {
const active = status === t.key;
const cnt = statusCount(t.key);
return (
<button
key={t.key}
type="button"
role="tab"
aria-selected={active}
onClick={() => switchStatus(t.key)}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-[12.5px] transition-colors"
style={
active
? { background: "var(--accent-soft)", color: "var(--accent)" }
: { color: "var(--ink-3)", background: "var(--panel)" }
}
>
{t.label}
{cnt != null && (
<span className="tabular-nums text-[11px] opacity-80">{cnt}</span>
)}
</button>
);
})}
</div>
<form onSubmit={applySearch} className="panel p-3 mb-4 flex gap-2 items-center" role="search">
<Search size={15} style={{ color: "var(--ink-3)" }} aria-hidden />
<input
className="field flex-1 !border-0 !shadow-none !bg-transparent py-1.5"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
placeholder={kind === "posts" ? "搜索帖子标题…" : "搜索评论正文…"}
aria-label="搜索内容"
/>
<button type="submit" className="btn btn-line btn-sm">
搜索
</button>
</form>
<div className="panel overflow-hidden">
{loading ? (
<div className="flex items-center justify-center gap-2 py-16 meta text-[13px]">
<Loader2 size={16} className="animate-spin" /> 加载中…
</div>
) : error ? (
<p className="alert-error m-4">{error}</p>
) : kind === "posts" && posts.length === 0 ? (
<p className="meta text-center py-16 text-[13px]">暂无帖子</p>
) : kind === "comments" && comments.length === 0 ? (
<p className="meta text-center py-16 text-[13px]">暂无评论</p>
) : kind === "posts" ? (
posts.map((p) => {
const key = `p-${p.id}`;
const busy = actingKey === key;
return (
<div
key={p.id}
className="flex items-start gap-3 px-4 sm:px-5 py-3.5"
style={{ borderTop: "1px solid var(--line)" }}
>
<Avatar name={p.user?.nickname || p.user?.username || "?"} src={p.user?.avatar || undefined} size={34} />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<span
className="text-[11px] px-2 py-0.5 rounded-full"
style={{
background: p.deleted ? "var(--danger-soft)" : "var(--tint)",
color: p.deleted ? "var(--danger)" : "var(--ink-3)",
}}
>
{statusLabel(p.status, p.deleted)}
</span>
<span className="meta text-[11.5px]">{p.board?.name}</span>
<span className="meta text-[11.5px] tabular-nums">{formatRelative(p.created_at)}</span>
</div>
{p.deleted ? (
<p className="mt-1 text-[14px]" style={{ color: "var(--ink-2)" }}>
{p.title}
</p>
) : (
<Link href={`/post/${p.id}`} className="mt-1 block text-[14px] hover:underline" style={{ color: "var(--ink)" }}>
{p.title}
</Link>
)}
<p className="meta text-[12px] mt-0.5">
{p.user?.nickname} · {p.comment_count} 评
</p>
</div>
<div className="flex items-center gap-1 shrink-0">
{p.deleted ? (
<>
<button
type="button"
className="btn btn-line btn-sm"
disabled={busy}
onClick={() =>
runAction(key, () => apiAdminRestorePost(p.id), "帖子已恢复")
}
>
{busy ? <Loader2 size={14} className="animate-spin" /> : <RotateCcw size={14} />}
恢复
</button>
<button
type="button"
className="btn btn-danger btn-sm"
disabled={busy}
onClick={() =>
askConfirm(
"彻底删除帖子",
`「${p.title}」将从数据库永久删除,且其下评论一并清除,不可撤销。`,
() =>
apiAdminPurgePost(p.id).then(() => {
toast("帖子已彻底删除", "ok");
reload(postsPage);
}),
true
)
}
>
<Trash2 size={14} /> 清除
</button>
</>
) : (
<button
type="button"
className="btn btn-line btn-sm"
disabled={busy}
onClick={() =>
askConfirm(
"移入回收站",
`确定软删帖子「${p.title}」吗?可稍后在「已删除」中恢复。`,
() =>
apiAdminSoftDeletePost(p.id).then(() => {
toast("已移入回收站", "ok");
reload(postsPage);
}),
true
)
}
>
<Trash2 size={14} /> 删除
</button>
)}
</div>
</div>
);
})
) : (
comments.map((c) => {
const key = `c-${c.id}`;
const busy = actingKey === key;
return (
<div
key={c.id}
className="flex items-start gap-3 px-4 sm:px-5 py-3.5"
style={{ borderTop: "1px solid var(--line)" }}
>
<Avatar name={c.user?.nickname || c.user?.username || "?"} src={c.user?.avatar || undefined} size={34} />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<span
className="text-[11px] px-2 py-0.5 rounded-full"
style={{
background: c.deleted ? "var(--danger-soft)" : "var(--tint)",
color: c.deleted ? "var(--danger)" : "var(--ink-3)",
}}
>
{statusLabel(c.status, c.deleted)}
</span>
<span className="meta text-[11.5px]">{c.board?.name}</span>
<span className="meta text-[11.5px] tabular-nums">{formatRelative(c.created_at)}</span>
</div>
<p className="mt-1 text-[13.5px] leading-relaxed" style={{ color: "var(--ink)" }}>
{excerpt(c.content)}
</p>
<p className="meta text-[12px] mt-0.5">
{c.user?.nickname} ·{" "}
{c.deleted ? (
c.post_title
) : (
<Link href={`/post/${c.post_id}`} className="hover:underline" style={{ color: "var(--accent)" }}>
{c.post_title || `帖子 #${c.post_id}`}
</Link>
)}
</p>
</div>
<div className="flex items-center gap-1 shrink-0">
{c.deleted ? (
<>
<button
type="button"
className="btn btn-line btn-sm"
disabled={busy}
onClick={() =>
runAction(key, () => apiAdminRestoreComment(c.id), "评论已恢复")
}
>
{busy ? <Loader2 size={14} className="animate-spin" /> : <RotateCcw size={14} />}
恢复
</button>
<button
type="button"
className="btn btn-danger btn-sm"
disabled={busy}
onClick={() =>
askConfirm(
"彻底删除评论",
"该评论将永久删除且不可恢复。",
() =>
apiAdminPurgeComment(c.id).then(() => {
toast("评论已彻底删除", "ok");
reload(commentsPage);
}),
true
)
}
>
<Trash2 size={14} /> 清除
</button>
</>
) : (
<button
type="button"
className="btn btn-line btn-sm"
disabled={busy}
onClick={() =>
askConfirm(
"移入回收站",
"确定软删这条评论吗?可在「已删除」中恢复。",
() =>
apiAdminSoftDeleteComment(c.id).then(() => {
toast("已移入回收站", "ok");
reload(commentsPage);
}),
true
)
}
>
<Trash2 size={14} /> 删除
</button>
)}
</div>
</div>
);
})
)}
{totalPages > 1 && (
<div
className="flex items-center justify-center gap-2 px-4 py-3"
style={{ borderTop: "1px solid var(--line)" }}
>
<button
type="button"
className="btn btn-line btn-sm"
disabled={page <= 1 || loading}
onClick={() => {
const p = page - 1;
if (kind === "posts") void loadPosts(p, status, appliedQ);
else void loadComments(p, status, appliedQ);
}}
>
<ChevronLeft size={14} /> 上一页
</button>
<span className="meta text-[12.5px] tabular-nums">
{page} / {totalPages}
</span>
<button
type="button"
className="btn btn-line btn-sm"
disabled={page >= totalPages || loading}
onClick={() => {
const p = page + 1;
if (kind === "posts") void loadPosts(p, status, appliedQ);
else void loadComments(p, status, appliedQ);
}}
>
下一页 <ChevronRight size={14} />
</button>
</div>
)}
</div>
<ConfirmDialog
open={!!confirm}
title={confirm?.title || ""}
message={confirm?.message || ""}
danger={confirm?.danger}
busy={confirmBusy}
confirmLabel={confirm?.danger ? "确认删除" : "确定"}
onCancel={() => !confirmBusy && setConfirm(null)}
onConfirm={() => {
if (!confirm) return;
setConfirmBusy(true);
confirm
.run()
.catch((e) => toast(e instanceof Error ? e.message : "操作失败"))
.finally(() => {
setConfirmBusy(false);
setConfirm(null);
});
}}
/>
</div>
);
}

View File

@@ -0,0 +1,56 @@
import type { Metadata } from "next";
import { cookies } from "next/headers";
import { authCookieHeader } from "@/lib/cookies";
import {
fetchAdminContentCounts,
fetchAdminContentPosts,
type AdminContentPost,
} from "@/lib/api";
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
import { canModerateAny, isSuperOrOwner } from "@/lib/roles";
import Forbidden from "../Forbidden";
import ContentAdmin from "./ContentAdmin";
export const metadata: Metadata = {
title: "内容管理",
};
export default async function AdminContentPage() {
const cookie = authCookieHeader(await cookies());
const me = await getMeCached(cookie || undefined);
if (!me.user || !canModerateAny(me.user)) {
return <Forbidden text="你所在的角色没有内容管理权限,或尚未被授权任何板块。" />;
}
let initialPosts: AdminContentPost[] = [];
let initialTotal = 0;
let initError = "";
const [postsSettled, counts] = await Promise.all([
fetchAdminContentPosts(1, "live", cookie || undefined)
.then((res) => ({ ok: true as const, res }))
.catch((e) => ({
ok: false as const,
error: e instanceof Error ? e.message : "获取内容列表失败",
})),
fetchAdminContentCounts(cookie || undefined),
]);
if (postsSettled.ok) {
initialPosts = postsSettled.res.posts;
initialTotal = postsSettled.res.total;
} else {
initError = postsSettled.error;
}
const settings = await getPublicSettingsCached();
return (
<ContentAdmin
initialPosts={initialPosts}
initialPostsTotal={initialTotal}
initialCounts={counts}
initError={initError}
canConfigureSettings={isSuperOrOwner(me.user.role)}
initialSettings={settings}
/>
);
}

View File

@@ -20,7 +20,6 @@ import {
apiAdminRejectPost,
apiAdminApproveComment,
apiAdminRejectComment,
apiUpdateSiteSettings,
type PendingCommentItem,
type PendingCounts,
type Post,
@@ -48,15 +47,10 @@ export default function ModerationAdmin({
initialPosts,
initialPostsTotal,
initError,
canConfigureSettings,
initialTrustReviewedPublish,
}: {
initialPosts: Post[];
initialPostsTotal: number;
initError: string;
/** 超管/站长可改站点免审策略(后端 PermSettings) */
canConfigureSettings: boolean;
initialTrustReviewedPublish: boolean;
}) {
const [tab, setTab] = useState<Tab>("posts");
const [counts, setCounts] = useState<PendingCounts>({ posts: initialPostsTotal, comments: 0 });
@@ -79,30 +73,12 @@ export default function ModerationAdmin({
const [confirmKey, setConfirmKey] = useState<string | null>(null);
const confirmTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const [trustReviewedPublish, setTrustReviewedPublish] = useState(initialTrustReviewedPublish);
const [savingTrust, setSavingTrust] = useState(false);
const refreshCounts = useCallback(() => {
apiAdminPendingCounts()
.then(setCounts)
.catch(() => {});
}, []);
const toggleTrustReviewedPublish = async () => {
if (!canConfigureSettings || savingTrust) return;
const next = !trustReviewedPublish;
setSavingTrust(true);
try {
const res = await apiUpdateSiteSettings({ trust_reviewed_publish: next });
setTrustReviewedPublish(res.trust_reviewed_publish);
toast(res.trust_reviewed_publish ? "已开启:过审用户后续免审" : "已关闭:所有普通用户均需审核", "ok");
} catch (e) {
toast(e instanceof Error ? e.message : "保存失败");
} finally {
setSavingTrust(false);
}
};
useEffect(() => {
refreshCounts();
}, [refreshCounts]);
@@ -235,44 +211,10 @@ export default function ModerationAdmin({
内容审核
</h1>
<p className="meta mt-1.5 text-[12.5px]">
普通用户发布的帖子与评论需审核通过后公开;板块管理员仅能处理授权板块的内容
普通用户发布的帖子与评论需审核通过后公开;板块管理员仅能处理授权板块的内容。内容策略与回收站见「内容管理」。
</p>
</div>
{canConfigureSettings && (
<section
className="panel p-4 sm:p-5 mb-5 flex items-start justify-between gap-4 flex-wrap"
aria-label="审核策略"
>
<div className="min-w-0 flex-1">
<h2 className="text-[14.5px] font-semibold" style={{ color: "var(--ink)" }}>
已过审用户后续免审
</h2>
<p className="meta mt-1 text-[12.5px] leading-relaxed">
开启后,若用户已有通过审核的帖子或评论,后续发帖与评论将直接公开,无需再次进入待审队列。默认开启。
</p>
</div>
<button
type="button"
role="switch"
aria-checked={trustReviewedPublish}
disabled={savingTrust}
onClick={() => void toggleTrustReviewedPublish()}
className="shrink-0 relative h-7 w-12 rounded-full transition-colors disabled:opacity-60"
style={{
background: trustReviewedPublish ? "var(--accent)" : "var(--line-2)",
}}
data-tip={trustReviewedPublish ? "点击关闭" : "点击开启"}
>
<span
className="absolute top-0.5 left-0.5 h-6 w-6 rounded-full bg-[var(--panel)] shadow transition-transform"
style={{ transform: trustReviewedPublish ? "translateX(1.25rem)" : "translateX(0)" }}
/>
<span className="sr-only">{trustReviewedPublish ? "已开启" : "已关闭"}</span>
</button>
</section>
)}
{/* 队列切换 */}
<div className="flex items-center gap-1.5 mb-4 flex-wrap" role="tablist" aria-label="审核队列">
{(

View File

@@ -2,8 +2,8 @@ import type { Metadata } from "next";
import { cookies } from "next/headers";
import { authCookieHeader } from "@/lib/cookies";
import { fetchAdminPendingPosts, type Post } from "@/lib/api";
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
import { canModerateAny, isSuperOrOwner } from "@/lib/roles";
import { getMeCached } from "@/lib/serverData";
import { canModerateAny } from "@/lib/roles";
import Forbidden from "../Forbidden";
import ModerationAdmin from "./ModerationAdmin";
@@ -17,7 +17,6 @@ export default async function ModerationPage() {
const cookie = authCookieHeader(await cookies());
const me = await getMeCached(cookie || undefined);
// 板块管理员及以上且(对板块管理员)拥有授权板块
if (!me.user || !canModerateAny(me.user)) {
return <Forbidden text="你所在的角色没有内容审核权限,或尚未被授权任何板块。" />;
}
@@ -33,16 +32,11 @@ export default async function ModerationPage() {
initError = e instanceof Error ? e.message : "获取待审核内容失败";
}
const canConfigureSettings = isSuperOrOwner(me.user.role);
const settings = await getPublicSettingsCached();
return (
<ModerationAdmin
initialPosts={initialPosts}
initialPostsTotal={initialTotal}
initError={initError}
canConfigureSettings={canConfigureSettings}
initialTrustReviewedPublish={settings.trust_reviewed_publish !== false}
/>
);
}

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} />;
}

View File

@@ -15,9 +15,11 @@ import {
Monitor,
RotateCcw,
Search,
ShieldAlert,
ShieldCheck,
Users as UsersIcon,
UserPlus,
Activity,
Hourglass,
X,
} from "lucide-react";
import {
@@ -121,7 +123,15 @@ export default function UsersAdmin({
}) {
const [users, setUsers] = useState<AdminUser[]>(initial?.users ?? []);
const [summary, setSummary] = useState<AdminUserSummary>(
initial?.summary ?? { total: 0, admins: 0, banned: 0, today_new: 0 }
initial?.summary ?? {
online: 0,
in_cooldown: 0,
active_7d: 0,
failed_logins_24h: 0,
total: 0,
admins: 0,
banned: 0,
}
);
const [total, setTotal] = useState(initial?.total ?? 0);
const [page, setPage] = useState(initialQuery.page);
@@ -468,37 +478,38 @@ export default function UsersAdmin({
</p>
</div>
{/* 汇总 */}
{/* 汇总:用户运营独有指标(与仪表盘站点总量区分) */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-4">
<MiniStat
label="注册用户"
value={String(summary.total)}
sub={summary.today_new > 0 ? `今日 +${summary.today_new}` : undefined}
icon={<UsersIcon size={16} />}
color="var(--accent)"
soft="var(--accent-soft)"
label="当前在线"
value={String(summary.online)}
sub="5 分钟内活跃"
icon={<Activity size={16} />}
color="var(--ok)"
soft="var(--ok-soft)"
/>
<MiniStat
label="管理团队"
value={String(summary.admins)}
icon={<ShieldCheck size={16} />}
label="发帖冷静期中"
value={String(summary.in_cooldown)}
sub="普通用户"
icon={<Hourglass size={16} />}
color="var(--gold)"
soft="var(--gold-soft)"
/>
<MiniStat
label="已封禁"
value={String(summary.banned)}
icon={<Ban size={16} />}
color="var(--danger)"
soft="var(--danger-soft)"
label="近 7 日活跃"
value={String(summary.active_7d)}
icon={<Monitor size={16} />}
color="var(--accent)"
soft="var(--accent-soft)"
/>
<MiniStat
label="当前筛选"
value={String(total)}
sub="人"
icon={<UserPlus size={16} />}
color="var(--ok)"
soft="var(--ok-soft)"
label="24h 登录失败"
value={String(summary.failed_logins_24h)}
sub="次"
icon={<ShieldAlert size={16} />}
color="var(--danger)"
soft="var(--danger-soft)"
/>
</div>

View File

@@ -37,18 +37,23 @@ const jetbrainsMono = JetBrains_Mono({
display: "swap",
});
export const metadata: Metadata = {
title: {
default: "姜十三论坛 · J13 FORUM",
template: "%s · 姜十三论坛",
},
description: "姜十三论坛 - 技术分享与讨论社区",
openGraph: {
title: "姜十三论坛 · J13 FORUM",
description: "姜十三论坛 - 技术分享与讨论社区",
type: "website",
},
};
export async function generateMetadata(): Promise<Metadata> {
const settings = await getPublicSettingsCached();
const name = settings.site_name || "姜十三论坛";
const desc = settings.site_description || "姜十三论坛 - 技术分享与讨论社区";
return {
title: {
default: `${name} · J13 FORUM`,
template: `%s · ${name}`,
},
description: desc,
openGraph: {
title: `${name} · J13 FORUM`,
description: desc,
type: "website",
},
};
}
export default async function RootLayout({
children,
@@ -64,7 +69,8 @@ export default async function RootLayout({
// 无 access cookie 即按游客渲染;客户端 Header 在 CSRF 可读时再静默校正一次。
// 站点主题色(公开设置):派生浅/暗两套 accent CSS 变量后随首屏 HTML 内联,
// 覆盖 globals.css 默认令牌——无主题闪动(FOUC),后端不可用时降级为默认靛蓝
const { accent } = await getPublicSettingsCached();
const settings = await getPublicSettingsCached();
const accent = settings.accent;
const accentCSS = accentStyleCSS(accent || "");
let user: User | null = null;
@@ -101,6 +107,8 @@ export default async function RootLayout({
initialUnread={unread}
initialChatUnread={chatUnread}
initialTheme={theme}
siteName={settings.site_name}
allowRegister={settings.allow_register}
/>
<main
id="main"
@@ -109,7 +117,7 @@ export default async function RootLayout({
>
{children}
</main>
<Footer />
<Footer siteName={settings.site_name} />
<Toaster />
<TipHost />
<AccountGuard />

View File

@@ -4,7 +4,7 @@ import { useState, useEffect } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import Link from "next/link";
import { MessagesSquare } from "lucide-react";
import { apiLogin, apiMe } from "@/lib/api";
import { apiLogin, apiMe, apiGetSettings } from "@/lib/api";
import { hasAuthCookieHint } from "@/lib/cookies";
// 仅允许同源相对路径跳转,拦截 //host、/\\host 与绝对 URL 形式的开放重定向
@@ -15,6 +15,28 @@ function safeRedirect(to: string | null): string {
return to;
}
function RegisterHint() {
const [allow, setAllow] = useState(true);
useEffect(() => {
apiGetSettings()
.then((s) => setAllow(s.allow_register))
.catch(() => {});
}, []);
if (!allow) {
return (
<p className="meta text-center mt-5">当前站点暂不开放注册</p>
);
}
return (
<p className="meta text-center mt-5">
还没有账号?
<Link href="/register" className="ml-1 font-semibold" style={{ color: "var(--accent)" }}>
立即注册
</Link>
</p>
);
}
export default function LoginPage() {
const router = useRouter();
const searchParams = useSearchParams();
@@ -107,12 +129,7 @@ export default function LoginPage() {
</button>
</form>
<p className="meta text-center mt-5">
还没有账号?
<Link href="/register" className="ml-1 font-semibold" style={{ color: "var(--accent)" }}>
立即注册
</Link>
</p>
<RegisterHint />
</div>
);
}

View File

@@ -4,7 +4,7 @@ import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { UserPlus } from "lucide-react";
import { apiRegister, apiMe } from "@/lib/api";
import { apiRegister, apiMe, apiGetSettings } from "@/lib/api";
import { hasAuthCookieHint } from "@/lib/cookies";
export default function RegisterPage() {
@@ -14,6 +14,7 @@ export default function RegisterPage() {
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const [registerClosed, setRegisterClosed] = useState(false);
// 已登录用户不必再注册,直接回首页
useEffect(() => {
@@ -25,6 +26,14 @@ export default function RegisterPage() {
.catch(() => {});
}, [router]);
useEffect(() => {
apiGetSettings()
.then((s) => {
if (!s.allow_register) setRegisterClosed(true);
})
.catch(() => {});
}, []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
@@ -39,13 +48,31 @@ export default function RegisterPage() {
// 极端情况:注册成功但自动签发失败,引导去登录
router.push("/login");
} else {
setError(res.error || "注册失败");
const msg = res.error || "注册失败";
if (msg.includes("关闭注册")) setRegisterClosed(true);
setError(msg);
}
} finally {
setLoading(false);
}
};
if (registerClosed) {
return (
<div className="max-w-md mx-auto pt-8 sm:pt-14">
<div className="panel p-8 text-center">
<h1 className="text-lg font-extrabold" style={{ color: "var(--ink)" }}>
暂不开放注册
</h1>
<p className="meta mt-2 text-[13px]">站点当前已关闭新用户注册,已有账号可直接登录。</p>
<Link href="/login" className="btn btn-primary mt-6">
去登录
</Link>
</div>
</div>
);
}
return (
<div className="max-w-md mx-auto pt-8 sm:pt-14">
<div className="mb-7 text-center">
@@ -59,7 +86,7 @@ export default function RegisterPage() {
创建账号
</h1>
<p className="text-sm mt-2" style={{ color: "var(--ink-2)" }}>
加入姜十三,开始你的第一帖
加入社区,开始你的第一帖
</p>
</div>