Show daily exam countdown dialog
This commit is contained in:
1 parent
2674c6b95f
commit
416e6e69ab
2 files changed
+6
-1
No files matched your search
@@ -3,6 +3,7 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { CalendarDays, CheckCircle2, ChevronLeft, ChevronRight, Cloud, LayoutList, Timer } from "lucide-react";
|
import { CalendarDays, CheckCircle2, ChevronLeft, ChevronRight, Cloud, LayoutList, Timer } from "lucide-react";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
import { Progress } from "@/components/ui/progress";
|
import { Progress } from "@/components/ui/progress";
|
||||||
import { daysBetween, plan } from "./plan";
|
import { daysBetween, plan } from "./plan";
|
||||||
|
|
||||||
@@ -17,6 +18,7 @@ export default function CheckinClient() {
|
|||||||
const [status, setStatus] = useState("正在读取云端进度…");
|
const [status, setStatus] = useState("正在读取云端进度…");
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [now, setNow] = useState(Date.now());
|
const [now, setNow] = useState(Date.now());
|
||||||
|
const [showCountdown, setShowCountdown] = useState(false);
|
||||||
const day = plan[selected], record = records[day.date] || { doneTasks: "[]", mood: "", note: "" };
|
const day = plan[selected], record = records[day.date] || { doneTasks: "[]", mood: "", note: "" };
|
||||||
const done = useMemo(() => parseTasks(record.doneTasks), [record.doneTasks]);
|
const done = useMemo(() => parseTasks(record.doneTasks), [record.doneTasks]);
|
||||||
const total = plan.reduce((n, p) => n + p.tasks.length, 0);
|
const total = plan.reduce((n, p) => n + p.tasks.length, 0);
|
||||||
@@ -29,11 +31,13 @@ export default function CheckinClient() {
|
|||||||
const remaining = { days: Math.floor(seconds / 86400), hours: Math.floor(seconds % 86400 / 3600), minutes: Math.floor(seconds % 3600 / 60) };
|
const remaining = { days: Math.floor(seconds / 86400), hours: Math.floor(seconds % 86400 / 3600), minutes: Math.floor(seconds % 3600 / 60) };
|
||||||
|
|
||||||
useEffect(() => { const id = window.setInterval(() => setNow(Date.now()), 30000); return () => window.clearInterval(id); }, []);
|
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<string, Rec> = {}; x.progress.forEach((p: Rec & { planDate: string }) => all[p.planDate] = p); setRecords(all); setStatus("云端同步已开启"); }).catch(e => setStatus(e.message)); }, []);
|
useEffect(() => { fetch("/api/progress").then(async r => { const x = await r.json(); if (!r.ok) throw Error(x.error); const all: Record<string, Rec> = {}; 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); } }
|
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)) });
|
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">
|
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 日 09: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><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><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 日 09: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 日 09:00</small></div></section>
|
||||||
<section className="calendar"><div className="calendar-head"><div><p className="eyebrow">CHECK-IN CALENDAR</p><h2>打卡日历</h2></div><div className="month-tabs">{[8, 9, 10, 11].map(m => <button 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; 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 " : "") + (p ? "" : "outside")}><b>{Number(date.slice(-2))}</b>{p && <small>{full ? "完成" : partial ? n + "/" + p.tasks.length : "待打卡"}</small>}</button>; })}</div><p className="calendar-note"><i className="full-dot" />全部完成 <i className="partial-dot" />进行中 <span>点击日期即可查看并打卡</span></p></section>
|
<section className="calendar"><div className="calendar-head"><div><p className="eyebrow">CHECK-IN CALENDAR</p><h2>打卡日历</h2></div><div className="month-tabs">{[8, 9, 10, 11].map(m => <button 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; 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 " : "") + (p ? "" : "outside")}><b>{Number(date.slice(-2))}</b>{p && <small>{full ? "完成" : partial ? n + "/" + p.tasks.length : "待打卡"}</small>}</button>; })}</div><p className="calendar-note"><i className="full-dot" />全部完成 <i className="partial-dot" />进行中 <span>点击日期即可查看并打卡</span></p></section>
|
||||||
|
|||||||
@@ -143,3 +143,4 @@ body {
|
|||||||
@media(max-width:900px){.type-cards{grid-template-columns:repeat(3,1fr)}}
|
@media(max-width:900px){.type-cards{grid-template-columns:repeat(3,1fr)}}
|
||||||
@media(max-width:760px){.header-actions{align-items:flex-start;flex-direction:column}.type-cards{grid-template-columns:repeat(2,1fr)}.task-list-head{align-items:flex-start;flex-direction:column}.type-progress{text-align:left}.task-row{grid-template-columns:24px 1fr;padding:14px}.task-row small{grid-column:2}.countdown{grid-column:span 2}}
|
@media(max-width:760px){.header-actions{align-items:flex-start;flex-direction:column}.type-cards{grid-template-columns:repeat(2,1fr)}.task-list-head{align-items:flex-start;flex-direction:column}.type-progress{text-align:left}.task-row{grid-template-columns:24px 1fr;padding:14px}.task-row small{grid-column:2}.countdown{grid-column:span 2}}
|
||||||
@media(max-width:420px){.type-cards{grid-template-columns:1fr}.countdown{grid-column:auto}.task-row p{font-size:.9rem}}
|
@media(max-width:420px){.type-cards{grid-template-columns:1fr}.countdown{grid-column:auto}.task-row p{font-size:.9rem}}
|
||||||
|
.countdown-dialog{max-width:420px!important;border:0!important;padding:30px!important;text-align:center!important;background:linear-gradient(145deg,#172554,#2563eb)!important;color:#fff!important;box-shadow:0 28px 70px #17255488!important}.dialog-kicker{margin:0;color:#bfdbfe;font-size:.78rem;font-weight:700;letter-spacing:.13em}.countdown-dialog [data-slot=dialog-title]{color:#fff;font-size:1.55rem}.countdown-dialog [data-slot=dialog-description]{color:#dbeafe;font-size:.9rem}.dialog-time{display:flex;justify-content:center;align-items:baseline;gap:7px;margin:12px 0 4px}.dialog-time b{font-size:2.7rem;line-height:1;color:#fff}.dialog-time span{font-size:.86rem;color:#bfdbfe;margin-right:3px}.dialog-note{margin:12px 0 20px;color:#dbeafe;font-size:.9rem}.dialog-button{width:100%;border:0;border-radius:12px;background:#fff;color:#1d4ed8;padding:12px;font:inherit;font-weight:700;cursor:pointer}.dialog-button:hover{background:#eff6ff}
|
||||||
Reference in new issue
Block a user