- 新增管理端板块管理页面与后端接口(admin_board) - 新增旧版数据导入:legacyimport 服务、导入面板、importusers 命令行工具 - 聊天用户卡片、板块图标等 UI 组件与界面优化 - 补充 about/公告/1Panel 部署等文档 - gitignore 排除 dist/ 构建产物与 .agents/ 本地工具目录
740 lines
24 KiB
TypeScript
740 lines
24 KiB
TypeScript
"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>
|
||
);
|
||
}
|