Add unified JSON backup and restore
This commit is contained in:
1 parent
5f4a193888
commit
bbdfe97c4e
6 files changed
+79
-2
No files matched your search
@@ -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 }); }
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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
Reference in new issue
Block a user