- 新增管理端板块管理页面与后端接口(admin_board) - 新增旧版数据导入:legacyimport 服务、导入面板、importusers 命令行工具 - 聊天用户卡片、板块图标等 UI 组件与界面优化 - 补充 about/公告/1Panel 部署等文档 - gitignore 排除 dist/ 构建产物与 .agents/ 本地工具目录
416 lines
14 KiB
TypeScript
416 lines
14 KiB
TypeScript
"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>
|
||
);
|
||
}
|