"use client"; import { useRef, useState } from "react"; import { AlertTriangle, CheckCircle2, ClipboardCheck, Database, FileUp, Images, ListChecks, ListX, Loader2, MessageSquare, ScanLine, Upload, Users, XCircle, } from "lucide-react"; import { AdminSettingsGroup, AdminSwitch, } from "@/components/admin"; import { apiAdminImportLegacy, type LegacyImportReport, type LegacyUserPreview, } 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; /** 单个旧用户的导入选择:是否建号 + 内容归属 */ type UserSel = { create: boolean; owner: "auto" | "operator" | "custom"; target: string }; /** * 站点设置 → 数据导入: * 上传旧站数据库(+可选头像包 / 帖子图片包)→ 预检(dry-run,返回用户/帖子/评论清单) * → 勾选导入范围(是否建号、内容归属、排除帖子/评论)→ 确认正式导入。 * 全程幂等:已导入对象自动跳过,可安全重复执行。 */ export default function ImportPanel() { const [source, setSource] = useState("jiang13"); const [dbFile, setDbFile] = useState(null); const [zipFile, setZipFile] = useState(null); const [imagesZipFile, setImagesZipFile] = useState(null); const [withContent, setWithContent] = useState(true); const [busy, setBusy] = useState<"check" | "import" | null>(null); const [error, setError] = useState(""); const [preview, setPreview] = useState(null); // 预检清单 const [report, setReport] = useState(null); // 最近一次执行结果 const dbInputRef = useRef(null); const zipInputRef = useRef(null); const imagesZipInputRef = useRef(null); // 勾选状态(预检后按清单初始化,默认全部导入) const [userSel, setUserSel] = useState>({}); const [postChecked, setPostChecked] = useState>(new Set()); const [commentChecked, setCommentChecked] = useState>(new Set()); // 「确认导入」只有在当前配置下跑过预检后才可用;文件/选项变化即重置 const [preflighted, setPreflighted] = useState(false); const resetResult = () => { setPreflighted(false); setPreview(null); setReport(null); setError(""); }; const pickDb = (f: File | null) => { setDbFile(f); resetResult(); }; const pickZip = (f: File | null) => { setZipFile(f); resetResult(); }; const pickImagesZip = (f: File | null) => { setImagesZipFile(f); resetResult(); }; const toggleContent = () => { setWithContent((v) => !v); resetResult(); }; const initSelections = (rep: LegacyImportReport) => { const users: Record = {}; (rep.user_list ?? []).forEach((u) => { users[u.id] = { create: true, owner: "auto", target: "" }; }); setUserSel(users); setPostChecked(new Set((rep.post_list ?? []).filter((p) => p.published && !p.poll).map((p) => p.id))); setCommentChecked(new Set((rep.comment_list ?? []).filter((c) => c.published).map((c) => c.id))); }; // 由勾选状态构造请求参数(相对预检清单求差集) const buildOptions = () => { const skipUsers: number[] = []; const operatorUsers: number[] = []; const userMap: { oldId: number; username: string }[] = []; Object.entries(userSel).forEach(([idStr, sel]) => { const id = Number(idStr); if (sel.owner === "operator") { skipUsers.push(id); operatorUsers.push(id); } else if (sel.owner === "custom" && sel.target.trim()) { skipUsers.push(id); userMap.push({ oldId: id, username: sel.target.trim() }); } else if (!sel.create) { skipUsers.push(id); } }); return { withContent, dryRun: false, skipUsers, operatorUsers, userMap, skipPosts: (preview?.post_list ?? []) .filter((p) => p.published && !p.poll && !postChecked.has(p.id)) .map((p) => p.id), skipComments: (preview?.comment_list ?? []) .filter((c) => c.published && !commentChecked.has(c.id)) .map((c) => c.id), }; }; const run = async (dryRun: boolean) => { if (!dbFile || busy) return; setBusy(dryRun ? "check" : "import"); setError(""); try { const opts = dryRun ? { withContent, dryRun } : buildOptions(); const rep = await apiAdminImportLegacy(source, dbFile, zipFile, imagesZipFile, { ...opts, dryRun, }); setReport(rep); if (dryRun) { setPreview(rep); initSelections(rep); setPreflighted(true); } } catch (e) { setError(e instanceof Error ? e.message : "导入失败"); } finally { setBusy(null); } }; const setUser = (id: number, patch: Partial) => { setUserSel((m) => { const cur = m[id] ?? { create: true, owner: "auto" as const, target: "" }; const next = { ...cur, ...patch }; // 归属指定已有账号 / 站长时,账号不再新建 if (next.owner !== "auto") next.create = false; return { ...m, [id]: next }; }); }; const toggleSet = (set: Set, id: number) => { const next = new Set(set); if (next.has(id)) next.delete(id); else next.add(id); return next; }; const hasLists = withContent && !!preview?.post_list?.length; return (
{SOURCES.map((s) => ( ))}
同时导入帖子与评论
{busy ? null : !dbFile ? "请先选择站点数据库" : !preflighted ? "请先运行预检" : "已生成清单,确认勾选后可正式导入"}
{error && (

{error}

)} {preflighted && preview?.user_list && preview.user_list.length > 0 && ( )} {hasLists && preview && ( setPostChecked((s) => toggleSet(s, id))} onAll={(on) => setPostChecked( on ? new Set((preview.post_list ?? []).filter((p) => p.published && !p.poll).map((p) => p.id)) : new Set() ) } /> )} {hasLists && preview && !!preview.comment_list?.length && ( setCommentChecked((s) => toggleSet(s, id))} onAll={(on) => setCommentChecked( on ? new Set(preview.comment_list!.filter((c) => c.published).map((c) => c.id)) : new Set() ) } /> )} {report && }
); } function FilePickRow({ label, hint, fileName, inputRef, accept, onPick, busy, }: { label: string; hint: string; fileName?: string; inputRef: React.RefObject; accept: string; onPick: (f: File | null) => void; busy: boolean; }) { return (

{label}

{fileName || hint}

{ onPick(e.target.files?.[0] ?? null); e.target.value = ""; }} />
); } // ===== 预检后勾选清单 ===== const fieldStyle = { background: "var(--panel)", border: "1px solid var(--line)" } as const; function UserSelectList({ list, sel, onChange, }: { list: LegacyUserPreview[]; sel: Record; onChange: (id: number, patch: Partial) => void; }) { return (

用户({list.length})——取消勾选则不创建账号,其内容按右侧归属落位

{list.map((u) => { const s = sel[u.id] ?? { create: true, owner: "auto" as const, target: "" }; return (
{u.username} {u.nickname && u.nickname !== u.username && ( {u.nickname} )} {u.exists && ( 同名已存在·导入时跳过建号 )}
帖 {u.posts} · 评 {u.comments} {s.owner === "custom" && ( onChange(u.id, { target: e.target.value })} className="rounded-lg px-2 py-1 text-[12.5px] w-36 shrink-0" style={fieldStyle} /> )}
); })}
); } function BulkBar({ label, onAll }: { label: string; onAll: (on: boolean) => void }) { return ( {label} ); } function PostSelectList({ list, checked, onToggle, onAll, }: { list: { id: number; title: string; author: string; board: string; created_at: string; poll: boolean; published: boolean }[]; checked: Set; onToggle: (id: number) => void; onAll: (on: boolean) => void; }) { const selectable = list.filter((p) => p.published && !p.poll).length; return (

帖子({selectable}/{list.length} 可导入)——取消勾选即排除,其评论一并跳过

{list.map((p) => { const disabled = !p.published || p.poll; return ( ); })}
); } function CommentSelectList({ list, checked, onToggle, onAll, }: { list: { id: number; post_title: string; author: string; excerpt: string; created_at: string; published: boolean }[]; checked: Set; onToggle: (id: number) => void; onAll: (on: boolean) => void; }) { const selectable = list.filter((c) => c.published).length; return (
评论({selectable}/{list.length} 可导入)
{list.map((c) => ( ))}
); } function fmtDate(s: string) { const d = new Date(s); return Number.isNaN(d.getTime()) ? "" : d.toLocaleDateString("zh-CN"); } // ===== 结果报告 ===== function StatChip({ icon, label, value, ok }: { icon: React.ReactNode; label: string; value: number; ok?: boolean }) { return (
{icon} {label} {value}
); } function ReportView({ report }: { report: LegacyImportReport }) { const isDry = report.dry_run; return (

{isDry ? ( <> 预检结果(未写入任何数据)——请在上方勾选导入范围,确认后点击「确认正式导入」 ) : ( <> 导入完成。重复执行时已导入的内容会自动跳过 )}

} title="用户"> } label="旧库用户" value={report.users.total} /> } label={isDry ? "将导入" : "已导入"} value={report.users.imported} /> } label="已存在跳过" value={report.users.skipped} ok={false} /> {report.users.excluded > 0 && ( } label="不建号" value={report.users.excluded} ok={false} /> )} {report.users.avatar_written > 0 && ( } label="头像写入" value={report.users.avatar_written} /> )} {report.boards && ( } title="板块"> } label={isDry ? "将新建" : "已新建"} value={report.boards.created} /> } label="复用同名" value={report.boards.reused} ok={false} /> )} {report.posts && } {report.comments && } {report.notes && report.notes.length > 0 && (
{report.notes.map((n, i) => (

{n}

))}
)}
); } function PostsView({ report, isDry }: { report: NonNullable; isDry: boolean }) { return ( } title="帖子"> } label="旧帖" value={report.total} /> } label={isDry ? "将导入" : "已导入"} value={report.imported} /> } label="已导入跳过" value={report.skipped} ok={false} /> {report.excluded > 0 && ( } label="手动排除" value={report.excluded} ok={false} /> )} {report.images_written > 0 && ( } label={isDry ? "将迁移图片" : "图片迁移"} value={report.images_written} /> )} {report.polls_skipped > 0 && ( } label="投票帖未迁移" value={report.polls_skipped} ok={false} /> )} ); } function CommentsView({ report, isDry }: { report: NonNullable; isDry: boolean }) { return ( } title="评论"> } label="旧评论" value={report.total} /> } label={isDry ? "将导入" : "已导入"} value={report.imported} /> } label="已导入跳过" value={report.skipped} ok={false} /> {report.excluded > 0 && ( } label="手动排除" value={report.excluded} ok={false} /> )} {report.images_written > 0 && ( } label={isDry ? "将迁移图片" : "图片迁移"} value={report.images_written} /> )} ); } function ReportCard({ icon, title, children, }: { icon: React.ReactNode; title: string; children: React.ReactNode; }) { return (

{icon} {title}

{children}
); } function DetailList({ title, items, color, }: { title: string; items?: string[]; color?: string; }) { if (!items || items.length === 0) return null; return (
{title}:{items.length}
    {items.map((it, i) => (
  • {it}
  • ))}
); }