+ }
+ description="取色器选色或输入 HEX 值;浅色下对比度过低时会自动校正,保证按钮与文字可读"
+ >
+
{dirty ? "有未保存的改动,整页正处于实时预览状态" : "设置已是保存状态"}
diff --git a/frontend/app/admin/content/ContentAdmin.tsx b/frontend/app/admin/content/ContentAdmin.tsx
index c08753c..744dee1 100644
--- a/frontend/app/admin/content/ContentAdmin.tsx
+++ b/frontend/app/admin/content/ContentAdmin.tsx
@@ -3,15 +3,12 @@
import { useCallback, useEffect, useState } from "react";
import Link from "next/link";
import {
- ChevronLeft,
- ChevronRight,
FileText,
FolderOpen,
History,
Loader2,
MessagesSquare,
RotateCcw,
- Search,
Trash2,
} from "lucide-react";
import {
@@ -38,6 +35,18 @@ import ConfirmDialog from "@/components/ConfirmDialog";
import StaffDeletePostModal from "@/components/StaffDeletePostModal";
import DeleteCommentModal from "@/components/DeleteCommentModal";
import CommentEditHistoryModal from "@/components/CommentEditHistoryModal";
+import {
+ AdminEmpty,
+ AdminIconButton,
+ AdminPageHeader,
+ AdminPager,
+ AdminSearchField,
+ AdminSegmented,
+ AdminSettingRow,
+ AdminStatusChip,
+ AdminSwitch,
+ contentStatusChip,
+} from "@/components/admin";
const PAGE_SIZE = 15;
@@ -51,14 +60,6 @@ const STATUS_TABS: { key: AdminContentStatus; label: string }[] = [
{ 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, " ")
@@ -69,36 +70,6 @@ function excerpt(md: string, max = 120): string {
return text.length > max ? `${text.slice(0, max)}…` : text;
}
-function Switch({
- checked,
- disabled,
- onChange,
- label,
-}: {
- checked: boolean;
- disabled?: boolean;
- onChange: () => void;
- label: string;
-}) {
- return (
-
- );
-}
-
export default function ContentAdmin({
initialPosts,
initialPostsTotal,
@@ -209,14 +180,19 @@ export default function ContentAdmin({
else void loadComments(1, st, appliedQ);
};
- const applySearch = (e: React.FormEvent) => {
- e.preventDefault();
+ const applySearch = () => {
const q = keyword.trim();
setAppliedQ(q);
if (kind === "posts") void loadPosts(1, status, q);
else void loadComments(1, status, q);
};
+ const clearSearch = () => {
+ setAppliedQ("");
+ if (kind === "posts") void loadPosts(1, status, "");
+ else void loadComments(1, status, "");
+ };
+
const runAction = (key: string, fn: () => Promise, okMsg: string) => {
setActingKey(key);
fn()
@@ -271,7 +247,6 @@ export default function ContentAdmin({
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") {
@@ -288,52 +263,38 @@ export default function ContentAdmin({
return null;
};
+ const goPage = (p: number) => {
+ if (kind === "posts") void loadPosts(p, status, appliedQ);
+ else void loadComments(p, status, appliedQ);
+ };
+
return (
-
-
-
-
-
- 内容管理
-
-
- 浏览与处理帖子/评论,含回收站恢复与彻底删除;策略开关仅超管/站长可改
-
-
+
{canConfigureSettings && (
-
-
-
-
- 已过审用户后续免审
-
-
- 开启后,已有过审内容的用户后续发帖与评论将直接公开。默认开启。
-
-
- void toggleTrust()}
- label="已过审用户后续免审"
- />
-
+
+
void toggleTrust()}
+ label="已过审用户后续免审"
+ />
+ }
+ />
-
+
)}
-
- {(
- [
- { key: "posts" as Kind, label: "帖子", icon: , count: counts.posts_live },
- {
- key: "comments" as Kind,
- label: "评论",
- icon: ,
- count: counts.comments_live,
- },
- ]
- ).map((t) => {
- const active = kind === t.key;
- return (
-
- );
- })}
-
-
-
- {STATUS_TABS.map((t) => {
- const active = status === t.key;
- const cnt = statusCount(t.key);
- return (
-
- );
- })}
-
-
-
-
+
+
+ ariaLabel="内容类型"
+ value={kind}
+ onChange={switchKind}
+ options={[
+ {
+ key: "posts",
+ label: "帖子",
+ icon: ,
+ count: counts.posts_live,
+ },
+ {
+ key: "comments",
+ label: "评论",
+ icon: ,
+ count: counts.comments_live,
+ },
+ ]}
+ />
+
+ ariaLabel="状态筛选"
+ size="sm"
+ value={status}
+ onChange={switchStatus}
+ options={STATUS_TABS.map((t) => ({
+ key: t.key,
+ label: t.label,
+ count: statusCount(t.key) ?? undefined,
+ }))}
+ />
+
+
+
{loading ? (
加载中…
@@ -444,40 +368,43 @@ export default function ContentAdmin({
) : error ? (
{error}
) : kind === "posts" && posts.length === 0 ? (
-
暂无帖子
+
} title="暂无帖子" />
) : kind === "comments" && comments.length === 0 ? (
-
暂无评论
+
} title="暂无评论" />
) : kind === "posts" ? (
- posts.map((p) => {
+ posts.map((p, idx) => {
const key = `p-${p.id}`;
const busy = actingKey === key;
+ const chip = contentStatusChip(p.status, p.deleted);
return (
-
+
-
- {statusLabel(p.status, p.deleted)}
-
+
{chip.label}
{p.board?.name}
-
{formatRelative(p.created_at)}
+
+ {formatRelative(p.created_at)}
+
{p.deleted ? (
{p.title}
) : (
-
+
{p.title}
)}
@@ -488,20 +415,24 @@ export default function ContentAdmin({
{p.deleted ? (
<>
-
-
+ ) : (
+
+ )}
+
+
askConfirm(
@@ -516,47 +447,46 @@ export default function ContentAdmin({
)
}
>
- 清除
-
+
+
>
) : (
-
+
+
)}
);
})
) : (
- comments.map((c) => {
+ comments.map((c, idx) => {
const key = `c-${c.id}`;
const busy = actingKey === key;
+ const chip = contentStatusChip(c.status, c.deleted);
return (
-
+
-
- {statusLabel(c.status, c.deleted)}
-
+
{chip.label}
{c.board?.name}
-
{formatRelative(c.created_at)}
+
+ {formatRelative(c.created_at)}
+
{excerpt(c.content)}
@@ -566,36 +496,44 @@ export default function ContentAdmin({
{c.deleted ? (
c.post_title
) : (
-
+
{c.post_title || `帖子 #${c.post_id}`}
)}
-
+
+
{c.deleted ? (
<>
-
-
+ ) : (
+
+ )}
+
+
askConfirm(
@@ -610,18 +548,18 @@ export default function ContentAdmin({
)
}
>
- 清除
-
+
+
>
) : (
-
+
+
)}
@@ -629,40 +567,13 @@ export default function ContentAdmin({
})
)}
- {totalPages > 1 && (
-
-
-
- {page} / {totalPages}
-
-
-
- )}
+
,
- hint: "全站可用积分之和",
- },
- {
- label: "今日签到",
- value: fmt(stats.checkin_today),
- icon:
,
- hint: "今日已签到人数",
- },
- {
- label: "托管中悬赏",
- value: `${fmt(stats.open_bounties)} / ${fmt(stats.escrowed_points)}`,
- icon:
,
- hint: "未结算帖数 / 托管积分",
- },
- {
- label: "流水条数",
- value: fmt(stats.ledger_count),
- icon:
,
- hint: "历史入账出账记录",
- },
- ];
-
const typeEntries = Object.entries(stats.posts_by_type || {}).sort(
(a, b) => b[1] - a[1]
);
return (
-
-
- 积分与类型帖看板
-
-
- 观察全站积分流通、悬赏托管与帖子类型分布
-
+
+
+
+
}
+ />
+
}
+ color="var(--clay)"
+ soft="var(--clay-soft)"
+ />
+
}
+ color="var(--gold)"
+ soft="var(--gold-soft)"
+ />
+
}
+ />
-
- {cards.map((c) => (
-
-
- {c.icon}
- {c.label}
-
-
- {c.value}
-
-
{c.hint}
-
- ))}
-
-
-
+
暂无数据
) : (
- {typeEntries.map(([t, n]) => (
+ {typeEntries.map(([t, n], idx) => (
-
{postTypeLabel(t)}
@@ -138,11 +122,11 @@ export default async function AdminEconomyPage() {
暂无流水
) : (
- {stats.recent_ledger.map((it) => (
+ {stats.recent_ledger.map((it, idx) => (
-
diff --git a/frontend/app/admin/moderation/ModerationAdmin.tsx b/frontend/app/admin/moderation/ModerationAdmin.tsx
index 9615e8f..d7c4094 100644
--- a/frontend/app/admin/moderation/ModerationAdmin.tsx
+++ b/frontend/app/admin/moderation/ModerationAdmin.tsx
@@ -4,8 +4,6 @@ import { useCallback, useEffect, useRef, useState } from "react";
import Link from "next/link";
import {
Check,
- ChevronLeft,
- ChevronRight,
ClipboardCheck,
FileText,
Loader2,
@@ -27,6 +25,12 @@ import {
import { formatRelative } from "@/lib/format";
import { toast } from "@/lib/toast";
import Avatar from "@/components/Avatar";
+import {
+ AdminEmpty,
+ AdminPageHeader,
+ AdminPager,
+ AdminSegmented,
+} from "@/components/admin";
const PAGE_SIZE = 15;
@@ -190,198 +194,106 @@ export default function ModerationAdmin({
}
};
- const totalPages = tab === "posts"
- ? Math.max(1, Math.ceil(postsTotal / PAGE_SIZE))
- : Math.max(1, Math.ceil(commentsTotal / PAGE_SIZE));
+ const page = tab === "posts" ? postsPage : commentsPage;
+ const total = tab === "posts" ? postsTotal : commentsTotal;
return (
- {/* 页头 */}
-
-
-
-
-
- 内容审核
-
-
- 普通用户发布的帖子与评论需审核通过后公开;板块管理员仅能处理授权板块的内容。内容策略与回收站见「内容管理」。
-
-
-
- {/* 队列切换 */}
-
- {(
- [
- { key: "posts" as Tab, label: "待审帖子", count: counts.posts, icon: },
- { key: "comments" as Tab, label: "待审评论", count: counts.comments, icon: },
- ]
- ).map((t) => {
- const active = tab === t.key;
- return (
-
- );
- })}
-
+
{error &&
{error}
}
-
+
+
+
+ ariaLabel="审核队列"
+ value={tab}
+ onChange={switchTab}
+ options={[
+ {
+ key: "posts",
+ label: "待审帖子",
+ count: counts.posts,
+ icon: ,
+ },
+ {
+ key: "comments",
+ label: "待审评论",
+ count: counts.comments,
+ icon: ,
+ },
+ ]}
+ />
+
+
{tab === "posts" ? (
posts.length === 0 ? (
-
} text="暂无待审核帖子" />
+
}
+ title="暂无待审核帖子"
+ description="新提交的内容会按时间顺序出现在这里"
+ />
) : (
-
- {posts.map((p) => {
- const key = `posts-${p.id}`;
- const busy = actingKey === key;
- const confirming = confirmKey === key;
- return (
-
-
-
- {p.board.name}
-
- {formatRelative(p.created_at)}
-
-
- {p.title}
-
-
- {excerpt(p.content) || "(无正文内容)"}
-
-
-
-
-
- {p.user.nickname}
-
-
@{p.user.username}
-
-
-
-
-
-
-
- );
- })}
-
- )
- ) : comments.length === 0 ? (
-
} text="暂无待审核评论" />
- ) : (
-
- {comments.map((c, idx) => {
- const key = `comments-${c.id}`;
+ posts.map((p, idx) => {
+ const key = `posts-${p.id}`;
const busy = actingKey === key;
const confirming = confirmKey === key;
return (
-
-
-
-
- {c.user.nickname}
-
-
- {c.board.name}
-
- {formatRelative(c.created_at)}
-
-
- {c.content}
-
+
- 所属帖子:《{c.post_title}》
+ {p.board.name}
-
+ {formatRelative(p.created_at)}
+
+
+ {p.title}
+
+
+ {excerpt(p.content) || "(无正文内容)"}
+
+
+
+
+
+ {p.user.nickname}
+
+
@{p.user.username}
+
+
);
- })}
-
+ })
+ )
+ ) : comments.length === 0 ? (
+
}
+ title="暂无待审核评论"
+ description="新提交的内容会按时间顺序出现在这里"
+ />
+ ) : (
+ comments.map((c, idx) => {
+ const key = `comments-${c.id}`;
+ const busy = actingKey === key;
+ const confirming = confirmKey === key;
+ return (
+
+
+
+
+
+ {c.user.nickname}
+
+
+ {c.board.name}
+
+ {formatRelative(c.created_at)}
+
+
+ {c.content}
+
+
+ 所属帖子:《{c.post_title}》
+
+
+
+ act(key, "comments", c.id, true, commentsPage, comments.length)
+ }
+ className="btn btn-sm btn-ok"
+ >
+ {busy ? : }
+ 通过
+
+
+ act(key, "comments", c.id, false, commentsPage, comments.length)
+ }
+ className="btn btn-sm btn-danger"
+ >
+ {confirming ? (
+ "确认拒绝?"
+ ) : (
+ <>
+ 拒绝
+ >
+ )}
+
+
+
+
+ );
+ })
)}
+
+
(tab === "posts" ? goPosts(p) : goComments(p))}
+ />
-
- {/* 分页 */}
- {totalPages > 1 && (
-
-
- 第 {tab === "posts" ? postsPage : commentsPage} / {totalPages} 页
-
-
- (tab === "posts" ? goPosts(postsPage - 1) : goComments(commentsPage - 1))}
- disabled={(tab === "posts" ? postsPage : commentsPage) <= 1 || loading}
- className="btn btn-line btn-sm"
- >
- 上一页
-
- (tab === "posts" ? goPosts(postsPage + 1) : goComments(commentsPage + 1))}
- disabled={(tab === "posts" ? postsPage : commentsPage) >= totalPages || loading}
- className="btn btn-line btn-sm"
- >
- 下一页
-
-
-
- )}
-
- );
-}
-
-function EmptyQueue({ icon, text }: { icon: React.ReactNode; text: string }) {
- return (
-
-
- {icon}
-
-
- {text}
-
-
新提交的内容会按时间顺序出现在这里
);
}
diff --git a/frontend/app/admin/page.tsx b/frontend/app/admin/page.tsx
index 4288822..822ffcc 100644
--- a/frontend/app/admin/page.tsx
+++ b/frontend/app/admin/page.tsx
@@ -9,6 +9,8 @@ import {
MessageSquareText,
FileText,
Users,
+ LayoutDashboard,
+ Coins,
} from "lucide-react";
import {
fetchOverview,
@@ -19,6 +21,7 @@ import { authCookieHeader } from "@/lib/cookies";
import { getMeCached } from "@/lib/serverData";
import { canModerateAny, isSuperOrOwner, isAdminOrAbove } from "@/lib/roles";
import Avatar from "@/components/Avatar";
+import { AdminPageHeader, AdminStatCard } from "@/components/admin";
import { formatRelative } from "@/lib/format";
export const metadata: Metadata = {
@@ -53,7 +56,7 @@ function PanelHead({ title, extra }: { title: string; extra?: string }) {
);
}
-/** 待办工作区:待审帖子 / 评论数量,整块进入审核页 */
+/** 待办:待审帖子 / 评论,整块进入审核页 */
function PendingWorkPanel({ counts }: { counts: PendingCounts }) {
const total = counts.posts + counts.comments;
const idle = total === 0;
@@ -70,7 +73,7 @@ function PendingWorkPanel({ counts }: { counts: PendingCounts }) {
background: idle ? "var(--tint)" : "var(--ok-soft)",
color: idle ? "var(--ink-3)" : "var(--ok)",
}}
- aria-hidden="true"
+ aria-hidden
>
@@ -81,30 +84,26 @@ function PendingWorkPanel({ counts }: { counts: PendingCounts }) {
{idle ? (
暂无待审
) : (
-
+
{fmt(total)} 项待处理
)}
-
+
待审帖子
{fmt(counts.posts)}
-
+
待审评论
{fmt(counts.comments)}
@@ -152,31 +151,35 @@ export default async function AdminDashboardPage() {
return (
-
-
- {greeting(now.getHours())},{me.user?.nickname}
-
-
{dateLabel} · 站点管理
-
+
{canMod && pendingCounts &&
}
{canEconomy && (
-
-
-
- 积分与类型帖看板
-
-
余额合计、悬赏托管、类型分布与最近流水
-
-
-
+
+
+
}
+ color="var(--gold)"
+ soft="var(--gold-soft)"
+ />
+
+ {stats && (
+
}
+ />
+ )}
+
)}
@@ -201,16 +204,10 @@ export default async function AdminDashboardPage() {
size={34}
/>
-
+
{u.nickname}
-
+
@{u.username}
@@ -218,7 +215,7 @@ export default async function AdminDashboardPage() {
className="shrink-0 inline-flex items-center gap-1 text-[11.5px]"
style={{ color: "var(--ink-3)" }}
>
-
+
{formatRelative(u.created_at)}加入
@@ -233,9 +230,9 @@ export default async function AdminDashboardPage() {
color: "var(--accent)",
}}
>
-
+
前往用户权限
-
+
)}
@@ -255,16 +252,13 @@ export default async function AdminDashboardPage() {
-
+
{b.name}
void;
- label: string;
-}) {
- return (
-
-
-
- );
-}
-
export default function SettingsAdmin({ initial }: { initial: PublicSettings }) {
const [siteName, setSiteName] = useState(initial.site_name);
const [siteDescription, setSiteDescription] = useState(initial.site_description);
@@ -204,229 +177,214 @@ export default function SettingsAdmin({ initial }: { initial: PublicSettings })
return (
-
-
+
+
+
-
-
-
- 站点设置
-
-
- 配置站点名称、简介与注册/评论开关;主题色请到「外观」页调整
-
-
+
+
setSiteName(e.target.value)}
+ placeholder="姜十三论坛"
+ />
-
-
- 基本信息
-
- 用于页眉品牌、页脚版权与 SEO 元信息
+
+
-
-
-
-
- 开放注册
-
-
- 关闭后,游客将无法提交注册;已有账号仍可登录。适用于内测或维护期。
-
-
- void toggleRegister()}
- label="开放注册"
- />
-
-
-
-
-
- 开放评论
-
-
- 关闭后全站禁止发表新评论与回复,已有评论仍可浏览。适用于 ICP/IPC
- 备案审核等合规需要;前台仅提示「暂停评论」,不会展示备案事由。
-
-
- void toggleComments()}
- label="开放评论"
- />
-
-
-
-
-
-
+
代码块自动折叠
-
-
- 开启后,帖子与评论中超过阈值行数的 Markdown 代码块默认折叠,读者可点「展开全部」。关闭则始终完整展示。
-
-
-
void toggleCodeFold()}
- label="代码块自动折叠"
- />
-
- {codeAutoFold ? (
-
-
-
- 代码块行数超过该值时折叠;折叠后约露出同样行数。设为 0 表示无预览。范围 0–100,默认 5。
-
-
- setFoldLines(Number(e.target.value))}
- aria-label="折叠行数阈值"
- />
- 行
- void saveFoldLines()}
- >
- {savingFoldLines ? (
-
- ) : (
-
- )}
- 保存
-
-
-
- ) : null}
-
+ >
+ }
+ description="开启后,帖子与评论中超过阈值行数的 Markdown 代码块默认折叠,读者可点「展开全部」。关闭则始终完整展示。"
+ control={
+
void toggleCodeFold()}
+ label="代码块自动折叠"
+ />
+ }
+ >
+ {codeAutoFold ? (
+ <>
+
+
+ 代码块行数超过该值时折叠;折叠后约露出同样行数。设为 0 表示无预览。范围 0–100,默认 5。
+
+
+ setFoldLines(Number(e.target.value))}
+ aria-label="折叠行数阈值"
+ />
+ 行
+ void saveFoldLines()}
+ >
+ {savingFoldLines ? (
+
+ ) : (
+
+ )}
+ 保存
+
+
+ >
+ ) : null}
+
-
-
-
-
+
界面动画
-
-
- 总开关关闭后全站即时切换(等同减少动态效果)。系统若已开启「减少动态效果」,始终优先生效。开启总开关后可细调子项。
-
-
-
void toggleMotionField("ui_animations", !uiAnimations)}
- label="界面动画总开关"
- />
-
- {uiAnimations ? (
-
-
-
-
- 代码折叠动画
-
-
- 代码块展开/收起的高度过渡与跟滚。
-
+ >
+ }
+ description="总开关关闭后全站即时切换(等同减少动态效果)。系统若已开启「减少动态效果」,始终优先生效。开启总开关后可细调子项。"
+ control={
+
void toggleMotionField("ui_animations", !uiAnimations)}
+ label="界面动画总开关"
+ />
+ }
+ >
+ {uiAnimations ? (
+
+
+
+
+ 代码折叠动画
+
+
+ 代码块展开/收起的高度过渡与跟滚。
+
+
+
void toggleMotionField("anim_code_fold", !animCodeFold)}
+ label="代码折叠动画"
+ />
-
void toggleMotionField("anim_code_fold", !animCodeFold)}
- label="代码折叠动画"
- />
-
-
-
-
- 平滑滚动
-
-
- 目录跳转、回顶、新内容提示等 scroll 行为。
-
+
+
+
+ 平滑滚动
+
+
+ 目录跳转、回顶、新内容提示等 scroll 行为。
+
+
+
+ void toggleMotionField("anim_smooth_scroll", !animSmoothScroll)
+ }
+ label="平滑滚动"
+ />
-
- void toggleMotionField("anim_smooth_scroll", !animSmoothScroll)
- }
- label="平滑滚动"
- />
-
-
-
-
- 装饰入场
-
-
- 列表入场、Toast、点赞弹出、在线脉冲等装饰性动画。
-
+
+
+
+ 装饰入场
+
+
+ 列表入场、Toast、点赞弹出、在线脉冲等装饰性动画。
+
+
+
void toggleMotionField("anim_chrome", !animChrome)}
+ label="装饰入场"
+ />
-
void toggleMotionField("anim_chrome", !animChrome)}
- label="装饰入场"
- />
-
- ) : null}
-
+ ) : null}
+
+
);
}
diff --git a/frontend/app/admin/users/UsersAdmin.tsx b/frontend/app/admin/users/UsersAdmin.tsx
index 6eed1e2..bced067 100644
--- a/frontend/app/admin/users/UsersAdmin.tsx
+++ b/frontend/app/admin/users/UsersAdmin.tsx
@@ -12,13 +12,11 @@ import {
History,
Loader2,
Monitor,
- Search,
ShieldAlert,
ShieldCheck,
- Users as UsersIcon,
+ Users,
Activity,
Hourglass,
- X,
} from "lucide-react";
import {
apiAdminListUsers,
@@ -32,12 +30,20 @@ import {
} from "@/lib/api";
import { isOwner, ROLES } from "@/lib/roles";
import { formatDate, formatRelative } from "@/lib/format";
-import { toast } from "@/lib/toast";
import { realtime } from "@/lib/realtime";
import Avatar from "@/components/Avatar";
import RoleBadge from "@/components/RoleBadge";
import Modal from "@/components/Modal";
import RoleAccountModal from "@/components/RoleAccountModal";
+import {
+ AdminEmpty,
+ AdminIconButton,
+ AdminPageHeader,
+ AdminPager,
+ AdminSearchField,
+ AdminSegmented,
+ AdminStatCard,
+} from "@/components/admin";
export type UserFilter = "all" | "staff" | "banned";
@@ -55,48 +61,6 @@ function filterToParams(filter: UserFilter): { role?: string; status?: string }
return {};
}
-// 汇总小卡
-function MiniStat({
- label,
- value,
- sub,
- icon,
- color,
- soft,
-}: {
- label: string;
- value: string;
- sub?: string;
- icon: React.ReactNode;
- color: string;
- soft: string;
-}) {
- return (
-
-
- {icon}
-
-
-
- {label}
-
-
- {value}
- {sub && (
-
- {sub}
-
- )}
-
-
-
- );
-}
-
export default function UsersAdmin({
initial,
initError,
@@ -227,8 +191,8 @@ export default function UsersAdmin({
router.replace(`/admin/users${qs ? `?${qs}` : ""}`, { scroll: false });
}, [appliedQ, filter, page, router]);
- const submitSearch = (e: React.FormEvent) => {
- e.preventDefault();
+ // Enter 立即提交,跳过防抖
+ const submitSearch = () => {
const q = keyword.trim();
if (q === appliedQ) return;
setAppliedQ(q);
@@ -267,10 +231,6 @@ export default function UsersAdmin({
// 有关键词或非默认筛选时,空态文案与"无成员"区分
const hasFilter = appliedQ !== "" || filter !== "all";
- // 行内操作按钮(圆形幽灵按钮,32px)
- const ghostBtn =
- "w-8 h-8 shrink-0 inline-flex items-center justify-center rounded-full transition-colors disabled:opacity-40 disabled:cursor-not-allowed";
-
// 角色授权成功 / 封禁成功后的统一回刷
const reload = () => load({ page, filter, q: appliedQ });
@@ -338,15 +298,14 @@ export default function UsersAdmin({
const renderActions = (u: AdminUser, isSelf: boolean) => {
const canAudit = isOwner(currentUserRole);
const auditBtn = canAudit ? (
-
-
+
) : null;
// 站长账号:不可改角色/封禁,保留主页与(站长自己的)内容档案
@@ -354,14 +313,9 @@ export default function UsersAdmin({
return (
);
}
@@ -379,47 +333,26 @@ export default function UsersAdmin({
);
}
const busy = actingId === u.id;
+ const roleTone = u.banned ? "danger" : u.role !== ROLES.USER ? "accent" : "default";
return (
{auditBtn}
-
-
-
+ setRoleTarget(u)}
- className={ghostBtn}
- style={
- u.banned
- ? { color: "var(--danger)" }
- : u.role === ROLES.USER
- ? { color: "var(--ink-3)" }
- : { color: "var(--accent)" }
- }
- onMouseEnter={(e) => {
- e.currentTarget.style.background = u.banned ? "var(--danger-soft)" : "var(--accent-soft)";
- e.currentTarget.style.color = u.banned ? "var(--danger)" : "var(--accent)";
- }}
- onMouseLeave={(e) => {
- e.currentTarget.style.background = "";
- e.currentTarget.style.color = u.banned
- ? "var(--danger)"
- : u.role === ROLES.USER
- ? "var(--ink-3)"
- : "var(--accent)";
- }}
>
-
+
);
};
@@ -432,28 +365,15 @@ export default function UsersAdmin({
return (
- {/* 页头 */}
-
-
-
-
-
- 用户管理
-
-
- 检索全部成员;在「角色与账号」中配置管理角色、板块授权与封禁;站长账号受保护
-
-
+
{/* 汇总:用户运营独有指标(与仪表盘站点总量区分) */}
-
-
- }
color="var(--accent)"
soft="var(--accent-soft)"
/>
-
- {/* 搜索 + 筛选 */}
-
-
-
-
- {FILTER_TABS.map((t) => {
- const active = filter === t.key;
- return (
- changeFilter(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.label}
-
- {tabCount(t.key)}
-
-
- );
- })}
-
-
-
-
- {/* 用户列表 */}
+ {/* 搜索 + 筛选 + 用户列表 */}
+
+
+
+ role="group"
+ ariaLabel="用户筛选"
+ value={filter}
+ onChange={changeFilter}
+ options={FILTER_TABS.map((t) => ({
+ key: t.key,
+ label: t.label,
+ count: tabCount(t.key),
+ }))}
+ />
+
+
加载中…
) : users.length === 0 ? (
-
-
-
-
-
- {hasFilter ? "没有匹配的成员" : "暂无成员"}
-
-
- {hasFilter ? "换个关键词或筛选条件试试" : "新注册的用户会出现在这里"}
-
-
+
}
+ title={hasFilter ? "没有匹配的成员" : "暂无成员"}
+ description={hasFilter ? "换个关键词或筛选条件试试" : "新注册的用户会出现在这里"}
+ />
) : (
users.map((u) => {
const isSelf = u.id === currentUserId;
@@ -648,7 +519,7 @@ export default function UsersAdmin({
{renderActions(u, isSelf)}
- {/* 移动卡片 */}
+ {/* 移动卡片:空间紧,保留 btn-line btn-sm */}
@@ -732,35 +603,14 @@ export default function UsersAdmin({
)}
- {/* 分页 */}
- {total > 0 && (
-
-
- 第 {page} / {totalPages} 页 · 共 {total} 人
-
-
- goPage(page - 1)}
- disabled={page <= 1 || loading}
- className="btn btn-line btn-sm"
- >
- 上一页
-
- goPage(page + 1)}
- disabled={page >= totalPages || loading}
- className="btn btn-line btn-sm"
- >
- 下一页
-
-
-
- )}
+
pageSize ? (
<>
diff --git a/frontend/app/admin/users/[id]/UserAuditClient.tsx b/frontend/app/admin/users/[id]/UserAuditClient.tsx
index c833002..c308e10 100644
--- a/frontend/app/admin/users/[id]/UserAuditClient.tsx
+++ b/frontend/app/admin/users/[id]/UserAuditClient.tsx
@@ -4,8 +4,6 @@ import { useCallback, useEffect, useState } from "react";
import Link from "next/link";
import {
ArrowLeft,
- ChevronLeft,
- ChevronRight,
ExternalLink,
FileText,
Loader2,
@@ -24,18 +22,20 @@ import {
import { formatDate, formatRelative } from "@/lib/format";
import Avatar from "@/components/Avatar";
import RoleBadge from "@/components/RoleBadge";
+import {
+ AdminEmpty,
+ AdminPager,
+ AdminSegmented,
+ AdminStatusChip,
+ type AdminStatusTone,
+} from "@/components/admin";
type TabKey = "posts" | "comments" | "messages";
-const TABS: { key: TabKey; label: string; icon: React.ReactNode }[] = [
- { key: "posts", label: "帖子", icon: },
- { key: "comments", label: "评论", icon: },
- { key: "messages", label: "私信与群消息", icon: },
-];
-
const PAGE_SIZE = 20;
-function StatusChip({
+/** 内容/消息状态粒:可叠加(如已删除 + 待审) */
+function StatusChips({
status,
deleted,
recalled,
@@ -44,72 +44,28 @@ function StatusChip({
deleted?: boolean;
recalled?: boolean;
}) {
- const chips: { label: string; color: string; soft: string }[] = [];
- if (deleted) chips.push({ label: "已删除", color: "var(--danger)", soft: "var(--danger-soft)" });
- if (recalled) chips.push({ label: "已撤回", color: "var(--ink-3)", soft: "var(--tint)" });
- if (status === "pending") chips.push({ label: "待审", color: "var(--gold)", soft: "var(--gold-soft)" });
- if (status === "rejected") chips.push({ label: "已驳回", color: "var(--danger)", soft: "var(--danger-soft)" });
+ const chips: { label: string; tone: AdminStatusTone }[] = [];
+ if (deleted) chips.push({ label: "已删除", tone: "danger" });
+ if (recalled) chips.push({ label: "已撤回", tone: "muted" });
+ if (status === "pending") chips.push({ label: "待审", tone: "warn" });
+ if (status === "rejected") chips.push({ label: "已驳回", tone: "danger" });
if (status === "published" && !deleted) {
- chips.push({ label: "已发布", color: "var(--ok)", soft: "var(--ok-soft)" });
+ chips.push({ label: "已发布", tone: "ok" });
}
if (chips.length === 0 && !recalled) {
- chips.push({ label: "正常", color: "var(--ink-3)", soft: "var(--tint)" });
+ chips.push({ label: "正常", tone: "muted" });
}
return (
{chips.map((c) => (
-
+
{c.label}
-
+
))}
);
}
-function Pager({
- page,
- total,
- onChange,
- loading,
-}: {
- page: number;
- total: number;
- onChange: (p: number) => void;
- loading?: boolean;
-}) {
- const pages = Math.max(1, Math.ceil(total / PAGE_SIZE));
- if (pages <= 1) return null;
- return (
-
- onChange(page - 1)}
- className="btn btn-line btn-sm"
- aria-label="上一页"
- >
-
-
-
- {page} / {pages}
-
- = pages || loading}
- onClick={() => onChange(page + 1)}
- className="btn btn-line btn-sm"
- aria-label="下一页"
- >
-
-
-
- );
-}
-
export default function UserAuditClient({
userId,
initial,
@@ -171,6 +127,15 @@ export default function UserAuditClient({
return profile.messages_total;
};
+ const emptyIcon =
+ tab === "posts" ? (
+
+ ) : tab === "comments" ? (
+
+ ) : (
+
+ );
+
return (
- {u.banned && (
-
- 已封禁
-
- )}
+ {u.banned &&
已封禁}
@{u.username}
@@ -240,34 +198,35 @@ export default function UserAuditClient({
-
- {TABS.map((t) => {
- const active = tab === t.key;
- return (
- switchTab(t.key)}
- className="inline-flex items-center gap-1.5 px-3 py-2.5 text-[13px] whitespace-nowrap -mb-px"
- style={{
- color: active ? "var(--accent)" : "var(--ink-3)",
- borderBottom: active ? "2px solid var(--accent)" : "2px solid transparent",
- }}
- >
- {t.icon}
- {t.label}
-
- {tabCount(t.key).toLocaleString("zh-CN")}
-
-
- );
- })}
-
-
+
+
+ ariaLabel="内容分类"
+ value={tab}
+ onChange={switchTab}
+ options={[
+ {
+ key: "posts",
+ label: "帖子",
+ icon: ,
+ count: tabCount("posts"),
+ },
+ {
+ key: "comments",
+ label: "评论",
+ icon: ,
+ count: tabCount("comments"),
+ },
+ {
+ key: "messages",
+ label: "私信与群消息",
+ icon: ,
+ count: tabCount("messages"),
+ },
+ ]}
+ />
+
+
{error && (
{error}
@@ -279,7 +238,7 @@ export default function UserAuditClient({
)}
{!error && !loading && total === 0 && (
- 该分类暂无内容
+
)}
{tab === "posts" &&
@@ -292,7 +251,7 @@ export default function UserAuditClient({
-
+
{p.board_name && (
{p.board_name}
)}
@@ -333,7 +292,7 @@ export default function UserAuditClient({
-
+
{c.deleted ? (
帖子 #{c.post_id}
) : (
@@ -370,7 +329,7 @@ export default function UserAuditClient({
-
+
{m.room_name}
{m.room_type === "direct" ? " · 私聊" : m.room_type === "group" ? " · 群聊" : ""}
@@ -393,7 +352,13 @@ export default function UserAuditClient({
))}
-
+
);
diff --git a/frontend/app/globals.css b/frontend/app/globals.css
index 8cf1a22..d54483d 100644
--- a/frontend/app/globals.css
+++ b/frontend/app/globals.css
@@ -1681,6 +1681,197 @@ a.j13-mention-token:hover {
.btn-sm { padding: 7px 14px; font-size: 13px; }
+ /* ----- 成功操作(审核通过等) ----- */
+ .btn-ok {
+ background: var(--ok-soft);
+ color: var(--ok);
+ }
+ .btn-ok:hover:not(:disabled) {
+ background: var(--ok);
+ color: #fff;
+ }
+ .dark .btn-ok:hover:not(:disabled) {
+ color: #0a1a10;
+ }
+
+ /* ============================================================
+ 管理后台控件(仅 /admin;颜色全部走令牌)
+ ============================================================ */
+ .admin-switch {
+ position: relative;
+ flex-shrink: 0;
+ width: 2.75rem;
+ height: 1.625rem;
+ border-radius: 999px;
+ border: none;
+ padding: 0;
+ cursor: pointer;
+ transition: background 0.15s ease;
+ background: var(--line-2);
+ }
+ .admin-switch[aria-checked="true"] {
+ background: var(--accent);
+ }
+ .admin-switch:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+ }
+ .admin-switch:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: 2px;
+ }
+ .admin-switch-knob {
+ position: absolute;
+ top: 0.125rem;
+ left: 0.125rem;
+ width: 1.375rem;
+ height: 1.375rem;
+ border-radius: 999px;
+ background: var(--panel);
+ box-shadow: var(--shadow-sm);
+ transition: transform 0.15s ease;
+ }
+ .admin-switch[aria-checked="true"] .admin-switch-knob {
+ transform: translateX(1.125rem);
+ }
+
+ .admin-seg {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.375rem;
+ padding: 0.5rem 0.875rem;
+ border-radius: 999px;
+ border: none;
+ font-size: 13px;
+ font-weight: 500;
+ line-height: 1.35;
+ cursor: pointer;
+ transition: background 0.15s ease, color 0.15s ease;
+ color: var(--ink-2);
+ background: var(--panel-2);
+ }
+ .admin-seg:hover:not(:disabled):not([aria-selected="true"]):not([aria-pressed="true"]) {
+ color: var(--ink);
+ background: var(--tint);
+ }
+ .admin-seg[aria-selected="true"],
+ .admin-seg[aria-pressed="true"],
+ .admin-seg[aria-current="page"] {
+ background: var(--nav-active-bg);
+ color: var(--nav-active-ink);
+ }
+ .admin-seg:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ }
+ .admin-seg:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: 2px;
+ }
+ .admin-seg-count {
+ min-width: 1.25rem;
+ padding: 0 0.375rem;
+ border-radius: 999px;
+ font-size: 11px;
+ font-variant-numeric: tabular-nums;
+ line-height: 1.7;
+ background: var(--panel);
+ color: var(--ink-3);
+ }
+ .admin-seg[aria-selected="true"] .admin-seg-count,
+ .admin-seg[aria-pressed="true"] .admin-seg-count,
+ .admin-seg[aria-current="page"] .admin-seg-count {
+ color: var(--nav-active-ink);
+ }
+ .admin-seg-sm {
+ padding: 0.375rem 0.75rem;
+ font-size: 12.5px;
+ }
+
+ .admin-icon-btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 2rem;
+ height: 2rem;
+ border-radius: 999px;
+ border: none;
+ padding: 0;
+ cursor: pointer;
+ color: var(--ink-3);
+ background: transparent;
+ transition: background 0.15s ease, color 0.15s ease;
+ }
+ .admin-icon-btn:hover:not(:disabled) {
+ background: var(--accent-soft);
+ color: var(--accent);
+ }
+ .admin-icon-btn.is-danger:hover:not(:disabled) {
+ background: var(--danger-soft);
+ color: var(--danger);
+ }
+ .admin-icon-btn.is-danger.is-active,
+ .admin-icon-btn.is-danger[aria-pressed="true"] {
+ color: var(--danger);
+ }
+ .admin-icon-btn.is-accent {
+ color: var(--accent);
+ }
+ .admin-icon-btn:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ }
+ .admin-icon-btn:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: 2px;
+ }
+
+ .admin-status {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.25rem;
+ padding: 0.125rem 0.5rem;
+ border-radius: 999px;
+ font-size: 11px;
+ font-weight: 500;
+ line-height: 1.55;
+ white-space: nowrap;
+ }
+ .admin-status.is-ok {
+ background: var(--ok-soft);
+ color: var(--ok);
+ }
+ .admin-status.is-warn {
+ background: var(--gold-soft);
+ color: var(--gold);
+ }
+ .admin-status.is-danger {
+ background: var(--danger-soft);
+ color: var(--danger);
+ }
+ .admin-status.is-muted {
+ background: var(--tint);
+ color: var(--ink-3);
+ }
+ .admin-status.is-accent {
+ background: var(--accent-soft);
+ color: var(--accent);
+ }
+
+ .admin-toolbar {
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+ padding: 0.75rem 1rem;
+ border-bottom: 1px solid var(--line);
+ background: var(--panel);
+ }
+ @media (min-width: 640px) {
+ .admin-toolbar {
+ padding: 0.875rem 1.25rem;
+ }
+ }
+
/* ----- 点赞按钮:未赞描边态,hover 同色系填充;已赞为浅底实心态 ----- */
.j13-like {
border-color: var(--line-2);
diff --git a/frontend/components/admin/AdminEmpty.tsx b/frontend/components/admin/AdminEmpty.tsx
new file mode 100644
index 0000000..5af1eb8
--- /dev/null
+++ b/frontend/components/admin/AdminEmpty.tsx
@@ -0,0 +1,30 @@
+import type { ReactNode } from "react";
+
+/** 图标 + 主文案 + 辅文案 */
+export default function AdminEmpty({
+ icon,
+ title,
+ description,
+ className = "",
+}: {
+ icon: ReactNode;
+ title: string;
+ description?: string;
+ className?: string;
+}) {
+ return (
+
+
+ {icon}
+
+
+ {title}
+
+ {description ?
{description}
: null}
+
+ );
+}
diff --git a/frontend/components/admin/AdminIconButton.tsx b/frontend/components/admin/AdminIconButton.tsx
new file mode 100644
index 0000000..70dcd68
--- /dev/null
+++ b/frontend/components/admin/AdminIconButton.tsx
@@ -0,0 +1,58 @@
+"use client";
+
+import Link from "next/link";
+import type { ReactNode } from "react";
+
+type Tone = "default" | "accent" | "danger";
+
+const toneClass: Record
= {
+ default: "",
+ accent: "is-accent",
+ danger: "is-danger",
+};
+
+/** 32px 圆形行内操作;hover/focus 走 CSS,禁止 JS 改 style */
+export default function AdminIconButton({
+ children,
+ tip,
+ ariaLabel,
+ tone = "default",
+ className = "",
+ href,
+ disabled,
+ onClick,
+ type = "button",
+}: {
+ children: ReactNode;
+ tip?: string;
+ ariaLabel: string;
+ tone?: Tone;
+ className?: string;
+ href?: string;
+ disabled?: boolean;
+ onClick?: () => void;
+ type?: "button" | "submit";
+}) {
+ const cls = `admin-icon-btn ${toneClass[tone]} ${className}`.trim();
+
+ if (href && !disabled) {
+ return (
+
+ {children}
+
+ );
+ }
+
+ return (
+
+ {children}
+
+ );
+}
diff --git a/frontend/components/admin/AdminPageHeader.tsx b/frontend/components/admin/AdminPageHeader.tsx
new file mode 100644
index 0000000..2e60aa3
--- /dev/null
+++ b/frontend/components/admin/AdminPageHeader.tsx
@@ -0,0 +1,39 @@
+import type { LucideIcon } from "lucide-react";
+import type { ReactNode } from "react";
+
+/** 后台统一页头:36px 图标槽 + 标题 + 说明 + 可选右侧 CTA */
+export default function AdminPageHeader({
+ icon: Icon,
+ title,
+ description,
+ actions,
+}: {
+ icon: LucideIcon;
+ title: string;
+ description?: ReactNode;
+ actions?: ReactNode;
+}) {
+ return (
+
+
+
+
+
+
+ {title}
+
+ {description != null && description !== "" && (
+
{description}
+ )}
+
+ {actions ?
{actions}
: null}
+
+ );
+}
diff --git a/frontend/components/admin/AdminPager.tsx b/frontend/components/admin/AdminPager.tsx
new file mode 100644
index 0000000..c42c02f
--- /dev/null
+++ b/frontend/components/admin/AdminPager.tsx
@@ -0,0 +1,58 @@
+"use client";
+
+import { ChevronLeft, ChevronRight } from "lucide-react";
+
+/** 紧凑上一页 / n / N / 下一页(客户端列表) */
+export default function AdminPager({
+ page,
+ total,
+ pageSize,
+ onChange,
+ loading,
+ summary,
+ className = "",
+}: {
+ page: number;
+ total: number;
+ pageSize: number;
+ onChange: (page: number) => void;
+ loading?: boolean;
+ /** 左侧摘要,默认「第 n / N 页」 */
+ summary?: string;
+ className?: string;
+}) {
+ const totalPages = Math.max(1, Math.ceil(total / pageSize));
+ if (totalPages <= 1 && total === 0) return null;
+ if (totalPages <= 1) return null;
+
+ return (
+
+
+ {summary ?? `第 ${page} / ${totalPages} 页`}
+
+
+ onChange(page - 1)}
+ disabled={page <= 1 || loading}
+ className="btn btn-line btn-sm"
+ aria-label="上一页"
+ >
+ 上一页
+
+ onChange(page + 1)}
+ disabled={page >= totalPages || loading}
+ className="btn btn-line btn-sm"
+ aria-label="下一页"
+ >
+ 下一页
+
+
+
+ );
+}
diff --git a/frontend/components/admin/AdminSearchField.tsx b/frontend/components/admin/AdminSearchField.tsx
new file mode 100644
index 0000000..8720c4e
--- /dev/null
+++ b/frontend/components/admin/AdminSearchField.tsx
@@ -0,0 +1,61 @@
+"use client";
+
+import { Search, X } from "lucide-react";
+import type { FormEvent } from "react";
+
+/** 左图标、可清除、Enter 提交;不要再并排「搜索」按钮 */
+export default function AdminSearchField({
+ value,
+ onChange,
+ onSubmit,
+ onClear,
+ placeholder,
+ ariaLabel,
+}: {
+ value: string;
+ onChange: (v: string) => void;
+ onSubmit?: () => void;
+ onClear?: () => void;
+ placeholder?: string;
+ ariaLabel: string;
+}) {
+ const handleSubmit = (e: FormEvent) => {
+ e.preventDefault();
+ onSubmit?.();
+ };
+
+ const clear = () => {
+ onChange("");
+ onClear?.();
+ };
+
+ return (
+
+ );
+}
diff --git a/frontend/components/admin/AdminSection.tsx b/frontend/components/admin/AdminSection.tsx
new file mode 100644
index 0000000..3a0dadb
--- /dev/null
+++ b/frontend/components/admin/AdminSection.tsx
@@ -0,0 +1,46 @@
+import type { ReactNode } from "react";
+
+/** 控制台模块:标题 / 说明 / 右上 extra;列表页一张表一块 */
+export default function AdminSection({
+ title,
+ description,
+ extra,
+ children,
+ className = "",
+ bodyClassName = "",
+ flush = false,
+}: {
+ title?: ReactNode;
+ description?: ReactNode;
+ extra?: ReactNode;
+ children: ReactNode;
+ className?: string;
+ bodyClassName?: string;
+ /** 无内边距,适合表格/列表铺满 */
+ flush?: boolean;
+}) {
+ const hasHead = title != null || description != null || extra != null;
+ return (
+
+ {hasHead && (
+
+
+ {title != null && (
+
+ {title}
+
+ )}
+ {description != null && description !== "" && (
+
{description}
+ )}
+
+ {extra != null &&
{extra}
}
+
+ )}
+ {children}
+
+ );
+}
diff --git a/frontend/components/admin/AdminSegmented.tsx b/frontend/components/admin/AdminSegmented.tsx
new file mode 100644
index 0000000..a77785e
--- /dev/null
+++ b/frontend/components/admin/AdminSegmented.tsx
@@ -0,0 +1,59 @@
+"use client";
+
+import type { ReactNode } from "react";
+
+export type AdminSegmentOption = {
+ key: T;
+ label: string;
+ icon?: ReactNode;
+ count?: number | string;
+ disabled?: boolean;
+};
+
+/** 分段筛选:可带 count / icon;选中态走 --nav-active-* */
+export default function AdminSegmented({
+ options,
+ value,
+ onChange,
+ ariaLabel,
+ size = "md",
+ role = "tablist",
+}: {
+ options: AdminSegmentOption[];
+ value: T;
+ onChange: (key: T) => void;
+ ariaLabel: string;
+ size?: "md" | "sm";
+ role?: "tablist" | "group";
+}) {
+ const useTabs = role === "tablist";
+ return (
+
+ {options.map((opt) => {
+ const active = value === opt.key;
+ return (
+ {
+ if (!active) onChange(opt.key);
+ }}
+ className={`admin-seg ${size === "sm" ? "admin-seg-sm" : ""}`.trim()}
+ >
+ {opt.icon}
+ {opt.label}
+ {opt.count != null && {opt.count}}
+
+ );
+ })}
+
+ );
+}
diff --git a/frontend/components/admin/AdminSettingRow.tsx b/frontend/components/admin/AdminSettingRow.tsx
new file mode 100644
index 0000000..21a65f9
--- /dev/null
+++ b/frontend/components/admin/AdminSettingRow.tsx
@@ -0,0 +1,36 @@
+import type { ReactNode } from "react";
+
+/** 「标题 + 说明 | 控件」一行,设置页与内容策略共用 */
+export default function AdminSettingRow({
+ title,
+ description,
+ control,
+ children,
+}: {
+ title: ReactNode;
+ description?: ReactNode;
+ control?: ReactNode;
+ /** 展开区(如阈值输入),画在分隔线下方 */
+ children?: ReactNode;
+}) {
+ return (
+
+
+
+
+ {title}
+
+ {description != null && description !== "" && (
+
{description}
+ )}
+
+ {control != null &&
{control}
}
+
+ {children != null && (
+
+ {children}
+
+ )}
+
+ );
+}
diff --git a/frontend/components/admin/AdminStatCard.tsx b/frontend/components/admin/AdminStatCard.tsx
new file mode 100644
index 0000000..dbc6574
--- /dev/null
+++ b/frontend/components/admin/AdminStatCard.tsx
@@ -0,0 +1,46 @@
+import type { ReactNode } from "react";
+
+/** KPI:图标软底 + 数字 + 说明 */
+export default function AdminStatCard({
+ label,
+ value,
+ sub,
+ hint,
+ icon,
+ color = "var(--accent)",
+ soft = "var(--accent-soft)",
+}: {
+ label: string;
+ value: ReactNode;
+ sub?: string;
+ hint?: string;
+ icon: ReactNode;
+ color?: string;
+ soft?: string;
+}) {
+ return (
+
+
+ {icon}
+
+
+
+ {label}
+
+
+ {value}
+ {sub ? (
+
+ {sub}
+
+ ) : null}
+
+ {hint ?
{hint}
: null}
+
+
+ );
+}
diff --git a/frontend/components/admin/AdminStatusChip.tsx b/frontend/components/admin/AdminStatusChip.tsx
new file mode 100644
index 0000000..91604c7
--- /dev/null
+++ b/frontend/components/admin/AdminStatusChip.tsx
@@ -0,0 +1,38 @@
+import type { ReactNode } from "react";
+
+export type AdminStatusTone = "ok" | "warn" | "danger" | "muted" | "accent";
+
+const toneClass: Record = {
+ ok: "is-ok",
+ warn: "is-warn",
+ danger: "is-danger",
+ muted: "is-muted",
+ accent: "is-accent",
+};
+
+/** 待审 / 已发布 / 已拒绝 / 草稿 / 已删除 等状态粒 */
+export default function AdminStatusChip({
+ children,
+ tone = "muted",
+ className = "",
+}: {
+ children: ReactNode;
+ tone?: AdminStatusTone;
+ className?: string;
+}) {
+ return (
+ {children}
+ );
+}
+
+/** 内容状态 → 文案 + tone */
+export function contentStatusChip(
+ status: string,
+ deleted: boolean
+): { label: string; tone: AdminStatusTone } {
+ if (deleted) return { label: "已删除", tone: "danger" };
+ if (status === "pending") return { label: "待审", tone: "warn" };
+ if (status === "rejected") return { label: "已拒绝", tone: "danger" };
+ if (status === "published") return { label: "已发布", tone: "ok" };
+ return { label: status, tone: "muted" };
+}
diff --git a/frontend/components/admin/AdminSwitch.tsx b/frontend/components/admin/AdminSwitch.tsx
new file mode 100644
index 0000000..62c11fc
--- /dev/null
+++ b/frontend/components/admin/AdminSwitch.tsx
@@ -0,0 +1,28 @@
+"use client";
+
+/** 后台唯一开关控件 */
+export default function AdminSwitch({
+ checked,
+ disabled,
+ onChange,
+ label,
+}: {
+ checked: boolean;
+ disabled?: boolean;
+ onChange: () => void;
+ label: string;
+}) {
+ return (
+
+
+
+ );
+}
diff --git a/frontend/components/admin/index.ts b/frontend/components/admin/index.ts
new file mode 100644
index 0000000..0b1d6d2
--- /dev/null
+++ b/frontend/components/admin/index.ts
@@ -0,0 +1,13 @@
+export { default as AdminPageHeader } from "./AdminPageHeader";
+export { default as AdminSection } from "./AdminSection";
+export { default as AdminSwitch } from "./AdminSwitch";
+export { default as AdminSettingRow } from "./AdminSettingRow";
+export { default as AdminSegmented } from "./AdminSegmented";
+export type { AdminSegmentOption } from "./AdminSegmented";
+export { default as AdminSearchField } from "./AdminSearchField";
+export { default as AdminPager } from "./AdminPager";
+export { default as AdminEmpty } from "./AdminEmpty";
+export { default as AdminStatCard } from "./AdminStatCard";
+export { default as AdminIconButton } from "./AdminIconButton";
+export { default as AdminStatusChip, contentStatusChip } from "./AdminStatusChip";
+export type { AdminStatusTone } from "./AdminStatusChip";