feat: 管理端板块管理与旧版数据导入,补充部署运营文档
- 新增管理端板块管理页面与后端接口(admin_board) - 新增旧版数据导入:legacyimport 服务、导入面板、importusers 命令行工具 - 聊天用户卡片、板块图标等 UI 组件与界面优化 - 补充 about/公告/1Panel 部署等文档 - gitignore 排除 dist/ 构建产物与 .agents/ 本地工具目录
This commit is contained in:
415
frontend/app/admin/settings/import/ImportPanel.tsx
Normal file
415
frontend/app/admin/settings/import/ImportPanel.tsx
Normal file
@@ -0,0 +1,415 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user