From e1cb648dbf420c609398f2f1a9819ae59246909b Mon Sep 17 00:00:00 2001 From: root Date: Tue, 18 Aug 2026 23:04:21 +0800 Subject: [PATCH] =?utf8?q?feat:=20=E5=AD=A6=E4=B9=A0=E6=97=A5=E5=8E=86?= =?utf8?q?=E6=8C=89=E6=97=A5=E6=98=BE=E7=A4=BA=E5=AD=A6=E4=B9=A0=E6=97=B6?= =?utf8?q?=E9=95=BF(=E5=88=86=E9=92=9F),=20=E7=BD=AE=E4=BA=8E=E6=97=A5?= =?utf8?q?=E6=9C=9F=E5=B7=A6=E4=BE=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit - 后端 /api/calendar 新增 duration_min: 当天最早一项到最后一项的时间跨度(中国时间) 历史 created_at 为 UTC、新记录为中国时间, 用 CASE 归一化到中国时间(与 log_date 对齐), 避免混算虚高 - 前端 StudyCalendar: 在线读 duration_min, 离线按本地 completed_at 同类计算; 日期单元格头部左侧显示"N分钟"徽标 - 仅 display>0 时显示; 离线降级与在线一致 --- backend/main.py | 31 ++++++++++++++++++--- frontend/src/components/StudyCalendar.css | 21 ++++++++++++++ frontend/src/components/StudyCalendar.js | 34 +++++++++++++++++++---- 3 files changed, 77 insertions(+), 9 deletions(-) diff --git a/backend/main.py b/backend/main.py index 3c2c7c0..74302fe 100644 --- a/backend/main.py +++ b/backend/main.py @@ -350,26 +350,49 @@ async def update_progress(data: ProgressUpdate): @app.get("/api/calendar") async def get_calendar(): - # 按日期聚合 study_logs,供学习日历使用 + # 按日期聚合 study_logs,供学习日历使用;同时计算每日学习时长(分钟) + # 时长 = 当天最早一项到最后一项的时间跨度(中国时间) + # 注意: created_at 历史记录为 UTC、新记录为中国时间, 用 CASE 归一化到中国时间(与 log_date 对齐), + # 避免同一天里 UTC/中国时间混算导致跨度虚高 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 + 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 FROM study_logs WHERE status='completed' ORDER BY day, op_type, CAST(group_id AS INTEGER) """) day_map = {} - for day, op_type, group_id in cursor.fetchall(): + day_span = {} # day -> [min_ct, max_ct] (已归一化到中国时间) + for day, op_type, group_id, ct 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 conn.close() op_order = ['study', 'test', 'review1', 'review2', 'review3'] result = [] for day in sorted(day_map.keys()): acts = day_map[day] activities = [{"op_type": op, "groups": acts[op]} for op in op_order if op in acts] - result.append({"date": day, "activities": activities}) + 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 + result.append(item) return result except Exception as e: raise HTTPException(status_code=500, detail=str(e)) diff --git a/frontend/src/components/StudyCalendar.css b/frontend/src/components/StudyCalendar.css index d2ec6b8..4c9712d 100644 --- a/frontend/src/components/StudyCalendar.css +++ b/frontend/src/components/StudyCalendar.css @@ -87,6 +87,23 @@ border: 2px solid #1d4ed8; } +.cal-day-head { + display: flex; + justify-content: space-between; + align-items: center; + gap: 4px; +} + +.cal-duration { + font-size: 11px; + font-weight: 600; + color: #1d4ed8; + background: #dbeafe; + border-radius: 4px; + padding: 1px 4px; + white-space: nowrap; +} + .cal-day-num { font-size: 13px; font-weight: 700; @@ -130,6 +147,10 @@ .cal-day-num { font-size: 11px; } + .cal-duration { + font-size: 9px; + padding: 1px 3px; + } .cal-weekday { font-size: 11px; padding: 6px 0; diff --git a/frontend/src/components/StudyCalendar.js b/frontend/src/components/StudyCalendar.js index 10449e5..83b2074 100644 --- a/frontend/src/components/StudyCalendar.js +++ b/frontend/src/components/StudyCalendar.js @@ -19,7 +19,9 @@ function StudyCalendar() { api.getCalendar() .then(res => { const m = {}; - res.data.forEach(item => { m[item.date] = item.activities; }); + res.data.forEach(item => { + m[item.date] = { activities: item.activities, durationMin: item.duration_min || 0 }; + }); setDayMap(m); }) .catch(async (err) => { @@ -27,6 +29,7 @@ function StudyCalendar() { console.error("获取日历失败, 降级读离线缓存:", err); const allProgress = await getAllProgress(); const tmp = {}; + const dayDur = {}; // day -> [min_completed_at, max_completed_at] 用于离线计算每日时长 allProgress.forEach(p => { const day = (p.completed_at || '').slice(0, 10); if (!day) return; @@ -34,10 +37,23 @@ 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; + } }); const m = {}; Object.keys(tmp).forEach(day => { - m[day] = OP_ORDER.filter(op => tmp[day][op]).map(op => ({ op_type: op, groups: tmp[day][op] })); + 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)); + } + m[day] = { activities, durationMin }; }); setDayMap(m); }); @@ -62,7 +78,8 @@ function StudyCalendar() { const studiedGroups = new Set(); Object.keys(dayMap).forEach(dateStr => { if (dateStr.slice(0, 7) === monthStr) { - dayMap[dateStr].forEach(a => { + const info = dayMap[dateStr]; + if (info && info.activities) info.activities.forEach(a => { if (a.op_type === 'study') a.groups.forEach(g => studiedGroups.add(g)); }); } @@ -83,7 +100,9 @@ function StudyCalendar() { {cells.map((d, i) => { if (d === null) return
; const dateStr = `${monthStr}-${pad(d)}`; - const acts = dayMap[dateStr]; + const dayInfo = dayMap[dateStr]; + const acts = dayInfo ? dayInfo.activities : undefined; + const durationMin = dayInfo ? dayInfo.durationMin : 0; const isActive = !!acts; const isToday = dateStr === todayStr; const cls = ['cal-day']; @@ -91,7 +110,12 @@ function StudyCalendar() { if (isToday) cls.push('today'); return (
-
{d}
+
+ {durationMin > 0 && ( + {durationMin}分钟 + )} +
{d}
+
{acts && acts.map((a, j) => (
{OP_LABEL[a.op_type] || a.op_type} G{a.groups.join(',')} -- 2.43.0