diff --git a/app/api/progress/route.ts b/app/api/progress/route.ts index b067302..81ae7fa 100644 --- a/app/api/progress/route.ts +++ b/app/api/progress/route.ts @@ -1,6 +1,29 @@ import { and, eq } from "drizzle-orm"; import { getDb } from "../../../db"; import { progress } from "../../../db/schema"; -const user=(r:Request)=>r.headers.get("oai-authenticated-user-id"); -export async function GET(r:Request){const id=user(r);if(!id)return Response.json({error:"请登录后再使用打卡功能。"},{status:401});try{return Response.json({progress:await getDb().select().from(progress).where(eq(progress.userId,id))});}catch{return Response.json({error:"暂时无法读取云端进度。"},{status:500});}} -export async function PUT(r:Request){const id=user(r);if(!id)return Response.json({error:"请登录后再保存打卡。"},{status:401});const b=await r.json() as {date?:string;doneTasks?:string[];mood?:string;note?:string};if(!/^2026-(09|10|11|12)-\d{2}$/.test(b.date||"")||!Array.isArray(b.doneTasks))return Response.json({error:"提交的数据格式不正确。"},{status:400});try{const db=getDb(), values={userId:id,planDate:b.date!,doneTasks:JSON.stringify(b.doneTasks),mood:(b.mood||"").slice(0,20),note:(b.note||"").slice(0,1000),updatedAt:new Date().toISOString()};await db.insert(progress).values(values).onConflictDoUpdate({target:[progress.userId,progress.planDate],set:values});const row=await db.select().from(progress).where(and(eq(progress.userId,id),eq(progress.planDate,b.date!)));return Response.json({progress:row[0]});}catch{return Response.json({error:"暂时无法保存到云端,请稍后重试。"},{status:500});}} + +const user = (r: Request) => r.headers.get("oai-authenticated-user-id"); +const todayInChina = () => new Intl.DateTimeFormat("en-CA", { timeZone: "Asia/Shanghai" }).format(new Date()); + +export async function GET(r: Request) { + const id = user(r); + if (!id) return Response.json({ error: "请登录后再使用打卡功能。" }, { status: 401 }); + try { return Response.json({ progress: await getDb().select().from(progress).where(eq(progress.userId, id)) }); } + catch { return Response.json({ error: "暂时无法读取云端进度。" }, { status: 500 }); } +} + +export async function PUT(r: Request) { + const id = user(r); + if (!id) return Response.json({ error: "请登录后再保存打卡。" }, { status: 401 }); + const b = await r.json() as { date?: string; doneTasks?: string[]; mood?: string; note?: string }; + if (!/^2026-(09|10|11|12)-\d{2}$/.test(b.date || "") || !Array.isArray(b.doneTasks)) return Response.json({ error: "提交的数据格式不正确。" }, { status: 400 }); + const today = todayInChina(); + if (b.date! > today) return Response.json({ error: "未来日期暂不能打卡。" }, { status: 400 }); + try { + const db = getDb(), now = new Date().toISOString(), checkinType = b.date! < today ? "makeup" : "normal"; + const values = { userId: id, planDate: b.date!, doneTasks: JSON.stringify(b.doneTasks), mood: (b.mood || "").slice(0, 20), note: (b.note || "").slice(0, 1000), checkinType, checkedAt: now, updatedAt: now }; + await db.insert(progress).values(values).onConflictDoUpdate({ target: [progress.userId, progress.planDate], set: values }); + const row = await db.select().from(progress).where(and(eq(progress.userId, id), eq(progress.planDate, b.date!))); + return Response.json({ progress: row[0] }); + } catch { return Response.json({ error: "暂时无法保存到云端,请稍后重试。" }, { status: 500 }); } +} diff --git a/app/checkin-client.tsx b/app/checkin-client.tsx index 125cde9..0a3cb43 100644 --- a/app/checkin-client.tsx +++ b/app/checkin-client.tsx @@ -1,13 +1,13 @@ "use client"; import { useEffect, useMemo, useState } from "react"; -import { CalendarDays, CheckCircle2, ChevronLeft, ChevronRight, Cloud, LayoutList, Timer } from "lucide-react"; +import { BarChart3, CalendarDays, CheckCircle2, ChevronLeft, ChevronRight, Cloud, LayoutList, Timer } from "lucide-react"; import { Checkbox } from "@/components/ui/checkbox"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Progress } from "@/components/ui/progress"; import { daysBetween, plan } from "./plan"; -type Rec = { doneTasks: string; mood: string; note: string }; +type Rec = { doneTasks: string; mood: string; note: string; checkinType?: "normal" | "makeup"; checkedAt?: string }; const fmt = (s: string) => new Intl.DateTimeFormat("zh-CN", { month: "long", day: "numeric", weekday: "short", timeZone: "UTC" }).format(new Date(s + "T00:00:00Z")); const parseTasks = (value?: string) => { try { return JSON.parse(value || "[]") as string[]; } catch { return []; } }; @@ -20,6 +20,8 @@ export default function CheckinClient() { const [now, setNow] = useState(Date.now()); const [showCountdown, setShowCountdown] = useState(false); const day = plan[selected], record = records[day.date] || { doneTasks: "[]", mood: "", note: "" }; + const today = new Intl.DateTimeFormat("en-CA", { timeZone: "Asia/Shanghai" }).format(new Date()); + const isFuture = day.date > today, isMakeup = day.date < today; const done = useMemo(() => parseTasks(record.doneTasks), [record.doneTasks]); const total = plan.reduce((n, p) => n + p.tasks.length, 0); const finished = Object.values(records).reduce((n, r) => n + parseTasks(r.doneTasks).length, 0); @@ -33,15 +35,15 @@ export default function CheckinClient() { useEffect(() => { const id = window.setInterval(() => setNow(Date.now()), 30000); return () => window.clearInterval(id); }, []); useEffect(() => { const today = new Intl.DateTimeFormat("en-CA", { timeZone: "Asia/Shanghai" }).format(new Date()); if (localStorage.getItem("cet6-countdown-notice") !== today) setShowCountdown(true); }, []); useEffect(() => { fetch("/api/progress").then(async r => { const x = await r.json(); if (!r.ok) throw Error(x.error); const all: Record = {}; x.progress.forEach((p: Rec & { planDate: string }) => all[p.planDate] = p); setRecords(all); setStatus("云端同步已开启"); }).catch(e => setStatus(e.message)); }, []); - async function save(next: Rec) { setRecords(v => ({ ...v, [day.date]: next })); setSaving(true); try { const r = await fetch("/api/progress", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ date: day.date, doneTasks: parseTasks(next.doneTasks), mood: next.mood, note: next.note }) }), x = await r.json(); if (!r.ok) throw Error(x.error); setRecords(v => ({ ...v, [day.date]: x.progress })); setStatus("已保存到云端"); } catch (e) { setStatus(e instanceof Error ? e.message : "保存失败"); } finally { setSaving(false); } } - const toggle = (key: string, on: boolean) => void save({ ...record, doneTasks: JSON.stringify(on ? [...done, key] : done.filter(x => x !== key)) }); + async function save(next: Rec) { if (isFuture) { setStatus("未来日期暂不能打卡"); return; } setRecords(v => ({ ...v, [day.date]: next })); setSaving(true); try { const r = await fetch("/api/progress", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ date: day.date, doneTasks: parseTasks(next.doneTasks), mood: next.mood, note: next.note }) }), x = await r.json(); if (!r.ok) throw Error(x.error); setRecords(v => ({ ...v, [day.date]: x.progress })); setStatus(isMakeup ? "补打卡已保存" : "已保存到云端"); } catch (e) { setStatus(e instanceof Error ? e.message : "保存失败"); } finally { setSaving(false); } } + const toggle = (key: string, on: boolean) => { if (!isFuture) void save({ ...record, doneTasks: JSON.stringify(on ? [...done, key] : done.filter(x => x !== key)) }); }; 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}

} 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
-

CHECK-IN CALENDAR

打卡日历

{[8, 9, 10, 11].map(m => )}
{["日", "一", "二", "三", "四", "五", "六"].map(x => {x})}
{blanks.map((_, i) => )}{dates.map(date => { const idx = plan.findIndex(p => p.date === date), p = plan[idx], n = parseTasks(records[date]?.doneTasks).length, full = !!p && n === p.tasks.length, partial = !!p && n > 0 && !full; return ; })}

全部完成 进行中 点击日期即可查看并打卡

-

{day.phase} · {day.week}

{fmt(day.date)}

已完成 {done.length} / {day.tasks.length} 项
{day.tasks.map(t => )}

今日状态

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