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

14 lines
5.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import {useEffect,useMemo,useState} from "react";
import {CalendarDays,CheckCircle2,Cloud,Flame,ChevronLeft,ChevronRight} from "lucide-react";
import {Checkbox} from "@/components/ui/checkbox";
import {Progress} from "@/components/ui/progress";
import {daysBetween,plan} from "./plan";
type Record={doneTasks:string;mood:string;note: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"));
export default function CheckinClient(){const [selected,setSelected]=useState(Math.max(0,Math.min(89,daysBetween("2026-09-13"))));const [records,setRecords]=useState<Record<string,Record>>({});const [status,setStatus]=useState("正在读取云端进度…");const [saving,setSaving]=useState(false);const day=plan[selected],record=records[day.date]||{doneTasks:"[]",mood:"",note:""};const done=useMemo(()=>{try{return JSON.parse(record.doneTasks)as string[]}catch{return[]}},[record.doneTasks]);const total=plan.reduce((n,p)=>n+p.tasks.length,0);const finished=Object.values(records).reduce((n,r)=>{try{return n+JSON.parse(r.doneTasks).length}catch{return n}},0);const completeDays=plan.filter(p=>{try{return JSON.parse(records[p.date]?.doneTasks||"[]").length===p.tasks.length}catch{return false}}).length;useEffect(()=>{fetch("/api/progress").then(async r=>{const x=await r.json();if(!r.ok)throw Error(x.error);const next:Record<string,Record>={};x.progress.forEach((p:Record&{planDate:string})=>next[p.planDate]=p);setRecords(next);setStatus("云端同步已开启");}).catch(e=>setStatus(e.message));},[]);
async function save(next:Record){setRecords(x=>({...x,[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:JSON.parse(next.doneTasks),mood:next.mood,note:next.note})});const 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))});
return <main><div className="shell"><header><div><p className="eyebrow">CET-6 · 2026</p><h1>大学英语六级 · 90天打卡</h1><p className="sub">从今天的一件事开始,完成比完美重要。</p></div><p className="sync"><Cloud size={16}/>{saving?"正在云端保存…":status}</p></header><section className="stats"><Stat icon={<CheckCircle2/>} value={Math.round(finished/total*100)+"%"} label="总体完成度" detail={finished+" / "+total+" 项任务"}/><Stat icon={<CalendarDays/>} value={completeDays+" 天"} label="完整完成" detail="目标共 90 天"/><Stat icon={<Flame/>} value={completeDays?"保持中":"0 天"} label="连续打卡" detail="按自己的节奏来"/><Stat icon={<CheckCircle2/>} value={day.phase} label="当前阶段" detail="12 月 12 日笔试"/></section><section className="layout"><article className="card"><div className="hero"><button className="round" onClick={()=>setSelected(n=>Math.max(0,n-1))} disabled={!selected}><ChevronLeft/></button><div><p>{day.phase} · {day.week}</p><h2>{fmt(day.date)}</h2><small>已完成 {done.length} / {day.tasks.length} 项</small></div><button className="round" onClick={()=>setSelected(n=>Math.min(89,n+1))} disabled={selected===89}><ChevronRight/></button><Progress value={done.length/day.tasks.length*100}/></div><div className="body"><div className="tasks">{day.tasks.map(t=><label className={done.includes(t.key)?"task done":"task"} key={t.key}><Checkbox checked={done.includes(t.key)} onCheckedChange={v=>toggle(t.key,v===true)}/><b>{t.label}</b><span>{t.text}</span></label>)}</div><div className="review"><p>今日状态</p><div>{["专注","平稳","有点累","需要调整"].map(m=><button className={record.mood===m?"pill active":"pill"} key={m} onClick={()=>void save({...record,mood:record.mood===m?"":m})}>{m}</button>)}</div><label>一句复盘(可选)<textarea value={record.note} onChange={e=>setRecords(x=>({...x,[day.date]:{...record,note:e.target.value}}))} onBlur={()=>void save(record)} placeholder="今天的收获、卡住的地方,或明天要调整的一点。"/></label></div></div></article><aside><div className="dark"><p>当前阶段</p><h2>{day.phase}</h2><p>{day.phase==="Ⅰ 恢复期"?"找回节奏,把基础重新接上。":day.phase==="Ⅱ 专项期"?"按模块补短板,练清楚错因。":day.phase==="Ⅲ 真题期"?"在计时和复盘中稳定能力。":"回炉核心内容,稳定发挥。"}</p></div><div className="side"><b>90 天阶段</b>{["Ⅰ 恢复期 · 9/13–10/5","Ⅱ 专项期 · 10/6–11/2","Ⅲ 真题期 · 11/3–11/29","Ⅳ 冲刺期 · 11/30–12/11"].map(x=><p key={x}>{x}</p>)}</div><p className="tip">忙的时候,优先完成一项最核心的任务;不断档,比补作业更重要。</p></aside></section></div></main>}
function Stat({icon,value,label,detail}:{icon:React.ReactNode;value:string;label:string;detail:string}){return <div className="stat"><span>{icon}</span><p>{label}</p><strong>{value}</strong><small>{detail}</small></div>}