Create CET-6 90-day check-in site
This commit is contained in:
commit
5c63f9f120
109 files changed
+19290
No files matched your search
@@ -0,0 +1,6 @@
|
||||
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});}}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export type ChatGPTUser = {
|
||||
userId: string;
|
||||
displayName: string;
|
||||
email: string;
|
||||
fullName: string | null;
|
||||
};
|
||||
|
||||
const USER_ID_HEADER = "oai-authenticated-user-id";
|
||||
const USER_EMAIL_HEADER = "oai-authenticated-user-email";
|
||||
const USER_FULL_NAME_HEADER = "oai-authenticated-user-full-name";
|
||||
const USER_FULL_NAME_ENCODING_HEADER =
|
||||
"oai-authenticated-user-full-name-encoding";
|
||||
const PERCENT_ENCODED_UTF8 = "percent-encoded-utf-8";
|
||||
const SIGN_IN_PATH = "/signin-with-chatgpt";
|
||||
const SIGN_OUT_PATH = "/signout-with-chatgpt";
|
||||
const CALLBACK_PATH = "/callback";
|
||||
|
||||
export async function getChatGPTUser(): Promise<ChatGPTUser | null> {
|
||||
const requestHeaders = await headers();
|
||||
const userId = requestHeaders.get(USER_ID_HEADER);
|
||||
const email = requestHeaders.get(USER_EMAIL_HEADER);
|
||||
if (!userId || !email) return null;
|
||||
|
||||
const encodedFullName = requestHeaders.get(USER_FULL_NAME_HEADER);
|
||||
const fullName =
|
||||
encodedFullName &&
|
||||
requestHeaders.get(USER_FULL_NAME_ENCODING_HEADER) === PERCENT_ENCODED_UTF8
|
||||
? safeDecodeURIComponent(encodedFullName)
|
||||
: null;
|
||||
|
||||
return {
|
||||
userId,
|
||||
displayName: fullName ?? email,
|
||||
email,
|
||||
fullName,
|
||||
};
|
||||
}
|
||||
|
||||
export async function requireChatGPTUser(
|
||||
returnTo: string,
|
||||
): Promise<ChatGPTUser> {
|
||||
const user = await getChatGPTUser();
|
||||
if (user) return user;
|
||||
|
||||
redirect(chatGPTSignInPath(returnTo));
|
||||
}
|
||||
|
||||
export function chatGPTSignInPath(returnTo: string): string {
|
||||
const safeReturnTo = safeRelativeReturnPath(returnTo);
|
||||
return `${SIGN_IN_PATH}?return_to=${encodeURIComponent(safeReturnTo)}`;
|
||||
}
|
||||
|
||||
export function chatGPTSignOutPath(returnTo = "/"): string {
|
||||
const safeReturnTo = safeRelativeReturnPath(returnTo);
|
||||
return `${SIGN_OUT_PATH}?return_to=${encodeURIComponent(safeReturnTo)}`;
|
||||
}
|
||||
|
||||
function safeRelativeReturnPath(value: string): string {
|
||||
if (!value.startsWith("/") || value.startsWith("//")) return "/";
|
||||
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(value, "https://app.local");
|
||||
} catch {
|
||||
return "/";
|
||||
}
|
||||
if (url.origin !== "https://app.local") return "/";
|
||||
if (isReservedAuthPath(url.pathname)) return "/";
|
||||
|
||||
return `${url.pathname}${url.search}${url.hash}`;
|
||||
}
|
||||
|
||||
function isReservedAuthPath(pathname: string): boolean {
|
||||
return (
|
||||
pathname === SIGN_IN_PATH ||
|
||||
pathname === SIGN_OUT_PATH ||
|
||||
pathname === CALLBACK_PATH
|
||||
);
|
||||
}
|
||||
|
||||
function safeDecodeURIComponent(value: string): string | null {
|
||||
try {
|
||||
return decodeURIComponent(value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
"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>}
|
||||
Loaded 3 of 109 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user