From bbdfe97c4e125fc97457cbe222e2970201730b08 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 17 Sep 2026 13:39:42 +0800 Subject: [PATCH] Add unified JSON backup and restore --- app/api/backup/route.ts | 57 ++++++++++++++++++++++++++++++++++++ app/backup/backup-client.tsx | 16 ++++++++++ app/backup/page.tsx | 3 ++ app/checkin-client.tsx | 2 +- app/globals.css | 1 + app/review/review-client.tsx | 2 +- 6 files changed, 79 insertions(+), 2 deletions(-) create mode 100644 app/api/backup/route.ts create mode 100644 app/backup/backup-client.tsx create mode 100644 app/backup/page.tsx diff --git a/app/api/backup/route.ts b/app/api/backup/route.ts new file mode 100644 index 0000000..3369133 --- /dev/null +++ b/app/api/backup/route.ts @@ -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 = {}; 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 }); } +} diff --git a/app/backup/backup-client.tsx b/app/backup/backup-client.tsx new file mode 100644 index 0000000..d4943eb --- /dev/null +++ b/app/backup/backup-client.tsx @@ -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(null), [pending, setPending] = useState(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) => { 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

DATA BACKUP

数据备份与迁移

一个 JSON 文件保存所有云端学习数据。

返回打卡

{status}

EXPORT

导出完整备份

下载一个 JSON 文件,包含每日打卡、学习用时、周复盘和模考记录。

IMPORT

从备份恢复

选择此前导出的 JSON 文件。相同日期的数据会由备份覆盖,其他已有记录会保留。

void selectFile(event)} />

导入前会先检查文件格式。 备份文件仅含你的学习记录;请妥善保管,不要上传到公开位置。

!open && setPending(null)}>确认导入这份备份?将导入 {counts}。相同日期的记录会被这份备份覆盖,操作完成后建议刷新其他已打开的页面。取消 void importAll()}>确认导入
; +} diff --git a/app/backup/page.tsx b/app/backup/page.tsx new file mode 100644 index 0000000..2d9423f --- /dev/null +++ b/app/backup/page.tsx @@ -0,0 +1,3 @@ +import BackupClient from "./backup-client"; + +export default function BackupPage() { return ; } diff --git a/app/checkin-client.tsx b/app/checkin-client.tsx index 537baea..32c6453 100644 --- a/app/checkin-client.tsx +++ b/app/checkin-client.tsx @@ -72,7 +72,7 @@ export default function CheckinClient() { const closeCountdown = () => { const today = new Intl.DateTimeFormat("en-CA", { timeZone: "Asia/Shanghai" }).format(new Date()); localStorage.setItem("cet6-countdown-notice", today); setShowCountdown(false); }; return
{ if (!open) closeCountdown(); }}>

CET-6 · 考试倒计时

今天也离目标更近一步2026 年 12 月 12 日 15:00 笔试
{remaining.days}天{String(remaining.hours).padStart(2, "0")}时{String(remaining.minutes).padStart(2, "0")}分

今天的任务,完成一项就是一次推进。

-

CET-6 · 2026

大学英语六级 · 90天打卡

从今天的一件事开始,完成比完美重要。

任务总览数据统计复盘与冲刺

{saving ? "正在云端保存…" : status}

+

CET-6 · 2026

大学英语六级 · 90天打卡

从今天的一件事开始,完成比完美重要。

任务总览数据统计复盘与冲刺数据备份

{saving ? "正在云端保存…" : status}

TODAY'S NOTE

今天怎么推进

{smartTip}

} value={Math.round(finished / total * 100) + "%"} label="总体完成度" detail={finished + " / " + total + " 项任务"} />} value={complete + " 天"} label="完整完成" detail="目标共 90 天" />} value={day.phase} label="当前阶段" detail="12 月 12 日笔试" />

距离六级笔试

{remaining.days}天 {remaining.hours}时 {remaining.minutes}分2026 年 12 月 12 日 15:00

{day.phase} · {day.week}

{fmt(day.date)}

{isFuture ? "未来日期,暂不可打卡" : isMakeup ? "补打卡 · 已完成项目不可取消" : "今日打卡"} · 已完成 {done.length} / {day.tasks.length} 项
{isToday ? "正在处理今天的任务" : "正在查看 " + fmt(day.date)}{!isToday && }
{day.tasks.map(t => { const locked = isFuture || (isMakeup && done.includes(t.key)); return ; })}
{isToday && done.length === day.tasks.length &&

TODAY COMPLETE

今天的任务已全部完成

生成一张不含复盘内容的打卡卡片,记录日期、任务清单、阶段与倒计时。

}

今日状态

{["专注", "平稳", "有点累", "需要调整"].map(m => )}