]> acesimba.cloud Git - words.git/commitdiff
feat: 学习日历按日显示学习时长(分钟), 置于日期左侧
authorroot <root@localhost.localdomain>
Tue, 18 Aug 2026 15:04:21 +0000 (23:04 +0800)
committerroot <root@localhost.localdomain>
Tue, 18 Aug 2026 15:04:21 +0000 (23:04 +0800)
- 后端 /api/calendar 新增 duration_min: 当天最早一项到最后一项的时间跨度(中国时间)
  历史 created_at 为 UTC、新记录为中国时间, 用 CASE 归一化到中国时间(与 log_date 对齐), 避免混算虚高
- 前端 StudyCalendar: 在线读 duration_min, 离线按本地 completed_at 同类计算; 日期单元格头部左侧显示"N分钟"徽标
- 仅 display>0 时显示; 离线降级与在线一致

backend/main.py
frontend/src/components/StudyCalendar.css
frontend/src/components/StudyCalendar.js

index 3c2c7c0b67a5272aba978b893845dd4f18e9d6d3..74302fe0dd428569dbb0e42918df14e4e331f6c4 100644 (file)
@@ -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))
index d2ec6b85b1adfca13edfc1c5b85b093e986ba72f..4c9712d659b21f90e12de265b4be72f58bb058c3 100644 (file)
     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;
     .cal-day-num {
         font-size: 11px;
     }
+    .cal-duration {
+        font-size: 9px;
+        padding: 1px 3px;
+    }
     .cal-weekday {
         font-size: 11px;
         padding: 6px 0;
index 10449e5e9bc4cb9281dee4015c33f180659db4a8..83b207469b2ab5a5e3bc16660700939daec12d11 100644 (file)
@@ -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 <div className="cal-day empty" key={i} />;
                         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 (
                             <div className={cls.join(' ')} key={i}>
-                                <div className="cal-day-num">{d}</div>
+                                <div className="cal-day-head">
+                                    {durationMin > 0 && (
+                                        <span className="cal-duration">{durationMin}分钟</span>
+                                    )}
+                                    <div className="cal-day-num">{d}</div>
+                                </div>
                                 {acts && acts.map((a, j) => (
                                     <div className="cal-act" key={j} style={{ color: OP_COLOR[a.op_type] || '#475569' }}>
                                         {OP_LABEL[a.op_type] || a.op_type} G{a.groups.join(',')}