Add unified JSON backup and restore

This commit is contained in:
Codex committed 2026-09-17 13:39:42 +08:00
1 parent 5f4a193888
commit bbdfe97c4e
6 files changed
+79 -2

No files matched your search

+57
View File
@@ -0,0 +1,57 @@
import { eq } from "drizzle-orm";
import { getDb } from "../../../db";
import { mockExams, progress, studyDays } from "../../../db/schema";
const user = (r: Request) => r.headers.get("oai-authenticated-user-id");
const validDate = (value: unknown) => typeof value === "string" && /^2026-(09|10|11|12)-\d{2}$/.test(value);
const safeText = (value: unknown, max: number) => typeof value === "string" ? value.slice(0, max) : "";
const taskKeys = new Set(["vocab", "listening", "reading", "output", "review"]);
export async function GET(r: Request) {
const id = user(r);
if (!id) return Response.json({ error: "请登录后再导出数据。" }, { status: 401 });
try {
const db = getDb();
const [allProgress, allStudyDays, allMockExams] = await Promise.all([
db.select().from(progress).where(eq(progress.userId, id)),
db.select().from(studyDays).where(eq(studyDays.userId, id)),
db.select().from(mockExams).where(eq(mockExams.userId, id)),
]);
return Response.json({ format: "cet6-checkin-backup", version: 1, exportedAt: new Date().toISOString(), data: {
progress: allProgress.map(({ userId, ...row }) => row),
studyDays: allStudyDays.map(({ userId, ...row }) => row),
mockExams: allMockExams.map(({ userId, ...row }) => row),
} });
} catch { return Response.json({ error: "暂时无法导出云端数据。" }, { status: 500 }); }
}
export async function POST(r: Request) {
const id = user(r);
if (!id) return Response.json({ error: "请登录后再导入数据。" }, { status: 401 });
let backup: any;
try { backup = await r.json(); } catch { return Response.json({ error: "文件不是有效的 JSON。" }, { status: 400 }); }
const data = backup?.data;
if (backup?.format !== "cet6-checkin-backup" || backup?.version !== 1 || !data || !Array.isArray(data.progress) || !Array.isArray(data.studyDays) || !Array.isArray(data.mockExams) || data.progress.length > 90 || data.studyDays.length > 90 || data.mockExams.length > 100) return Response.json({ error: "这不是可识别的六级打卡备份文件。" }, { status: 400 });
try {
const db = getDb(), now = new Date().toISOString();
for (const item of data.progress) {
if (!validDate(item?.planDate) || !Array.isArray(item?.doneTasks) || item.doneTasks.some((key: unknown) => typeof key !== "string" || !taskKeys.has(key))) return Response.json({ error: "打卡记录中含有无效数据。" }, { status: 400 });
const values = { userId: id, planDate: item.planDate, doneTasks: JSON.stringify([...new Set(item.doneTasks)]), mood: safeText(item.mood, 20), note: safeText(item.note, 1000), checkinType: item.checkinType === "makeup" ? "makeup" : "normal", checkedAt: safeText(item.checkedAt, 40) || now, updatedAt: now };
await db.insert(progress).values(values).onConflictDoUpdate({ target: [progress.userId, progress.planDate], set: values });
}
for (const item of data.studyDays) {
if (!validDate(item?.planDate)) return Response.json({ error: "学习记录中含有无效日期。" }, { status: 400 });
let durations: Record<string, number> = {}; try { durations = typeof item.durations === "string" ? JSON.parse(item.durations) : item.durations || {}; } catch { return Response.json({ error: "学习用时格式不正确。" }, { status: 400 }); }
const cleanDurations = Object.fromEntries(Object.entries(durations).map(([key, value]) => [key, Math.max(0, Math.min(600, Number(value) || 0))]));
const values = { userId: id, planDate: item.planDate, durations: JSON.stringify(cleanDurations), weeklyReview: safeText(item.weeklyReview, 1500), nextFocus: safeText(item.nextFocus, 400), updatedAt: now };
await db.insert(studyDays).values(values).onConflictDoUpdate({ target: [studyDays.userId, studyDays.planDate], set: values });
}
for (const item of data.mockExams) {
if (!validDate(item?.examDate)) return Response.json({ error: "模考记录中含有无效日期。" }, { status: 400 });
const score = (value: unknown) => safeText(value, 16).replace(/[^\d.]/g, "").slice(0, 6);
const values = { userId: id, examDate: item.examDate, listening: score(item.listening), reading: score(item.reading), writing: score(item.writing), translation: score(item.translation), note: safeText(item.note, 1000), updatedAt: now };
await db.insert(mockExams).values(values).onConflictDoUpdate({ target: [mockExams.userId, mockExams.examDate], set: values });
}
return Response.json({ imported: { progress: data.progress.length, studyDays: data.studyDays.length, mockExams: data.mockExams.length } });
} catch { return Response.json({ error: "导入失败,云端数据未能完整更新。" }, { status: 500 }); }
}
+16
View File
@@ -0,0 +1,16 @@
"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>;
}
+3
View File
@@ -0,0 +1,3 @@
import BackupClient from "./backup-client";
export default function BackupPage() { return <BackupClient />; }
Loaded 3 of 6 files, more files were not shown because too many files have changed in this diff. Show more