"use client"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Award, ChevronRight, Plus, Trash2, Users } from "lucide-react"; import Avatar from "@/components/Avatar"; import BadgeChip from "@/components/BadgeChip"; import ConfirmDialog from "@/components/ConfirmDialog"; import AdminEmpty from "@/components/admin/AdminEmpty"; import AdminPageHeader from "@/components/admin/AdminPageHeader"; 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 [selectedId, setSelectedId] = 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 [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 detailRef = useRef(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 selected = useMemo( () => badges.find((b) => b.id === selectedId) ?? null, [badges, selectedId], ); const openCreate = () => { setForm({ name: "", icon: "medal", color: "accent" }); setCreating(true); setSelectedId(null); }; const selectBadge = (b: BadgeWithHolders, scroll = true) => { setCreating(false); setSelectedId(b.id); // 窄屏单列时,点选后滚动到右侧详情 if (scroll && window.matchMedia("(max-width: 1023px)").matches) { detailRef.current?.scrollIntoView({ behavior: "smooth", block: "start" }); } }; // 首次加载完成后自动选中第一个徽章(删除刷新期间跳过,避免选中已删除项) useEffect(() => { if (loading || creating || deletingBusy || selectedId != null) return; if (badges.length > 0) selectBadge(badges[0], false); // eslint-disable-next-line react-hooks/exhaustive-deps }, [loading, badges, creating, deletingBusy, selectedId]); // 选中项被删除时回落到第一个(或清空) useEffect(() => { if (creating || loading || selectedId == null) return; if (!badges.some((b) => b.id === selectedId)) { setSelectedId(badges.length > 0 ? badges[0].id : null); } }, [badges, selectedId, creating, loading]); // 选中变化:同步表单草稿并重置搜索(badges 刻意不作为依赖,避免授予/撤销刷新时清掉搜索结果与未保存草稿) useEffect(() => { if (creating || selectedId == null) return; const sel = badges.find((b) => b.id === selectedId); if (!sel) return; setForm({ name: sel.name, icon: sel.icon || "medal", color: sel.color || "accent" }); setUserQuery(""); setSearchResults([]); // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedId, creating]); // 持有者加载:选中变化或徽章列表刷新(授予/撤销后)重新拉取 useEffect(() => { if (creating || selectedId == null) { setHolders([]); setHoldersLoading(false); return; } let alive = true; setHoldersLoading(true); apiAdminBadgeHolders(selectedId) .then((res) => { if (alive) setHolders(res.holders ?? []); }) .catch((e) => { if (alive) { toast(e instanceof Error ? e.message : "获取持有者失败"); setHolders([]); } }) .finally(() => { if (alive) setHoldersLoading(false); }); return () => { alive = false; }; }, [selectedId, creating, badges]); const formDirty = creating || (selected != null && (form.name !== selected.name || form.icon !== (selected.icon || "medal") || form.color !== (selected.color || "accent"))); 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 (creating) { const created = await apiAdminCreateBadge(form); toast("徽章已创建", "ok"); setCreating(false); setSelectedId(created.id); } else if (selected) { await apiAdminUpdateBadge(selected.id, form); toast("徽章已更新", "ok"); } 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 (selectedId === deleting.id) setSelectedId(null); await reload(); } catch (e) { toast(e instanceof Error ? e.message : "删除失败"); } finally { setDeletingBusy(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 (!selected || grantingId != null) return; setGrantingId(userId); try { await apiAdminGrantBadge(selected.id, userId); toast("已授予", "ok"); await reload(); } catch (e) { toast(e instanceof Error ? e.message : "授予失败"); } finally { setGrantingId(null); } }; const revoke = async (userId: number) => { if (!selected) return; try { await apiAdminRevokeBadge(selected.id, userId); toast("已撤销", "ok"); await reload(); } catch (e) { toast(e instanceof Error ? e.message : "撤销失败"); } }; // 授予/撤销后 reload 刷新持有数;这里再同步持有者列表 useEffect(() => { if (selectedId == null || creating) return; setHoldersLoading(true); apiAdminBadgeHolders(selectedId) .then((res) => setHolders(res.holders ?? [])) .catch(() => setHolders([])) .finally(() => setHoldersLoading(false)); }, [badges, selectedId, creating]); const holdersHeldIds = useMemo(() => new Set(holders.map((h) => h.user_id)), [holders]); const nameOk = runeCount(form.name.trim()) >= 1 && runeCount(form.name.trim()) <= MAX_NAME_RUNES; return (
新建徽章 } />
{/* 左半屏:徽章列表 */}

全部徽章

{!loading && badges.length > 0 && ( {badges.length} 枚 )}
{loading ? (

加载徽章列表…

) : badges.length === 0 ? (

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

) : (
    {badges.map((b, i) => { const active = !creating && b.id === selectedId; return (
  • ); })}
)}
{/* 右半屏:编辑表单 + 持有者 */}
{creating || selected ? ( <> {/* 编辑/新建表单 */}

{creating ? "新建徽章" : "编辑徽章"}

{!creating && selected && ( )}
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 ( ); })}

预览

{creating ? ( ) : ( )}
{/* 持有者管理 */} {!creating && selected && (

持有者 {holders.length} 人

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")} 授予
))}
)}
)} ) : (
} title={loading ? "加载中…" : "从左侧选择一个徽章"} description={loading ? undefined : "选中后可在此编辑名称、图标与颜色,管理持有者"} />
)}
{/* 删除确认 */} setDeleting(null)} onConfirm={() => void doDelete()} />
); }