Files
jiang13-bbs/frontend/app/admin/settings/import/ImportPanel.tsx
freefire 3e55b5d230 feat: 管理端板块管理与旧版数据导入,补充部署运营文档
- 新增管理端板块管理页面与后端接口(admin_board)
- 新增旧版数据导入:legacyimport 服务、导入面板、importusers 命令行工具
- 聊天用户卡片、板块图标等 UI 组件与界面优化
- 补充 about/公告/1Panel 部署等文档
- gitignore 排除 dist/ 构建产物与 .agents/ 本地工具目录
2026-09-24 03:37:44 +08:00

416 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}