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