feat: 站点媒体库与移动端底部导航,服务测试补强
- 新增媒体库:media_library/media_thumbs 服务(WebP 缩略图)、管理端 media 页面 - 移动端底部导航 MobileTabBar 替换 MobilePostBar - 旧数据导入增强与测试、路由与登录会话/板块侧边栏测试补强 - site-doc 组件精简(移除 Breadcrumb),文档新增迁移公告说明
This commit is contained in:
@@ -1,11 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Pencil, Plus, Trash2, Users } from "lucide-react";
|
||||
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 Modal from "@/components/Modal";
|
||||
import AdminEmpty from "@/components/admin/AdminEmpty";
|
||||
import AdminPageHeader from "@/components/admin/AdminPageHeader";
|
||||
import { BADGE_ICONS } from "@/lib/badgeIcons";
|
||||
import { toast } from "@/lib/toast";
|
||||
import {
|
||||
@@ -32,8 +33,8 @@ export default function BadgesClient() {
|
||||
const [badges, setBadges] = useState<BadgeWithHolders[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// 编辑/新建表单
|
||||
const [editing, setEditing] = useState<BadgeWithHolders | null>(null);
|
||||
// 主从布局:左侧列表选中项 / 右侧详情(编辑表单 + 持有者)
|
||||
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);
|
||||
@@ -43,7 +44,6 @@ export default function BadgesClient() {
|
||||
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("");
|
||||
@@ -51,6 +51,8 @@ export default function BadgesClient() {
|
||||
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();
|
||||
@@ -66,22 +68,85 @@ export default function BadgesClient() {
|
||||
void reload();
|
||||
}, [reload]);
|
||||
|
||||
const selected = useMemo(
|
||||
() => badges.find((b) => b.id === selectedId) ?? null,
|
||||
[badges, selectedId],
|
||||
);
|
||||
|
||||
const openCreate = () => {
|
||||
setForm({ name: "", icon: "medal", color: "accent" });
|
||||
setEditing(null);
|
||||
setCreating(true);
|
||||
setSelectedId(null);
|
||||
};
|
||||
|
||||
const openEdit = (b: BadgeWithHolders) => {
|
||||
setForm({ name: b.name, icon: b.icon || "medal", color: b.color || "accent" });
|
||||
const selectBadge = (b: BadgeWithHolders, scroll = true) => {
|
||||
setCreating(false);
|
||||
setEditing(b);
|
||||
setSelectedId(b.id);
|
||||
// 窄屏单列时,点选后滚动到右侧详情
|
||||
if (scroll && window.matchMedia("(max-width: 1023px)").matches) {
|
||||
detailRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}
|
||||
};
|
||||
|
||||
const closeForm = () => {
|
||||
setCreating(false);
|
||||
setEditing(null);
|
||||
};
|
||||
// 首次加载完成后自动选中第一个徽章(删除刷新期间跳过,避免选中已删除项)
|
||||
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;
|
||||
@@ -91,14 +156,15 @@ export default function BadgesClient() {
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
if (editing) {
|
||||
await apiAdminUpdateBadge(editing.id, form);
|
||||
toast("徽章已更新", "ok");
|
||||
} else {
|
||||
await apiAdminCreateBadge(form);
|
||||
if (creating) {
|
||||
const created = await apiAdminCreateBadge(form);
|
||||
toast("徽章已创建", "ok");
|
||||
setCreating(false);
|
||||
setSelectedId(created.id);
|
||||
} else if (selected) {
|
||||
await apiAdminUpdateBadge(selected.id, form);
|
||||
toast("徽章已更新", "ok");
|
||||
}
|
||||
closeForm();
|
||||
await reload();
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : "保存失败");
|
||||
@@ -114,7 +180,7 @@ export default function BadgesClient() {
|
||||
await apiAdminDeleteBadge(deleting.id);
|
||||
toast("徽章已删除", "ok");
|
||||
setDeleting(null);
|
||||
if (holdersFor?.id === deleting.id) setHoldersFor(null);
|
||||
if (selectedId === deleting.id) setSelectedId(null);
|
||||
await reload();
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : "删除失败");
|
||||
@@ -123,22 +189,6 @@ export default function BadgesClient() {
|
||||
}
|
||||
};
|
||||
|
||||
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;
|
||||
@@ -161,12 +211,11 @@ export default function BadgesClient() {
|
||||
};
|
||||
|
||||
const grant = async (userId: number) => {
|
||||
if (!holdersFor || grantingId != null) return;
|
||||
if (!selected || grantingId != null) return;
|
||||
setGrantingId(userId);
|
||||
try {
|
||||
await apiAdminGrantBadge(holdersFor.id, userId);
|
||||
await apiAdminGrantBadge(selected.id, userId);
|
||||
toast("已授予", "ok");
|
||||
await openHolders(holdersFor);
|
||||
await reload();
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : "授予失败");
|
||||
@@ -176,254 +225,335 @@ export default function BadgesClient() {
|
||||
};
|
||||
|
||||
const revoke = async (userId: number) => {
|
||||
if (!holdersFor) return;
|
||||
if (!selected) return;
|
||||
try {
|
||||
await apiAdminRevokeBadge(holdersFor.id, userId);
|
||||
await apiAdminRevokeBadge(selected.id, userId);
|
||||
toast("已撤销", "ok");
|
||||
await openHolders(holdersFor);
|
||||
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">
|
||||
<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-primary 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 ? "搜索中…" : "搜索"}
|
||||
<AdminPageHeader
|
||||
icon={Award}
|
||||
title="徽章管理"
|
||||
description="管理员自定义徽章:全站作者处展示前 2 枚,个人主页展示徽章墙;授予后用户会收到站内通知。"
|
||||
actions={
|
||||
<button type="button" className="btn btn-primary btn-sm" onClick={openCreate}>
|
||||
<Plus size={14} /> 新建徽章
|
||||
</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>
|
||||
) : (
|
||||
}
|
||||
/>
|
||||
|
||||
<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"
|
||||
className="btn btn-primary btn-sm"
|
||||
disabled={grantingId === u.id}
|
||||
onClick={() => void grant(u.id)}
|
||||
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,
|
||||
}}
|
||||
>
|
||||
{grantingId === u.id ? "授予中…" : "授予"}
|
||||
<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>
|
||||
</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)}
|
||||
<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)" }}
|
||||
>
|
||||
撤销
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
{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>
|
||||
|
||||
{/* 新建/编辑表单 */}
|
||||
<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",
|
||||
}}
|
||||
{/* 持有者管理 */}
|
||||
{!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)" }}
|
||||
>
|
||||
<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>
|
||||
<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 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-primary btn-sm"
|
||||
disabled={saving || runeCount(form.name.trim()) === 0}
|
||||
onClick={() => void saveForm()}
|
||||
>
|
||||
{saving ? "保存中…" : "保存"}
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
|
||||
{/* 删除确认 */}
|
||||
<ConfirmDialog
|
||||
|
||||
Reference in New Issue
Block a user