Files
jiang13-bbs/frontend/app/admin/settings/import/ImportPanel.tsx
freefire 1d862a0dd3 feat: 站点媒体库与移动端底部导航,服务测试补强
- 新增媒体库:media_library/media_thumbs 服务(WebP 缩略图)、管理端 media 页面
- 移动端底部导航 MobileTabBar 替换 MobilePostBar
- 旧数据导入增强与测试、路由与登录会话/板块侧边栏测试补强
- site-doc 组件精简(移除 Breadcrumb),文档新增迁移公告说明
2026-09-27 02:36:11 +08:00

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