feat: 管理端板块管理与旧版数据导入,补充部署运营文档
- 新增管理端板块管理页面与后端接口(admin_board) - 新增旧版数据导入:legacyimport 服务、导入面板、importusers 命令行工具 - 聊天用户卡片、板块图标等 UI 组件与界面优化 - 补充 about/公告/1Panel 部署等文档 - gitignore 排除 dist/ 构建产物与 .agents/ 本地工具目录
This commit is contained in:
739
frontend/app/admin/boards/BoardsAdmin.tsx
Normal file
739
frontend/app/admin/boards/BoardsAdmin.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user