# 每日学习计时上报功能 ## 目标 在课程学习页面(CourseLearn)实现后台静默学习计时,每 5 分钟调用 `tasks.track` 上报分钟数,页面关闭/离线时再次上报剩余时长,驱动"每日学习"和"学习达人"任务自动完成。 ## 现状分析 - **后端已就绪**:`TaskTrackAPIView` + `TaskDefinition` 种子数据(每日学习 5min / 学习达人 30min) - **前端已定义未调用**:`api_request.login.tasks.track()` 已定义但从未使用 - **CourseLearn.tsx 无计时**:当前无任何计时相关代码 - **后端 count 单位 = 分钟**:target_count=5 即 5 分钟 ## 设计方案 ### 计时逻辑 - 进入课程学习页面即开始计时(页面打开即计时) - 切到后台也继续计算(不检测活跃时间) - 不显示计时器(后台静默) ### 上报策略 - **每 5 分钟上报一次**:count=5 - **页面关闭/离线前上报剩余时长**:监听 `beforeunload` + `visibilitychange`(隐藏时) - **首次进入立即上报 1 分钟**:保证用户停留即有意义,后续每 5 分钟补报 ### 数据存储(防丢失) - 用 `localStorage` 记录当前课程累计已上报分钟数 - key:`learn_time_{courseId}`,value:已上报分钟数 - 页面卸载时同步保存,重新进入时恢复 ### 防重复 - 已上报的分钟数不重复上报(通过 localStorage 追踪) - 页面卸载时把未上报的剩余分钟数一次性补报 ## 实现文件 ### 1. 新建 `src/hooks/useLearnTimer.ts`(计时 Hook) ``` 功能: - 接收 courseId,返回空(纯副作用) - 进入时读取 localStorage 恢复已上报时长 - 每 1 分钟 setInterval 累加本地时长 - 每满 5 分钟调用 api_request.login.tasks.track({ action_type: 'learn', count: 5 }) - 上报成功后更新 localStorage 中的已上报值 - beforeunload / visibilitychange(hidden) 时: - 上报剩余未报分钟数(count = 剩余分钟) - 使用 navigator.sendBeacon 或同步 XHR 保证可靠性 ``` ### 2. 修改 `src/pages/CourseLearn/CourseLearn.tsx` ``` - 导入 useLearnTimer - 在组件内调用 useLearnTimer(courseData?.id) - 无需新增 UI 元素(后台静默) ``` ## 关键代码结构 ### useLearnTimer Hook ```typescript export const useLearnTimer = (courseId: number | undefined) => { const [reportedMinutes, setReportedMinutes] = useState(0); const [currentMinutes, setCurrentMinutes] = useState(0); useEffect(() => { if (!courseId) return; // 从 localStorage 恢复 const saved = localStorage.getItem(`learn_time_${courseId}`); const savedReported = saved ? parseInt(saved, 10) : 0; setReportedMinutes(savedReported); setCurrentMinutes(savedReported); const interval = setInterval(() => { setCurrentMinutes(prev => { const next = prev + 1; const newReported = reportedMinutes + Math.floor((next - reportedMinutes) / 5) * 5; // 每满 5 分钟上报 const batchesReady = Math.floor((next - reportedMinutes) / 5); if (batchesReady >= 1) { const toReport = batchesReady * 5; trackLearnTime(toReport); setReportedMinutes(reportedMinutes + toReport); localStorage.setItem(`learn_time_${courseId}`, String(reportedMinutes + toReport)); } return next; }); }, 60000); // 每分钟检查 // 页面卸载上报剩余 const handleUnload = () => { // 上报剩余分钟(不足 5 分钟的部分也上报) const remaining = currentMinutes - reportedMinutes; if (remaining > 0) { trackLearnTime(remaining); localStorage.removeItem(`learn_time_${courseId}`); } }; window.addEventListener('beforeunload', handleUnload); document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') handleUnload(); }); return () => { clearInterval(interval); window.removeEventListener('beforeunload', handleUnload); }; }, [courseId]); }; ``` ### trackLearnTime 辅助函数 ```typescript const trackLearnTime = async (minutes: number) => { try { await api_request.login.tasks.track({ action_type: 'learn', count: minutes, }); } catch { // 静默失败,不阻塞用户 } }; ``` ## 上报时序示例 | 时间 | 动作 | 累计学习 | 上报 | 后端进度 | |------|------|----------|------|----------| | 00:00 | 进入页面 | 0 | - | 0/5 | | 01:00 | 第 1 分钟 | 1 | - | - | | 05:00 | 满 5 分钟 | 5 | count=5 | 5/5 ✅ | | 10:00 | 满 10 分钟 | 10 | count=5 | 10/5(每日学习已完成,累计到学习达人) | | 15:00 | 关闭页面 | 12 | count=2(剩余) | 12/5 | ## 验证步骤 1. 打开任意课程学习页面 2. 等待 5 分钟,检查 TaskCenter 页面"每日学习"进度是否更新 3. 关闭页面,检查是否有补报请求(Network 面板) 4. 切换语言后确认功能不受影响 5. 未登录用户:计时不上报(或游客模式本地存储,登录后合并上报) ## 假设与决策 - **计时从页面打开开始**:用户选择,无需检测活跃时间 - **每 5 分钟上报一次**:用户选择,平衡实时性与请求频率 - **关闭页面用 beforeunload + visibilitychange**:双重保障,防止离线丢失 - **后台静默不显示 UI**:用户选择 - **action_type='learn'**:匹配种子数据中每日学习/学习达人的 action_type