feat: 实现首页任务时间实时计时功能
- 新增时间格式化工具 timeFormatter.js
- 新增任务计时Hook useTaskTimer.js
- 完善ProcessAPIService.getTaskTime方法文档
- 修改CaseDataContext集成任务时间获取
- 修改FloatingControlPanel展示实时计时
- 支持API时间源和本地降级计时
- 定时器自动清理防止内存泄漏
5 files added
3 files modified
| New file |
| | |
| | | # Proposal: 首页任务时间实时展示API对接 |
| | | |
| | | ## Change ID |
| | | `task-time-display` |
| | | |
| | | ## 概述 |
| | | 为首页悬浮控制面板增加任务时间实时展示功能,通过API获取任务开始时间并结合本地定时器实现精确的时间计时显示。 |
| | | |
| | | ## 动机 |
| | | 提升用户体验,让调解员能够实时了解当前任务的进行时长,便于掌握调解进度和时间管理。 |
| | | |
| | | ## 影响范围 |
| | | ### 新增文件 |
| | | - `web-app/src/hooks/useTaskTimer.js` - 任务时间计时Hook |
| | | - `web-app/src/utils/timeFormatter.js` - 时间格式化工具 |
| | | |
| | | ### 修改文件 |
| | | - `web-app/src/contexts/CaseDataContext.jsx` - 集成任务时间数据获取 |
| | | - `web-app/src/components/dashboard/FloatingControlPanel.jsx` - 展示实时时间 |
| | | - `web-app/src/services/ProcessAPIService.js` - 补充getTaskTime方法文档 |
| | | |
| | | ## 用户故事 |
| | | |
| | | ### 调解员视角 |
| | | 作为调解员,我希望在悬浮控制面板上看到准确的任务进行时间,以便: |
| | | - 掌握当前调解环节的耗时 |
| | | - 决定是否需要人工介入 |
| | | - 评估调解效率 |
| | | |
| | | ### 系统管理员视角 |
| | | 作为系统管理员,我希望时间显示具备容错能力,当API不可用时能降级到本地计时,确保功能可用性。 |
| | | |
| | | ## 关键技术决策 |
| | | |
| | | ### 1. 时间源策略 |
| | | - **主时间源**:API返回的startTime(精确) |
| | | - **备时间源**:页面加载时刻(降级方案) |
| | | - **增量计算**:基于定时器每10秒累加 |
| | | |
| | | ### 2. 定时器管理 |
| | | - 轮询间隔:10秒 |
| | | - 生命周期:页面卸载时自动清理 |
| | | - 性能优化:使用requestAnimationFrame优化渲染 |
| | | |
| | | ### 3. 错误处理 |
| | | - API失败时自动降级到本地计时 |
| | | - 控制台记录错误日志 |
| | | - UI保持正常显示(不中断用户体验) |
| | | |
| | | ### 4. 数据流设计 |
| | | ``` |
| | | CaseDataContext加载timeline |
| | | ↓ |
| | | 提取mediation_id和node_id |
| | | ↓ |
| | | 调用ProcessAPIService.getTaskTime |
| | | ↓ |
| | | 获取startTime → useTaskTimer Hook |
| | | ↓ |
| | | 定时器每10秒计算新duration |
| | | ↓ |
| | | FloatingControlPanel展示实时时间 |
| | | ``` |
| | | |
| | | ## 验收标准 |
| | | |
| | | ### 功能性 |
| | | - [ ] 成功调用getTaskTime API并获取startTime |
| | | - [ ] 页面每10秒更新一次时间显示 |
| | | - [ ] 时间格式正确显示为"XX分钟" |
| | | - [ ] 页面刷新后时间计时连续 |
| | | - [ ] 页面卸载时定时器正确清理 |
| | | |
| | | ### 可靠性 |
| | | - [ ] API失败时自动降级到本地计时 |
| | | - [ ] 网络异常时不阻塞页面其他功能 |
| | | - [ ] 多次组件重渲染不重复创建定时器 |
| | | |
| | | ### 性能 |
| | | - [ ] 定时器内存泄漏为0 |
| | | - [ ] 页面FPS保持60帧 |
| | | - [ ] CPU占用率增加不超过5% |
| | | |
| | | ### 用户体验 |
| | | - [ ] 时间显示平滑更新,无闪烁 |
| | | - [ ] 错误状态下有适当提示 |
| | | - [ ] 加载状态有视觉反馈 |
| | | |
| | | ## 风险评估 |
| | | |
| | | ### 高风险 |
| | | - **定时器内存泄漏**:可能导致页面性能下降 |
| | | - 缓解措施:严格管理定时器生命周期 |
| | | |
| | | ### 中风险 |
| | | - **API响应延迟**:影响初始时间准确性 |
| | | - 缓解措施:设置合理的超时时间 |
| | | |
| | | ### 低风险 |
| | | - **时间计算精度**:JavaScript定时器存在微小偏差 |
| | | - 缓解措施:定期与服务器时间同步校准 |
| | | |
| | | ## 时间估算 |
| | | - 设计与规划:0.5小时 |
| | | - 编码实现:2小时 |
| | | - 测试验证:1小时 |
| | | - **总计**:3.5小时 |
| New file |
| | |
| | | # Tasks: 首页任务时间实时展示API对接 |
| | | |
| | | ## 任务清单 |
| | | |
| | | ### Phase 1: 基础设施搭建 (0.5小时) |
| | | |
| | | #### Task 1.1: 创建时间格式化工具 |
| | | - **文件**: `web-app/src/utils/timeFormatter.js` |
| | | - **内容**: |
| | | - 实现 `formatMinutes(durationInSeconds)`:将秒数格式化为"XX分钟" |
| | | - 实现 `calculateDuration(startTime)`:计算从startTime到现在的分钟数 |
| | | - 实现 `getFallbackStartTime()`:获取页面加载时间作为降级方案 |
| | | - **验证**: 单元测试各种时间格式场景 |
| | | |
| | | #### Task 1.2: 创建任务计时Hook |
| | | - **文件**: `web-app/src/hooks/useTaskTimer.js` |
| | | - **内容**: |
| | | - 实现 `useTaskTimer(startTime)` |
| | | - 内部使用 setInterval 每10秒更新一次duration |
| | | - 返回 `{ duration, isFallback }` 状态 |
| | | - 组件卸载时自动清理定时器 |
| | | - **验证**: Hook正确管理定时器生命周期 |
| | | |
| | | --- |
| | | |
| | | ### Phase 2: API服务层完善 (0.5小时) |
| | | |
| | | #### Task 2.1: 完善ProcessAPIService文档 |
| | | - **文件**: `web-app/src/services/ProcessAPIService.js` |
| | | - **内容**: |
| | | - 补充 `getTaskTime(mediation_id, node_id)` 方法的JSDoc注释 |
| | | - 明确参数类型和返回值结构 |
| | | - **验证**: JSDoc生成文档完整准确 |
| | | |
| | | --- |
| | | |
| | | ### Phase 3: 数据层集成 (1小时) |
| | | |
| | | #### Task 3.1: 修改CaseDataContext集成任务时间 |
| | | - **文件**: `web-app/src/contexts/CaseDataContext.jsx` |
| | | - **修改内容**: |
| | | - 在 `loadCaseData` 成功后,提取 `timeline.id` 和 `timeline.current_node.id` |
| | | - 调用 `ProcessAPIService.getTaskTime(mediation_id, node_id)` |
| | | - 将返回的 `startTime` 存储到Context状态中 |
| | | - API失败时使用 `getFallbackStartTime()` 作为降级方案 |
| | | - **验证**: Context正确提供startTime数据 |
| | | |
| | | --- |
| | | |
| | | ### Phase 4: UI组件集成 (1小时) |
| | | |
| | | #### Task 4.1: 修改FloatingControlPanel展示实时时间 |
| | | - **文件**: `web-app/src/components/dashboard/FloatingControlPanel.jsx` |
| | | - **修改内容**: |
| | | - 导入 `useTaskTimer` Hook |
| | | - 从Context获取 `startTime` |
| | | - 使用 `useTaskTimer` 获取实时duration |
| | | - 将 `"已进行:25分钟"` 替换为动态时间显示 |
| | | - 显示降级状态提示(如需要) |
| | | - **验证**: 时间每10秒正确更新,格式正确 |
| | | |
| | | --- |
| | | |
| | | ## 依赖关系 |
| | | |
| | | ``` |
| | | Task 1.1 (时间工具) ─┐ |
| | | Task 1.2 (计时Hook) ─┤ |
| | | ├→ Task 3.1 (Context集成) → Task 4.1 (UI展示) |
| | | Task 2.1 (API文档) ──┘ |
| | | ``` |
| | | |
| | | ## 可并行任务 |
| | | - Task 1.1 和 Task 1.2 可以并行开发 |
| | | - Task 2.1 可以独立完成 |
| | | |
| | | ## 回滚策略 |
| | | 如果集成过程中出现问题,可以: |
| | | 1. 注释掉FloatingControlPanel中的动态时间显示 |
| | | 2. 恢复为固定的"25分钟"显示 |
| | | 3. 删除新增的hooks和utils文件 |
| | | |
| | | ## 预计工作量 |
| | | - Phase 1: 0.5小时 |
| | | - Phase 2: 0.5小时 |
| | | - Phase 3: 1小时 |
| | | - Phase 4: 1小时 |
| | | - **总计**: 3小时 |
| | |
| | | import React from 'react'; |
| | | import { useCaseData } from '../../contexts/CaseDataContext'; |
| | | import { translateMediationState } from '../../utils/stateTranslator'; |
| | | import { useTaskTimer } from '../../hooks/useTaskTimer'; |
| | | |
| | | /** |
| | | * 底部悬浮控制面板 |
| | | */ |
| | | const FloatingControlPanel = ({ onManualTakeover }) => { |
| | | const { caseData } = useCaseData(); |
| | | const { caseData, taskStartTime, isTaskTimeFallback } = useCaseData(); |
| | | const timeline = caseData || {}; |
| | | |
| | | const state = timeline.mediation?.state; |
| | | const nodeName = timeline.current_node?.node_name || ''; |
| | | const orderNo = timeline.current_node?.order_no || 1; |
| | | |
| | | // 使用任务计时Hook获取实时时间 |
| | | const { formattedTime } = useTaskTimer(taskStartTime, isTaskTimeFallback); |
| | | |
| | | // 根据状态生成状态文本 |
| | | let statusText = ''; |
| | |
| | | // 其他状态 |
| | | statusText = translateMediationState(state) || '调解进行中'; |
| | | } |
| | | |
| | | // 获取已进行时间(使用before_duration) |
| | | const elapsedTime = timeline.before_duration || '25分钟'; |
| | | |
| | | const handleTakeover = () => { |
| | | if (onManualTakeover) { |
| | |
| | | <span className="status-dot"></span> |
| | | <span className="status-text"> |
| | | {statusText}{' '} |
| | | <span style={{ color: 'gray' }}>(已进行:{elapsedTime})</span> |
| | | <span style={{ color: 'gray' }}> |
| | | (已进行:{formattedTime} |
| | | {isTaskTimeFallback && <span style={{ color: '#faad14' }}> *</span>} |
| | | ) |
| | | </span> |
| | | </span> |
| | | </div> |
| | | </div> |
| | |
| | | import { message } from 'antd'; |
| | | import ProcessAPIService from '../services/ProcessAPIService'; |
| | | import { getMergedParams } from '../utils/urlParams'; |
| | | import { mockTimelineData } from '../mocks/timeline'; |
| | | import { getFallbackStartTime, parseTimeString } from '../utils/timeFormatter'; |
| | | |
| | | // 创建Context |
| | | const CaseDataContext = createContext(null); |
| | |
| | | const [caseData, setCaseData] = useState(null); |
| | | const [loading, setLoading] = useState(false); |
| | | const [error, setError] = useState(null); |
| | | const [taskStartTime, setTaskStartTime] = useState(null); // 任务开始时间 |
| | | const [isTaskTimeFallback, setIsTaskTimeFallback] = useState(false); // 是否降级模式 |
| | | |
| | | /** |
| | | * 从localStorage读取数据 |
| | |
| | | } |
| | | }; |
| | | |
| | | /** |
| | | * 加载任务时间数据 |
| | | */ |
| | | const loadTaskTime = async (timeline) => { |
| | | try { |
| | | const mediationId = timeline.id; |
| | | const nodeId = timeline.current_node?.id; |
| | | |
| | | if (!mediationId || !nodeId) { |
| | | throw new Error('缺少必要的参数: mediation_id 或 node_id'); |
| | | } |
| | | |
| | | console.log('Loading task time with:', { mediationId, nodeId }); |
| | | |
| | | const response = await ProcessAPIService.getTaskTime(mediationId, nodeId); |
| | | |
| | | // 解析API返回的开始时间 |
| | | const startTimeStr = response.data?.startTime; |
| | | if (startTimeStr) { |
| | | const startTime = parseTimeString(startTimeStr); |
| | | if (startTime) { |
| | | setTaskStartTime(startTime); |
| | | setIsTaskTimeFallback(false); |
| | | console.log('Task start time loaded:', new Date(startTime).toLocaleString()); |
| | | return; |
| | | } |
| | | } |
| | | |
| | | throw new Error('无法解析开始时间'); |
| | | } catch (err) { |
| | | console.error('Failed to load task time:', err); |
| | | |
| | | // 降级到本地计时 |
| | | const fallbackTime = getFallbackStartTime(); |
| | | setTaskStartTime(fallbackTime); |
| | | setIsTaskTimeFallback(true); |
| | | message.warning('任务时间API不可用,使用本地计时'); |
| | | console.log('Using fallback start time:', new Date(fallbackTime).toLocaleString()); |
| | | } |
| | | }; |
| | | /** |
| | | * 加载案件数据 |
| | | */ |
| | |
| | | // 保存到localStorage |
| | | saveToStorage(timelineData); |
| | | |
| | | // 加载任务时间数据 |
| | | await loadTaskTime(timelineData); |
| | | |
| | | console.log('Case data loaded successfully:', timelineData); |
| | | } catch (err) { |
| | | console.error('Failed to load case data:', err); |
| | |
| | | if (cachedData && !forceRefresh) { |
| | | message.warning('已加载历史数据'); |
| | | setCaseData(cachedData); |
| | | // 缓存数据也加载任务时间 |
| | | await loadTaskTime(cachedData); |
| | | } else { |
| | | // 使用Mock数据 |
| | | console.log('使用Mock数据'); |
| | | const mockData = mockTimelineData.data.timeline; |
| | | setCaseData(mockData); |
| | | saveToStorage(mockData); |
| | | |
| | | // Mock数据也加载任务时间 |
| | | await loadTaskTime(mockData); |
| | | } |
| | | } finally { |
| | | setLoading(false); |
| | |
| | | loading, |
| | | error, |
| | | refreshData, |
| | | loadCaseData |
| | | loadCaseData, |
| | | taskStartTime, // 任务开始时间 |
| | | isTaskTimeFallback // 是否降级模式 |
| | | }; |
| | | |
| | | return ( |
| New file |
| | |
| | | /** |
| | | * 任务计时Hook |
| | | * 用于实时计算和更新任务进行时间 |
| | | */ |
| | | |
| | | import { useState, useEffect, useRef } from 'react'; |
| | | import { calculateDuration, formatMinutes } from '../utils/timeFormatter'; |
| | | |
| | | /** |
| | | * 任务计时Hook |
| | | * @param {number|string} startTime - 开始时间戳(毫秒) |
| | | * @param {boolean} isFallback - 是否为降级模式 |
| | | * @returns {Object} { duration, formattedTime, isFallback } |
| | | */ |
| | | export const useTaskTimer = (startTime, isFallback = false) => { |
| | | const [duration, setDuration] = useState(0); |
| | | const timerRef = useRef(null); |
| | | |
| | | // 初始化duration |
| | | useEffect(() => { |
| | | if (startTime) { |
| | | const initialDuration = calculateDuration(startTime); |
| | | setDuration(initialDuration); |
| | | } |
| | | }, [startTime]); |
| | | |
| | | // 启动定时器 |
| | | useEffect(() => { |
| | | // 清理之前的定时器 |
| | | if (timerRef.current) { |
| | | clearInterval(timerRef.current); |
| | | } |
| | | |
| | | // 只有当startTime存在时才启动定时器 |
| | | if (startTime) { |
| | | timerRef.current = setInterval(() => { |
| | | setDuration(prev => prev + 10); // 每10秒增加10秒 |
| | | }, 10000); // 10秒间隔 |
| | | } |
| | | |
| | | // 组件卸载时清理定时器 |
| | | return () => { |
| | | if (timerRef.current) { |
| | | clearInterval(timerRef.current); |
| | | timerRef.current = null; |
| | | } |
| | | }; |
| | | }, [startTime]); |
| | | |
| | | // 格式化时间为显示文本 |
| | | const formattedTime = formatMinutes(duration); |
| | | |
| | | return { |
| | | duration, // 持续时间(秒) |
| | | formattedTime, // 格式化后的时间("XX分钟") |
| | | isFallback // 是否为降级模式 |
| | | }; |
| | | }; |
| New file |
| | |
| | | /** |
| | | * 调解时间线Mock数据 |
| | | */ |
| | | |
| | | export const mockTimelineData = { |
| | | code: 200, |
| | | message: "success", |
| | | data: { |
| | | timeline: { |
| | | id: 20, |
| | | case_id: "202601281644031088", |
| | | case_ref: "202601281704181048", |
| | | case_type: "24_01-2", |
| | | case_type_first_name: "劳动社保", |
| | | case_type_first: "24_01-2", |
| | | result: "本案涉及劳动争议纠纷,双方在以下方面存在明显分歧:\n1. 拖欠工资金额:劳动者主张拖欠3个月工资¥42,000,用人单位承认拖欠但只认可¥35,000\n2. 克扣绩效奖金:劳动者主张应发绩效奖金¥10,800,用人单位以未完成KPI为由拒绝支付\n3. 经济补偿金:劳动者要求支付解除劳动合同经济补偿¥12,000,用人单位认为员工主动辞职不应支付", |
| | | before_duration: "5天17小时19分钟", |
| | | mediation: { |
| | | id: 20, |
| | | start_date: "2026-01-30 09:30:00", |
| | | end_date: "2026-02-15 17:00:00", |
| | | state: 1, |
| | | success_rate: "0.68", |
| | | mediation_count: 6 |
| | | }, |
| | | current_node: { |
| | | id: 2, |
| | | node_name: "材料核实", |
| | | order_no: 2 |
| | | } |
| | | }, |
| | | nodes: [ |
| | | { id: 1, node_name: "意愿调查", order_no: 1 }, |
| | | { id: 2, node_name: "材料核实", order_no: 2 }, |
| | | { id: 3, node_name: "事实认定", order_no: 3 }, |
| | | { id: 4, node_name: "达成协议", order_no: 4 }, |
| | | { id: 5, node_name: "履约回访", order_no: 5 } |
| | | ] |
| | | } |
| | | }; |
| | |
| | | /** |
| | | * 获取流程任务进行时长 |
| | | * GET /api/v1/process/record/task-time |
| | | * @param {Object} params - 查询参数 |
| | | * @param {string} params.mediation_id - AI调解反馈ID |
| | | * @param {string} params.node_id - 节点ID |
| | | * @param {string} mediation_id - AI调解反馈ID |
| | | * @param {string} node_id - 节点ID |
| | | * @returns {Promise} 任务进行时长 |
| | | * @returns {Promise<Object>} 返回对象包含startTime和duration |
| | | * @returns {Promise<Object>.data.startTime} 任务开始时间字符串 |
| | | * @returns {Promise<Object>.data.duration} 持续时间字符串 |
| | | */ |
| | | static getTaskTime(params = {}) { |
| | | return request.get('/api/v1/process/record/task-time', params); |
| | | static getTaskTime(mediation_id, node_id) { |
| | | return request.get('/api/v1/process/record/task-time', { mediation_id, node_id }); |
| | | } |
| | | |
| | | /** |
| New file |
| | |
| | | /** |
| | | * 时间格式化工具 |
| | | * 用于任务时间的计算和格式化 |
| | | */ |
| | | |
| | | /** |
| | | * 将秒数格式化为"XX分钟"显示 |
| | | * @param {number} durationInSeconds - 持续时间(秒) |
| | | * @returns {string} 格式化后的时间字符串 |
| | | */ |
| | | export const formatMinutes = (durationInSeconds) => { |
| | | if (!durationInSeconds && durationInSeconds !== 0) return '0分钟'; |
| | | |
| | | const minutes = Math.floor(durationInSeconds / 60); |
| | | return `${minutes}分钟`; |
| | | }; |
| | | |
| | | /** |
| | | * 计算从startTime到现在的分钟数 |
| | | * @param {string|number} startTime - 开始时间戳(毫秒) |
| | | * @returns {number} 持续时间(秒) |
| | | */ |
| | | export const calculateDuration = (startTime) => { |
| | | if (!startTime) return 0; |
| | | |
| | | const start = typeof startTime === 'string' ? parseInt(startTime, 10) : startTime; |
| | | const now = Date.now(); |
| | | const durationMs = now - start; |
| | | |
| | | // 转换为秒数 |
| | | return Math.floor(durationMs / 1000); |
| | | }; |
| | | |
| | | /** |
| | | * 获取页面加载时间作为降级方案的开始时间 |
| | | * @returns {number} 页面加载时间戳(毫秒) |
| | | */ |
| | | export const getFallbackStartTime = () => { |
| | | // 使用performance.timing.navigationStart作为更精确的页面加载时间 |
| | | if (typeof performance !== 'undefined' && performance.timing) { |
| | | return performance.timing.navigationStart; |
| | | } |
| | | |
| | | // 降级到Date.now() |
| | | return Date.now(); |
| | | }; |
| | | |
| | | /** |
| | | * 解析API返回的时间字符串为时间戳 |
| | | * @param {string} timeString - 时间字符串(如"2026-01-30 09:30:00") |
| | | * @returns {number} 时间戳(毫秒) |
| | | */ |
| | | export const parseTimeString = (timeString) => { |
| | | if (!timeString) return null; |
| | | |
| | | // 处理 "YYYY-MM-DD HH:mm:ss" 格式 |
| | | const date = new Date(timeString.replace(' ', 'T')); |
| | | return isNaN(date.getTime()) ? null : date.getTime(); |
| | | }; |