Add study review planning and mock exam hub

This commit is contained in:
Codex committed 2026-09-17 12:37:58 +08:00
1 parent f5bfd501a5
commit 0e118b6eaf
7 files changed
+109 -1

No files matched your search

+34
View File
@@ -0,0 +1,34 @@
import { and, eq } from "drizzle-orm";
import { getDb } from "../../../db";
import { mockExams, studyDays } from "../../../db/schema";
const user = (r: Request) => r.headers.get("oai-authenticated-user-id");
const validDate = (s?: string) => /^2026-(09|10|11|12)-\d{2}$/.test(s || "");
export async function GET(r: Request) {
const id = user(r); if (!id) return Response.json({ error: "请登录后再使用学习中心。" }, { status: 401 });
try { const db = getDb(); return Response.json({ studyDays: await db.select().from(studyDays).where(eq(studyDays.userId, id)), mockExams: await db.select().from(mockExams).where(eq(mockExams.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 { type?: string; date?: string; durations?: Record<string, number>; weeklyReview?: string; nextFocus?: string; listening?: string; reading?: string; writing?: string; translation?: string; note?: string };
if (!validDate(b.date)) return Response.json({ error: "日期格式不正确。" }, { status: 400 });
try {
const db = getDb(), now = new Date().toISOString();
if (b.type === "day") {
const durations = Object.fromEntries(Object.entries(b.durations || {}).map(([k, v]) => [k, Math.max(0, Math.min(600, Number(v) || 0))]));
const values = { userId: id, planDate: b.date!, durations: JSON.stringify(durations), weeklyReview: (b.weeklyReview || "").slice(0, 1500), nextFocus: (b.nextFocus || "").slice(0, 400), updatedAt: now };
await db.insert(studyDays).values(values).onConflictDoUpdate({ target: [studyDays.userId, studyDays.planDate], set: values });
return Response.json({ studyDay: (await db.select().from(studyDays).where(and(eq(studyDays.userId, id), eq(studyDays.planDate, b.date!))))[0] });
}
if (b.type === "mock") {
const clean = (v?: string) => String(v || "").replace(/[^\d.]/g, "").slice(0, 6);
const values = { userId: id, examDate: b.date!, listening: clean(b.listening), reading: clean(b.reading), writing: clean(b.writing), translation: clean(b.translation), note: (b.note || "").slice(0, 1000), updatedAt: now };
await db.insert(mockExams).values(values).onConflictDoUpdate({ target: [mockExams.userId, mockExams.examDate], set: values });
return Response.json({ mockExam: (await db.select().from(mockExams).where(and(eq(mockExams.userId, id), eq(mockExams.examDate, b.date!))))[0] });
}
return Response.json({ error: "请求类型不正确。" }, { status: 400 });
} catch { return Response.json({ error: "暂时无法保存,请稍后重试。" }, { status: 500 }); }
}
+1 -1
View File
@@ -41,7 +41,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); }; const closeCountdown = () => { const today = new Intl.DateTimeFormat("en-CA", { timeZone: "Asia/Shanghai" }).format(new Date()); localStorage.setItem("cet6-countdown-notice", today); setShowCountdown(false); };
return <main><Dialog open={showCountdown} onOpenChange={open => { if (!open) closeCountdown(); }}><DialogContent className="countdown-dialog" showCloseButton={false}><DialogHeader><p className="dialog-kicker">CET-6 · 考试倒计时</p><DialogTitle>今天也离目标更近一步</DialogTitle><DialogDescription>2026 年 12 月 12 日 15:00 笔试</DialogDescription></DialogHeader><div className="dialog-time"><b>{remaining.days}</b><span>天</span><b>{String(remaining.hours).padStart(2, "0")}</b><span>时</span><b>{String(remaining.minutes).padStart(2, "0")}</b><span>分</span></div><p className="dialog-note">今天的任务,完成一项就是一次推进。</p><button className="dialog-button" onClick={closeCountdown}>开始今天的打卡</button></DialogContent></Dialog><div className="shell"> return <main><Dialog open={showCountdown} onOpenChange={open => { if (!open) closeCountdown(); }}><DialogContent className="countdown-dialog" showCloseButton={false}><DialogHeader><p className="dialog-kicker">CET-6 · 考试倒计时</p><DialogTitle>今天也离目标更近一步</DialogTitle><DialogDescription>2026 年 12 月 12 日 15:00 笔试</DialogDescription></DialogHeader><div className="dialog-time"><b>{remaining.days}</b><span>天</span><b>{String(remaining.hours).padStart(2, "0")}</b><span>时</span><b>{String(remaining.minutes).padStart(2, "0")}</b><span>分</span></div><p className="dialog-note">今天的任务,完成一项就是一次推进。</p><button className="dialog-button" onClick={closeCountdown}>开始今天的打卡</button></DialogContent></Dialog><div className="shell">
<header><div><p className="eyebrow">CET-6 · 2026</p><h1>大学英语六级 · 90天打卡</h1><p className="sub">从今天的一件事开始,完成比完美重要。</p></div><div className="header-actions"><a className="overview-link" href="/tasks"><LayoutList size={17} />任务总览</a><a className="overview-link" href="/stats"><BarChart3 size={17} />数据统计</a><p className="sync"><Cloud size={16} />{saving ? "正在云端保存…" : status}</p></div></header> <header><div><p className="eyebrow">CET-6 · 2026</p><h1>大学英语六级 · 90天打卡</h1><p className="sub">从今天的一件事开始,完成比完美重要。</p></div><div className="header-actions"><a className="overview-link" href="/tasks"><LayoutList size={17} />任务总览</a><a className="overview-link" href="/stats"><BarChart3 size={17} />数据统计</a><a className="overview-link" href="/review">复盘与冲刺</a><p className="sync"><Cloud size={16} />{saving ? "正在云端保存…" : status}</p></div></header>
<section className="stats"><Stat icon={<CheckCircle2 />} value={Math.round(finished / total * 100) + "%"} label="总体完成度" detail={finished + " / " + total + " 项任务"} /><Stat icon={<CalendarDays />} value={complete + " 天"} label="完整完成" detail="目标共 90 天" /><Stat icon={<CheckCircle2 />} value={day.phase} label="当前阶段" detail="12 月 12 日笔试" /><div className="countdown"><span><Timer size={19} /></span><p>距离六级笔试</p><strong>{remaining.days}<em>天</em> {remaining.hours}<em>时</em> {remaining.minutes}<em>分</em></strong><small>2026 年 12 月 12 日 15:00</small></div></section> <section className="stats"><Stat icon={<CheckCircle2 />} value={Math.round(finished / total * 100) + "%"} label="总体完成度" detail={finished + " / " + total + " 项任务"} /><Stat icon={<CalendarDays />} value={complete + " 天"} label="完整完成" detail="目标共 90 天" /><Stat icon={<CheckCircle2 />} value={day.phase} label="当前阶段" detail="12 月 12 日笔试" /><div className="countdown"><span><Timer size={19} /></span><p>距离六级笔试</p><strong>{remaining.days}<em>天</em> {remaining.hours}<em>时</em> {remaining.minutes}<em>分</em></strong><small>2026 年 12 月 12 日 15:00</small></div></section>
<section className="layout"><article className="card"><div className="hero"><button className="round" aria-label="前一天" 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>{isFuture ? "未来日期,暂不可打卡" : isMakeup ? "补打卡 · 已完成项目不可取消" : "今日打卡"} · 已完成 {done.length} / {day.tasks.length} 项</small></div><button className="round" aria-label="后一天" 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="task-toolbar"><span>{isToday ? "正在处理今天的任务" : "正在查看 " + fmt(day.date)}</span>{!isToday && <button className="today-button" onClick={() => { setSelected(todayIndex); setMonth(new Date(plan[todayIndex].date + "T00:00:00Z").getUTCMonth()); }}>回到今天</button>}</div><div className="tasks">{day.tasks.map(t => { const locked = isFuture || (isMakeup && done.includes(t.key)); return <label className={(done.includes(t.key) ? "task done " : "task ") + (locked ? "locked" : "")} key={t.key}><Checkbox disabled={locked} checked={done.includes(t.key)} onCheckedChange={v => toggle(t.key, v === true)} /><b>{t.label}</b><span>{t.text}</span>{isMakeup && done.includes(t.key) && <small className="locked-note">已完成,已锁定</small>}</label>; })}</div><div className="review"><p>今日状态</p><div>{["专注", "平稳", "有点累", "需要调整"].map(m => <button disabled={isFuture} className={record.mood === m ? "pill active" : "pill"} key={m} onClick={() => void save({ ...record, mood: record.mood === m ? "" : m })}>{m}</button>)}</div><label>一句复盘(可选)<textarea disabled={isFuture} value={record.note} onChange={e => setRecords(x => ({ ...x, [day.date]: { ...record, note: e.target.value } }))} onBlur={() => void save(record)} placeholder={isFuture ? "未到此日期,暂不能修改。" : "今天的收获、卡住的地方,或明天要调整的一点。"} /></label></div></div></article><aside><section className="calendar side-calendar"><div className="calendar-head"><div><p className="eyebrow">CHECK-IN</p><h2>打卡日历</h2></div><div className="month-tabs">{[8, 9, 10, 11].map(m => <button aria-label={`查看${m + 1}月`} className={month === m ? "active" : ""} onClick={() => setMonth(m)} key={m}>{m + 1}月</button>)}</div></div><div className="weekdays">{["日", "一", "二", "三", "四", "五", "六"].map(x => <span key={x}>{x}</span>)}</div><div className="calendar-grid">{blanks.map((_, i) => <span key={"e" + 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, makeup = records[date]?.checkinType === "makeup"; return <button disabled={!p} key={date} onClick={() => { if (p) { setSelected(idx); setMonth(new Date(date + "T00:00:00Z").getUTCMonth()); } }} className={"calendar-day " + (idx === selected ? "chosen " : "") + (full ? "full " : partial ? "partial " : "") + (makeup ? "makeup " : "") + (p ? "" : "outside")}><b>{Number(date.slice(-2))}</b>{p && <small>{full ? makeup ? "补打卡" : "完成" : partial ? n + "/" + p.tasks.length : date > today ? "未到" : "待打卡"}</small>}</button>; })}</div><p className="calendar-note"><i className="full-dot" />完成 <i className="partial-dot" />进行中 <i className="makeup-dot" />补打卡</p></section><div className="dark"><p>{isFuture ? "日期限制" : isMakeup ? "补打卡" : "当前阶段"}</p><h2>{isFuture ? "暂未开放" : isMakeup ? "补做未完成项目" : day.phase}</h2><p>{isFuture ? "当天到来后即可正常打卡。" : isMakeup ? "已有完成项已锁定,只能勾选此前未完成的项目。" : "每天稳步推进,答案会慢慢变得清楚。"}</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></aside></section> <section className="layout"><article className="card"><div className="hero"><button className="round" aria-label="前一天" 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>{isFuture ? "未来日期,暂不可打卡" : isMakeup ? "补打卡 · 已完成项目不可取消" : "今日打卡"} · 已完成 {done.length} / {day.tasks.length} 项</small></div><button className="round" aria-label="后一天" 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="task-toolbar"><span>{isToday ? "正在处理今天的任务" : "正在查看 " + fmt(day.date)}</span>{!isToday && <button className="today-button" onClick={() => { setSelected(todayIndex); setMonth(new Date(plan[todayIndex].date + "T00:00:00Z").getUTCMonth()); }}>回到今天</button>}</div><div className="tasks">{day.tasks.map(t => { const locked = isFuture || (isMakeup && done.includes(t.key)); return <label className={(done.includes(t.key) ? "task done " : "task ") + (locked ? "locked" : "")} key={t.key}><Checkbox disabled={locked} checked={done.includes(t.key)} onCheckedChange={v => toggle(t.key, v === true)} /><b>{t.label}</b><span>{t.text}</span>{isMakeup && done.includes(t.key) && <small className="locked-note">已完成,已锁定</small>}</label>; })}</div><div className="review"><p>今日状态</p><div>{["专注", "平稳", "有点累", "需要调整"].map(m => <button disabled={isFuture} className={record.mood === m ? "pill active" : "pill"} key={m} onClick={() => void save({ ...record, mood: record.mood === m ? "" : m })}>{m}</button>)}</div><label>一句复盘(可选)<textarea disabled={isFuture} value={record.note} onChange={e => setRecords(x => ({ ...x, [day.date]: { ...record, note: e.target.value } }))} onBlur={() => void save(record)} placeholder={isFuture ? "未到此日期,暂不能修改。" : "今天的收获、卡住的地方,或明天要调整的一点。"} /></label></div></div></article><aside><section className="calendar side-calendar"><div className="calendar-head"><div><p className="eyebrow">CHECK-IN</p><h2>打卡日历</h2></div><div className="month-tabs">{[8, 9, 10, 11].map(m => <button aria-label={`查看${m + 1}月`} className={month === m ? "active" : ""} onClick={() => setMonth(m)} key={m}>{m + 1}月</button>)}</div></div><div className="weekdays">{["日", "一", "二", "三", "四", "五", "六"].map(x => <span key={x}>{x}</span>)}</div><div className="calendar-grid">{blanks.map((_, i) => <span key={"e" + 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, makeup = records[date]?.checkinType === "makeup"; return <button disabled={!p} key={date} onClick={() => { if (p) { setSelected(idx); setMonth(new Date(date + "T00:00:00Z").getUTCMonth()); } }} className={"calendar-day " + (idx === selected ? "chosen " : "") + (full ? "full " : partial ? "partial " : "") + (makeup ? "makeup " : "") + (p ? "" : "outside")}><b>{Number(date.slice(-2))}</b>{p && <small>{full ? makeup ? "补打卡" : "完成" : partial ? n + "/" + p.tasks.length : date > today ? "未到" : "待打卡"}</small>}</button>; })}</div><p className="calendar-note"><i className="full-dot" />完成 <i className="partial-dot" />进行中 <i className="makeup-dot" />补打卡</p></section><div className="dark"><p>{isFuture ? "日期限制" : isMakeup ? "补打卡" : "当前阶段"}</p><h2>{isFuture ? "暂未开放" : isMakeup ? "补做未完成项目" : day.phase}</h2><p>{isFuture ? "当天到来后即可正常打卡。" : isMakeup ? "已有完成项已锁定,只能勾选此前未完成的项目。" : "每天稳步推进,答案会慢慢变得清楚。"}</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></aside></section>
</div></main>; </div></main>;
+4
View File
@@ -178,3 +178,7 @@ aside{display:grid;gap:14px}.side-calendar{margin:0;padding:18px;border-radius:2
@media(max-width:980px){.layout{grid-template-columns:minmax(0,1fr) 300px}.calendar-day{min-height:43px}.calendar-day small{font-size:.51rem}.month-tabs button{padding:5px 6px;min-width:31px}} @media(max-width:980px){.layout{grid-template-columns:minmax(0,1fr) 300px}.calendar-day{min-height:43px}.calendar-day small{font-size:.51rem}.month-tabs button{padding:5px 6px;min-width:31px}}
@media(max-width:760px){.shell{padding:26px 15px 42px}.layout{grid-template-columns:1fr}.side-calendar{order:-1}.calendar-day{min-height:55px;padding:6px}.calendar-day small{font-size:.6rem}.month-tabs button{padding:6px 10px;min-width:38px}} @media(max-width:760px){.shell{padding:26px 15px 42px}.layout{grid-template-columns:1fr}.side-calendar{order:-1}.calendar-day{min-height:55px;padding:6px}.calendar-day small{font-size:.6rem}.month-tabs button{padding:6px 10px;min-width:38px}}
@media(max-width:420px){.task-toolbar{align-items:flex-start;flex-direction:column}.today-button{width:100%}} @media(max-width:420px){.task-toolbar{align-items:flex-start;flex-direction:column}.today-button{width:100%}}
.study-hub{max-width:1160px}.hub-grid{display:grid;grid-template-columns:.9fr 1.1fr;gap:20px;margin-top:22px}.hub-grid.lower{grid-template-columns:1fr 1fr}.hub-card{background:#fff;border:1px solid #d9e6ff;border-radius:22px;padding:24px;box-shadow:0 14px 34px #bfd5fb66}.hub-card h2{margin:5px 0 18px;font-size:1.42rem;letter-spacing:-.025em}.hub-card>p:not(.eyebrow){color:#64748b;line-height:1.65}.plan-card{background:linear-gradient(145deg,#122a61,#2865d9);color:#fff}.plan-card .eyebrow{color:#c9dcff}.plan-card strong{display:block;font-size:3.1rem;line-height:1;margin:18px 0 8px}.plan-card p,.plan-card small{color:#dceaff}.hub-card label{display:grid;gap:8px;margin:13px 0;color:#334155;font-size:.88rem;font-weight:700}.hub-card input,.hub-card textarea{width:100%;box-sizing:border-box;border:1px solid #d5e0f0;background:#fbfdff;border-radius:11px;padding:10px 11px;font:inherit;color:#172554}.hub-card textarea{min-height:88px;resize:vertical}.date-field{max-width:190px}.duration-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:9px}.duration-grid label{font-size:.76rem}.duration-grid input{min-width:0;padding:9px}.score-grid{grid-template-columns:repeat(4,1fr)}.primary-action{display:inline-flex;align-items:center;gap:8px;border:0;border-radius:11px;background:#2563eb;color:#fff;padding:11px 15px;margin-top:8px;font:inherit;font-weight:700;cursor:pointer;box-shadow:0 8px 18px #bfdbfe}.primary-action:hover{background:#1d4ed8}.score-total{float:right;color:#2563eb;font-size:1.85rem}.mock-history{border-top:1px solid #e6edf9;margin-top:19px;padding-top:8px}.mock-history p{display:flex;justify-content:space-between;color:#64748b;font-size:.84rem;margin:10px 0}.mock-history b{color:#334155}
@media(max-width:800px){.hub-grid,.hub-grid.lower{grid-template-columns:1fr}.duration-grid{grid-template-columns:repeat(3,1fr)}.score-grid{grid-template-columns:repeat(4,1fr)}}
@media(max-width:480px){.hub-card{padding:18px}.duration-grid{grid-template-columns:repeat(2,1fr)}.score-grid{grid-template-columns:repeat(2,1fr)}}
Loaded 3 of 7 files, more files were not shown because too many files have changed in this diff. Show more