29 lines
7.8 KiB
TypeScript
29 lines
7.8 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useMemo, useState } from "react";
|
|
import { ArrowLeft, Download, Save, Timer, Trophy } from "lucide-react";
|
|
import { plan, TaskKey } from "../plan";
|
|
|
|
type Progress = { planDate: string; doneTasks: string };
|
|
type Study = { planDate: string; durations: string; weeklyReview: string; nextFocus: string };
|
|
type Mock = { examDate: string; listening: string; reading: string; writing: string; translation: string; note: string };
|
|
const keys: { key: TaskKey; label: string }[] = [{ key: "vocab", label: "词汇" }, { key: "listening", label: "听力" }, { key: "reading", label: "阅读" }, { key: "output", label: "输出" }, { key: "review", label: "复盘" }];
|
|
const day = () => new Intl.DateTimeFormat("en-CA", { timeZone: "Asia/Shanghai" }).format(new Date());
|
|
const parse = (v?: string) => { try { return JSON.parse(v || "{}") as Record<string, number>; } catch { return {}; } };
|
|
const checked = (v?: string) => { try { return JSON.parse(v || "[]") as string[]; } catch { return []; } };
|
|
const initialMock = { listening: "", reading: "", writing: "", translation: "", note: "" };
|
|
|
|
export default function ReviewClient() {
|
|
const [progress, setProgress] = useState<Record<string, Progress>>({}), [studies, setStudies] = useState<Record<string, Study>>({}), [mocks, setMocks] = useState<Mock[]>([]), [date, setDate] = useState(day()), [durations, setDurations] = useState<Record<string, number>>({}), [review, setReview] = useState(""), [focus, setFocus] = useState(""), [mock, setMock] = useState(initialMock), [status, setStatus] = useState("正在读取学习中心…");
|
|
useEffect(() => { Promise.all([fetch("/api/progress").then(r => r.json()), fetch("/api/study").then(r => r.json())]).then(([p, s]) => { if (p.error || s.error) throw Error(p.error || s.error); setProgress(Object.fromEntries(p.progress.map((x: Progress) => [x.planDate, x]))); const all = Object.fromEntries(s.studyDays.map((x: Study) => [x.planDate, x])); setStudies(all); setMocks(s.mockExams); const current = all[date]; if (current) { setDurations(parse(current.durations)); setReview(current.weeklyReview); setFocus(current.nextFocus); } setStatus("云端数据已同步"); }).catch(e => setStatus(e.message)); }, []);
|
|
const insight = useMemo(() => { const past = plan.filter(x => x.date < day()), future = plan.filter(x => x.date >= day()); const missing = past.reduce((n, x) => n + x.tasks.filter(t => !checked(progress[x.date]?.doneTasks).includes(t.key)).length, 0); const extra = future.length ? Math.ceil(missing / future.length) : missing; const week = plan.find(x => x.date === date)?.week || "本周"; return { missing, extra, week }; }, [progress, date]);
|
|
const total = ([mock.listening, mock.reading, mock.writing, mock.translation].reduce((n, x) => n + (Number(x) || 0), 0));
|
|
const saveDay = async () => { setStatus("正在保存…"); const r = await fetch("/api/study", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ type: "day", date, durations, weeklyReview: review, nextFocus: focus }) }); const x = await r.json(); if (!r.ok) return setStatus(x.error); setStudies(v => ({ ...v, [date]: x.studyDay })); setStatus("学习记录已保存"); };
|
|
const saveMock = async () => { setStatus("正在保存模考…"); const r = await fetch("/api/study", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ type: "mock", date, ...mock }) }); const x = await r.json(); if (!r.ok) return setStatus(x.error); setMocks(v => [...v.filter(m => m.examDate !== date), x.mockExam].sort((a, b) => a.examDate.localeCompare(b.examDate))); setStatus("模考记录已保存"); };
|
|
const exportData = () => { const rows = ["日期,词汇分钟,听力分钟,阅读分钟,输出分钟,复盘分钟,周复盘,下周重点", ...Object.values(studies).sort((a,b)=>a.planDate.localeCompare(b.planDate)).map(x => { const d = parse(x.durations); return [x.planDate, ...keys.map(k => d[k.key] || 0), x.weeklyReview, x.nextFocus].map(v => `"${String(v).replaceAll('"','""')}"`).join(","); }), "", "模考日期,听力,阅读,写作,翻译,总分,备注", ...mocks.map(x => [x.examDate,x.listening,x.reading,x.writing,x.translation,[x.listening,x.reading,x.writing,x.translation].reduce((n,v)=>n+(Number(v)||0),0),x.note].map(v => `"${String(v).replaceAll('"','""')}"`).join(","))]; const url = URL.createObjectURL(new Blob([rows.join("\n")], { type: "text/csv;charset=utf-8" })); const a = document.createElement("a"); a.href = url; a.download = "cet6-study-record.csv"; a.click(); URL.revokeObjectURL(url); };
|
|
return <main><div className="shell study-hub"><header><div><p className="eyebrow">STUDY HUB</p><h1>复盘与冲刺</h1><p className="sub">记录投入、复盘阶段成果,再把计划推进到下一天。</p></div><div className="header-actions"><a className="overview-link" href="/backup">数据备份</a><a className="overview-link" href="/"><ArrowLeft size={17} />返回打卡</a></div></header><p className="overview-status">{status}</p>
|
|
<section className="hub-grid"><article className="hub-card plan-card"><p className="eyebrow">PLAN PULSE</p><h2>计划调整建议</h2><strong>{insight.missing} 项</strong><p>历史日期仍有未完成任务。若要在原计划结束前补齐,建议未来每天额外完成 <b>{insight.extra} 项</b>;优先补“复盘”和当天同类型任务。</p><small>这是顺延建议,不会自动改动原计划。</small></article><article className="hub-card"><p className="eyebrow">DAILY TIME</p><h2>今日用时</h2><label className="date-field">记录日期<input type="date" min="2026-09-13" max="2026-12-11" value={date} onChange={e => { setDate(e.target.value); const x = studies[e.target.value]; setDurations(parse(x?.durations)); setReview(x?.weeklyReview || ""); setFocus(x?.nextFocus || ""); }} /></label><div className="duration-grid">{keys.map(x => <label key={x.key}>{x.label}<input inputMode="numeric" value={durations[x.key] || ""} onChange={e => setDurations(v => ({ ...v, [x.key]: Number(e.target.value) || 0 }))} placeholder="分钟" /></label>)}</div><button className="primary-action" onClick={saveDay}><Timer size={17} />保存用时</button></article></section>
|
|
<section className="hub-grid lower"><article className="hub-card"><p className="eyebrow">WEEKLY REVIEW · {insight.week}</p><h2>本周复盘</h2><label>本周哪里最有效?哪里卡住?<textarea value={review} onChange={e => setReview(e.target.value)} placeholder="例如:长篇匹配定位更稳,但听力仍容易漏掉转折词。" /></label><label>下周只抓的一件事<textarea value={focus} onChange={e => setFocus(e.target.value)} placeholder="写一条具体、可执行的重点。" /></label><button className="primary-action" onClick={saveDay}><Save size={17} />保存复盘</button></article><article className="hub-card"><p className="eyebrow">MOCK EXAM</p><h2>模考记录 <span className="score-total">{total || "—"}</span></h2><div className="duration-grid score-grid">{(["listening","reading","writing","translation"] as const).map(k => <label key={k}>{({listening:"听力",reading:"阅读",writing:"写作",translation:"翻译"}[k])}<input inputMode="decimal" value={mock[k]} onChange={e => setMock(v => ({ ...v, [k]: e.target.value }))} placeholder="分数" /></label>)}</div><label>本次模考观察<textarea value={mock.note} onChange={e => setMock(v => ({ ...v, note: e.target.value }))} placeholder="写下失分原因、时间分配或下一次要验证的策略。" /></label><button className="primary-action" onClick={saveMock}><Trophy size={17} />保存本次模考</button><div className="mock-history">{mocks.length ? mocks.slice(-4).reverse().map(x => <p key={x.examDate}><b>{x.examDate}</b><span>{[x.listening,x.reading,x.writing,x.translation].reduce((n,v)=>n+(Number(v)||0),0)} 分</span></p>) : <p>还没有模考记录。</p>}</div></article></section>
|
|
</div></main>;
|
|
}
|