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

@@ -3,7 +3,7 @@
import { useEffect, useMemo, useRef, useState } from "react";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { ArrowLeft, Copy, ExternalLink, Loader2 } from "lucide-react";
import { ArrowLeft, Copy, ExternalLink, Loader2, Megaphone } from "lucide-react";
import {
apiAdminCreateAnnouncement,
apiAdminUpdateAnnouncement,
@@ -246,34 +246,68 @@ export default function AnnouncementComposeClient({
return (
<div className="admin-doc-page admin-doc-editor">
<header className="admin-doc-head">
<div className="min-w-0">
<Link href={backHref} className="admin-doc-back">
<ArrowLeft size={14} aria-hidden /> 返回列表
</Link>
<div className="admin-doc-head-title">
<header className="admin-doc-sticky">
<div className="admin-doc-sticky-inner">
<div className="admin-doc-sticky-title">
<Link href={backHref} className="admin-doc-back" aria-label="返回公告列表">
<ArrowLeft size={15} aria-hidden />
<span className="admin-doc-back-text">返回列表</span>
</Link>
<span className="admin-doc-sticky-icon" aria-hidden>
<Megaphone size={15} />
</span>
<h1>{isEdit ? "编辑公告" : "新建公告"}</h1>
<AdminStatusChip tone={savedPublished ? "ok" : savedPublished === false ? "warn" : "accent"}>
{savedPublished ? "已发布" : savedPublished === false ? "草稿" : "尚未保存"}
</AdminStatusChip>
</div>
<div className="admin-doc-sticky-actions">
{canViewOnline ? (
<a
href={publicPath}
target="_blank"
rel="noopener noreferrer"
className="btn btn-line btn-sm"
title="打开已发布内容。未保存的修改不会出现在这里。"
>
<ExternalLink size={14} aria-hidden /> 线上页面
</a>
) : null}
<AdminSegmented<PublishKey>
role="group"
ariaLabel="保存后状态"
size="sm"
value={form.published ? "published" : "draft"}
onChange={(key) => patchForm({ published: key === "published" })}
options={[
{ key: "draft", label: "草稿" },
{ key: "published", label: "发布" },
]}
/>
<button type="button" className="btn btn-line btn-sm" disabled={!dirty || saving} onClick={discard}>
撤销修改
</button>
<button
type="button"
className="btn btn-primary btn-sm"
disabled={!dirty || saving}
onClick={requestSave}
aria-busy={saving || undefined}
>
{saving ? <Loader2 size={14} className="animate-spin" aria-hidden /> : null}
{saving ? "保存中…" : saveLabel}
</button>
<p className={`admin-doc-status${errors.form ? " is-error" : ""}`} aria-live="polite">
{statusText}
</p>
</div>
</div>
{canViewOnline ? (
<a
href={publicPath}
target="_blank"
rel="noopener noreferrer"
className="btn btn-line"
title="打开已发布内容。未保存的修改不会出现在这里。"
>
<ExternalLink size={14} aria-hidden /> 查看线上页面
</a>
) : null}
</header>
<div className="admin-doc-split">
<div className="admin-doc-main">
<AdminField
className="admin-doc-title"
id="anno-title"
label="标题"
maxLength={200}
@@ -328,7 +362,12 @@ export default function AnnouncementComposeClient({
/>
<div className="admin-doc-field">
<p className="admin-doc-label">标签颜色</p>
<p className="admin-doc-label">
标签颜色
<span className="admin-doc-label-value">
{ANNOUNCEMENT_COLORS.find((c) => c.value === form.tag_color)?.label}
</span>
</p>
<div className="admin-doc-colors" role="radiogroup" aria-label="标签颜色">
{ANNOUNCEMENT_COLORS.map((c) => (
<button
@@ -343,7 +382,12 @@ export default function AnnouncementComposeClient({
/>
))}
</div>
<p className="admin-doc-hint">只影响标签颜色,不改正文。</p>
{/* 与前台同一套 j13-anno-tag 标记,所见即所得 */}
<div className="admin-doc-tagpreview" data-note="前台胶囊效果">
<span className={`j13-anno-tag is-${form.tag_color}`}>
{form.tag.trim() || "公告"}
</span>
</div>
{errors.tag_color ? (
<p className="admin-settings-row-error" role="alert">
{errors.tag_color}
@@ -366,65 +410,25 @@ export default function AnnouncementComposeClient({
</p>
</div>
{isEdit && saved ? (
<div className="admin-doc-field">
<p className="admin-doc-label">访问地址</p>
<p className="admin-doc-hint">
{publicPath}
{origin ? ` · ${origin.replace(/\/$/, "")}${publicPath}` : ""}
</p>
<p className="admin-doc-hint">地址按公告编号生成,不能改成自定义路径。</p>
<button type="button" className="btn btn-line admin-doc-copy" onClick={() => void copyAddress()}>
<Copy size={14} aria-hidden /> 复制地址
</button>
</div>
) : (
<p className="admin-doc-hint">保存后才会有公开地址 /announcement/编号。草稿没有公开入口。</p>
)}
<div className="admin-doc-field">
<p className="admin-doc-label">保存后状态</p>
<AdminSegmented<PublishKey>
role="group"
ariaLabel="保存后状态"
size="sm"
value={form.published ? "published" : "draft"}
onChange={(key) => patchForm({ published: key === "published" })}
options={[
{ key: "draft", label: "草稿" },
{ key: "published", label: "已发布" },
]}
/>
<p className="admin-doc-hint">点选只改变这次要保存的状态,不会立刻发布或下线。</p>
<p className="admin-doc-label">访问地址</p>
{isEdit && saved ? (
<>
<p className="admin-doc-hint">
{publicPath}
{origin ? ` · ${origin.replace(/\/$/, "")}${publicPath}` : ""}
</p>
<p className="admin-doc-hint">地址按公告编号生成,不能改成自定义路径。</p>
<button type="button" className="btn btn-line admin-doc-copy" onClick={() => void copyAddress()}>
<Copy size={14} aria-hidden /> 复制地址
</button>
</>
) : (
<p className="admin-doc-hint">保存后按公告编号生成 /announcement/编号;草稿没有公开入口。</p>
)}
</div>
</div>
</details>
<div className="admin-doc-actions" aria-busy={saving || undefined}>
<div className="admin-doc-actions-btns">
<button type="button" className="btn btn-line" disabled={!dirty || saving} onClick={discard}>
撤销修改
</button>
<button
type="button"
className="btn btn-primary"
disabled={!dirty || saving}
onClick={requestSave}
>
{saving ? <Loader2 size={15} className="animate-spin" aria-hidden /> : null}
{saving ? "保存中…" : saveLabel}
</button>
<p className={`admin-doc-status${errors.form ? " is-error" : ""}`} aria-live="polite">
{statusText}
</p>
</div>
<p className="admin-doc-hint">一次保存会写入标题、正文和公告设置。Ctrl/⌘ + Enter 与这个按钮相同。</p>
{canViewOnline ? (
<p className="admin-doc-hint">查看线上页面打开的是已发布内容,未保存的修改不会出现在那里。</p>
) : (
<p className="admin-doc-hint">草稿没有公开地址。要看正文,用编辑器里的预览。</p>
)}
</div>
</div>
<ConfirmDialog

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>
);
}

View File

@@ -0,0 +1,33 @@
import type { Metadata } from "next";
import { cookies } from "next/headers";
import { authCookieHeader } from "@/lib/cookies";
import { fetchAdminBoards, type AdminBoard } from "@/lib/api";
import { getMeCached } from "@/lib/serverData";
import { isOwner } from "@/lib/roles";
import Forbidden from "../Forbidden";
import BoardsAdmin from "./BoardsAdmin";
export const metadata: Metadata = {
title: "板块管理",
};
export default async function AdminBoardsPage() {
const cookie = authCookieHeader(await cookies());
const me = await getMeCached(cookie || undefined);
// 仅站长可管理板块
if (!me.user || !isOwner(me.user.role)) {
return <Forbidden text="板块管理仅站长可操作。" />;
}
let initial: AdminBoard[] | null = null;
let initError = "";
try {
const r = await fetchAdminBoards(cookie || undefined);
initial = r.boards ?? [];
} catch (e) {
initError = e instanceof Error ? e.message : "获取板块列表失败";
}
return <BoardsAdmin initial={initial} initError={initError} />;
}

View File

@@ -3,7 +3,7 @@
import { useEffect, useMemo, useRef, useState } from "react";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { ArrowLeft, Copy, ExternalLink, Loader2 } from "lucide-react";
import { ArrowLeft, Copy, ExternalLink, FileText, Loader2 } from "lucide-react";
import {
apiAdminCreateSitePage,
apiAdminUpdateSitePage,
@@ -283,34 +283,68 @@ export default function PageComposeClient({
return (
<div className="admin-doc-page admin-doc-editor">
<header className="admin-doc-head">
<div className="min-w-0">
<Link href={backHref} className="admin-doc-back">
<ArrowLeft size={14} aria-hidden /> 返回列表
</Link>
<div className="admin-doc-head-title">
<header className="admin-doc-sticky">
<div className="admin-doc-sticky-inner">
<div className="admin-doc-sticky-title">
<Link href={backHref} className="admin-doc-back" aria-label="返回单页列表">
<ArrowLeft size={15} aria-hidden />
<span className="admin-doc-back-text">返回列表</span>
</Link>
<span className="admin-doc-sticky-icon" aria-hidden>
<FileText size={15} />
</span>
<h1>{isEdit ? "编辑单页" : "新建单页"}</h1>
<AdminStatusChip tone={savedPublished ? "ok" : savedPublished === false ? "warn" : "accent"}>
{savedPublished ? "已发布" : savedPublished === false ? "草稿" : "尚未保存"}
</AdminStatusChip>
</div>
<div className="admin-doc-sticky-actions">
{canViewOnline && savedPage ? (
<a
href={sitePagePublicPath(savedPage.slug)}
target="_blank"
rel="noopener noreferrer"
className="btn btn-line btn-sm"
title="打开已发布内容。未保存的修改不会出现在这里。"
>
<ExternalLink size={14} aria-hidden /> 线上页面
</a>
) : null}
<AdminSegmented<PublishKey>
role="group"
ariaLabel="保存后状态"
size="sm"
value={form.published ? "published" : "draft"}
onChange={(key) => patchForm({ published: key === "published" })}
options={[
{ key: "draft", label: "草稿" },
{ key: "published", label: "发布" },
]}
/>
<button type="button" className="btn btn-line btn-sm" disabled={!dirty || saving} onClick={discard}>
撤销修改
</button>
<button
type="button"
className="btn btn-primary btn-sm"
disabled={!dirty || saving}
onClick={requestSave}
aria-busy={saving || undefined}
>
{saving ? <Loader2 size={14} className="animate-spin" aria-hidden /> : null}
{saving ? "保存中…" : saveLabel}
</button>
<p className={`admin-doc-status${errors.form ? " is-error" : ""}`} aria-live="polite">
{statusText}
</p>
</div>
</div>
{canViewOnline && savedPage ? (
<a
href={sitePagePublicPath(savedPage.slug)}
target="_blank"
rel="noopener noreferrer"
className="btn btn-line"
title="打开已发布内容。未保存的修改不会出现在这里。"
>
<ExternalLink size={14} aria-hidden /> 查看线上页面
</a>
) : null}
</header>
<div className="admin-doc-split">
<div className="admin-doc-main">
<AdminField
className="admin-doc-title"
id="page-title"
label="标题"
maxLength={200}
@@ -437,50 +471,8 @@ export default function PageComposeClient({
/>
<p className="admin-doc-hint">数字越小越靠前,可以为负数。</p>
</div>
<div className="admin-doc-field">
<p className="admin-doc-label">保存后状态</p>
<AdminSegmented<PublishKey>
role="group"
ariaLabel="保存后状态"
size="sm"
value={form.published ? "published" : "draft"}
onChange={(key) => patchForm({ published: key === "published" })}
options={[
{ key: "draft", label: "草稿" },
{ key: "published", label: "已发布" },
]}
/>
<p className="admin-doc-hint">点选只改变这次要保存的状态,不会立刻发布或下线。</p>
</div>
</div>
</details>
<div className="admin-doc-actions" aria-busy={saving || undefined}>
<div className="admin-doc-actions-btns">
<button type="button" className="btn btn-line" disabled={!dirty || saving} onClick={discard}>
撤销修改
</button>
<button
type="button"
className="btn btn-primary"
disabled={!dirty || saving}
onClick={requestSave}
>
{saving ? <Loader2 size={15} className="animate-spin" aria-hidden /> : null}
{saving ? "保存中…" : saveLabel}
</button>
<p className={`admin-doc-status${errors.form ? " is-error" : ""}`} aria-live="polite">
{statusText}
</p>
</div>
<p className="admin-doc-hint">一次保存会写入标题、正文和页面设置。Ctrl/⌘ + Enter 与这个按钮相同。</p>
{canViewOnline ? (
<p className="admin-doc-hint">查看线上页面打开的是已发布内容,未保存的修改不会出现在那里。</p>
) : (
<p className="admin-doc-hint">草稿没有公开地址。要看正文,用编辑器里的预览。</p>
)}
</div>
</div>
<ConfirmDialog

View File

@@ -1,6 +1,6 @@
"use client";
import { ExternalLink, LogIn, MessageSquare, MessagesSquare, ShieldCheck, Timer } from "lucide-react";
import { ExternalLink, LogIn, MessageSquare, MessagesSquare, ShieldCheck, ShieldOff, Timer } from "lucide-react";
import { useState } from "react";
import {
AdminSettingsGroup,
@@ -27,6 +27,7 @@ export default function AccessSettings({ initial }: { initial: PublicSettings })
const [allowMessages, setAllowMessages] = useState(initial.allow_messages !== false);
const [postLinkNewTab, setPostLinkNewTab] = useState(initial.post_link_new_tab !== false);
const [trustOn, setTrustOn] = useState(initial.trust_reviewed_publish !== false);
const [withoutReviewOn, setWithoutReviewOn] = useState(initial.publish_without_review === true);
const [cooldown, setCooldown] = useState(initial.post_cooldown_hours);
const [savedCooldown, setSavedCooldown] = useState(initial.post_cooldown_hours);
const [togglingComments, setTogglingComments] = useState(false);
@@ -34,6 +35,7 @@ export default function AccessSettings({ initial }: { initial: PublicSettings })
const [togglingMessages, setTogglingMessages] = useState(false);
const [togglingLink, setTogglingLink] = useState(false);
const [togglingTrust, setTogglingTrust] = useState(false);
const [togglingWithoutReview, setTogglingWithoutReview] = useState(false);
const [savingCooldown, setSavingCooldown] = useState(false);
const [moduleDirty, setModuleDirty] = useState(false);
const leave = useUnsavedGuard(moduleDirty);
@@ -103,6 +105,25 @@ export default function AccessSettings({ initial }: { initial: PublicSettings })
}
};
const toggleWithoutReview = async () => {
if (togglingWithoutReview) return;
setTogglingWithoutReview(true);
try {
const res = await apiUpdateSiteSettings({ publish_without_review: !withoutReviewOn });
setWithoutReviewOn(res.publish_without_review === true);
toast(
res.publish_without_review
? "已开启全站免审(发帖与评论直接公开,无需过审记录)"
: "已关闭全站免审(将按过审免审规则决定是否待审)",
"ok",
);
} catch (e) {
toast(e instanceof Error ? e.message : "保存失败");
} finally {
setTogglingWithoutReview(false);
}
};
const toggleTrust = async () => {
if (togglingTrust) return;
setTogglingTrust(true);
@@ -218,18 +239,40 @@ export default function AccessSettings({ initial }: { initial: PublicSettings })
<InstantNote saving={togglingLink} />
</div>
</AdminSettingsRow>
<AdminSettingsRow
label={
<span className="inline-flex items-center gap-1.5">
<ShieldOff size={15} /> 全站免审
</span>
}
hintBelow="所有人发帖与评论直接公开,无需有过审记录;开启后优先生效。"
>
<div className="flex items-center gap-2">
<AdminSwitch
checked={withoutReviewOn}
disabled={togglingWithoutReview}
onChange={() => void toggleWithoutReview()}
label="全站免审"
/>
<InstantNote saving={togglingWithoutReview} />
</div>
</AdminSettingsRow>
<AdminSettingsRow
label={
<span className="inline-flex items-center gap-1.5">
<ShieldCheck size={15} /> 过审免审
</span>
}
hintBelow="用户曾有过审内容后,后续发帖与评论直接公开。"
hintBelow={
withoutReviewOn
? "全站免审已开启,所有内容直接公开,本开关暂不参与判定。"
: "用户曾有过审内容后,后续发帖与评论直接公开。"
}
>
<div className="flex items-center gap-2">
<AdminSwitch
checked={trustOn}
disabled={togglingTrust}
disabled={withoutReviewOn || togglingTrust}
onChange={() => void toggleTrust()}
label="过审免审"
/>

View File

@@ -16,6 +16,7 @@ const NAV: { href: string; label: string }[] = [
{ href: "/admin/settings/mail", label: "邮件服务" },
{ href: "/admin/settings/storage", label: "文件与存储" },
{ href: "/admin/settings/filter", label: "内容过滤" },
{ href: "/admin/settings/import", label: "数据导入" },
{ href: "/admin/settings/maintenance", label: "维护与诊断" },
];

View File

@@ -0,0 +1,415 @@
"use client";
import { useRef, useState } from "react";
import {
AlertTriangle,
CheckCircle2,
ClipboardCheck,
Database,
FileUp,
ListChecks,
ListX,
Loader2,
MessageSquare,
ScanLine,
Upload,
Users,
XCircle,
} from "lucide-react";
import {
AdminSettingsGroup,
AdminSwitch,
} from "@/components/admin";
import {
apiAdminImportLegacy,
type LegacyCommentReport,
type LegacyImportReport,
type LegacyPostReport,
} from "@/lib/api";
/** 数据源清单:enabled=false 的来源展示为「计划中」,为后续 WordPress/Typecho 适配预留 */
const SOURCES = [
{ key: "jiang13", label: "姜十三论坛(SQLite)", enabled: true },
{ key: "wordpress", label: "WordPress", enabled: false },
{ key: "typecho", label: "Typecho", enabled: false },
] as const;
/**
* 站点设置 → 数据导入:
* 上传旧站数据库(+可选头像包)→ 预检(dry-run,不写库)→ 确认正式导入。
* 全程幂等:已导入对象自动跳过,可安全重复执行。
*/
export default function ImportPanel() {
const [source, setSource] = useState<string>("jiang13");
const [dbFile, setDbFile] = useState<File | null>(null);
const [zipFile, setZipFile] = useState<File | null>(null);
const [withContent, setWithContent] = useState(true);
const [busy, setBusy] = useState<"check" | "import" | null>(null);
const [error, setError] = useState("");
const [report, setReport] = useState<LegacyImportReport | null>(null);
const dbInputRef = useRef<HTMLInputElement>(null);
const zipInputRef = useRef<HTMLInputElement>(null);
// 「确认导入」只有在当前配置下跑过预检后才可用;文件/选项变化即重置
const [preflighted, setPreflighted] = useState(false);
const resetResult = () => {
setPreflighted(false);
setReport(null);
setError("");
};
const pickDb = (f: File | null) => {
setDbFile(f);
resetResult();
};
const pickZip = (f: File | null) => {
setZipFile(f);
resetResult();
};
const toggleContent = () => {
setWithContent((v) => !v);
resetResult();
};
const run = async (dryRun: boolean) => {
if (!dbFile || busy) return;
setBusy(dryRun ? "check" : "import");
setError("");
try {
const rep = await apiAdminImportLegacy(source, dbFile, zipFile, {
withContent,
dryRun,
});
setReport(rep);
if (dryRun) setPreflighted(true);
} catch (e) {
setError(e instanceof Error ? e.message : "导入失败");
} finally {
setBusy(null);
}
};
return (
<div className="flex flex-col gap-4">
<AdminSettingsGroup
title="数据源"
description="选择要导入的站点程序类型;其他平台适配将陆续提供"
>
<div className="flex flex-wrap gap-2">
{SOURCES.map((s) => (
<button
key={s.key}
type="button"
disabled={!s.enabled}
onClick={() => {
setSource(s.key);
resetResult();
}}
className={`btn btn-sm ${source === s.key && s.enabled ? "btn-primary" : "btn-line"} ${!s.enabled ? "opacity-50 cursor-not-allowed" : ""}`}
aria-pressed={source === s.key}
title={s.enabled ? undefined : "计划中,敬请期待"}
>
<Database size={14} aria-hidden />
{s.label}
{!s.enabled && (
<span className="text-[11px] font-semibold" style={{ color: "var(--ink-3)" }}>
计划中
</span>
)}
</button>
))}
</div>
</AdminSettingsGroup>
<AdminSettingsGroup
title="导入文件"
description="第一步:从旧站服务器或本地备份中取得以下文件"
>
<div className="flex flex-col gap-3">
<FilePickRow
label="站点数据库(必填)"
hint="旧站 data 目录下的 jiang13.db,最大 64MB"
fileName={dbFile?.name}
inputRef={dbInputRef}
accept=".db,.sqlite,.sqlite3,application/octet-stream"
onPick={pickDb}
busy={busy !== null}
/>
<FilePickRow
label="头像压缩包(可选)"
hint="把旧站 data/uploads/avatars 目录压缩为 zip(直接右键压缩该文件夹即可),最大 128MB"
fileName={zipFile?.name}
inputRef={zipInputRef}
accept=".zip,application/zip"
onPick={pickZip}
busy={busy !== null}
/>
</div>
</AdminSettingsGroup>
<AdminSettingsGroup
title="导入范围"
description="第二步:确认导入范围。板块按旧板块名自动创建(同名复用);帖子与评论保留原发布时间与浏览/点赞数;作者账号缺失时归到站长。投票帖暂不迁移。"
>
<div className="flex items-center gap-3">
<AdminSwitch checked={withContent} onChange={toggleContent} label="同时导入帖子与评论" />
<span className="text-[13px]" style={{ color: "var(--ink)" }}>
同时导入帖子与评论
</span>
</div>
</AdminSettingsGroup>
<AdminSettingsGroup
title="预检与导入"
description="第三步:先预检确认将导入的内容,再正式写入。可重复执行,已导入的内容自动跳过。"
>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
className="btn btn-line btn-sm"
disabled={!dbFile || busy !== null}
onClick={() => void run(true)}
>
{busy === "check" ? <Loader2 size={14} className="animate-spin" aria-hidden /> : <ScanLine size={14} aria-hidden />}
{busy === "check" ? "预检中…" : "预检(不写入)"}
</button>
<button
type="button"
className="btn btn-primary btn-sm"
disabled={!dbFile || busy !== null || !preflighted}
onClick={() => void run(false)}
>
{busy === "import" ? <Loader2 size={14} className="animate-spin" aria-hidden /> : <ClipboardCheck size={14} aria-hidden />}
{busy === "import" ? "导入中…" : "确认正式导入"}
</button>
<span className="meta text-[12px]" aria-live="polite">
{busy
? null
: !dbFile
? "请先选择站点数据库"
: !preflighted
? "请先运行预检"
: "预检通过,可以正式导入"}
</span>
</div>
{error && (
<p className="mt-3 flex items-center gap-1.5 text-[12.5px]" style={{ color: "var(--danger)" }} role="alert">
<AlertTriangle size={14} aria-hidden />
{error}
</p>
)}
{report && <ReportView report={report} />}
</AdminSettingsGroup>
</div>
);
}
function FilePickRow({
label,
hint,
fileName,
inputRef,
accept,
onPick,
busy,
}: {
label: string;
hint: string;
fileName?: string;
inputRef: React.RefObject<HTMLInputElement | null>;
accept: string;
onPick: (f: File | null) => void;
busy: boolean;
}) {
return (
<div
className="rounded-xl px-3.5 py-3 flex items-center justify-between gap-3 flex-wrap"
style={{ background: "var(--panel-2)", border: "1px solid var(--line)" }}
>
<div className="min-w-0">
<p className="text-[13px] font-semibold" style={{ color: "var(--ink)" }}>
{label}
</p>
<p className="meta text-[12px] mt-0.5 truncate">{fileName || hint}</p>
</div>
<button
type="button"
className="btn btn-line btn-sm shrink-0"
onClick={() => inputRef.current?.click()}
disabled={busy}
>
<Upload size={14} aria-hidden />
选择文件
</button>
<input
ref={inputRef}
type="file"
accept={accept}
className="hidden"
onChange={(e) => {
onPick(e.target.files?.[0] ?? null);
e.target.value = "";
}}
/>
</div>
);
}
function StatChip({ icon, label, value, ok }: { icon: React.ReactNode; label: string; value: number; ok?: boolean }) {
return (
<div
className="rounded-lg px-2.5 py-1.5 inline-flex items-center gap-1.5 text-[12.5px]"
style={{ background: "var(--panel)", border: "1px solid var(--line)" }}
>
<span style={{ color: ok === false ? "var(--ink-3)" : "var(--accent)" }}>{icon}</span>
<span style={{ color: "var(--ink-3)" }}>{label}</span>
<strong style={{ color: "var(--ink)" }}>{value}</strong>
</div>
);
}
function ReportView({ report }: { report: LegacyImportReport }) {
const isDry = report.dry_run;
return (
<div className="mt-4 flex flex-col gap-3">
<p
className="flex items-center gap-1.5 text-[13px] font-semibold"
style={{ color: isDry ? "var(--ink-3)" : "var(--ok)" }}
>
{isDry ? (
<>
<ScanLine size={15} aria-hidden />
预检结果(未写入任何数据)——确认无误后点击「确认正式导入」
</>
) : (
<>
<CheckCircle2 size={15} aria-hidden />
导入完成。重复执行时已导入的内容会自动跳过
</>
)}
</p>
<ReportCard icon={<Users size={14} aria-hidden />} title="用户">
<StatChip icon={<ListChecks size={13} aria-hidden />} label="旧库用户" value={report.users.total} />
<StatChip icon={<CheckCircle2 size={13} aria-hidden />} label={isDry ? "将导入" : "已导入"} value={report.users.imported} />
<StatChip icon={<ListX size={13} aria-hidden />} label="已存在跳过" value={report.users.skipped} ok={false} />
{report.users.avatar_written > 0 && (
<StatChip icon={<FileUp size={13} aria-hidden />} label="头像写入" value={report.users.avatar_written} />
)}
<DetailList title="已存在(跳过)" items={report.users.conflicts} />
<DetailList title="头像源缺失(字段置空)" items={report.users.avatar_missing} color="var(--danger)" />
<DetailList title="失败" items={report.users.failed} color="var(--danger)" />
</ReportCard>
{report.boards && (
<ReportCard icon={<Database size={14} aria-hidden />} title="板块">
<StatChip icon={<CheckCircle2 size={13} aria-hidden />} label={isDry ? "将新建" : "已新建"} value={report.boards.created} />
<StatChip icon={<ListX size={13} aria-hidden />} label="复用同名" value={report.boards.reused} ok={false} />
<DetailList title={isDry ? "将新建板块" : "新建板块"} items={report.boards.names} />
</ReportCard>
)}
{report.posts && <PostsView report={report.posts} isDry={isDry} />}
{report.comments && <CommentsView report={report.comments} isDry={isDry} />}
{report.notes && report.notes.length > 0 && (
<div
className="rounded-xl px-3.5 py-3 flex flex-col gap-1 text-[12.5px]"
style={{ background: "var(--panel-2)", border: "1px solid var(--line)" }}
>
{report.notes.map((n, i) => (
<p key={i} className="flex items-start gap-1.5" style={{ color: "var(--ink-3)" }}>
<AlertTriangle size={13} className="mt-0.5 shrink-0" aria-hidden />
{n}
</p>
))}
</div>
)}
</div>
);
}
function PostsView({ report, isDry }: { report: LegacyPostReport; isDry: boolean }) {
return (
<ReportCard icon={<ListChecks size={14} aria-hidden />} title="帖子">
<StatChip icon={<ListChecks size={13} aria-hidden />} label="旧帖" value={report.total} />
<StatChip icon={<CheckCircle2 size={13} aria-hidden />} label={isDry ? "将导入" : "已导入"} value={report.imported} />
<StatChip icon={<ListX size={13} aria-hidden />} label="已导入跳过" value={report.skipped} ok={false} />
{report.polls_skipped > 0 && (
<StatChip icon={<XCircle size={13} aria-hidden />} label="投票帖未迁移" value={report.polls_skipped} ok={false} />
)}
<DetailList title="投票帖(未迁移)" items={report.poll_titles} color="var(--danger)" />
<DetailList title="作者缺失(归到站长)" items={report.owner_fallback} />
<DetailList title="失败 / 非公开" items={report.failed} color="var(--danger)" />
</ReportCard>
);
}
function CommentsView({ report, isDry }: { report: LegacyCommentReport; isDry: boolean }) {
return (
<ReportCard icon={<MessageSquare size={14} aria-hidden />} title="评论">
<StatChip icon={<ListChecks size={13} aria-hidden />} label="旧评论" value={report.total} />
<StatChip icon={<CheckCircle2 size={13} aria-hidden />} label={isDry ? "将导入" : "已导入"} value={report.imported} />
<StatChip icon={<ListX size={13} aria-hidden />} label="已导入跳过" value={report.skipped} ok={false} />
<DetailList title="非公开 / 已导入跳过明细" items={report.skipped_detail} />
<DetailList title="作者缺失(归到站长)" items={report.owner_fallback} />
<DetailList title="失败" items={report.failed} color="var(--danger)" />
</ReportCard>
);
}
function ReportCard({
icon,
title,
children,
}: {
icon: React.ReactNode;
title: string;
children: React.ReactNode;
}) {
return (
<div
className="rounded-xl px-3.5 py-3 flex flex-col gap-2"
style={{ background: "var(--panel-2)", border: "1px solid var(--line)" }}
>
<p className="flex items-center gap-1.5 text-[13px] font-semibold" style={{ color: "var(--ink)" }}>
{icon}
{title}
</p>
<div className="flex flex-wrap gap-1.5">{children}</div>
</div>
);
}
function DetailList({
title,
items,
color,
}: {
title: string;
items?: string[];
color?: string;
}) {
if (!items || items.length === 0) return null;
return (
<details className="w-full">
<summary
className="flex items-center gap-1.5 cursor-pointer select-none text-[12.5px]"
style={{ color: color || "var(--ink-3)" }}
>
<ListX size={13} aria-hidden />
{title}:{items.length}
</summary>
<ul className="mt-1 ml-5 list-disc max-h-40 overflow-y-auto text-[12.5px]" style={{ color: "var(--ink-3)" }}>
{items.map((it, i) => (
<li key={i} className="break-all">
{it}
</li>
))}
</ul>
</details>
);
}

View File

@@ -0,0 +1,10 @@
import type { Metadata } from "next";
import ImportPanel from "./ImportPanel";
export const metadata: Metadata = {
title: "数据导入",
};
export default function ImportSettingsPage() {
return <ImportPanel />;
}