"use client"; import { useCallback, useEffect, useMemo, useState } from "react"; import { Pencil, Plus, Trash2, Users } from "lucide-react"; import Avatar from "@/components/Avatar"; import BadgeChip from "@/components/BadgeChip"; import ConfirmDialog from "@/components/ConfirmDialog"; import Modal from "@/components/Modal"; import { BADGE_ICONS } from "@/lib/badgeIcons"; import { toast } from "@/lib/toast"; import { apiAdminBadgeHolders, apiAdminCreateBadge, apiAdminDeleteBadge, apiAdminGrantBadge, apiAdminListBadges, apiAdminListUsers, apiAdminRevokeBadge, apiAdminUpdateBadge, type BadgeHolder, type BadgeInput, type BadgeWithHolders, } from "@/lib/api"; /** 与后端 model.BadgeColors 同步的色板(CSS token 名) */ const BADGE_COLORS = ["gold", "accent", "ok", "danger", "info", "purple", "teal"] as const; const MAX_NAME_RUNES = 16; const runeCount = (s: string) => [...s].length; export default function BadgesClient() { const [badges, setBadges] = useState([]); const [loading, setLoading] = useState(true); // 编辑/新建表单 const [editing, setEditing] = useState(null); const [creating, setCreating] = useState(false); const [form, setForm] = useState({ name: "", icon: "medal", color: "accent" }); const [saving, setSaving] = useState(false); // 删除确认 const [deleting, setDeleting] = useState(null); const [deletingBusy, setDeletingBusy] = useState(false); // 持有者面板 const [holdersFor, setHoldersFor] = useState(null); const [holders, setHolders] = useState([]); const [holdersLoading, setHoldersLoading] = useState(false); const [userQuery, setUserQuery] = useState(""); const [searchResults, setSearchResults] = useState<{ id: number; nickname: string; username: string; avatar: string }[]>([]); const [searching, setSearching] = useState(false); const [grantingId, setGrantingId] = useState(null); const reload = useCallback(async () => { try { const res = await apiAdminListBadges(); setBadges(res.badges ?? []); } catch (e) { toast(e instanceof Error ? e.message : "获取徽章列表失败"); } finally { setLoading(false); } }, []); useEffect(() => { void reload(); }, [reload]); const openCreate = () => { setForm({ name: "", icon: "medal", color: "accent" }); setEditing(null); setCreating(true); }; const openEdit = (b: BadgeWithHolders) => { setForm({ name: b.name, icon: b.icon || "medal", color: b.color || "accent" }); setCreating(false); setEditing(b); }; const closeForm = () => { setCreating(false); setEditing(null); }; const saveForm = async () => { if (saving) return; if (runeCount(form.name.trim()) < 1 || runeCount(form.name.trim()) > MAX_NAME_RUNES) { toast(`名称需 1–${MAX_NAME_RUNES} 字`); return; } setSaving(true); try { if (editing) { await apiAdminUpdateBadge(editing.id, form); toast("徽章已更新", "ok"); } else { await apiAdminCreateBadge(form); toast("徽章已创建", "ok"); } closeForm(); await reload(); } catch (e) { toast(e instanceof Error ? e.message : "保存失败"); } finally { setSaving(false); } }; const doDelete = async () => { if (!deleting || deletingBusy) return; setDeletingBusy(true); try { await apiAdminDeleteBadge(deleting.id); toast("徽章已删除", "ok"); setDeleting(null); if (holdersFor?.id === deleting.id) setHoldersFor(null); await reload(); } catch (e) { toast(e instanceof Error ? e.message : "删除失败"); } finally { setDeletingBusy(false); } }; const openHolders = async (b: BadgeWithHolders) => { setHoldersFor(b); setUserQuery(""); setSearchResults([]); setHoldersLoading(true); try { const res = await apiAdminBadgeHolders(b.id); setHolders(res.holders ?? []); } catch (e) { toast(e instanceof Error ? e.message : "获取持有者失败"); setHolders([]); } finally { setHoldersLoading(false); } }; const searchUsers = async () => { const q = userQuery.trim(); if (!q || searching) return; setSearching(true); try { const res = await apiAdminListUsers({ q, size: 8 }); setSearchResults( (res.users ?? []).map((u) => ({ id: u.id, nickname: u.nickname, username: u.username, avatar: u.avatar, })), ); } catch (e) { toast(e instanceof Error ? e.message : "搜索用户失败"); } finally { setSearching(false); } }; const grant = async (userId: number) => { if (!holdersFor || grantingId != null) return; setGrantingId(userId); try { await apiAdminGrantBadge(holdersFor.id, userId); toast("已授予", "ok"); await openHolders(holdersFor); await reload(); } catch (e) { toast(e instanceof Error ? e.message : "授予失败"); } finally { setGrantingId(null); } }; const revoke = async (userId: number) => { if (!holdersFor) return; try { await apiAdminRevokeBadge(holdersFor.id, userId); toast("已撤销", "ok"); await openHolders(holdersFor); await reload(); } catch (e) { toast(e instanceof Error ? e.message : "撤销失败"); } }; const holdersHeldIds = useMemo(() => new Set(holders.map((h) => h.user_id)), [holders]); return (

徽章管理

管理员自定义徽章:全站作者处展示前 2 枚,个人主页展示徽章墙;授予后用户会收到站内通知。

{loading ? (

加载徽章列表…

) : badges.length === 0 ? (

还没有徽章,点击右上角“新建徽章”开始。

) : (
{badges.map((b, i) => (
{b.icon || "medal"} · {b.color || "accent"} · {b.holders} 人持有
))}
)} {/* 持有者面板 */} setHoldersFor(null)} maxWidthClass="max-w-lg" >
setUserQuery(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") void searchUsers(); }} placeholder="输入用户名或昵称搜索" className="flex-1 min-w-0" />
{searchResults.length > 0 && (

搜索结果

{searchResults.map((u) => (
{u.nickname || u.username} @{u.username} {holdersHeldIds.has(u.id) ? ( 已持有 ) : ( )}
))}
)}

当前持有者({holders.length})

{holdersLoading ? (

加载持有者…

) : holders.length === 0 ? (

暂无人持有。

) : (
{holders.map((h) => (
{h.nickname || h.username} @{h.username} {new Date(h.awarded_at).toLocaleDateString("zh-CN")} 授予
))}
)}
{/* 新建/编辑表单 */}
setForm((f) => ({ ...f, name: e.target.value }))} maxLength={MAX_NAME_RUNES * 2} placeholder="如:元老、技术之星" />
{Object.keys(BADGE_ICONS).map((key) => { const Icon = BADGE_ICONS[key]; const active = form.icon === key; return ( ); })}
{BADGE_COLORS.map((c) => { const active = form.color === c; return ( ); })}

预览

{/* 删除确认 */} setDeleting(null)} onConfirm={() => void doDelete()} />
); }