From f9b8b14c9afffbb32032bd093c63f65ee6701240 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 18 Aug 2026 23:22:50 +0800 Subject: [PATCH] =?utf8?q?feat:=20=E5=AD=A6=E4=B9=A0=E6=97=A5=E5=8E=86?= =?utf8?q?=E6=98=BE=E7=A4=BA=E6=AF=8F=E6=97=A5=E5=AE=9E=E9=99=85=E5=AD=A6?= =?utf8?q?=E4=B9=A0=E6=97=B6=E9=95=BF(=E5=90=84=E6=AC=A1=E5=AD=A6=E4=B9=A0?= =?utf8?q?=E6=97=B6=E6=AE=B5=E4=B9=8B=E5=92=8C,=20=E4=B8=8D=E5=90=AB?= =?utf8?q?=E9=97=B4=E9=9A=94)?= MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit - 改为方案B: 记录每次学习的真实起止时长并按时长求和 - 前端 StudyMode/TestMode: 进入时记录 sessionStartRef, 完成时计算 durationSec 传入 putProgress - db.putProgress 增加 duration_sec 字段; sync push/pull 携带 duration_sec(双向同步) - 后端 SyncEvent/ProgressUpdate 增加 duration_sec; sync/push 写入, sync/pull 返回 - /api/calendar 改为按日 SUM(duration_sec) 得实际学习分钟(替代之前的跨度算法) - 日历离线降级同样按本地 duration_sec 求和; 日期左侧"N分钟"徽标不变 - 历史记录 duration_sec 多为0, 故旧日期不显示时长, 仅新学习的日期开始累计 --- backend/main.py | 57 +++++++++++------------- frontend/src/components/StudyCalendar.js | 18 +++----- frontend/src/components/StudyMode.js | 6 ++- frontend/src/components/TestMode.js | 6 ++- frontend/src/db.js | 4 +- frontend/src/sync.js | 5 ++- 6 files changed, 45 insertions(+), 51 deletions(-) diff --git a/backend/main.py b/backend/main.py index 74302fe..a63f592 100644 --- a/backend/main.py +++ b/backend/main.py @@ -56,6 +56,7 @@ class WordData(BaseModel): class ProgressUpdate(BaseModel): group_id: Union[str, int] op_type: str + duration_sec: int = 0 @field_validator('group_id', mode='before') @classmethod @@ -66,6 +67,7 @@ class SyncEvent(BaseModel): group_id: Union[str, int] op_type: str completed_at: str + duration_sec: int = 0 @field_validator('group_id', mode='before') @classmethod @@ -339,8 +341,8 @@ async def update_progress(data: ProgressUpdate): conn.close() return {"status": "skipped", "date": now, "reason": "duplicate same-day record"} cursor.execute( - "INSERT INTO study_logs (group_id, op_type, status, created_at, log_date) VALUES (?, ?, ?, ?, ?)", - (data.group_id, data.op_type, 'completed', now, today_prefix) + "INSERT INTO study_logs (group_id, op_type, status, created_at, log_date, duration_sec) VALUES (?, ?, ?, ?, ?, ?)", + (data.group_id, data.op_type, 'completed', now, today_prefix, data.duration_sec or 0) ) conn.commit() conn.close() @@ -350,32 +352,28 @@ async def update_progress(data: ProgressUpdate): @app.get("/api/calendar") async def get_calendar(): - # 按日期聚合 study_logs,供学习日历使用;同时计算每日学习时长(分钟) - # 时长 = 当天最早一项到最后一项的时间跨度(中国时间) - # 注意: created_at 历史记录为 UTC、新记录为中国时间, 用 CASE 归一化到中国时间(与 log_date 对齐), - # 避免同一天里 UTC/中国时间混算导致跨度虚高 + # 按日期聚合 study_logs,供学习日历使用;同时计算每日"实际学习时长"(分钟) + # 实际学习时长 = 当天各 (group,op) 完成时长(duration_sec)之和, 不含间隔, 即把各次学习时段相加 try: conn = sqlite3.connect(DATABASE_PATH) cursor = conn.cursor() cursor.execute(""" - SELECT COALESCE(log_date, substr(created_at,1,10)) as day, op_type, group_id, - CASE - WHEN log_date IS NOT NULL AND substr(created_at,1,10) <> log_date - THEN datetime(created_at, '+8 hours') - ELSE created_at - END as ct + SELECT COALESCE(log_date, substr(created_at,1,10)) as day, op_type, group_id FROM study_logs WHERE status='completed' ORDER BY day, op_type, CAST(group_id AS INTEGER) """) day_map = {} - day_span = {} # day -> [min_ct, max_ct] (已归一化到中国时间) - for day, op_type, group_id, ct in cursor.fetchall(): + for day, op_type, group_id in cursor.fetchall(): day_map.setdefault(day, {}).setdefault(op_type, []).append(str(group_id)) - if ct: - sp = day_span.setdefault(day, [ct, ct]) - if ct < sp[0]: sp[0] = ct - if ct > sp[1]: sp[1] = ct + # 每日实际学习时长(秒) = 各 (group,op) 完成时长之和 + cursor.execute(""" + SELECT COALESCE(log_date, substr(created_at,1,10)) as day, COALESCE(SUM(duration_sec), 0) as total_sec + FROM study_logs + WHERE status='completed' + GROUP BY day + """) + day_total = {day: total for day, total in cursor.fetchall()} conn.close() op_order = ['study', 'test', 'review1', 'review2', 'review3'] result = [] @@ -383,15 +381,9 @@ async def get_calendar(): acts = day_map[day] activities = [{"op_type": op, "groups": acts[op]} for op in op_order if op in acts] item = {"date": day, "activities": activities} - if day in day_span: - t0, t1 = day_span[day] - try: - dt0 = datetime.datetime.strptime(t0, "%Y-%m-%d %H:%M:%S") - dt1 = datetime.datetime.strptime(t1, "%Y-%m-%d %H:%M:%S") - # 向上取整到分钟 - item["duration_min"] = int((dt1 - dt0).total_seconds() + 59) // 60 - except Exception: - pass + total = day_total.get(day, 0) + if total and total > 0: + item["duration_min"] = int(round(total / 60)) result.append(item) return result except Exception as e: @@ -421,10 +413,11 @@ async def sync_push(data: SyncPushRequest): continue completed_at = ev.completed_at or datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") log_date = completed_at[:10] + duration_sec = ev.duration_sec or 0 cursor.execute( - "INSERT OR IGNORE INTO study_logs (group_id, op_type, status, created_at, log_date) " - "VALUES (?, ?, 'completed', ?, ?)", - (ev.group_id, op, completed_at, log_date)) + "INSERT OR IGNORE INTO study_logs (group_id, op_type, status, created_at, log_date, duration_sec) " + "VALUES (?, ?, 'completed', ?, ?, ?)", + (ev.group_id, op, completed_at, log_date, duration_sec)) updated += 1 conn.commit() conn.close() @@ -440,12 +433,12 @@ async def sync_pull(): conn = sqlite3.connect(DATABASE_PATH) cursor = conn.cursor() cursor.execute( - "SELECT group_id, op_type, MAX(COALESCE(log_date, created_at)) " + "SELECT group_id, op_type, MAX(COALESCE(log_date, created_at)), MAX(duration_sec) " "FROM study_logs WHERE status='completed' GROUP BY group_id, op_type" ) rows = cursor.fetchall() conn.close() - events = [{"group_id": str(r[0]), "op_type": r[1], "completed_at": r[2]} + events = [{"group_id": str(r[0]), "op_type": r[1], "completed_at": r[2], "duration_sec": r[3] or 0} for r in rows if r[2]] return {"status": "success", "events": events} except Exception as e: diff --git a/frontend/src/components/StudyCalendar.js b/frontend/src/components/StudyCalendar.js index 83b2074..dceb13d 100644 --- a/frontend/src/components/StudyCalendar.js +++ b/frontend/src/components/StudyCalendar.js @@ -29,7 +29,7 @@ function StudyCalendar() { console.error("获取日历失败, 降级读离线缓存:", err); const allProgress = await getAllProgress(); const tmp = {}; - const dayDur = {}; // day -> [min_completed_at, max_completed_at] 用于离线计算每日时长 + const dayDurSec = {}; // day -> 累计学习秒数(离线按本地 duration_sec 求和, 与后端一致) allProgress.forEach(p => { const day = (p.completed_at || '').slice(0, 10); if (!day) return; @@ -37,22 +37,14 @@ function StudyCalendar() { if (!tmp[day][p.op_type]) tmp[day][p.op_type] = []; const g = String(p.group_id); if (!tmp[day][p.op_type].includes(g)) tmp[day][p.op_type].push(g); - const t = p.completed_at; - if (!dayDur[day]) dayDur[day] = [t, t]; - else { - if (t < dayDur[day][0]) dayDur[day][0] = t; - if (t > dayDur[day][1]) dayDur[day][1] = t; - } + if (!dayDurSec[day]) dayDurSec[day] = 0; + dayDurSec[day] += (p.duration_sec || 0); }); const m = {}; Object.keys(tmp).forEach(day => { const activities = OP_ORDER.filter(op => tmp[day][op]).map(op => ({ op_type: op, groups: tmp[day][op] })); - let durationMin = 0; - if (dayDur[day]) { - const a = new Date(dayDur[day][0].replace(' ', 'T')); - const b = new Date(dayDur[day][1].replace(' ', 'T')); - durationMin = Math.max(0, Math.ceil((b - a) / 60000)); - } + const totalSec = dayDurSec[day] || 0; + const durationMin = totalSec > 0 ? Math.round(totalSec / 60) : 0; m[day] = { activities, durationMin }; }); setDayMap(m); diff --git a/frontend/src/components/StudyMode.js b/frontend/src/components/StudyMode.js index b4a501f..52a1de7 100644 --- a/frontend/src/components/StudyMode.js +++ b/frontend/src/components/StudyMode.js @@ -10,6 +10,8 @@ function StudyMode() { const { groupId } = useParams(); const navigate = useNavigate(); const isOnline = useOnlineStatus(); + // 学习会话开始时间(毫秒时间戳), 用于计算本次学习的实际时长 + const sessionStartRef = useRef(Date.now()); const [words, setWords] = useState([]); const [currentIndex, setCurrentIndex] = useState(0); const [phase, setPhase] = useState('learning'); // learning, flashcard, correction @@ -102,8 +104,10 @@ function StudyMode() { if (submittedRef.current) return; // 防重入(按住Enter等场景) submittedRef.current = true; const now = localTimestamp(); + // 本次学习实际时长(秒) = 完成时刻 - 会话开始时刻 + const durationSec = Math.max(0, Math.round((Date.now() - sessionStartRef.current) / 1000)); // 本地优先: 先写本地 dirty (必定保存, 不丢记录) - await putProgress(groupId, 'study', now, true); + await putProgress(groupId, 'study', now, true, durationSec); // 在线则后台自动同步 (成功清 dirty, 失败保留) if (isOnline) triggerAutoSync(); alert("恭喜完成本组学习!"); diff --git a/frontend/src/components/TestMode.js b/frontend/src/components/TestMode.js index 1ebd1fb..f228eba 100644 --- a/frontend/src/components/TestMode.js +++ b/frontend/src/components/TestMode.js @@ -13,6 +13,8 @@ function TestMode() { // 活动类型:test / review1 / review2 / review3(首页点击复习时通过 ?type= 传入;缺省为 test) const activityType = searchParams.get('type') || 'test'; const isOnline = useOnlineStatus(); + // 测试/复习会话开始时间(毫秒时间戳), 用于计算本次学习的实际时长 + const sessionStartRef = useRef(Date.now()); const [words, setWords] = useState([]); const [queue, setQueue] = useState([]); // 当前测试队列 const [wrongQueue, setWrongQueue] = useState([]); // 错词重考队列 @@ -144,8 +146,10 @@ function TestMode() { if (submittedRef.current) return; // 防重入(按住Enter等场景) submittedRef.current = true; const now = localTimestamp(); + // 本次测试/复习实际时长(秒) = 完成时刻 - 会话开始时刻 + const durationSec = Math.max(0, Math.round((Date.now() - sessionStartRef.current) / 1000)); // 本地优先: 先写本地 dirty (必定保存, 不丢记录) - await putProgress(groupId, activityType, now, true); + await putProgress(groupId, activityType, now, true, durationSec); // 在线则后台自动同步 (成功清 dirty, 失败保留) if (isOnline) triggerAutoSync(); navigate('/'); diff --git a/frontend/src/db.js b/frontend/src/db.js index 325fcbb..a3603ec 100644 --- a/frontend/src/db.js +++ b/frontend/src/db.js @@ -81,12 +81,12 @@ export async function getAllProgress() { return singleReq('progress', 'readonly', s => s.getAll()); } -export async function putProgress(group_id, op_type, completed_at, dirty) { +export async function putProgress(group_id, op_type, completed_at, dirty, duration_sec = 0) { const key = `${group_id}:${op_type}`; const db = await openDB(); return new Promise((resolve, reject) => { const t = db.transaction('progress', 'readwrite'); - t.objectStore('progress').put({ key, group_id: String(group_id), op_type, completed_at, dirty }); + t.objectStore('progress').put({ key, group_id: String(group_id), op_type, completed_at, dirty, duration_sec: duration_sec || 0 }); t.oncomplete = () => resolve(); t.onerror = () => reject(t.error); }); diff --git a/frontend/src/sync.js b/frontend/src/sync.js index d349a2e..7009d23 100644 --- a/frontend/src/sync.js +++ b/frontend/src/sync.js @@ -44,6 +44,7 @@ export async function syncNow() { group_id: p.group_id, op_type: p.op_type, completed_at: p.completed_at, + duration_sec: p.duration_sec || 0, })); const r = await api.syncPush(events); result.pushed = r.data.updated || events.length; @@ -62,7 +63,7 @@ export async function syncNow() { const local = localAll.find(p => p.key === key); if (!local) { // 本地缺失: 直接补入(布尔并集) - await putProgress(ev.group_id, ev.op_type, ev.completed_at, false); + await putProgress(ev.group_id, ev.op_type, ev.completed_at, false, ev.duration_sec || 0); result.pulled++; result.pulledEvents.push(ev); } else { @@ -71,7 +72,7 @@ export async function syncNow() { const localDate = (local.completed_at || '').slice(0, 10); const serverDate = (ev.completed_at || '').slice(0, 10); if (serverDate > localDate) { - await putProgress(ev.group_id, ev.op_type, ev.completed_at, false); + await putProgress(ev.group_id, ev.op_type, ev.completed_at, false, ev.duration_sec || 0); result.pulled++; result.pulledEvents.push(ev); } -- 2.43.0