Files
jiang13-bbs/frontend/app/admin/badges/BadgesClient.tsx
freefire 4ae57d9392 feat: 用户徽章与等级体系,积分经济管理重构
- 新增徽章系统:badge 服务与 handler、管理端配置页、BadgeChip/LevelBadge 展示组件
- 新增等级模型:model/level 及前端 levels 元数据
- 管理端经济模块拆分为 points/badges/levels 独立页面,EconomySettings 迁入设置
- 积分、签到、评论等服务适配等级与徽章逻辑
2026-09-25 16:38:29 +08:00

442 lines
15 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, 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<BadgeWithHolders[]>([]);
const [loading, setLoading] = useState(true);
// 编辑/新建表单
const [editing, setEditing] = useState<BadgeWithHolders | 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 [holdersFor, setHoldersFor] = useState<BadgeWithHolders | null>(null);
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 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 (
<div className="admin-settings-stack">
<div className="flex items-center justify-between flex-wrap gap-3">
<div>
<h2 className="text-lg font-bold" style={{ color: "var(--ink)" }}>
徽章管理
</h2>
<p className="meta text-sm mt-1">
管理员自定义徽章:全站作者处展示前 2 枚,个人主页展示徽章墙;授予后用户会收到站内通知。
</p>
</div>
<button type="button" className="btn btn-solid btn-sm" onClick={openCreate}>
<Plus size={14} /> 新建徽章
</button>
</div>
{loading ? (
<p className="meta px-1 py-6">加载徽章列表…</p>
) : badges.length === 0 ? (
<div className="panel text-center py-12">
<p className="meta">还没有徽章,点击右上角“新建徽章”开始。</p>
</div>
) : (
<div className="panel p-0 overflow-hidden">
{badges.map((b, i) => (
<div
key={b.id}
className="flex items-center gap-3 px-4 py-3 flex-wrap"
style={{ borderTop: i === 0 ? "none" : "1px solid var(--line)" }}
>
<BadgeChip badge={b} />
<span className="meta text-xs">
{b.icon || "medal"} · {b.color || "accent"} · {b.holders} 人持有
</span>
<span className="flex-1" />
<button
type="button"
className="btn btn-line btn-sm"
onClick={() => void openHolders(b)}
>
<Users size={13} /> 持有者
</button>
<button type="button" className="btn btn-line btn-sm" onClick={() => openEdit(b)}>
<Pencil size={13} /> 编辑
</button>
<button
type="button"
className="btn btn-line btn-sm"
style={{ color: "var(--danger)" }}
onClick={() => setDeleting(b)}
>
<Trash2 size={13} /> 删除
</button>
</div>
))}
</div>
)}
{/* 持有者面板 */}
<Modal
open={holdersFor != null}
title={`持有者 · ${holdersFor?.name ?? ""}`}
onClose={() => setHoldersFor(null)}
maxWidthClass="max-w-lg"
>
<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-solid 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-64 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>
)}
</Modal>
{/* 新建/编辑表单 */}
<Modal
open={creating || editing != null}
title={editing ? "编辑徽章" : "新建徽章"}
onClose={closeForm}
maxWidthClass="max-w-md"
>
<div className="flex flex-col gap-4">
<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 mt-5">
<button type="button" className="btn btn-line btn-sm" onClick={closeForm}>
取消
</button>
<button
type="button"
className="btn btn-solid btn-sm"
disabled={saving || runeCount(form.name.trim()) === 0}
onClick={() => void saveForm()}
>
{saving ? "保存中…" : "保存"}
</button>
</div>
</Modal>
{/* 删除确认 */}
<ConfirmDialog
open={deleting != null}
title="删除徽章"
message={`确定删除徽章「${deleting?.name ?? ""}」?该徽章的全部授予记录(${deleting?.holders ?? 0} 人)会被一并删除,用户主页将不再显示。`}
confirmLabel="删除"
danger
busy={deletingBusy}
onCancel={() => setDeleting(null)}
onConfirm={() => void doDelete()}
/>
</div>
);
}