feat: 管理端板块管理与旧版数据导入,补充部署运营文档

- 新增管理端板块管理页面与后端接口(admin_board)
- 新增旧版数据导入:legacyimport 服务、导入面板、importusers 命令行工具
- 聊天用户卡片、板块图标等 UI 组件与界面优化
- 补充 about/公告/1Panel 部署等文档
- gitignore 排除 dist/ 构建产物与 .agents/ 本地工具目录
This commit is contained in:
2026-09-24 03:37:44 +08:00
parent c1f6a6636b
commit 3e55b5d230
53 changed files with 5078 additions and 409 deletions

View File

@@ -0,0 +1,739 @@
"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>
);
}