Files
jiang13-bbs/frontend/app/admin/boards/BoardsAdmin.tsx
freefire 3e55b5d230 feat: 管理端板块管理与旧版数据导入,补充部署运营文档
- 新增管理端板块管理页面与后端接口(admin_board)
- 新增旧版数据导入:legacyimport 服务、导入面板、importusers 命令行工具
- 聊天用户卡片、板块图标等 UI 组件与界面优化
- 补充 about/公告/1Panel 部署等文档
- gitignore 排除 dist/ 构建产物与 .agents/ 本地工具目录
2026-09-24 03:37:44 +08:00

740 lines
24 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, useState } from "react";
import {
FolderOpen,
Plus,
Pencil,
Trash2,
Users as UsersIcon,
ChevronUp,
ChevronDown,
EyeOff,
Check,
} from "lucide-react";
import {
apiAdminAddBoardModerator,
apiAdminCreateBoard,
apiAdminDeleteBoard,
apiAdminListBoardModerators,
apiAdminListBoards,
apiAdminReorderBoards,
apiAdminRemoveBoardModerator,
apiAdminUpdateBoard,
BOARD_POST_POLICY,
type AdminBoard,
type AdminBoardInput,
type AdminBoardModerator,
} from "@/lib/api";
import {
AdminEmpty,
AdminPageHeader,
AdminStatusChip,
} from "@/components/admin";
import Modal from "@/components/Modal";
import Avatar from "@/components/Avatar";
import BoardIcon, {
BOARD_ICONS,
BOARD_COLOR_COUNT,
boardColor,
} from "@/components/BoardIcon";
const POLICY_LABEL: Record<string, string> = {
[BOARD_POST_POLICY.ALL]: "全员可发",
[BOARD_POST_POLICY.STAFF]: "仅管理员",
};
const POST_POLICY_OPTIONS: { value: string; label: string; desc: string }[] = [
{
value: BOARD_POST_POLICY.ALL,
label: "全员可发",
desc: "所有登录用户均可在此板块发帖",
},
{
value: BOARD_POST_POLICY.STAFF,
label: "仅管理员及以上",
desc: "仅版主、超级管理员、站长可发帖",
},
];
function emptyInput(): AdminBoardInput {
return {
name: "",
description: "",
icon: "message-circle",
color_index: -1,
sort_order: 0,
post_policy: BOARD_POST_POLICY.ALL,
visible: true,
};
}
function toInput(b: AdminBoard): AdminBoardInput {
return {
name: b.name,
description: b.description,
icon: b.icon,
color_index: b.color_index,
sort_order: b.sort_order,
post_policy: b.post_policy,
visible: b.visible,
};
}
function BoardEditModal({
open,
initial,
title,
busy,
editTarget,
onClose,
onSubmit,
}: {
open: boolean;
initial: AdminBoardInput;
title: string;
busy: boolean;
/** 编辑中的板块(新建为 null);自动配色预览按其 ID 轮换 */
editTarget: AdminBoard | null;
onClose: () => void;
onSubmit: (input: AdminBoardInput) => void;
}) {
const [form, setForm] = useState<AdminBoardInput>(initial);
useEffect(() => {
if (open) setForm(initial);
}, [open, initial]);
const update = (patch: Partial<AdminBoardInput>) => setForm((f) => ({ ...f, ...patch }));
// 预览配色:固定(>0)按存值取模;自动(-1/0)按已有板块 ID 轮换,新板块用 accent 占位
const previewColor =
(form.color_index ?? 0) > 0
? (form.color_index ?? 0) % BOARD_COLOR_COUNT
: editTarget
? editTarget.id % BOARD_COLOR_COUNT
: -1;
return (
<Modal
open={open}
title={title}
onClose={onClose}
busy={busy}
maxWidthClass="max-w-lg"
footer={
<>
<button type="button" className="btn btn-ghost" onClick={onClose} disabled={busy}>
取消
</button>
<button
type="button"
className="btn btn-primary"
onClick={() => onSubmit(form)}
disabled={busy || !form.name.trim()}
>
保存
</button>
</>
}
>
<div className="flex flex-col gap-4">
<label className="flex flex-col gap-1.5">
<span className="text-[12.5px] font-medium" style={{ color: "var(--ink-2)" }}>
板块名称 <span style={{ color: "var(--danger)" }}>*</span>
</span>
<input
type="text"
value={form.name}
maxLength={64}
onChange={(e) => update({ name: e.target.value })}
className="field"
placeholder="如:综合讨论"
/>
</label>
{/* 外观:实时预览 + 图标 + 颜色,联动更新 */}
<div
className="flex flex-col gap-3.5 rounded-xl p-3.5"
style={{ background: "var(--panel-2)", border: "1px solid var(--line)" }}
>
<div className="flex items-center gap-3">
<span
className={`w-11 h-11 rounded-xl flex items-center justify-center shrink-0 ${
previewColor >= 0 ? `av-${previewColor}` : ""
}`}
style={
previewColor < 0
? { background: "var(--accent-soft)", color: "var(--accent)" }
: undefined
}
aria-hidden
>
<BoardIcon icon={form.icon} name={form.name || "板"} size={20} />
</span>
<div className="min-w-0">
<p className="text-[13.5px] font-semibold truncate" style={{ color: "var(--ink)" }}>
{form.name.trim() || "板块名称预览"}
</p>
<p className="meta text-[12px] truncate">
{form.description?.trim() || "描述将显示在这里"}
</p>
</div>
<span className="ml-auto meta text-[11px] shrink-0 tracking-wide">前台预览</span>
</div>
<div>
<p className="text-[12px] font-medium mb-1.5" style={{ color: "var(--ink-3)" }}>
图标
</p>
<div className="flex flex-wrap gap-1.5" role="radiogroup" aria-label="板块图标">
{BOARD_ICONS.map((o) => {
const active = form.icon === o.value;
return (
<button
key={o.value}
type="button"
role="radio"
aria-checked={active}
data-tip={o.label}
aria-label={o.label}
onClick={() => update({ icon: o.value })}
className="w-9 h-9 rounded-[10px] flex items-center justify-center transition-colors"
style={{
background: "var(--panel)",
border: `1.5px solid ${active ? "var(--accent)" : "var(--line)"}`,
color: active ? "var(--accent)" : "var(--ink-2)",
boxShadow: active ? "0 0 0 3px var(--accent-soft)" : undefined,
}}
>
<BoardIcon icon={o.value} name="" size={16} />
</button>
);
})}
</div>
</div>
<div>
<p className="text-[12px] font-medium mb-1.5" style={{ color: "var(--ink-3)" }}>
图标颜色
</p>
<div className="flex flex-wrap gap-1.5" role="radiogroup" aria-label="图标颜色">
<button
type="button"
role="radio"
aria-checked={(form.color_index ?? 0) <= 0}
data-tip="自动:按板块编号轮换配色"
aria-label="自动配色"
onClick={() => update({ color_index: -1 })}
className="h-9 px-2.5 rounded-[10px] flex items-center justify-center text-[11.5px] font-medium transition-colors"
style={{
background: "var(--panel)",
border: `1.5px dashed ${(form.color_index ?? 0) <= 0 ? "var(--accent)" : "var(--line)"}`,
color: (form.color_index ?? 0) <= 0 ? "var(--accent)" : "var(--ink-3)",
boxShadow:
(form.color_index ?? 0) <= 0 ? "0 0 0 3px var(--accent-soft)" : undefined,
}}
>
自动
</button>
{/* 存值 1..16 → 渲染 av-1..av-15、av-0;0 与 -1 均视为自动 */}
{Array.from({ length: BOARD_COLOR_COUNT }, (_, k) => k + 1).map((v) => {
const active = (form.color_index ?? 0) === v;
return (
<button
key={v}
type="button"
role="radio"
aria-checked={active}
data-tip={`颜色 ${v}`}
aria-label={`颜色 ${v}`}
onClick={() => update({ color_index: v })}
className={`av-${v % BOARD_COLOR_COUNT} w-9 h-9 rounded-[10px] flex items-center justify-center transition-colors`}
style={{
border: `1.5px solid ${active ? "currentColor" : "transparent"}`,
boxShadow: active ? "0 0 0 3px var(--accent-soft)" : undefined,
}}
>
{active && <Check size={14} strokeWidth={3} />}
</button>
);
})}
</div>
<p className="meta text-[11.5px] mt-1.5">
{(form.color_index ?? 0) <= 0
? "自动模式:前台按板块编号分配配色,各页面保持一致。"
: "指定固定配色,前台所有位置保持一致。"}
</p>
</div>
</div>
<label className="flex flex-col gap-1.5">
<span className="text-[12.5px] font-medium" style={{ color: "var(--ink-2)" }}>
描述
</span>
<textarea
value={form.description}
maxLength={512}
rows={3}
onChange={(e) => update({ description: e.target.value })}
className="field"
placeholder="板块简介,可选"
/>
</label>
<label className="flex flex-col gap-1.5">
<span className="text-[12.5px] font-medium" style={{ color: "var(--ink-2)" }}>
排序权重
</span>
<input
type="number"
value={form.sort_order ?? 0}
onChange={(e) => update({ sort_order: parseInt(e.target.value || "0", 10) })}
className="field"
placeholder="数字越小越靠前"
/>
</label>
<div className="flex flex-col gap-1.5">
<span className="text-[12.5px] font-medium" style={{ color: "var(--ink-2)" }}>
发帖策略
</span>
<div
className="grid grid-cols-2 gap-2"
role="radiogroup"
aria-label="发帖策略"
>
{POST_POLICY_OPTIONS.map((o) => {
const active = (form.post_policy || BOARD_POST_POLICY.ALL) === o.value;
return (
<button
key={o.value}
type="button"
role="radio"
aria-checked={active}
onClick={() => update({ post_policy: o.value })}
className="flex flex-col gap-0.5 rounded-[10px] px-3 py-2.5 text-left transition-colors"
style={{
background: active ? "var(--accent-soft)" : "var(--panel-2)",
border: `1.5px solid ${active ? "var(--accent)" : "var(--line)"}`,
cursor: "pointer",
}}
>
<span
className="text-[13px] font-semibold"
style={{ color: active ? "var(--accent)" : "var(--ink)" }}
>
{o.label}
</span>
<span
className="text-[11.5px] leading-snug"
style={{ color: active ? "var(--accent)" : "var(--ink-3)" }}
>
{o.desc}
</span>
</button>
);
})}
</div>
</div>
<label className="inline-flex items-center gap-2 text-[13px]" style={{ color: "var(--ink)" }}>
<input
type="checkbox"
checked={!!form.visible}
onChange={(e) => update({ visible: e.target.checked })}
className="accent-[var(--accent)]"
/>
前台可见(关闭后仅管理团队可见入口)
</label>
</div>
</Modal>
);
}
function ModeratorsModal({
open,
board,
busy,
onClose,
onChanged,
}: {
open: boolean;
board: AdminBoard | null;
busy: boolean;
onClose: () => void;
onChanged: () => void;
}) {
const [mods, setMods] = useState<AdminBoardModerator[]>([]);
const [username, setUsername] = useState("");
const [loadErr, setLoadErr] = useState("");
const [actErr, setActErr] = useState("");
const reload = useCallback(async () => {
if (!board) return;
setLoadErr("");
try {
const r = await apiAdminListBoardModerators(board.id);
setMods(r.moderators ?? []);
} catch (e) {
setLoadErr(e instanceof Error ? e.message : "获取版主失败");
}
}, [board]);
useEffect(() => {
if (open && board) {
setUsername("");
setActErr("");
reload();
}
}, [open, board, reload]);
const add = async () => {
if (!board) return;
const u = username.trim();
if (!u) return;
setActErr("");
try {
await apiAdminAddBoardModerator(board.id, u);
setUsername("");
await reload();
onChanged();
} catch (e) {
setActErr(e instanceof Error ? e.message : "添加版主失败");
}
};
const remove = async (uid: number) => {
if (!board) return;
setActErr("");
try {
await apiAdminRemoveBoardModerator(board.id, uid);
await reload();
onChanged();
} catch (e) {
setActErr(e instanceof Error ? e.message : "移除版主失败");
}
};
return (
<Modal
open={open}
title={`版主管理 · ${board?.name ?? ""}`}
description="版主需为 board_admin 角色。新加入用户若已非该角色将被自动升级并强制下线;移除最后一块授权时自动降级为普通用户。"
onClose={onClose}
busy={busy}
maxWidthClass="max-w-lg"
footer={
<button type="button" className="btn btn-ghost" onClick={onClose} disabled={busy}>
关闭
</button>
}
>
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2">
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="按用户名添加版主"
className="field flex-1"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
add();
}
}}
/>
<button
type="button"
className="btn btn-primary btn-sm shrink-0"
onClick={add}
disabled={busy || !username.trim()}
>
添加
</button>
</div>
{actErr && <p className="text-[12.5px]" style={{ color: "var(--danger)" }}>{actErr}</p>}
{loadErr ? (
<p className="text-[12.5px]" style={{ color: "var(--danger)" }}>{loadErr}</p>
) : mods.length === 0 ? (
<p className="meta text-[12.5px] py-4 text-center">暂无版主</p>
) : (
<ul className="flex flex-col gap-1">
{mods.map((m) => (
<li
key={m.id}
className="flex items-center gap-3 px-2 py-2 rounded-[var(--r-sm)]"
style={{ background: "var(--panel-2)" }}
>
<Avatar name={m.nickname || m.username} src={m.avatar || undefined} size={30} />
<div className="min-w-0 flex-1">
<p className="text-[13px] truncate" style={{ color: "var(--ink)" }}>
{m.nickname || m.username}
</p>
<p className="meta text-[11.5px] truncate">@{m.username}</p>
</div>
{m.banned && (
<span className="admin-status is-muted" style={{ color: "var(--danger)" }}>
已封禁
</span>
)}
<button
type="button"
className="btn btn-line btn-sm shrink-0"
onClick={() => remove(m.id)}
disabled={busy}
style={{ color: "var(--danger)" }}
>
<Trash2 size={13} /> 移除
</button>
</li>
))}
</ul>
)}
</div>
</Modal>
);
}
export default function BoardsAdmin({
initial,
initError,
}: {
initial: AdminBoard[] | null;
initError: string;
}) {
const [boards, setBoards] = useState<AdminBoard[]>(initial ?? []);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(initError);
const [editOpen, setEditOpen] = useState(false);
const [editTarget, setEditTarget] = useState<AdminBoard | null>(null);
const [editInitial, setEditInitial] = useState<AdminBoardInput>(emptyInput());
const [busy, setBusy] = useState(false);
const [modsOpen, setModsOpen] = useState(false);
const [modsTarget, setModsTarget] = useState<AdminBoard | null>(null);
const reload = useCallback(async () => {
setLoading(true);
setError("");
try {
const r = await apiAdminListBoards();
setBoards(r.boards ?? []);
} catch (e) {
setError(e instanceof Error ? e.message : "获取板块列表失败");
} finally {
setLoading(false);
}
}, []);
const openCreate = () => {
setEditTarget(null);
setEditInitial(emptyInput());
setEditOpen(true);
};
const openEdit = (b: AdminBoard) => {
setEditTarget(b);
setEditInitial(toInput(b));
setEditOpen(true);
};
const submitEdit = async (input: AdminBoardInput) => {
setBusy(true);
setError("");
try {
if (editTarget) {
const r = await apiAdminUpdateBoard(editTarget.id, input);
setBoards((prev) => prev.map((b) => (b.id === r.board.id ? { ...b, ...r.board } : b)));
} else {
const r = await apiAdminCreateBoard(input);
setBoards((prev) => [...prev, r.board]);
}
setEditOpen(false);
} catch (e) {
setError(e instanceof Error ? e.message : "保存失败");
} finally {
setBusy(false);
}
};
const remove = async (b: AdminBoard) => {
if (!window.confirm(`确认删除板块「${b.name}」?该操作不可恢复。`)) return;
setBusy(true);
setError("");
try {
await apiAdminDeleteBoard(b.id);
setBoards((prev) => prev.filter((x) => x.id !== b.id));
} catch (e) {
setError(e instanceof Error ? e.message : "删除失败");
} finally {
setBusy(false);
}
};
const move = async (idx: number, dir: -1 | 1) => {
const target = idx + dir;
if (target < 0 || target >= boards.length) return;
const next = [...boards];
const tmp = next[idx];
next[idx] = next[target];
next[target] = tmp;
// 乐观更新:失败回滚
const prev = boards;
setBoards(next);
try {
await apiAdminReorderBoards(next.map((b) => b.id));
} catch (e) {
setBoards(prev);
setError(e instanceof Error ? e.message : "排序失败");
}
};
const openMods = (b: AdminBoard) => {
setModsTarget(b);
setModsOpen(true);
};
return (
<div>
<AdminPageHeader
icon={FolderOpen}
title="板块管理"
description="新建、编辑、删除论坛板块,配置发帖策略与可见性,并指派版主。仅站长可操作。"
actions={
<button type="button" className="btn btn-primary btn-sm" onClick={openCreate}>
<Plus size={14} /> 新建板块
</button>
}
/>
{error && (
<div
className="mb-3 px-3 py-2 rounded-[var(--r-sm)] text-[12.5px]"
style={{ background: "var(--danger-soft)", color: "var(--danger)" }}
>
{error}
</div>
)}
{boards.length === 0 ? (
<AdminEmpty
icon={<FolderOpen size={22} />}
title="暂无板块"
description="点击右上角「新建板块」创建第一个板块。"
/>
) : (
<div className="panel overflow-hidden boards-panel">
<div className="boards-head">
<span>排序</span>
<span>板块</span>
<span className="boards-actions">操作</span>
</div>
{boards.map((b, i) => (
<div key={b.id} className="boards-row">
<div className="flex items-center gap-1">
<button
type="button"
className="admin-icon-btn"
onClick={() => move(i, -1)}
disabled={i === 0 || busy}
aria-label="上移"
>
<ChevronUp size={14} />
</button>
<button
type="button"
className="admin-icon-btn"
onClick={() => move(i, 1)}
disabled={i === boards.length - 1 || busy}
aria-label="下移"
>
<ChevronDown size={14} />
</button>
</div>
<div className="min-w-0 flex items-center gap-2.5">
<span
className={`av-${boardColor(b)} w-8 h-8 rounded-[10px] flex items-center justify-center shrink-0`}
aria-hidden
>
<BoardIcon icon={b.icon} name={b.name} size={15} />
</span>
<div className="min-w-0">
<p className="text-[13.5px] font-semibold truncate" style={{ color: "var(--ink)" }}>
{b.name}
</p>
{b.description && (
<p className="meta text-[12px] truncate">{b.description}</p>
)}
{/* 低信息密度列表:策略/状态/帖数并入元信息行,替代三列 */}
<div className="boards-meta">
<AdminStatusChip tone="muted">
{POLICY_LABEL[b.post_policy] ?? b.post_policy}
</AdminStatusChip>
{b.visible ? (
<AdminStatusChip tone="ok">可见</AdminStatusChip>
) : (
<AdminStatusChip tone="muted">
<EyeOff size={11} /> 隐藏
</AdminStatusChip>
)}
<span className="boards-meta-posts">{b.post_count ?? 0} 帖</span>
</div>
</div>
</div>
<div className="flex items-center gap-1.5 flex-wrap boards-actions">
<button
type="button"
className="btn btn-line btn-sm"
onClick={() => openEdit(b)}
disabled={busy}
>
<Pencil size={13} /> 编辑
</button>
<button
type="button"
className="btn btn-line btn-sm"
onClick={() => openMods(b)}
disabled={busy}
>
<UsersIcon size={13} /> 版主
</button>
<button
type="button"
className="btn btn-line btn-sm"
onClick={() => remove(b)}
disabled={busy}
style={{ color: "var(--danger)" }}
>
<Trash2 size={13} /> 删除
</button>
</div>
</div>
))}
</div>
)}
{loading && (
<p className="meta text-[12px] mt-3 text-center">加载中…</p>
)}
<BoardEditModal
open={editOpen}
initial={editInitial}
title={editTarget ? `编辑板块 · ${editTarget.name}` : "新建板块"}
busy={busy}
editTarget={editTarget}
onClose={() => setEditOpen(false)}
onSubmit={submitEdit}
/>
<ModeratorsModal
open={modsOpen}
board={modsTarget}
busy={busy}
onClose={() => setModsOpen(false)}
onChanged={reload}
/>
</div>
);
}