Files
cet6-90-day-checkin/app/backup/backup-client.tsx
T

17 lines
5.2 KiB
TypeScript

"use client";
import { useRef, useState } from "react";
import { ArrowLeft, Download, FileUp, ShieldCheck } from "lucide-react";
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog";
type Backup = { format: "cet6-checkin-backup"; version: 1; exportedAt: string; data: { progress: unknown[]; studyDays: unknown[]; mockExams: unknown[] } };
export default function BackupClient() {
const input = useRef<HTMLInputElement>(null), [pending, setPending] = useState<Backup | null>(null), [status, setStatus] = useState("导出文件可留作换设备或恢复数据时使用。"), [working, setWorking] = useState(false);
const exportAll = async () => { setWorking(true); setStatus("正在生成完整备份…"); try { const response = await fetch("/api/backup"), backup = await response.json(); if (!response.ok) throw Error(backup.error); const blob = new Blob([JSON.stringify(backup, null, 2)], { type: "application/json" }), url = URL.createObjectURL(blob), link = document.createElement("a"); link.href = url; link.download = `cet6-checkin-backup-${backup.exportedAt.slice(0, 10)}.json`; link.click(); URL.revokeObjectURL(url); setStatus(`备份已下载:含 ${backup.data.progress.length} 条打卡、${backup.data.studyDays.length} 条学习记录和 ${backup.data.mockExams.length} 条模考记录。`); } catch (error) { setStatus(error instanceof Error ? error.message : "备份生成失败,请稍后重试。"); } finally { setWorking(false); } };
const selectFile = async (event: React.ChangeEvent<HTMLInputElement>) => { const file = event.target.files?.[0]; event.target.value = ""; if (!file) return; if (file.size > 2 * 1024 * 1024) return setStatus("备份文件过大,请选择 2 MB 以内的 JSON 文件。"); try { const backup = JSON.parse(await file.text()) as Backup; if (backup.format !== "cet6-checkin-backup" || backup.version !== 1 || !backup.data || !Array.isArray(backup.data.progress) || !Array.isArray(backup.data.studyDays) || !Array.isArray(backup.data.mockExams)) throw Error("这不是可识别的六级打卡备份文件。"); setPending(backup); } catch (error) { setStatus(error instanceof Error ? error.message : "无法读取该文件。"); } };
const importAll = async () => { if (!pending) return; setWorking(true); try { const response = await fetch("/api/backup", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(pending) }), result = await response.json(); if (!response.ok) throw Error(result.error); setStatus(`导入完成:${result.imported.progress} 条打卡、${result.imported.studyDays} 条学习记录、${result.imported.mockExams} 条模考记录。`); setPending(null); } catch (error) { setStatus(error instanceof Error ? error.message : "导入失败,请稍后重试。"); } finally { setWorking(false); } };
const counts = pending ? `${pending.data.progress.length} 条打卡、${pending.data.studyDays.length} 条学习记录、${pending.data.mockExams.length} 条模考记录` : "";
return <main><div className="shell backup-page"><header><div><p className="eyebrow">DATA BACKUP</p><h1>数据备份与迁移</h1><p className="sub">一个 JSON 文件保存所有云端学习数据。</p></div><a className="overview-link" href="/"><ArrowLeft size={17} />返回打卡</a></header><p className="overview-status">{status}</p><section className="backup-grid"><article className="backup-card export-card"><div className="backup-icon"><Download size={26} /></div><p className="eyebrow">EXPORT</p><h2>导出完整备份</h2><p>下载一个 JSON 文件,包含每日打卡、学习用时、周复盘和模考记录。</p><button className="backup-action" disabled={working} onClick={() => void exportAll()}><Download size={18} />导出 JSON 备份</button></article><article className="backup-card"><div className="backup-icon"><FileUp size={26} /></div><p className="eyebrow">IMPORT</p><h2>从备份恢复</h2><p>选择此前导出的 JSON 文件。相同日期的数据会由备份覆盖,其他已有记录会保留。</p><input ref={input} className="file-input" type="file" accept="application/json,.json" onChange={event => void selectFile(event)} /><button className="backup-action outline" disabled={working} onClick={() => input.current?.click()}><FileUp size={18} />选择备份文件</button></article></section><section className="backup-note"><ShieldCheck size={20} /><p><b>导入前会先检查文件格式。</b> 备份文件仅含你的学习记录;请妥善保管,不要上传到公开位置。</p></section><AlertDialog open={!!pending} onOpenChange={open => !open && setPending(null)}><AlertDialogContent><AlertDialogHeader><AlertDialogTitle>确认导入这份备份?</AlertDialogTitle><AlertDialogDescription>将导入 {counts}。相同日期的记录会被这份备份覆盖,操作完成后建议刷新其他已打开的页面。</AlertDialogDescription></AlertDialogHeader><AlertDialogFooter><AlertDialogCancel disabled={working}>取消</AlertDialogCancel><AlertDialogAction disabled={working} onClick={() => void importAll()}>确认导入</AlertDialogAction></AlertDialogFooter></AlertDialogContent></AlertDialog></div></main>;
}