Files
jiang13-forum/frontend/src/pages/admin/AdminBadgesPage.tsx

525 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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';
import AdminSortableList, { SortableDragHandle, SortableMoveButtons } from '../../components/admin/AdminSortableList';
import { mergeReorderedSubset, persistSortOrderChanges, shouldShowSortableMoveButtons } from '../../utils/sortOrder';
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 [reordering, setReordering] = useState(false);
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);
}
};
const handleBadgeReorder = async (reorderedFiltered: BadgeDef[]) => {
const subsetBefore = [...filtered];
const before = [...rows];
setReordering(true);
try {
const merged = mergeReorderedSubset(before, subsetBefore, reorderedFiltered);
const after = await persistSortOrderChanges(before, merged, badge =>
api.adminUpsertBadge({ ...badge, sort_order: badge.sort_order }),
);
setRows(after);
notify.success('徽章排序已更新');
} catch (e: unknown) {
setRows(before);
notify.error(e instanceof Error ? e.message : '排序保存失败');
} finally {
setReordering(false);
}
};
const showMoveButtons = shouldShowSortableMoveButtons(filtered.length);
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>
) : (
<AdminSortableList
strategy="grid"
className="admin-badge-grid"
items={filtered}
getId={b => b.id}
onReorder={handleBadgeReorder}
showMoveButtons="auto"
ariaLabel="徽章列表排序"
renderItem={(b, _index, controls) => {
const Icon = badgeIcon(b.icon);
return (
<article
ref={controls.setNodeRef}
style={controls.style}
className={cn(
'admin-badge-card',
'admin-sortable-grid-item',
!b.enabled && 'is-disabled',
controls.isDragging && 'is-dragging',
)}
>
<div className="admin-sortable-grid-item__handle-wrap">
<SortableDragHandle label={`拖拽调整「${b.name}」顺序`} {...controls.dragHandleProps} />
</div>
<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">
{showMoveButtons && <SortableMoveButtons controls={controls} />}
<label className="admin-badge-switch" title={b.enabled ? '点击停用' : '点击启用'}>
<span className="sr-only">启用</span>
<Switch
checked={b.enabled}
disabled={togglingId === b.id || reordering}
onCheckedChange={() => toggleEnabled(b)}
/>
</label>
<Button size="sm" variant="outline" onClick={() => openEdit(b)} disabled={reordering}>
<Pencil size={13} />
编辑
</Button>
</div>
</div>
</article>
);
}}
/>
)}
<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>
);
}