- 新增 smartDiff 差异计算与 EditHistoryModal 编辑历史弹窗,替换原 CommentEditHistoryModal - 新增 PostEditedMark 编辑标记展示 - 新增内容锁定机制(content_lock)防止并发编辑冲突 - 审核与通知服务适配
442 lines
15 KiB
TypeScript
442 lines
15 KiB
TypeScript
"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-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 ? "搜索中…" : "搜索"}
|
||
</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-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-primary 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>
|
||
);
|
||
}
|