增加用户认证、等级、徽章与积分体系,并优化管理后台体验。

覆盖站长调账与积分解锁内容;后台按审核优先分组导航,仪表盘展示待办,用户管理改为成员目录式布局。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-06 02:42:18 +08:00
parent b075495540
commit 6b0a1d4281
44 changed files with 4455 additions and 254 deletions

View File

@@ -17,6 +17,7 @@ import type { LayoutCtx } from '../layouts/MainLayout';
import { loginPath } from '../utils/authRedirect';
import { useNoIndexSEO } from '../hooks/usePageSEO';
import { parsePermalinkID, postPath } from '../utils/permalink';
import { skipsModeration } from '../utils/userMeta';
interface ComposeBaseline {
title: string;
@@ -258,7 +259,7 @@ export default function ComposePage() {
};
if (isEdit) {
await api.updatePost(editId!, payload);
notify.success(user?.role === 'admin' ? '帖子已更新' : '已更新并重新提交审核');
notify.success(skipsModeration(user) ? '帖子已更新' : '已更新并重新提交审核');
markSaved();
nav(postPath(editId!, limits));
} else {

View File

@@ -628,7 +628,7 @@ export default function PostDetailPage() {
: authorInitial}
</UserLink>
<div className="post-detail-author-info">
<UserLink user={post.user} className="post-detail-author-name" />
<UserLink user={post.user} className="post-detail-author-name" showBadges />
<span className="post-detail-meta-line">
{formatDateTime(post.created_at)}
{showEdited && (
@@ -665,8 +665,10 @@ export default function PostDetailPage() {
<PostContent
html={post.content || ''}
isLoggedIn={!!user}
postId={post.id}
onHeadingsChange={handleHeadingsChange}
onRequestReply={scrollToCommentBox}
onUnlocked={() => { void reloadPostContent(); }}
/>
<div className="post-detail-actions">

View File

@@ -23,6 +23,8 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Badge } from '@/components/ui/badge';
import UserBadges from '../components/UserBadges';
import PointsWalletPanel from '../components/PointsWalletPanel';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
import { Spinner } from '@/components/ui/spinner';
import { notify } from '@/lib/notify';
@@ -389,7 +391,7 @@ export default function ProfilePage() {
<div className="profile-header-main">
<div className="profile-name-row">
<h2 className="profile-display-name">{user.nickname}</h2>
{user.role === 'admin' && <Badge variant="green"></Badge>}
<UserBadges user={user} compact={false} maxAchievement={6} />
</div>
<div className="profile-username">@{user.username}</div>
<div className="profile-id-row">
@@ -489,9 +491,11 @@ export default function ProfilePage() {
onConfirm={onCropConfirm}
/>
<PointsWalletPanel />
{user.role === 'admin' && (
<div className="section-card admin-entry-card">
<div className="section-card-title"></div>
<div className="section-card-title"></div>
<p className="admin-entry-desc">
</p>

View File

@@ -13,6 +13,7 @@ import {
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import UserBadges from '../components/UserBadges';
import { Spinner } from '@/components/ui/spinner';
import { notify } from '@/lib/notify';
import { api } from '../api/client';
@@ -150,7 +151,7 @@ export default function UserProfilePage() {
<div className="profile-header-main">
<div className="profile-name-row">
<h1 className="profile-display-name">{profile.nickname}</h1>
{profile.role === 'admin' && <Badge variant="green"></Badge>}
<UserBadges user={profile} compact={false} maxAchievement={6} />
{profile.banned && <Badge variant="destructive"></Badge>}
</div>
<div className="profile-username">@{profile.username}</div>
@@ -199,6 +200,13 @@ export default function UserProfilePage() {
</div>
</div>
{!!profile.badges?.length && (
<div className="profile-badge-wall" aria-label="徽章墙">
<h3 className="profile-badge-wall-title"></h3>
<UserBadges user={profile} compact={false} maxAchievement={20} showLevel={false} />
</div>
)}
<div className="profile-stat-grid" aria-label="活动统计">
<div className="profile-stat">
<FileText size={16} aria-hidden />

View File

@@ -0,0 +1,483 @@
import { useEffect, useMemo, useState } from 'react';
import {
Award, Pencil, Plus, Search, Sparkles, Trophy,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { Switch } from '@/components/ui/switch';
import { Spinner } from '@/components/ui/spinner';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { notify } from '@/lib/notify';
import { cn } from '@/lib/utils';
import { api } from '../../api/client';
import { useAdminGuard } from '../../layouts/AdminLayout';
import type { BadgeDef } from '../../api/types';
import {
BADGE_ICON_OPTIONS,
BADGE_METRIC_OPTIONS,
badgeIcon,
formatBadgeCondition,
} from '../../utils/badgeIcons';
type KindTab = 'all' | 'auto' | 'limited';
const EMPTY: Partial<BadgeDef> = {
code: '',
name: '',
description: '',
icon: 'star',
kind: 'limited',
metric: 'tenure_days',
threshold: 30,
sort_order: 100,
enabled: true,
};
function slugifyCode(name: string): string {
const ascii = name
.trim()
.toLowerCase()
.replace(/\s+/g, '_')
.replace(/[^a-z0-9_-]/g, '');
return ascii.slice(0, 32) || `badge_${Date.now().toString(36)}`;
}
/** 后台:徽章定义(卡片预览 + 弹窗编辑) */
export default function AdminBadgesPage() {
const { ready } = useAdminGuard();
const [rows, setRows] = useState<BadgeDef[]>([]);
const [loading, setLoading] = useState(true);
const [tab, setTab] = useState<KindTab>('all');
const [query, setQuery] = useState('');
const [dialogOpen, setDialogOpen] = useState(false);
const [form, setForm] = useState<Partial<BadgeDef>>({ ...EMPTY });
const [saving, setSaving] = useState(false);
const [togglingId, setTogglingId] = useState<number | null>(null);
const load = () => {
setLoading(true);
api.adminListBadges()
.then(d => setRows(d.badges ?? []))
.catch(e => notify.error(e.message))
.finally(() => setLoading(false));
};
useEffect(() => {
if (ready) load();
}, [ready]);
const counts = useMemo(() => ({
all: rows.length,
auto: rows.filter(b => b.kind === 'auto').length,
limited: rows.filter(b => b.kind === 'limited').length,
disabled: rows.filter(b => !b.enabled).length,
}), [rows]);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
return rows.filter(b => {
if (tab === 'auto' && b.kind !== 'auto') return false;
if (tab === 'limited' && b.kind !== 'limited') return false;
if (!q) return true;
return [b.code, b.name, b.description, b.icon, b.metric]
.join(' ')
.toLowerCase()
.includes(q);
});
}, [rows, tab, query]);
const openCreate = () => {
setForm({ ...EMPTY });
setDialogOpen(true);
};
const openEdit = (b: BadgeDef) => {
setForm({ ...b });
setDialogOpen(true);
};
const save = async () => {
const name = form.name?.trim() || '';
let code = form.code?.trim() || '';
if (!name) {
notify.warning('请填写徽章名称');
return;
}
if (!form.id && !code) {
code = slugifyCode(name);
}
if (!code) {
notify.warning('请填写徽章代码');
return;
}
if (form.kind === 'auto' && !form.metric) {
notify.warning('请选择自动成就指标');
return;
}
setSaving(true);
try {
const r = await api.adminUpsertBadge({
...form,
code,
name,
kind: form.kind || 'limited',
metric: form.kind === 'auto' ? (form.metric || 'tenure_days') : '',
threshold: form.kind === 'auto' ? (form.threshold ?? 0) : 0,
icon: form.icon || 'star',
enabled: form.enabled !== false,
});
notify.success(r.message);
setDialogOpen(false);
resetForm();
load();
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '保存失败');
} finally {
setSaving(false);
}
};
const resetForm = () => setForm({ ...EMPTY });
const toggleEnabled = async (b: BadgeDef) => {
setTogglingId(b.id);
try {
await api.adminUpsertBadge({ ...b, enabled: !b.enabled });
notify.success(b.enabled ? '已停用' : '已启用');
load();
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '操作失败');
} finally {
setTogglingId(null);
}
};
if (!ready) return null;
const PreviewIcon = badgeIcon(form.icon);
const isEdit = !!form.id;
return (
<div className="admin-page">
<div className="admin-page-head">
<div className="admin-page-head-row">
<div>
<h1></h1>
<p></p>
</div>
<Button onClick={openCreate}>
<Plus size={16} />
</Button>
</div>
</div>
<div className="admin-badge-stats" aria-label="徽章统计">
<div className="admin-badge-stat">
<strong>{counts.all}</strong>
<span></span>
</div>
<div className="admin-badge-stat">
<strong>{counts.auto}</strong>
<span></span>
</div>
<div className="admin-badge-stat">
<strong>{counts.limited}</strong>
<span></span>
</div>
<div className="admin-badge-stat">
<strong>{counts.disabled}</strong>
<span></span>
</div>
</div>
<div className="admin-badge-toolbar">
<div className="admin-tabs" role="tablist" aria-label="徽章类型">
{([
['all', '全部'],
['auto', '自动成就'],
['limited', '限定徽章'],
] as const).map(([key, label]) => (
<button
key={key}
type="button"
role="tab"
aria-selected={tab === key}
className={cn('admin-tab', tab === key && 'active')}
onClick={() => setTab(key)}
>
{label}
</button>
))}
</div>
<div className="admin-badge-search">
<Search size={15} aria-hidden />
<Input
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="搜索名称、代码、说明…"
aria-label="搜索徽章"
/>
</div>
</div>
{loading ? (
<div className="flex justify-center py-16"><Spinner size="lg" /></div>
) : filtered.length === 0 ? (
<div className="admin-badge-empty">
<Award size={36} strokeWidth={1.25} aria-hidden />
<h3>{query ? '没有匹配的徽章' : '还没有徽章'}</h3>
<p>
{query
? '试试其他关键词,或切换类型筛选'
: '创建自动成就(达条件发放)或限定徽章(站长颁发)'}
</p>
{!query && (
<Button onClick={openCreate}>
<Plus size={16} />
</Button>
)}
</div>
) : (
<div className="admin-badge-grid">
{filtered.map(b => {
const Icon = badgeIcon(b.icon);
return (
<article
key={b.id}
className={cn('admin-badge-card', !b.enabled && 'is-disabled')}
>
<div className="admin-badge-card-top">
<div className={cn('admin-badge-preview', b.kind === 'limited' && 'is-limited')}>
<Icon size={22} aria-hidden />
</div>
<div className="admin-badge-card-meta">
<div className="admin-badge-card-title-row">
<h3>{b.name}</h3>
{b.kind === 'auto'
? <Badge variant="secondary"></Badge>
: <Badge variant="orange"></Badge>}
{!b.enabled && <Badge variant="destructive"></Badge>}
</div>
<code className="admin-badge-code">{b.code}</code>
</div>
</div>
<p className="admin-badge-desc">
{b.description?.trim() || (b.kind === 'limited' ? '站长手动颁发的限定徽章' : '达成条件后自动获得')}
</p>
<div className="admin-badge-card-foot">
<span className="admin-badge-condition" title="获得条件">
{b.kind === 'auto' ? <Sparkles size={13} aria-hidden /> : <Trophy size={13} aria-hidden />}
{formatBadgeCondition(b)}
</span>
<div className="admin-badge-card-actions">
<label className="admin-badge-switch" title={b.enabled ? '点击停用' : '点击启用'}>
<span className="sr-only"></span>
<Switch
checked={b.enabled}
disabled={togglingId === b.id}
onCheckedChange={() => toggleEnabled(b)}
/>
</label>
<Button size="sm" variant="outline" onClick={() => openEdit(b)}>
<Pencil size={13} />
</Button>
</div>
</div>
</article>
);
})}
</div>
)}
<Dialog open={dialogOpen} onOpenChange={(open) => {
setDialogOpen(open);
if (!open) resetForm();
}}>
<DialogContent className="admin-badge-dialog sm:max-w-xl">
<DialogHeader>
<DialogTitle>{isEdit ? '编辑徽章' : '新建徽章'}</DialogTitle>
<DialogDescription>
{isEdit
? '修改后立即对展示生效;代码不可更改。'
: '自动成就按指标发放,限定徽章需在用户管理中手动颁发。'}
</DialogDescription>
</DialogHeader>
<div className="admin-badge-dialog-preview">
<div className={cn('admin-badge-preview admin-badge-preview--lg', form.kind === 'limited' && 'is-limited')}>
<PreviewIcon size={28} aria-hidden />
</div>
<div>
<div className="admin-badge-dialog-preview-name">{form.name?.trim() || '徽章名称'}</div>
<div className="admin-badge-dialog-preview- Cond">
{formatBadgeCondition({
kind: form.kind,
metric: form.metric,
threshold: form.threshold,
description: form.description,
})}
</div>
</div>
</div>
<div className="admin-badge-dialog-fields">
<div className="admin-badge-kind-seg" role="group" aria-label="徽章类型">
<button
type="button"
className={cn(form.kind !== 'limited' && 'active')}
onClick={() => setForm(f => ({ ...f, kind: 'auto', metric: f.metric || 'tenure_days' }))}
>
<Sparkles size={14} />
</button>
<button
type="button"
className={cn(form.kind === 'limited' && 'active')}
onClick={() => setForm(f => ({ ...f, kind: 'limited' }))}
>
<Trophy size={14} />
</button>
</div>
<div className="admin-badge-field-row">
<div className="admin-badge-field">
<Label htmlFor="badge-name"></Label>
<Input
id="badge-name"
value={form.name || ''}
onChange={e => {
const name = e.target.value;
setForm(f => ({
...f,
name,
code: isEdit ? f.code : (f.code?.trim() ? f.code : slugifyCode(name)),
}));
}}
placeholder="例如:资深居民"
autoFocus
/>
</div>
<div className="admin-badge-field">
<Label htmlFor="badge-code"></Label>
<Input
id="badge-code"
value={form.code || ''}
onChange={e => setForm(f => ({ ...f, code: e.target.value }))}
placeholder="tenure_365"
disabled={isEdit}
className="font-mono text-sm"
/>
</div>
</div>
<div className="admin-badge-field">
<Label htmlFor="badge-desc"></Label>
<Input
id="badge-desc"
value={form.description || ''}
onChange={e => setForm(f => ({ ...f, description: e.target.value }))}
placeholder="鼠标悬停时显示的获得条件"
/>
</div>
<div className="admin-badge-field">
<Label></Label>
<div className="admin-badge-icon-picker" role="listbox" aria-label="选择图标">
{BADGE_ICON_OPTIONS.map(opt => {
const active = (form.icon || 'star') === opt.key;
return (
<button
key={opt.key}
type="button"
role="option"
aria-selected={active}
title={opt.label}
className={cn('admin-badge-icon-opt', active && 'active')}
onClick={() => setForm(f => ({ ...f, icon: opt.key }))}
>
<opt.Icon size={18} aria-hidden />
</button>
);
})}
</div>
</div>
{form.kind === 'auto' && (
<div className="admin-badge-field-row">
<div className="admin-badge-field">
<Label htmlFor="badge-metric"></Label>
<select
id="badge-metric"
className="admin-select"
value={form.metric || 'tenure_days'}
onChange={e => setForm(f => ({ ...f, metric: e.target.value }))}
>
{BADGE_METRIC_OPTIONS.map(m => (
<option key={m.value} value={m.value}>{m.label}</option>
))}
</select>
<span className="admin-badge-field-hint">
{BADGE_METRIC_OPTIONS.find(m => m.value === (form.metric || 'tenure_days'))?.hint}
</span>
</div>
<div className="admin-badge-field">
<Label htmlFor="badge-threshold"></Label>
<Input
id="badge-threshold"
type="number"
min={0}
value={form.threshold ?? 0}
onChange={e => setForm(f => ({ ...f, threshold: Number(e.target.value) || 0 }))}
/>
</div>
</div>
)}
<div className="admin-badge-field-row admin-badge-field-row--end">
<div className="admin-badge-field">
<Label htmlFor="badge-sort"></Label>
<Input
id="badge-sort"
type="number"
value={form.sort_order ?? 100}
onChange={e => setForm(f => ({ ...f, sort_order: Number(e.target.value) || 0 }))}
/>
</div>
<label className="admin-badge-enable-row">
<Switch
checked={form.enabled !== false}
onCheckedChange={v => setForm(f => ({ ...f, enabled: v }))}
/>
<span></span>
</label>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDialogOpen(false)} disabled={saving}>
</Button>
<Button onClick={save} loading={saving}>
{isEdit ? '保存修改' : '创建徽章'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -1,10 +1,12 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { FileText, Flag, MessageSquare } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { Badge } from '@/components/ui/badge';
import { api } from '../../api/client';
import { useAdminGuard } from '../../layouts/AdminLayout';
import type { AdminDashboard } from '../../api/types';
import { cn } from '@/lib/utils';
export default function AdminDashboardPage() {
const nav = useNavigate();
@@ -24,28 +26,97 @@ export default function AdminDashboardPage() {
}
if (!data) return null;
const pendingPosts = data.pending_posts ?? 0;
const pendingComments = data.pending_comments ?? 0;
const pendingReports = data.pending_reports ?? 0;
const pendingTotal = pendingPosts + pendingComments + pendingReports;
const stats = [
{ label: '注册用户', value: data.users, cls: 'admin-stat-users' },
{ label: '帖子总数', value: data.posts, cls: 'admin-stat-posts' },
{ label: '板块数量', value: data.boards, cls: 'admin-stat-boards' },
{ label: '评论总数', value: data.comments, cls: 'admin-stat-comments' },
{ label: '注册用户', value: data.users },
{ label: '帖子总数', value: data.posts },
{ label: '板块数量', value: data.boards },
{ label: '评论总数', value: data.comments },
];
const queues = [
{
key: 'posts',
label: '待审帖子',
count: pendingPosts,
hint: '新帖与修改待审核',
to: '/admin/posts',
icon: FileText,
},
{
key: 'comments',
label: '待审评论',
count: pendingComments,
hint: '评论与回复待审核',
to: '/admin/comments',
icon: MessageSquare,
},
{
key: 'reports',
label: '待处理举报',
count: pendingReports,
hint: '用户举报需人工处理',
to: '/admin/reports',
icon: Flag,
},
];
return (
<div className="admin-page">
<div className="admin-page-head">
<h1></h1>
<p></p>
<p></p>
</div>
<div className="admin-stat-grid">
{stats.map(s => (
<div key={s.label} className={`admin-stat-card ${s.cls}`}>
<div className="admin-stat-value">{s.value}</div>
<div className="admin-stat-label">{s.label}</div>
</div>
))}
</div>
<section className="admin-queue-section" aria-label="待处理事项">
<div className="admin-section-label">
<span></span>
{pendingTotal > 0 ? (
<Badge variant="orange">{pendingTotal} </Badge>
) : (
<span className="admin-section-muted"></span>
)}
</div>
<div className="admin-queue-grid">
{queues.map(q => {
const Icon = q.icon;
const hasWork = q.count > 0;
return (
<button
key={q.key}
type="button"
className={cn('admin-queue-card', hasWork && 'has-work')}
onClick={() => nav(q.to)}
>
<div className="admin-queue-card-top">
<Icon size={18} aria-hidden />
<span className="admin-queue-count">{q.count}</span>
</div>
<div className="admin-queue-label">{q.label}</div>
<div className="admin-queue-hint">{q.hint}</div>
</button>
);
})}
</div>
</section>
<section className="admin-stat-section" aria-label="运行概览">
<div className="admin-section-label">
<span></span>
</div>
<div className="admin-stat-grid">
{stats.map(s => (
<div key={s.label} className="admin-stat-card">
<div className="admin-stat-value">{s.value}</div>
<div className="admin-stat-label">{s.label}</div>
</div>
))}
</div>
</section>
<div className="admin-card">
<div className="admin-card-head">

View File

@@ -112,8 +112,10 @@ export default function AdminReportsPage() {
return (
<div className="admin-page">
<h1 className="admin-page-title"></h1>
<p className="admin-page-desc"></p>
<div className="admin-page-head">
<h1></h1>
<p></p>
</div>
<div className="admin-tabs">
{tabs.map((t) => (

View File

@@ -1,124 +1,580 @@
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Award, Ban, BadgeCheck, MoreHorizontal, Search, Shield, UserCog,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { Spinner } from '@/components/ui/spinner';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { notify } from '@/lib/notify';
import { cn } from '@/lib/utils';
import { api } from '../../api/client';
import { useAdminGuard } from '../../layouts/AdminLayout';
import type { User } from '../../api/types';
import type { BadgeDef, User } from '../../api/types';
import { resolveUserLevel } from '../../utils/userMeta';
import { formatDateTime, formatTime } from '../../utils/content';
import { badgeIcon } from '../../utils/badgeIcons';
type FilterTab = 'all' | 'verified' | 'banned' | 'admin';
function fmtAbs(v?: string) {
if (!v) return '—';
return formatDateTime(v);
}
function fmtRel(v?: string) {
if (!v) return '—';
return formatTime(v);
}
function UserAvatar({ user }: { user: User }) {
const initial = (user.nickname || user.username || '?').slice(0, 1).toUpperCase();
if (user.avatar) {
return <img src={user.avatar} alt="" className="admin-user-avatar" loading="lazy" decoding="async" />;
}
return <span className="admin-user-avatar admin-user-avatar--fallback" aria-hidden>{initial}</span>;
}
/** 后台用户管理:成员目录式列表 + 详情弹窗 */
export default function AdminUsersPage() {
const nav = useNavigate();
const { ready } = useAdminGuard();
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const [totalPages, setTotalPages] = useState(1);
const [filter, setFilter] = useState<FilterTab>('all');
const [keyword, setKeyword] = useState('');
const [search, setSearch] = useState('');
const [limitedBadges, setLimitedBadges] = useState<BadgeDef[]>([]);
const load = (p = page) => {
const [manageUser, setManageUser] = useState<User | null>(null);
const [levelVal, setLevelVal] = useState(1);
const [pointsDelta, setPointsDelta] = useState('10');
const [pointsNote, setPointsNote] = useState('');
const [badgeId, setBadgeId] = useState<number | ''>('');
const [saving, setSaving] = useState(false);
const [banTarget, setBanTarget] = useState<User | null>(null);
const load = useCallback((p = 1, kw = search, f = filter) => {
setLoading(true);
api.adminUsers(p)
api.adminUsers(p, { keyword: kw, filter: f })
.then(d => {
setUsers(d.users ?? []);
setPage(d.page);
setTotal(d.total);
setTotalPages(d.total_pages);
})
.catch(e => notify.error(e.message))
.finally(() => setLoading(false));
};
}, [search, filter]);
useEffect(() => {
if (ready) load(1);
if (!ready) return;
load(1, search, filter);
}, [ready, filter]); // eslint-disable-line react-hooks/exhaustive-deps -- 鉴权与筛选变化时重载
useEffect(() => {
if (!ready) return;
api.adminListBadges()
.then(d => setLimitedBadges((d.badges ?? []).filter(b => b.kind === 'limited' && b.enabled)))
.catch(() => {});
}, [ready]);
const toggleBan = async (user: User) => {
if (user.role === 'admin') {
notify.warning('不能禁言管理员');
return;
const openManage = (user: User) => {
setManageUser(user);
setLevelVal(resolveUserLevel(user));
setPointsDelta('10');
setPointsNote('');
setBadgeId(limitedBadges[0]?.id ?? '');
};
const refreshManaged = async (patch?: Partial<User>) => {
if (manageUser && patch) {
setManageUser({ ...manageUser, ...patch });
}
load(page);
};
const toggleVerify = async (user: User) => {
try {
const r = await api.adminBanUser(user.id, !user.banned);
const r = await api.adminVerifyUser(user.id, !user.verified);
notify.success(r.message);
load();
if (manageUser?.id === user.id) {
setManageUser({ ...user, verified: r.verified });
}
load(page);
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '操作失败');
}
};
const confirmBan = async () => {
if (!banTarget) return;
try {
const r = await api.adminBanUser(banTarget.id, !banTarget.banned);
notify.success(r.message);
if (manageUser?.id === banTarget.id) {
setManageUser({ ...banTarget, banned: r.banned });
}
setBanTarget(null);
load(page);
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '操作失败');
}
};
const saveLevel = async () => {
if (!manageUser) return;
if (!Number.isInteger(levelVal) || levelVal < 1 || levelVal > 10) {
notify.warning('等级须为 110 的整数');
return;
}
setSaving(true);
try {
const r = await api.adminSetUserLevel(manageUser.id, levelVal);
notify.success(r.message);
await refreshManaged({ level: r.level, exp: r.exp });
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '操作失败');
} finally {
setSaving(false);
}
};
const savePoints = async () => {
if (!manageUser) return;
const delta = Number(pointsDelta);
if (!Number.isFinite(delta) || delta === 0) {
notify.warning('请输入非零数字(正加负减)');
return;
}
setSaving(true);
try {
const r = await api.adminAdjustPoints(manageUser.id, delta, pointsNote.trim() || undefined);
notify.success(`${r.message},余额 ${r.points}`);
setPointsDelta('10');
setPointsNote('');
await refreshManaged({ points: r.points });
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '操作失败');
} finally {
setSaving(false);
}
};
const saveBadge = async () => {
if (!manageUser) return;
if (!badgeId) {
notify.warning('请选择要颁发的限定徽章');
return;
}
setSaving(true);
try {
const r = await api.adminAwardBadge(manageUser.id, Number(badgeId), false);
notify.success(r.message);
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '操作失败');
} finally {
setSaving(false);
}
};
const onSearch = (e: React.FormEvent) => {
e.preventDefault();
const kw = keyword.trim();
setSearch(kw);
load(1, kw, filter);
};
const switchFilter = (f: FilterTab) => {
setFilter(f);
setPage(1);
};
if (!ready) return null;
const filters: { key: FilterTab; label: string }[] = [
{ key: 'all', label: '全部' },
{ key: 'verified', label: '已认证' },
{ key: 'banned', label: '已禁言' },
{ key: 'admin', label: '站长' },
];
return (
<div className="admin-page">
<div className="admin-page admin-users-page">
<div className="admin-page-head">
<h1></h1>
<p></p>
<p></p>
</div>
<div className="admin-card">
<div className="admin-users-panel">
<div className="admin-users-panel-head">
<form className="admin-users-search" onSubmit={onSearch}>
<div className="admin-users-search-field">
<Search size={16} aria-hidden className="admin-users-search-icon" />
<Input
value={keyword}
onChange={e => setKeyword(e.target.value)}
placeholder="搜索 ID、用户名、昵称或邮箱"
aria-label="搜索用户"
/>
</div>
<Button type="submit" size="sm"></Button>
{search ? (
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => {
setKeyword('');
setSearch('');
load(1, '', filter);
}}
>
</Button>
) : null}
</form>
<div className="admin-users-filters" role="tablist" aria-label="用户筛选">
{filters.map(f => (
<button
key={f.key}
type="button"
role="tab"
aria-selected={filter === f.key}
className={cn('admin-users-filter', filter === f.key && 'active')}
onClick={() => switchFilter(f.key)}
>
{f.label}
</button>
))}
</div>
</div>
{loading ? (
<div className="flex justify-center py-12"><Spinner size="lg" /></div>
<div className="flex justify-center py-16"><Spinner size="lg" /></div>
) : users.length === 0 ? (
<div className="admin-users-empty">
{search || filter !== 'all' ? '没有符合条件的用户' : '暂无用户'}
</div>
) : (
<>
<div className="admin-table-scroll">
<table className="admin-table">
<thead>
<tr>
<th>ID</th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th> IP</th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{users.map(u => (
<tr key={u.id}>
<td>{u.id}</td>
<td>{u.username}</td>
<td>
<button type="button" className="admin-text-link" onClick={() => nav(`/user/${u.id}`)}>
{u.nickname}
</button>
</td>
<td className="admin-table-email">{u.email || '—'}</td>
<td>
{u.role === 'admin'
? <Badge variant="orange"></Badge>
: <Badge variant="secondary"></Badge>}
</td>
<td>{u.banned ? <Badge variant="destructive"></Badge> : '正常'}</td>
<td>{u.last_login_at ? new Date(u.last_login_at).toLocaleString('zh-CN') : '—'}</td>
<td className="admin-table-mono">{u.last_login_ip || '—'}</td>
<td>{u.created_at ? new Date(u.created_at).toLocaleString('zh-CN') : '—'}</td>
<td>
{u.role !== 'admin' && (
<Button size="sm" variant="outline" onClick={() => toggleBan(u)}>
{u.banned ? '解除禁言' : '禁言'}
</Button>
)}
</td>
</tr>
))}
</tbody>
</table>
<div className="admin-users-cols" aria-hidden>
<span className="admin-users-cols-who"></span>
<span></span>
<span></span>
<span></span>
<span className="admin-users-cols-action" />
</div>
<ul className="admin-users-list" aria-label="用户列表">
{users.map(u => (
<li
key={u.id}
className={cn('admin-users-row', u.banned && 'admin-users-row--banned')}
>
<div className="admin-users-who">
<UserAvatar user={u} />
<div className="admin-users-who-text">
<div className="admin-users-who-line">
<button
type="button"
className="admin-user-nick"
onClick={() => nav(`/user/${u.id}`)}
>
{u.nickname}
</button>
{u.role === 'admin' && <Badge variant="orange"></Badge>}
{u.role !== 'admin' && u.verified && <Badge variant="green"></Badge>}
{u.banned && <Badge variant="destructive"></Badge>}
</div>
<div className="admin-users-handle">@{u.username}</div>
{u.email?.trim() ? (
<div className="admin-users-mail" title={u.email}>{u.email}</div>
) : null}
</div>
</div>
<div className="admin-users-metric" data-label="等级">
<span className="admin-users-metric-value">Lv.{resolveUserLevel(u)}</span>
</div>
<div className="admin-users-metric" data-label="积分">
<span className="admin-users-metric-value">{u.points ?? 0}</span>
</div>
<div
className="admin-users-metric admin-users-metric--time"
data-label="最近登录"
title={fmtAbs(u.last_login_at)}
>
<span className="admin-users-metric-value">{fmtRel(u.last_login_at)}</span>
</div>
<div className="admin-users-action">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="sm"
variant="ghost"
className="admin-users-manage-btn"
aria-label={`管理 ${u.nickname}`}
>
<MoreHorizontal size={15} aria-hidden />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44">
<DropdownMenuItem onClick={() => openManage(u)}>
<UserCog size={14} aria-hidden />
</DropdownMenuItem>
<DropdownMenuItem onClick={() => nav(`/user/${u.id}`)}>
</DropdownMenuItem>
{u.role !== 'admin' && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => toggleVerify(u)}>
<BadgeCheck size={14} aria-hidden />
{u.verified ? '取消认证' : '设为认证'}
</DropdownMenuItem>
<DropdownMenuItem
className={u.banned ? undefined : 'text-destructive focus:text-destructive'}
onClick={() => setBanTarget(u)}
>
<Ban size={14} aria-hidden />
{u.banned ? '解除禁言' : '禁言'}
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
</li>
))}
</ul>
<div className="admin-users-footer">
<span className="admin-users-total">{total} </span>
{totalPages > 1 && (
<div className="admin-users-pager">
<Button size="sm" variant="outline" disabled={page <= 1} onClick={() => load(page - 1)}>
</Button>
<span>{page} / {totalPages}</span>
<Button size="sm" variant="outline" disabled={page >= totalPages} onClick={() => load(page + 1)}>
</Button>
</div>
)}
</div>
{users.length === 0 && <div className="admin-empty"></div>}
{totalPages > 1 && (
<div className="admin-pagination">
<Button size="sm" variant="outline" disabled={page <= 1} onClick={() => load(page - 1)}></Button>
<span> {page} / {totalPages} </span>
<Button size="sm" variant="outline" disabled={page >= totalPages} onClick={() => load(page + 1)}></Button>
</div>
)}
</>
)}
</div>
<Dialog open={!!manageUser} onOpenChange={open => { if (!open) setManageUser(null); }}>
<DialogContent className="admin-user-manage-dialog sm:max-w-md">
{manageUser && (
<>
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
@{manageUser.username} · #{manageUser.id}
</DialogDescription>
</DialogHeader>
<div className="admin-user-manage-head">
<UserAvatar user={manageUser} />
<div>
<div className="admin-user-manage-name">{manageUser.nickname}</div>
<div className="admin-user-email">{manageUser.email || '未填写邮箱'}</div>
<div className="admin-user-badges mt-1.5">
{manageUser.role === 'admin' && <Badge variant="orange"></Badge>}
{manageUser.role !== 'admin' && manageUser.verified && <Badge variant="green"></Badge>}
{manageUser.banned && <Badge variant="destructive"></Badge>}
{manageUser.role !== 'admin' && !manageUser.verified && !manageUser.banned && (
<Badge variant="secondary"></Badge>
)}
</div>
</div>
</div>
<div className="admin-user-manage-section">
<div className="admin-user-manage-section-title"></div>
<dl className="admin-user-fact-grid">
<div>
<dt></dt>
<dd title={fmtAbs(manageUser.last_login_at)}>{fmtRel(manageUser.last_login_at)}</dd>
</div>
<div>
<dt> IP</dt>
<dd className="admin-table-mono">{manageUser.last_login_ip || '—'}</dd>
</div>
<div>
<dt>访</dt>
<dd title={fmtAbs(manageUser.last_access_at)}>{fmtRel(manageUser.last_access_at)}</dd>
</div>
<div>
<dt></dt>
<dd title={fmtAbs(manageUser.created_at)}>{fmtRel(manageUser.created_at)}</dd>
</div>
</dl>
</div>
{manageUser.role !== 'admin' && (
<div className="admin-user-manage-section">
<div className="admin-user-manage-section-title">
<Shield size={14} aria-hidden />
</div>
<div className="admin-user-manage-actions">
<Button size="sm" variant="outline" onClick={() => toggleVerify(manageUser)}>
<BadgeCheck size={14} aria-hidden />
{manageUser.verified ? '取消认证' : '设为认证'}
</Button>
<Button
size="sm"
variant={manageUser.banned ? 'outline' : 'destructive'}
onClick={() => setBanTarget(manageUser)}
>
<Ban size={14} aria-hidden />
{manageUser.banned ? '解除禁言' : '禁言'}
</Button>
</div>
</div>
)}
<div className="admin-user-manage-section">
<div className="admin-user-manage-section-title">
· Lv.{resolveUserLevel(manageUser)}
</div>
<div className="admin-user-manage-row">
<Input
type="number"
min={1}
max={10}
value={levelVal}
onChange={e => setLevelVal(Number(e.target.value))}
aria-label="等级"
/>
<Button size="sm" loading={saving} onClick={saveLevel}></Button>
</div>
<p className="admin-user-manage-hint"> {manageUser.exp ?? 0} Exp</p>
</div>
<div className="admin-user-manage-section">
<div className="admin-user-manage-section-title">
· {manageUser.points ?? 0}
</div>
<div className="admin-user-manage-row">
<Input
type="number"
value={pointsDelta}
onChange={e => setPointsDelta(e.target.value)}
placeholder="正加负减"
aria-label="积分变动"
/>
<Button size="sm" loading={saving} onClick={savePoints}></Button>
</div>
<Input
className="mt-2"
value={pointsNote}
onChange={e => setPointsNote(e.target.value)}
placeholder="备注(可选)"
aria-label="积分备注"
/>
</div>
<div className="admin-user-manage-section">
<div className="admin-user-manage-section-title">
<Award size={14} aria-hidden />
</div>
{limitedBadges.length === 0 ? (
<p className="admin-user-manage-hint"></p>
) : (
<div className="admin-user-manage-row">
<select
className="admin-user-select"
value={badgeId}
onChange={e => setBadgeId(e.target.value ? Number(e.target.value) : '')}
aria-label="选择徽章"
>
{limitedBadges.map(b => (
<option key={b.id} value={b.id}>
{b.name}{b.code}
</option>
))}
</select>
<Button size="sm" loading={saving} onClick={saveBadge}></Button>
</div>
)}
{badgeId !== '' && limitedBadges.find(b => b.id === badgeId) && (
<div className="admin-user-badge-preview">
{(() => {
const b = limitedBadges.find(x => x.id === badgeId)!;
const Icon = badgeIcon(b.icon);
return (
<>
<Icon size={16} aria-hidden />
<span>{b.name}</span>
<span className="admin-user-manage-hint">{b.description || b.code}</span>
</>
);
})()}
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setManageUser(null)}></Button>
</DialogFooter>
</>
)}
</DialogContent>
</Dialog>
<AlertDialog open={!!banTarget} onOpenChange={open => { if (!open) setBanTarget(null); }}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{banTarget?.banned ? '解除禁言' : '确认禁言'}
</AlertDialogTitle>
<AlertDialogDescription>
{banTarget?.banned
? `确定解除对 ${banTarget?.nickname} 的禁言吗?`
: `确定禁言 ${banTarget?.nickname}?被禁言用户将无法发帖与评论。`}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={confirmBan}>
{banTarget?.banned ? '解除禁言' : '确认禁言'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}