- 新增媒体库:media_library/media_thumbs 服务(WebP 缩略图)、管理端 media 页面 - 移动端底部导航 MobileTabBar 替换 MobilePostBar - 旧数据导入增强与测试、路由与登录会话/板块侧边栏测试补强 - site-doc 组件精简(移除 Breadcrumb),文档新增迁移公告说明
775 lines
28 KiB
TypeScript
775 lines
28 KiB
TypeScript
"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<string>("jiang13");
|
||
const [dbFile, setDbFile] = useState<File | null>(null);
|
||
const [zipFile, setZipFile] = useState<File | null>(null);
|
||
const [imagesZipFile, setImagesZipFile] = useState<File | null>(null);
|
||
const [withContent, setWithContent] = useState(true);
|
||
const [busy, setBusy] = useState<"check" | "import" | null>(null);
|
||
const [error, setError] = useState("");
|
||
const [preview, setPreview] = useState<LegacyImportReport | null>(null); // 预检清单
|
||
const [report, setReport] = useState<LegacyImportReport | null>(null); // 最近一次执行结果
|
||
const dbInputRef = useRef<HTMLInputElement>(null);
|
||
const zipInputRef = useRef<HTMLInputElement>(null);
|
||
const imagesZipInputRef = useRef<HTMLInputElement>(null);
|
||
|
||
// 勾选状态(预检后按清单初始化,默认全部导入)
|
||
const [userSel, setUserSel] = useState<Record<number, UserSel>>({});
|
||
const [postChecked, setPostChecked] = useState<Set<number>>(new Set());
|
||
const [commentChecked, setCommentChecked] = useState<Set<number>>(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<number, UserSel> = {};
|
||
(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<UserSel>) => {
|
||
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<number>, 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 (
|
||
<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}
|
||
/>
|
||
<FilePickRow
|
||
label="帖子图片压缩包(可选)"
|
||
hint="把旧站 data/uploads/posts 目录压缩为 zip,最大 128MB;帖子/评论正文里的旧站图片会迁移到新站并改写链接"
|
||
fileName={imagesZipFile?.name}
|
||
inputRef={imagesZipInputRef}
|
||
accept=".zip,application/zip"
|
||
onPick={pickImagesZip}
|
||
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>
|
||
)}
|
||
|
||
{preflighted && preview?.user_list && preview.user_list.length > 0 && (
|
||
<UserSelectList list={preview.user_list} sel={userSel} onChange={setUser} />
|
||
)}
|
||
{hasLists && preview && (
|
||
<PostSelectList
|
||
list={preview.post_list ?? []}
|
||
checked={postChecked}
|
||
onToggle={(id) => 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 && (
|
||
<CommentSelectList
|
||
list={preview.comment_list}
|
||
checked={commentChecked}
|
||
onToggle={(id) => 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 && <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>
|
||
);
|
||
}
|
||
|
||
// ===== 预检后勾选清单 =====
|
||
|
||
const fieldStyle = { background: "var(--panel)", border: "1px solid var(--line)" } as const;
|
||
|
||
function UserSelectList({
|
||
list,
|
||
sel,
|
||
onChange,
|
||
}: {
|
||
list: LegacyUserPreview[];
|
||
sel: Record<number, UserSel>;
|
||
onChange: (id: number, patch: Partial<UserSel>) => void;
|
||
}) {
|
||
return (
|
||
<div className="mt-2 flex flex-col gap-2">
|
||
<p className="flex items-center gap-1.5 text-[13px] font-semibold" style={{ color: "var(--ink)" }}>
|
||
<Users size={14} aria-hidden />
|
||
用户({list.length})——取消勾选则不创建账号,其内容按右侧归属落位
|
||
</p>
|
||
<div className="max-h-80 overflow-y-auto flex flex-col gap-1.5 pr-1">
|
||
{list.map((u) => {
|
||
const s = sel[u.id] ?? { create: true, owner: "auto" as const, target: "" };
|
||
return (
|
||
<div
|
||
key={u.id}
|
||
className="rounded-lg px-3 py-2 flex items-center gap-2.5 flex-wrap"
|
||
style={fieldStyle}
|
||
>
|
||
<label className="inline-flex items-center gap-2 text-[12.5px] shrink-0" style={{ color: "var(--ink)" }}>
|
||
<input
|
||
type="checkbox"
|
||
className="accent-[var(--accent)]"
|
||
checked={s.create}
|
||
disabled={s.owner !== "auto"}
|
||
onChange={(e) => onChange(u.id, { create: e.target.checked })}
|
||
/>
|
||
建号
|
||
</label>
|
||
<div className="min-w-0 flex-1 flex items-baseline gap-1.5 flex-wrap">
|
||
<span className="text-[13px] font-semibold truncate" style={{ color: "var(--ink)" }}>
|
||
{u.username}
|
||
</span>
|
||
{u.nickname && u.nickname !== u.username && (
|
||
<span className="meta text-[12px] truncate">{u.nickname}</span>
|
||
)}
|
||
{u.exists && (
|
||
<span className="text-[11px] font-semibold" style={{ color: "var(--warn, #b45309)" }}>
|
||
同名已存在·导入时跳过建号
|
||
</span>
|
||
)}
|
||
</div>
|
||
<span className="meta text-[12px] shrink-0">
|
||
帖 {u.posts} · 评 {u.comments}
|
||
</span>
|
||
<select
|
||
value={s.owner}
|
||
onChange={(e) => onChange(u.id, { owner: e.target.value as UserSel["owner"] })}
|
||
className="rounded-lg px-2 py-1 text-[12.5px] shrink-0"
|
||
style={fieldStyle}
|
||
>
|
||
<option value="auto">归属:跟随默认</option>
|
||
<option value="operator">归到站长</option>
|
||
<option value="custom">指定已有账号…</option>
|
||
</select>
|
||
{s.owner === "custom" && (
|
||
<input
|
||
value={s.target}
|
||
placeholder="本站用户名"
|
||
onChange={(e) => onChange(u.id, { target: e.target.value })}
|
||
className="rounded-lg px-2 py-1 text-[12.5px] w-36 shrink-0"
|
||
style={fieldStyle}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function BulkBar({ label, onAll }: { label: string; onAll: (on: boolean) => void }) {
|
||
return (
|
||
<span className="inline-flex items-center gap-1.5 text-[12px]" style={{ color: "var(--ink-3)" }}>
|
||
{label}
|
||
<button type="button" className="btn btn-line btn-xs" onClick={() => onAll(true)}>
|
||
全选
|
||
</button>
|
||
<button type="button" className="btn btn-line btn-xs" onClick={() => onAll(false)}>
|
||
全不选
|
||
</button>
|
||
</span>
|
||
);
|
||
}
|
||
|
||
function PostSelectList({
|
||
list,
|
||
checked,
|
||
onToggle,
|
||
onAll,
|
||
}: {
|
||
list: { id: number; title: string; author: string; board: string; created_at: string; poll: boolean; published: boolean }[];
|
||
checked: Set<number>;
|
||
onToggle: (id: number) => void;
|
||
onAll: (on: boolean) => void;
|
||
}) {
|
||
const selectable = list.filter((p) => p.published && !p.poll).length;
|
||
return (
|
||
<div className="mt-2 flex flex-col gap-2">
|
||
<div className="flex items-center gap-3 flex-wrap">
|
||
<p className="flex items-center gap-1.5 text-[13px] font-semibold" style={{ color: "var(--ink)" }}>
|
||
<ListChecks size={14} aria-hidden />
|
||
帖子({selectable}/{list.length} 可导入)——取消勾选即排除,其评论一并跳过
|
||
</p>
|
||
<BulkBar label="批量:" onAll={onAll} />
|
||
</div>
|
||
<div className="max-h-80 overflow-y-auto flex flex-col gap-1.5 pr-1">
|
||
{list.map((p) => {
|
||
const disabled = !p.published || p.poll;
|
||
return (
|
||
<label
|
||
key={p.id}
|
||
className={`rounded-lg px-3 py-2 flex items-center gap-2.5 ${disabled ? "opacity-55" : "cursor-pointer"}`}
|
||
style={fieldStyle}
|
||
>
|
||
<input
|
||
type="checkbox"
|
||
className="accent-[var(--accent)] shrink-0"
|
||
checked={!disabled && checked.has(p.id)}
|
||
disabled={disabled}
|
||
onChange={() => onToggle(p.id)}
|
||
/>
|
||
<span className="text-[13px] truncate flex-1" style={{ color: "var(--ink)" }}>
|
||
{p.title}
|
||
</span>
|
||
{p.poll && (
|
||
<span className="text-[11px] font-semibold shrink-0" style={{ color: "var(--danger)" }}>
|
||
投票帖·不可迁移
|
||
</span>
|
||
)}
|
||
{!p.published && (
|
||
<span className="text-[11px] font-semibold shrink-0" style={{ color: "var(--danger)" }}>
|
||
非公开
|
||
</span>
|
||
)}
|
||
<span className="meta text-[12px] shrink-0 truncate max-w-[180px]">
|
||
{p.author} · {p.board} · {fmtDate(p.created_at)}
|
||
</span>
|
||
</label>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function CommentSelectList({
|
||
list,
|
||
checked,
|
||
onToggle,
|
||
onAll,
|
||
}: {
|
||
list: { id: number; post_title: string; author: string; excerpt: string; created_at: string; published: boolean }[];
|
||
checked: Set<number>;
|
||
onToggle: (id: number) => void;
|
||
onAll: (on: boolean) => void;
|
||
}) {
|
||
const selectable = list.filter((c) => c.published).length;
|
||
return (
|
||
<details className="mt-2 flex flex-col gap-2">
|
||
<summary className="flex items-center gap-2 flex-wrap cursor-pointer select-none">
|
||
<span className="flex items-center gap-1.5 text-[13px] font-semibold" style={{ color: "var(--ink)" }}>
|
||
<MessageSquare size={14} aria-hidden />
|
||
评论({selectable}/{list.length} 可导入)
|
||
</span>
|
||
<BulkBar label="批量:" onAll={onAll} />
|
||
</summary>
|
||
<div className="max-h-80 overflow-y-auto flex flex-col gap-1.5 pr-1">
|
||
{list.map((c) => (
|
||
<label
|
||
key={c.id}
|
||
className={`rounded-lg px-3 py-2 flex items-center gap-2.5 ${!c.published ? "opacity-55" : "cursor-pointer"}`}
|
||
style={fieldStyle}
|
||
>
|
||
<input
|
||
type="checkbox"
|
||
className="accent-[var(--accent)] shrink-0"
|
||
checked={c.published && checked.has(c.id)}
|
||
disabled={!c.published}
|
||
onChange={() => onToggle(c.id)}
|
||
/>
|
||
<span className="text-[12.5px] truncate flex-1" style={{ color: "var(--ink)" }}>
|
||
{c.excerpt || "(无文字)"}
|
||
</span>
|
||
{!c.published && (
|
||
<span className="text-[11px] font-semibold shrink-0" style={{ color: "var(--danger)" }}>
|
||
非公开
|
||
</span>
|
||
)}
|
||
<span className="meta text-[12px] shrink-0 truncate max-w-[240px]">
|
||
《{c.post_title}》{c.author} · {fmtDate(c.created_at)}
|
||
</span>
|
||
</label>
|
||
))}
|
||
</div>
|
||
</details>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<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.excluded > 0 && (
|
||
<StatChip icon={<ListX size={13} aria-hidden />} label="不建号" value={report.users.excluded} 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: NonNullable<LegacyImportReport["posts"]>; 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.excluded > 0 && (
|
||
<StatChip icon={<ListX size={13} aria-hidden />} label="手动排除" value={report.excluded} ok={false} />
|
||
)}
|
||
{report.images_written > 0 && (
|
||
<StatChip icon={<Images size={13} aria-hidden />} label={isDry ? "将迁移图片" : "图片迁移"} value={report.images_written} />
|
||
)}
|
||
{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.excluded_detail} />
|
||
<DetailList title="图片缺失(保留原链接)" items={report.images_missing} color="var(--danger)" />
|
||
<DetailList title="作者缺失(归到站长)" items={report.owner_fallback} />
|
||
<DetailList title="失败 / 非公开" items={report.failed} color="var(--danger)" />
|
||
</ReportCard>
|
||
);
|
||
}
|
||
|
||
function CommentsView({ report, isDry }: { report: NonNullable<LegacyImportReport["comments"]>; 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} />
|
||
{report.excluded > 0 && (
|
||
<StatChip icon={<ListX size={13} aria-hidden />} label="手动排除" value={report.excluded} ok={false} />
|
||
)}
|
||
{report.images_written > 0 && (
|
||
<StatChip icon={<Images size={13} aria-hidden />} label={isDry ? "将迁移图片" : "图片迁移"} value={report.images_written} />
|
||
)}
|
||
<DetailList title="手动排除" items={report.excluded_detail} />
|
||
<DetailList title="非公开 / 已导入跳过明细" items={report.skipped_detail} />
|
||
<DetailList title="图片缺失(保留原链接)" items={report.images_missing} color="var(--danger)" />
|
||
<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>
|
||
);
|
||
}
|