Files
jiang13-bbs/frontend/app/admin/badges/BadgesClient.tsx
freefire 1d862a0dd3 feat: 站点媒体库与移动端底部导航,服务测试补强
- 新增媒体库:media_library/media_thumbs 服务(WebP 缩略图)、管理端 media 页面
- 移动端底部导航 MobileTabBar 替换 MobilePostBar
- 旧数据导入增强与测试、路由与登录会话/板块侧边栏测试补强
- site-doc 组件精简(移除 Breadcrumb),文档新增迁移公告说明
2026-09-27 02:36:11 +08:00

572 lines
23 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.
"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<BadgeWithHolders[]>([]);
const [loading, setLoading] = useState(true);
// 主从布局:左侧列表选中项 / 右侧详情(编辑表单 + 持有者)
const [selectedId, setSelectedId] = useState<number | null>(null);
const [creating, setCreating] = useState(false);
const [form, setForm] = useState<BadgeInput>({ name: "", icon: "medal", color: "accent" });
const [saving, setSaving] = useState(false);
// 删除确认
const [deleting, setDeleting] = useState<BadgeWithHolders | null>(null);
const [deletingBusy, setDeletingBusy] = useState(false);
// 持有者面板
const [holders, setHolders] = useState<BadgeHolder[]>([]);
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<number | null>(null);
const detailRef = useRef<HTMLDivElement>(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 (
<div className="admin-settings-stack">
<AdminPageHeader
icon={Award}
title="徽章管理"
description="管理员自定义徽章:全站作者处展示前 2 枚,个人主页展示徽章墙;授予后用户会收到站内通知。"
actions={
<button type="button" className="btn btn-primary btn-sm" onClick={openCreate}>
<Plus size={14} /> 新建徽章
</button>
}
/>
<div className="grid gap-5 items-start lg:grid-cols-2">
{/* 左半屏:徽章列表 */}
<section className="panel p-0 overflow-hidden" aria-label="徽章列表">
<div
className="flex items-center justify-between gap-2 px-4 py-3"
style={{ borderBottom: "1px solid var(--line)" }}
>
<p className="text-[13.5px] font-semibold" style={{ color: "var(--ink)" }}>
全部徽章
</p>
{!loading && badges.length > 0 && (
<span className="meta text-xs tabular-nums">{badges.length} 枚</span>
)}
</div>
{loading ? (
<p className="meta px-4 py-6">加载徽章列表…</p>
) : badges.length === 0 ? (
<div className="px-4 py-12 text-center">
<p className="meta">还没有徽章,点击右上角“新建徽章”开始。</p>
</div>
) : (
<ul>
{badges.map((b, i) => {
const active = !creating && b.id === selectedId;
return (
<li key={b.id}>
<button
type="button"
onClick={() => selectBadge(b)}
aria-current={active ? "true" : undefined}
className="w-full flex items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-[var(--panel-2)] focus-visible:bg-[var(--panel-2)]"
style={{
borderTop: i === 0 ? "none" : "1px solid var(--line)",
background: active ? "var(--accent-soft)" : undefined,
}}
>
<BadgeChip badge={b} />
<span className="meta text-xs">{b.holders} 人持有</span>
<span className="flex-1" />
<ChevronRight
size={14}
className="shrink-0 transition-transform duration-200"
style={{
color: active ? "var(--accent)" : "var(--ink-3)",
transform: active ? "translateX(2px)" : "none",
}}
aria-hidden
/>
</button>
</li>
);
})}
</ul>
)}
</section>
{/* 右半屏:编辑表单 + 持有者 */}
<div ref={detailRef} className="flex flex-col gap-5 min-w-0">
{creating || selected ? (
<>
{/* 编辑/新建表单 */}
<section className="panel overflow-hidden">
<div
className="flex items-center justify-between gap-3 px-4 py-3.5 sm:px-5"
style={{ borderBottom: "1px solid var(--line)" }}
>
<p className="text-[15px] font-semibold" style={{ color: "var(--ink)" }}>
{creating ? "新建徽章" : "编辑徽章"}
</p>
{!creating && selected && (
<button
type="button"
className="btn btn-line btn-sm"
style={{ color: "var(--danger)" }}
onClick={() => setDeleting(selected)}
>
<Trash2 size={13} /> 删除
</button>
)}
</div>
<div className="flex flex-col gap-4 px-4 py-4 sm:px-5">
<div>
<label className="text-sm font-medium block mb-1.5" style={{ color: "var(--ink)" }}>
名称(1–{MAX_NAME_RUNES} 字)
</label>
<input
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
maxLength={MAX_NAME_RUNES * 2}
placeholder="如:元老、技术之星"
/>
</div>
<div>
<label className="text-sm font-medium block mb-1.5" style={{ color: "var(--ink)" }}>
图标
</label>
<div className="grid grid-cols-8 gap-1.5">
{Object.keys(BADGE_ICONS).map((key) => {
const Icon = BADGE_ICONS[key];
const active = form.icon === key;
return (
<button
key={key}
type="button"
aria-label={key}
title={key}
onClick={() => setForm((f) => ({ ...f, icon: key }))}
className="h-9 rounded-lg inline-flex items-center justify-center transition-colors"
style={{
background: active ? "var(--accent-soft)" : "var(--panel-2)",
color: active ? "var(--accent)" : "var(--ink-2)",
outline: active ? "1.5px solid var(--accent)" : "none",
}}
>
<Icon size={16} />
</button>
);
})}
</div>
</div>
<div>
<label className="text-sm font-medium block mb-1.5" style={{ color: "var(--ink)" }}>
颜色
</label>
<div className="flex gap-2 flex-wrap">
{BADGE_COLORS.map((c) => {
const active = form.color === c;
return (
<button
key={c}
type="button"
aria-label={c}
title={c}
onClick={() => setForm((f) => ({ ...f, color: c }))}
className="px-3 h-8 rounded-full text-xs font-medium transition-colors"
style={{
background: `var(--${c}-soft)`,
color: `var(--${c})`,
outline: active ? "1.5px solid var(--ink)" : "1.5px solid transparent",
}}
>
{c}
</button>
);
})}
</div>
</div>
<div>
<p className="meta text-xs mb-1.5">预览</p>
<BadgeChip badge={{ name: form.name.trim() || "徽章名称", icon: form.icon, color: form.color }} />
</div>
</div>
<div
className="flex justify-end gap-2 px-4 py-3 sm:px-5"
style={{ borderTop: "1px solid var(--line)" }}
>
{creating ? (
<button type="button" className="btn btn-line btn-sm" onClick={() => setCreating(false)}>
取消
</button>
) : (
<button
type="button"
className="btn btn-line btn-sm"
disabled={!formDirty || saving}
onClick={() => selected && setForm({ name: selected.name, icon: selected.icon || "medal", color: selected.color || "accent" })}
>
重置
</button>
)}
<button
type="button"
className="btn btn-primary btn-sm"
disabled={saving || !nameOk || (!creating && !formDirty)}
onClick={() => void saveForm()}
>
{saving ? "保存中…" : creating ? "创建" : "保存修改"}
</button>
</div>
</section>
{/* 持有者管理 */}
{!creating && selected && (
<section className="panel overflow-hidden">
<div
className="flex items-center gap-2.5 px-4 py-3.5 sm:px-5"
style={{ borderBottom: "1px solid var(--line)" }}
>
<span
className="w-8 h-8 rounded-lg flex items-center justify-center shrink-0"
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
aria-hidden
>
<Users size={16} />
</span>
<p className="text-[15px] font-semibold" style={{ color: "var(--ink)" }}>
持有者
<span className="meta ml-2 text-[12px] font-normal tabular-nums">{holders.length} 人</span>
</p>
</div>
<div className="px-4 py-4 sm:px-5">
<div className="flex gap-2 mb-4">
<input
value={userQuery}
onChange={(e) => setUserQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void searchUsers();
}}
placeholder="输入用户名或昵称搜索"
className="flex-1 min-w-0"
/>
<button
type="button"
className="btn btn-line btn-sm shrink-0"
disabled={searching || !userQuery.trim()}
onClick={() => void searchUsers()}
>
{searching ? "搜索中…" : "搜索"}
</button>
</div>
{searchResults.length > 0 && (
<div className="mb-4">
<p className="meta text-xs mb-1.5">搜索结果</p>
<div className="flex flex-col gap-1">
{searchResults.map((u) => (
<div key={u.id} className="flex items-center gap-2.5 px-2 py-1.5 rounded-lg" style={{ background: "var(--panel-2)" }}>
<Avatar name={u.nickname || u.username} src={u.avatar || undefined} size={26} />
<span className="text-sm truncate" style={{ color: "var(--ink)" }}>
{u.nickname || u.username}
<span className="meta ml-1">@{u.username}</span>
</span>
<span className="flex-1" />
{holdersHeldIds.has(u.id) ? (
<span className="meta text-xs">已持有</span>
) : (
<button
type="button"
className="btn btn-primary btn-sm"
disabled={grantingId === u.id}
onClick={() => void grant(u.id)}
>
{grantingId === u.id ? "授予中…" : "授予"}
</button>
)}
</div>
))}
</div>
</div>
)}
<p className="meta text-xs mb-1.5">当前持有者({holders.length})</p>
{holdersLoading ? (
<p className="meta text-sm py-3">加载持有者…</p>
) : holders.length === 0 ? (
<p className="meta text-sm py-3">暂无人持有。</p>
) : (
<div className="flex flex-col gap-1 max-h-72 overflow-y-auto">
{holders.map((h) => (
<div key={h.user_id} className="flex items-center gap-2.5 px-2 py-1.5 rounded-lg" style={{ background: "var(--panel-2)" }}>
<span className="text-sm truncate" style={{ color: "var(--ink)" }}>
{h.nickname || h.username}
<span className="meta ml-1">@{h.username}</span>
</span>
<span className="meta text-xs">{new Date(h.awarded_at).toLocaleDateString("zh-CN")} 授予</span>
<span className="flex-1" />
<button
type="button"
className="btn btn-line btn-sm"
style={{ color: "var(--danger)" }}
onClick={() => void revoke(h.user_id)}
>
撤销
</button>
</div>
))}
</div>
)}
</div>
</section>
)}
</>
) : (
<section className="panel">
<AdminEmpty
icon={<Award size={22} />}
title={loading ? "加载中…" : "从左侧选择一个徽章"}
description={loading ? undefined : "选中后可在此编辑名称、图标与颜色,管理持有者"}
/>
</section>
)}
</div>
</div>
{/* 删除确认 */}
<ConfirmDialog
open={deleting != null}
title="删除徽章"
message={`确定删除徽章「${deleting?.name ?? ""}」?该徽章的全部授予记录(${deleting?.holders ?? 0} 人)会被一并删除,用户主页将不再显示。`}
confirmLabel="删除"
danger
busy={deletingBusy}
onCancel={() => setDeleting(null)}
onConfirm={() => void doDelete()}
/>
</div>
);
}