diff --git a/frontend/app/admin/AdminNav.tsx b/frontend/app/admin/AdminNav.tsx index cd6fdfc..556dd69 100644 --- a/frontend/app/admin/AdminNav.tsx +++ b/frontend/app/admin/AdminNav.tsx @@ -209,7 +209,11 @@ export function AdminSidebar({ user }: { user: User }) { : { color: "var(--ink-2)" } } > - + {item.label} {item.pendingBadge && } @@ -274,30 +278,25 @@ export function AdminNavMobile({ user }: { user: User }) { key={item.href} href={item.href} aria-current={active ? "page" : undefined} - className="shrink-0 inline-flex items-center gap-1.5 px-3.5 py-2 rounded-full text-[13px] transition-colors" + className="admin-seg shrink-0" style={ active - ? { background: "var(--nav-active-bg)", color: "var(--nav-active-ink)" } - : { color: "var(--ink-2)", background: "var(--panel)", border: "1px solid var(--line)" } + ? undefined + : { background: "var(--panel)", border: "1px solid var(--line)" } } > {item.label} {item.pendingBadge && pendingTotal > 0 && ( - - {pendingTotal > 99 ? "99+" : pendingTotal} - + {pendingTotal > 99 ? "99+" : pendingTotal} )} ); })} 返回站点 diff --git a/frontend/app/admin/announcements/AnnouncementAdmin.tsx b/frontend/app/admin/announcements/AnnouncementAdmin.tsx index 75d3b23..a496337 100644 --- a/frontend/app/admin/announcements/AnnouncementAdmin.tsx +++ b/frontend/app/admin/announcements/AnnouncementAdmin.tsx @@ -1,7 +1,6 @@ "use client"; import { useCallback, useEffect, useState } from "react"; -import Link from "next/link"; import { Plus, Pencil, @@ -24,6 +23,12 @@ import { import { formatDate } from "@/lib/format"; import { toast } from "@/lib/toast"; import ConfirmDialog from "@/components/ConfirmDialog"; +import { + AdminEmpty, + AdminIconButton, + AdminPageHeader, + AdminSegmented, +} from "@/components/admin"; const COLORS: { value: AnnouncementColor; label: string }[] = [ { value: "blue", label: "蓝色" }, @@ -35,6 +40,7 @@ const COLORS: { value: AnnouncementColor; label: string }[] = [ ]; type FormState = AnnouncementInput & { id: number | null }; +type PublishKey = "published" | "draft"; const EMPTY_FORM: FormState = { id: null, @@ -130,26 +136,18 @@ export default function AnnouncementAdmin() { return (
- {/* 页头 */} -
-
-

- - - - 站点公告管理 -

-

发布、编辑与下线右栏站点公告,支持自定义标签与标签颜色

-
- {form?.id == null && ( - - )} -
+ + 新建公告 + + ) : undefined + } + />
{/* 编辑/新建表单:仅在新建或编辑时展开,空闲不占位 */} @@ -204,25 +202,17 @@ export default function AnnouncementAdmin() {

发布状态

-
- - +
+ + role="group" + ariaLabel="发布状态" + value={form.published ? "published" : "draft"} + onChange={(key) => setForm({ ...form, published: key === "published" })} + options={[ + { key: "published", label: "立即发布" }, + { key: "draft", label: "存为草稿" }, + ]} + />
)) diff --git a/frontend/app/admin/appearance/AppearanceAdmin.tsx b/frontend/app/admin/appearance/AppearanceAdmin.tsx index 0f46ff4..ee5a73e 100644 --- a/frontend/app/admin/appearance/AppearanceAdmin.tsx +++ b/frontend/app/admin/appearance/AppearanceAdmin.tsx @@ -1,7 +1,6 @@ "use client"; import { useEffect, useMemo, useRef, useState } from "react"; -import Link from "next/link"; import { useRouter } from "next/navigation"; import { Palette, @@ -10,9 +9,9 @@ import { Save, Undo2, RotateCcw, - ChevronRight, Pipette, } from "lucide-react"; +import { AdminPageHeader, AdminSection } from "@/components/admin"; import { apiUpdateSiteSettings } from "@/lib/api"; import { toast } from "@/lib/toast"; import { @@ -78,181 +77,184 @@ export default function AppearanceAdmin({ initialAccent }: { initialAccent: stri }; return ( -
- {/* 面包屑 */} - +
+ -
- + - - -
-

外观设置

-

配置站点主题色,全站按钮、链接、选中态会统一联动

-
-
- - {/* 预设色板 */} -
-

- 预设主题色 -

-

浅/暗两套色板均已调校,保证文字对比度达标

-
- {ACCENT_PRESETS.map((p) => { - const active = normalized === p.light; - const isDefault = p.light === DEFAULT_ACCENT; - return ( - - ); - })} -
-
- - {/* 自定义颜色 */} -
-

- 自定义颜色 -

-

- 取色器选色或输入 HEX 值;浅色下对比度过低时会自动校正,保证按钮与文字可读 -

-
- {/* 原生取色器 */} - - {/* HEX 文本输入 */} -
-
- # - setDraft("#" + e.target.value.replace(/[^0-9a-fA-F#]/g, ""))} - className="flex-1 min-w-0 bg-transparent outline-none ml-1 uppercase" - style={{ color: "var(--ink)" }} - maxLength={7} - spellCheck={false} - aria-label="主题色 HEX 值" - /> -
+ + + + + + {active && ( + + + + )} + + + {p.name} + + + ); + })}
- {pair && ( -
- - - 浅色 {pair.light} - - - - 暗色 {pair.dark} - -
- )} -
- {invalid && ( -

- 颜色格式无效,请输入 6 位 HEX 值(如 3B5BDB) -

- )} - {pair?.adjusted && ( -

- 该颜色与白底对比度不足,浅色模式已自动校正为 {pair.light}(暗色模式同步派生 {pair.dark}) -

- )} - {preset && preset.light === DEFAULT_ACCENT && ( -

当前为内置默认色,保存时会清除自定义配置

- )} -
+ - {/* 实时预览(整页已实时换肤,此处展示典型组件) */} -
-

实时预览

-
-
- - - e.preventDefault()}> - 文字链接示例 - -
-
- 未选中 - 选中标签 - - 导航选中态 + + 自定义颜色 + } + description="取色器选色或输入 HEX 值;浅色下对比度过低时会自动校正,保证按钮与文字可读" + > +
+ +
+
+ # + setDraft("#" + e.target.value.replace(/[^0-9a-fA-F#]/g, ""))} + className="flex-1 min-w-0 bg-transparent outline-none ml-1 uppercase" + style={{ color: "var(--ink)" }} + maxLength={7} + spellCheck={false} + aria-label="主题色 HEX 值" + /> +
+
+ {pair && ( +
+ + + 浅色 {pair.light} + + + + 暗色 {pair.dark} + +
+ )}
-
-
- - + {invalid && ( +

+ 颜色格式无效,请输入 6 位 HEX 值(如 3B5BDB) +

+ )} + {pair?.adjusted && ( +

+ 该颜色与白底对比度不足,浅色模式已自动校正为 {pair.light}(暗色模式同步派生{" "} + {pair.dark}) +

+ )} + {preset && preset.light === DEFAULT_ACCENT && ( +

当前为内置默认色,保存时会清除自定义配置

+ )} + + + +
+
+ + + e.preventDefault()} + > + 文字链接示例 + +
+
+ 未选中 + + 选中标签 - 选中的板块卡片 + + 导航选中态 + +
+
+
+ + + + 选中的板块卡片 +
-
-
+ +
- {/* 操作栏 */}

{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="已过审用户后续免审" + /> + } + /> -
-

- 新用户发帖冷静期 -

-

- 注册后满该小时数才能发帖;设为 0 表示关闭。上限 168 小时(7 天)。 -

+
-
+
)} -
- {( - [ - { 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 ( - - ); - })} -
- -
- - setKeyword(e.target.value)} - placeholder={kind === "posts" ? "搜索帖子标题…" : "搜索评论正文…"} - aria-label="搜索内容" - /> - - -
+
+ + 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 ? ( <> - - + + ) : ( - + + )}
); }) ) : ( - 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 ? ( <> - - + + ) : ( - + + )}
@@ -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}》 + +
      + + +
      +
      +
      + ); + }) )} + + (tab === "posts" ? goPosts(p) : goComments(p))} + />
      - - {/* 分页 */} - {totalPages > 1 && ( -
      - - 第 {tab === "posts" ? postsPage : commentsPage} / {totalPages} 页 - -
      - - -
      -
      - )} -
      - ); -} - -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)} 项待处理 )}
      - -