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 = { 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([]); const [loading, setLoading] = useState(true); const [tab, setTab] = useState('all'); const [query, setQuery] = useState(''); const [dialogOpen, setDialogOpen] = useState(false); const [form, setForm] = useState>({ ...EMPTY }); const [saving, setSaving] = useState(false); const [togglingId, setTogglingId] = useState(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 (

徽章管理

设计自动成就与限定徽章;限定徽章在「用户管理」中颁发给用户

{counts.all} 全部
{counts.auto} 自动成就
{counts.limited} 限定徽章
{counts.disabled} 已停用
{([ ['all', '全部'], ['auto', '自动成就'], ['limited', '限定徽章'], ] as const).map(([key, label]) => ( ))}
setQuery(e.target.value)} placeholder="搜索名称、代码、说明…" aria-label="搜索徽章" />
{loading ? (
) : filtered.length === 0 ? (

{query ? '没有匹配的徽章' : '还没有徽章'}

{query ? '试试其他关键词,或切换类型筛选' : '创建自动成就(达条件发放)或限定徽章(站长颁发)'}

{!query && ( )}
) : ( b.id} onReorder={handleBadgeReorder} showMoveButtons="auto" ariaLabel="徽章列表排序" renderItem={(b, _index, controls) => { const Icon = badgeIcon(b.icon); return (

{b.name}

{b.kind === 'auto' ? 自动 : 限定} {!b.enabled && 停用}
{b.code}

{b.description?.trim() || (b.kind === 'limited' ? '站长手动颁发的限定徽章' : '达成条件后自动获得')}

{b.kind === 'auto' ? : } {formatBadgeCondition(b)}
{showMoveButtons && }
); }} /> )} { setDialogOpen(open); if (!open) resetForm(); }}> {isEdit ? '编辑徽章' : '新建徽章'} {isEdit ? '修改后立即对展示生效;代码不可更改。' : '自动成就按指标发放,限定徽章需在用户管理中手动颁发。'}
{form.name?.trim() || '徽章名称'}
{formatBadgeCondition({ kind: form.kind, metric: form.metric, threshold: form.threshold, description: form.description, })}
{ const name = e.target.value; setForm(f => ({ ...f, name, code: isEdit ? f.code : (f.code?.trim() ? f.code : slugifyCode(name)), })); }} placeholder="例如:资深居民" autoFocus />
setForm(f => ({ ...f, code: e.target.value }))} placeholder="tenure_365" disabled={isEdit} className="font-mono text-sm" />
setForm(f => ({ ...f, description: e.target.value }))} placeholder="鼠标悬停时显示的获得条件" />
{BADGE_ICON_OPTIONS.map(opt => { const active = (form.icon || 'star') === opt.key; return ( ); })}
{form.kind === 'auto' && (
{BADGE_METRIC_OPTIONS.find(m => m.value === (form.metric || 'tenure_days'))?.hint}
setForm(f => ({ ...f, threshold: Number(e.target.value) || 0 }))} />
)}
setForm(f => ({ ...f, sort_order: Number(e.target.value) || 0 }))} />
); }