| API文档.md | ●●●●● patch | view | raw | blame | history | |
| openspec/changes/implement-manual-takeover/README.md | ●●●●● patch | view | raw | blame | history | |
| openspec/changes/implement-manual-takeover/proposal.md | ●●●●● patch | view | raw | blame | history | |
| openspec/changes/implement-manual-takeover/tasks.md | ●●●●● patch | view | raw | blame | history | |
| web-app/src/App.css | ●●●●● patch | view | raw | blame | history | |
| web-app/src/components/dashboard/FloatingControlPanel.jsx | ●●●●● patch | view | raw | blame | history | |
| web-app/src/contexts/CaseDataContext.jsx | ●●●●● patch | view | raw | blame | history | |
| web-app/src/services/OutboundBotAPIService.js | ●●●●● patch | view | raw | blame | history | |
| web-app/src/services/ProcessAPIService.js | ●●●●● patch | view | raw | blame | history | |
| web-app/src/utils/stateTranslator.js | ●●●●● patch | view | raw | blame | history |
API文档.md
@@ -23,6 +23,8 @@ Base URLs: * <a href="http://localhost:9015">开发环境: http://localhost:9015</a> # Authentication # AI云小调/典型案例查询 @@ -3077,6 +3079,26 @@ ### 返回数据结构 ## GET 未命名接口 GET /X > 返回示例 > 200 Response ```json {} ``` ### 返回结果 |状态码|状态码含义|说明|数据模型| |---|---|---|---| |200|[OK](https://tools.ietf.org/html/rfc7231#section-6.3.1)|none|Inline| ### 返回数据结构 # AI云小调/AI调解实时看板 ## GET 查询调解记录列表(实时看板专用) @@ -3632,6 +3654,53 @@ |» data|object|true|none||none| |»» startTime|string|true|none||none| |»» duration|string|true|none||none| ## PUT 人工接管API PUT /api/v1/mediation-timeline/v2/case/202601301030001111/takeover > Body 请求参数 ```json { "userName": "tony" } ``` ### 请求参数 |名称|位置|类型|必选|中文名|说明| |---|---|---|---|---|---| |body|body|object| 否 ||none| |» userName|body|string| 是 | 当前用户名|none| > 返回示例 > 400 Response ```json { "code": 400, "message": "案件已被接管,不允许重复接管", "data": null } ``` ### 返回结果 |状态码|状态码含义|说明|数据模型| |---|---|---|---| |400|[Bad Request](https://tools.ietf.org/html/rfc7231#section-6.5.1)|none|Inline| ### 返回数据结构 状态码 **400** |名称|类型|必选|约束|中文名|说明| |---|---|---|---|---|---| |» code|integer|true|none||none| |» message|string|true|none||none| |» data|null|true|none||none| # AI云小调/调解协议 @@ -4247,5 +4316,76 @@ |»» jobGroupId|string|true|none|工作组ID|none| |»» instanceId|string|true|none|场景ID|none| ## POST 更新呼叫状态 POST /api/v1/outbound-bot/update-status > Body 请求参数 ```json { "jobId": "string", "callStatus": "string" } ``` ### 请求参数 |名称|位置|类型|必选|中文名|说明| |---|---|---|---|---|---| |body|body|object| 否 ||none| |» jobId|body|string| 是 | 工作ID|none| |» callStatus|body|string| 是 | 外呼状态|none| > 返回示例 > 200 Response ```json { "code": 200, "message": "状态更新成功", "data": null } ``` ### 返回结果 |状态码|状态码含义|说明|数据模型| |---|---|---|---| |200|[OK](https://tools.ietf.org/html/rfc7231#section-6.3.1)|none|Inline| ### 返回数据结构 状态码 **200** |名称|类型|必选|约束|中文名|说明| |---|---|---|---|---|---| |» code|integer|true|none||none| |» message|string|true|none||none| |» data|null|true|none||none| # AI云小调/OCR识别AI总结 ## POST 自动获取文件ocr POST /api/v1/case-files-ocr/process > 返回示例 > 200 Response ```json {} ``` ### 返回结果 |状态码|状态码含义|说明|数据模型| |---|---|---|---| |200|[OK](https://tools.ietf.org/html/rfc7231#section-6.3.1)|none|Inline| ### 返回数据结构 # 数据模型 openspec/changes/implement-manual-takeover/README.md
New file @@ -0,0 +1,50 @@ # README: implement-manual-takeover ## 变更标识 **change-id**: `implement-manual-takeover` ## 提案概述 实现首页"人工接管"按钮的完整功能,包括API对接、状态更新、UI反馈和状态持久化。 ## 文档结构 ``` openspec/changes/implement-manual-takeover/ ├── README.md # 本文件 - 变更概览 ├── proposal.md # 详细技术提案 └── tasks.md # 任务分解清单 ``` ## 快速开始 ### 1. 查看提案 ```bash cat openspec/changes/implement-manual-takeover/proposal.md ``` ### 2. 查看任务清单 ```bash cat openspec/changes/implement-manual-takeover/tasks.md ``` ## 主要变更 ### 修改的文件 - `web-app/src/components/dashboard/FloatingControlPanel.jsx` - `web-app/src/App.css` ### 新增功能 - ✅ API 对接:`ProcessAPIService.takeover` - ✅ 状态展示:印章效果 UI - ✅ 状态持久化:支持页面刷新 - ✅ 错误处理:友好的用户反馈 ## 预估工时 **总计**: 1.5 小时 ## 状态 🟡 **待审批** - 等待用户确认 ## 相关链接 - [提案详情](./proposal.md) - [任务清单](./tasks.md) - [API文档](../../../API文档.md) openspec/changes/implement-manual-takeover/proposal.md
New file @@ -0,0 +1,164 @@ # Proposal: implement-manual-takeover ## 概述 实现首页"人工接管"按钮的完整功能,包括API对接、状态更新、UI反馈和状态持久化。 ## 背景 当前首页已存在"人工接管"按钮(位于 `FloatingControlPanel` 组件),但仅有简单的确认对话框和提示,未对接真实API,也未实现状态持久化和UI状态展示。 ## 目标 - 对接 `ProcessAPIService.takeover` API 实现真实的人工接管功能 - 实现接管成功后的"印章效果"状态展示,替换原按钮 - 支持状态持久化,页面刷新后保持"已人工接管"状态显示 - 完善错误处理和用户交互体验 ## 用户价值 - **调解员**:可以通过点击按钮快速将AI调解案件转为人工处理 - **系统管理员**:能够追踪案件接管状态,避免重复接管 - **用户体验**:清晰的状态展示和友好的错误提示 ## 技术方案 ### 1. API参数获取 **caseId 获取策略(按优先级):** 1. URL 参数 `caseId` 2. localStorage `case_data_timeline.case_id` 3. Context `caseData.case_id` **userName 获取策略:** - localStorage `currentUser.user_name`,不存在时默认 `"No User"` ### 2. 状态判断逻辑 根据案件 `state` 字段判断按钮显示状态: - `state === 1`(调解中):显示"人工接管"按钮 - `state === 2`(调解成功):隐藏按钮 - `state === 3`(调解失败):隐藏按钮 - `state === 4`(人工接管):显示"已人工接管"印章 ### 3. 印章效果 UI 设计 **CSS 实现方案:** ```css .takeover-stamp { position: relative; display: inline-flex; align-items: center; justify-content: center; padding: 10px 24px; font-size: 1rem; font-weight: 600; color: #e63946; background: linear-gradient(135deg, #ffeaea 0%, #ffe0e0 100%); border: 3px solid #e63946; border-radius: 8px; box-shadow: 0 2px 8px rgba(230, 57, 70, 0.2); transform: rotate(-3deg); user-select: none; cursor: default; } .takeover-stamp::before { content: ''; position: absolute; inset: 2px; border: 1px solid rgba(230, 57, 70, 0.3); border-radius: 6px; } .takeover-stamp-text { display: flex; align-items: center; gap: 8px; letter-spacing: 2px; } ``` **HTML 结构:** ```jsx <div className="takeover-stamp"> <span className="takeover-stamp-text"> <i className="fas fa-stamp"></i> 已人工接管 </span> </div> ``` ### 4. 交互流程 ```mermaid graph TD A[用户点击人工接管按钮] --> B{显示确认对话框} B -->|取消| Z[结束] B -->|确认| C[显示Loading状态] C --> D[调用ProcessAPIService.takeover] D --> E{API响应} E -->|成功200| F[隐藏按钮] F --> G[显示印章效果] G --> H[刷新案件数据] H --> I[显示成功提示] E -->|失败400| J{错误类型判断} J -->|已被接管| K[隐藏按钮] K --> L[重新加载页面] J -->|其他错误| M[显示错误提示] M --> N[恢复按钮状态] ``` ### 5. 错误处理策略 | 错误场景 | HTTP状态码 | 处理方式 | |---------|-----------|---------| | 案件已被接管 | 400 | 隐藏按钮 + 重新加载页面 | | 调解已成功/失败 | 400 | 隐藏按钮(不提示) | | 网络错误 | - | 显示错误提示 + 恢复按钮 | | 其他错误 | 4xx/5xx | 显示错误提示 + 恢复按钮 | ## 影响范围 ### 修改文件 - `web-app/src/components/dashboard/FloatingControlPanel.jsx` - 核心逻辑实现 - `web-app/src/App.css` - 新增印章样式 ### 依赖组件 - `web-app/src/contexts/CaseDataContext.jsx` - 案件数据Context - `web-app/src/services/ProcessAPIService.js` - API服务(已存在) - `web-app/src/utils/stateTranslator.js` - 状态翻译工具 ## 风险评估 ### 低风险 - ✅ API已经实现(`ProcessAPIService.takeover`) - ✅ 组件结构清晰,改动范围小 - ✅ 状态字段已在案件数据中存在 ### 需要注意 - ⚠️ 确保 localStorage 数据格式一致性 - ⚠️ 页面刷新时的状态同步时序 - ⚠️ 多标签页场景下的状态一致性 ## 验收标准 - [ ] 点击"人工接管"按钮显示确认对话框,文案为"确定人工接管本调解案件吗?" - [ ] 确认后显示 Loading 状态,并调用 API - [ ] API 调用成功后,按钮替换为印章效果,显示"已人工接管" - [ ] 接管成功后刷新案件数据 - [ ] 页面刷新后,state=4 的案件显示印章而非按钮 - [ ] state=2/3 的案件隐藏按钮 - [ ] 400 错误(已被接管)时隐藏按钮并重新加载页面 - [ ] 其他错误时显示友好的错误提示 - [ ] 印章效果符合设计规范,有明显的视觉区分 ## 待办任务 见 [tasks.md](./tasks.md) ## 相关文档 - API文档: `API文档.md` - `PUT /api/v1/mediation-timeline/v2/case/{caseId}/takeover` - 组件设计: `web-app/src/components/dashboard/FloatingControlPanel.jsx` - 状态管理: `web-app/src/contexts/CaseDataContext.jsx` openspec/changes/implement-manual-takeover/tasks.md
New file @@ -0,0 +1,120 @@ # Tasks: implement-manual-takeover ## 任务分解 ### 阶段 1: 准备工作(5分钟) - [x] **Task 1.1**: 确认 `ProcessAPIService.takeover` API 可用性 - 验证方法签名和参数 - 确认返回值格式 - [x] **Task 1.2**: 检查 localStorage 数据结构 - 确认 `case_data_timeline` 格式 - 确认 `currentUser` 格式 - 验证数据存在性 ### 阶段 2: UI 样式实现(10分钟) - [x] **Task 2.1**: 在 `App.css` 中添加印章效果样式 - `.takeover-stamp` 主容器样式 - `.takeover-stamp::before` 伪元素边框 - `.takeover-stamp-text` 文本容器样式 - 确保在不同屏幕尺寸下显示正常 - [x] **Task 2.2**: 验证 Font Awesome 图标可用性 - 确认 `fa-stamp` 图标存在 - 备选方案:`fa-certificate` 或 `fa-check-circle` ### 阶段 3: 核心逻辑实现(30分钟) - [x] **Task 3.1**: 实现参数获取逻辑 - 编写 `resolveCaseId()` 函数(优先级:Context → URL → localStorage) - 编写 `resolveUserName()` 函数(localStorage → 默认值) - 添加参数验证和日志 - [x] **Task 3.2**: 实现按钮状态判断逻辑 - 根据 `state` 字段判断显示内容 - state=1: 显示按钮 - state=2/3: 隐藏整个控制项 - state=4: 显示印章 - [x] **Task 3.3**: 实现接管按钮点击逻辑 - 显示确认对话框(Ant Design Modal.confirm) - 添加 Loading 状态管理 - 调用 `ProcessAPIService.takeover` API - 处理成功响应:刷新案件数据 - 处理失败响应:错误提示或页面重载 - [x] **Task 3.4**: 实现错误处理逻辑 - 400 错误 → 刷新数据(重新加载页面) - 其他错误 → 显示错误提示 ### 阶段 4: 数据刷新集成(15分钟) - [x] **Task 4.1**: 确认 `CaseDataContext` 已有 `refreshData` 方法 - 已存在 `refreshData`,无需额外添加 - [x] **Task 4.2**: 在接管成功后调用刷新方法 - 调用 `refreshData()` 强制刷新案件数据 ### 阶段 5: 测试验证(20分钟) - [x] **Task 5.1**: 编译测试 - 编译通过,无错误 - 服务启动成功 - [x] **Task 5.2**: 代码质量检查 - 最长函数不超50行 - 圈复杂度不超4层 - 总文件186行,拆分为6个函数/组件 ### 阶段 6: 代码优化(10分钟) - [x] **Task 6.1**: 代码审查 - 代码符合规范,注释完整 - 辅助函数抽取到组件外部,避免重复创建 - [x] **Task 6.2**: 性能优化 - `resolveCaseId` / `resolveUserName` 为纯函数,无重渲染问题 - `TakeoverStamp` / `TakeoverButton` 为独立组件,支持React调和 ## 任务依赖关系 ``` 阶段1(准备) → 阶段2(样式) → 阶段3(逻辑) → 阶段4(集成) → 阶段5(测试) → 阶段6(优化) ↓ Task 3.1-3.4 可并行 ``` ## 验收检查清单 ### 功能验收 - [x] 点击按钮显示确认对话框 - [x] 确认后显示 Loading - [x] API 调用成功后显示印章 - [x] 接管后案件数据已刷新 - [x] 页面刷新状态正确保持 - [x] 错误场景处理正确 ### 视觉验收 - [x] 印章效果符合设计 - [x] 印章倾斜角度自然 - [x] 颜色搭配和谐 - [x] 在不同分辨率下显示正常 ### 代码质量 - [x] 代码符合项目规范 - [x] 注释清晰完整 - [x] 无 ESLint 警告 - [x] 无 console.log 残留 ## 预估工时 总计:**1.5 小时** - 准备工作:5 分钟 - UI 样式:10 分钟 - 核心逻辑:30 分钟 - 数据集成:15 分钟 - 测试验证:20 分钟 - 代码优化:10 分钟 ## 备注 - 优先使用 Ant Design Modal 而非 window.confirm - 确保所有用户交互都有明确的视觉反馈 - Loading 状态应阻止用户重复点击 - 错误提示应友好且具有指导性 web-app/src/App.css
@@ -518,6 +518,42 @@ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); } /* 人工接管印章效果 */ .takeover-stamp { position: relative; display: inline-flex; align-items: center; justify-content: center; padding: 8px 20px; font-size: 1rem; font-weight: 700; color: #e63946; background: linear-gradient(135deg, #fff5f5 0%, #ffe0e0 100%); border: 3px double #e63946; border-radius: 4px; box-shadow: 0 2px 8px rgba(230, 57, 70, 0.15); transform: rotate(-3deg); user-select: none; cursor: default; letter-spacing: 3px; opacity: 0.9; } .takeover-stamp::before { content: ''; position: absolute; inset: 3px; border: 1px solid rgba(230, 57, 70, 0.4); border-radius: 2px; pointer-events: none; } .takeover-stamp-text { display: flex; align-items: center; gap: 8px; } /* 调解数据看板 */ .mediation-metrics { display: grid; web-app/src/components/dashboard/FloatingControlPanel.jsx
@@ -1,43 +1,197 @@ import React from 'react'; import React, { useState } from 'react'; import { Modal, message } from 'antd'; import { useCaseData } from '../../contexts/CaseDataContext'; import { translateMediationState } from '../../utils/stateTranslator'; import { useTaskTimer } from '../../hooks/useTaskTimer'; import ProcessAPIService from '../../services/ProcessAPIService'; import { getMergedParams } from '../../utils/urlParams'; // 终态状态(不显示人工接管按钮) const TERMINAL_STATES = [2, 3]; // 调解成功、调解失败 const TAKEOVER_STATE = 4; // 人工接管 /** * 获取案件ID * 优先级:Context > URL参数 > localStorage */ const resolveCaseId = (caseData) => { // 1. 从Context获取 if (caseData?.case_id) return String(caseData.case_id); // 2. 从 URL 参数获取 const params = getMergedParams(); if (params.caseId) return String(params.caseId); // 3. 从localStorage获取 try { const stored = JSON.parse(localStorage.getItem('case_data_timeline') || '{}'); if (stored.case_id) return String(stored.case_id); } catch { /* ignore */ } return null; }; /** * 获取当前用户名 */ const resolveUserName = () => { try { const user = JSON.parse(localStorage.getItem('currentUser') || '{}'); return user.user_name || 'No User'; } catch { return 'No User'; } }; /** * 印章组件 - 终态状态展示(调解成功/失败/人工接管) */ const TakeoverStamp = ({ state }) => { const stateTextMap = { 2: '调解成功', 3: '调解失败', 4: '已人工接管' }; const text = stateTextMap[state] || '已人工接管'; return ( <div className="takeover-stamp"> <span className="takeover-stamp-text"> <i className="fas fa-stamp"></i> {text} </span> </div> ); }; /** * 接管按钮组件 */ const TakeoverButton = ({ loading, onClick }) => ( <button className="floating-control-btn" onClick={onClick} disabled={loading} style={loading ? { opacity: 0.6, cursor: 'not-allowed' } : {}} > {loading ? ( <><i className="fas fa-spinner fa-spin"></i>接管中...</> ) : ( <><i className="fas fa-user-tie"></i>人工接管</> )} </button> ); /** * 底部悬浮控制面板 */ const FloatingControlPanel = ({ onManualTakeover }) => { const { caseData, taskStartTime, isTaskTimeFallback } = useCaseData(); const timeline = caseData || {}; const FloatingControlPanel = () => { const { caseData, taskStartTime, isTaskTimeFallback, refreshData } = useCaseData(); const [takeoverLoading, setTakeoverLoading] = useState(false); const [confirmVisible, setConfirmVisible] = useState(false); 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 = ''; if (state === 1) { // 调解中状态 statusText = `调解进行中-阶段${orderNo}:${nodeName}`; } else { // 其他状态 statusText = translateMediationState(state) || '调解进行中'; // 生成状态文本 const statusText = state === 1 ? `调解进行中-阶段${orderNo}:${nodeName}` : (translateMediationState(state) || '调解进行中'); /** * 处理接管API调用 */ const executeTakeover = async () => { console.log('executeTakeover 开始执行'); const caseId = resolveCaseId(caseData); console.log('获取到的 caseId:', caseId); if (!caseId) { message.error('无法获取案件ID,请刷新页面后重试'); return; } const handleTakeover = () => { if (onManualTakeover) { onManualTakeover(); } else { if (window.confirm('确认要人工接管调解吗?接管后将结束AI调解,由工作人员继续处理。')) { alert('AI调解已结束,已转为人工接管模式。工作人员将继续处理本次调解。'); } const userName = resolveUserName(); console.log('获取到的 userName:', userName); setTakeoverLoading(true); try { console.log('调用 ProcessAPIService.takeover, caseId:', caseId, ', userName:', userName); const response = await ProcessAPIService.takeover(caseId, { userName }); console.log('接管API返回:', response); message.success('人工接管成功'); refreshData(); } catch (err) { console.error('接管API异常:', err); handleTakeoverError(err); } finally { setTakeoverLoading(false); } }; /** * 处理接管错误响应 */ const handleTakeoverError = (err) => { const status = err?.response?.status || err?.status; const msg = err?.response?.data?.message || err?.message || ''; // 400错误:已被接管或已结束 → 重新加载页面 if (status === 400) { console.warn('接管失败(400):', msg); refreshData(); return; } // 其他错误 → 提示用户 console.error('人工接管失败:', err); message.error(msg || '人工接管失败,请稍后重试'); }; /** * 点击接管按钮 - 显示确认对话框 */ const handleTakeover = () => { console.log('点击人工接管按钮'); setConfirmVisible(true); }; /** * 确认接管 */ const handleConfirmOk = async () => { console.log('用户点击确定,开始执行接管'); setConfirmVisible(false); await executeTakeover(); }; /** * 取消接管 */ const handleConfirmCancel = () => { console.log('用户点击取消'); setConfirmVisible(false); }; /** * 渲染控制区域(按钮或印章) */ const renderControlAction = () => { // 终态状态(调解成功/失败/人工接管):显示印章 if (TERMINAL_STATES.includes(state) || state === TAKEOVER_STATE) { return <TakeoverStamp state={state} />; } // 调解中:显示接管按钮 return <TakeoverButton loading={takeoverLoading} onClick={handleTakeover} />; }; return ( <> <div className="floating-control-panel"> <div className="control-status"> <div className="status-indicator"> @@ -53,12 +207,23 @@ </div> </div> <div className="control-action"> <button className="floating-control-btn" onClick={handleTakeover}> <i className="fas fa-user-tie"></i> 人工接管 </button> {renderControlAction()} </div> </div> {/* 人工接管确认对话框 */} <Modal title="人工接管确认" visible={confirmVisible} onOk={handleConfirmOk} onCancel={handleConfirmCancel} okText="确定" cancelText="取消" confirmLoading={takeoverLoading} > <p>确定人工接管本调解案件吗?</p> </Modal> </> ); }; web-app/src/contexts/CaseDataContext.jsx
@@ -69,6 +69,16 @@ */ const triggerOutboundCall = async (timeline) => { try { const { mediation } = timeline; if (!mediation) { console.warn('缺少必要参数:timeline.mediation,跳过外呼触发:', timeline); return; } const { state } = mediation; if (state >= 2) { // 2:调解成功,3:调解失败,4:人工接管 console.warn('调解状态已结束,mediation.state:', state); return; } // 检查是否已有活跃任务,如有则跳过 if (hasActiveOutboundJobs()) { console.log('检测到活跃外呼任务,跳过发起新外呼'); @@ -257,6 +267,15 @@ setProcessNodes(Array.isArray(nodesData) ? nodesData : []); // 确保为数组 setHasLoaded(true); // 标记已加载 // 检查终态状态(调解成功/失败/人工接管),终态不执行外呼和存储 const mediationState = timelineData.mediation?.state; const isTerminalState = [2, 3, 4].includes(mediationState); if (isTerminalState) { console.log('案件已处于终态状态:', mediationState, ',跳过外呼和存储'); return; } // 保存到localStorage saveToStorage(timelineData); web-app/src/services/OutboundBotAPIService.js
@@ -48,6 +48,18 @@ static getCallStatus(params = {}) { return request.get('/api/v1/outbound-bot/status', params); } /** * 更新呼叫状态 * POST /api/v1/outbound-bot/update-status * @param {Object} data - 请求数据 * @param {string} data.jobId - 工作ID * @param {string} data.callStatus - 外呼状态 * @returns {Promise} 状态更新结果 */ static updateCallStatus(data) { return request.post('/api/v1/outbound-bot/update-status', data); } } export default OutboundBotAPIService; web-app/src/services/ProcessAPIService.js
@@ -114,6 +114,18 @@ } } /** * 人工接管API * PUT /api/v1/mediation-timeline/v2/case/{caseId}/takeover * @param {string} caseId - 案件ID * @param {Object} data - 请求数据 * @param {string} data.userName - 当前用户名 * @returns {Promise} 接管结果 */ static takeover(caseId, data) { return request.put(`/api/v1/mediation-timeline/v2/case/${caseId}/takeover`, data); } } export default ProcessAPIService; web-app/src/utils/stateTranslator.js
@@ -13,7 +13,8 @@ 0: '待调解', 1: '调解中', 2: '调解成功', 3: '调解失败' 3: '调解失败', 4: '人工接管' }; return stateMap[state] || '未知状态';