Compare commits

...

58 Commits

Author SHA1 Message Date
b2ce9d93db fix(导出): 修复客户数据导出功能并增强表单数据处理
- 更新导出API端点以匹配后端接口变更
- 重构表单数据解析逻辑,支持多种数据结构格式
- 动态生成Excel列宽,避免数据截断
- 添加客户姓名字段到导出数据
- 优化重复字段处理,确保导出数据完整性
2026-01-29 19:58:21 +08:00
8260b345dc feat(表单信息展示): 支持多表单数据展示和筛选功能
- 重构表单数据解析逻辑,支持从数组格式中提取多表单数据
- 添加表单筛选按钮,可按表单标题筛选显示内容
- 优化数据结构处理,支持问答列表和旧格式的兼容
- 改进UI布局,添加表单标题和分割线提升可读性
2026-01-23 18:56:46 +08:00
2cd59adfc9 feat(person): 在销售时间轴任务列表中增加已完成用户分组显示
- 将待操作用户列表重构为独立的任务区块,根据选定阶段动态显示标题
- 新增已完成用户区块,根据销售阶段自动计算已完成联系人
- 为不同区块添加视觉区分样式,包括标题颜色和背景
- 添加阶段排序逻辑和表单完成状态检查函数
- 改进联系人数据构建函数以支持多种数据源
2026-01-23 18:07:34 +08:00
baa89001c8 style(manager): 简化按钮文本并调整样式
移除按钮的内边距,统一字体大小,简化切换按钮文本为"原文"和"分析"
2026-01-12 14:55:05 +08:00
9b3c5da105 feat(manager): 添加优秀录音文件获取及展示功能
新增获取优秀录音文件的API接口,并在管理页面添加GoodMusic组件展示录音文件列表
支持录音文件的下载、转文字及分析功能,优化了页面布局和间距
2026-01-12 14:50:30 +08:00
a4c0aca1c2 refactor(CustomerDetail): 重构基础信息分析逻辑为调用API
将原有的本地处理表单、聊天记录和通话记录的逻辑替换为直接调用后端API获取客户基础信息
2025-12-19 11:48:00 +08:00
9f19b8fb66 Merge branch 'Breach' of https://git.yinlihupo.cn/LiuRui/DJKB into Breach 2025-11-25 20:37:27 +08:00
14ddd2839b fix(manager): 修复团队异常预警中可选链操作符的使用
处理原始数据可能为undefined的情况,使用可选链操作符避免潜在的错误
2025-11-25 20:37:26 +08:00
6d2d3bda67 refactor(manager): 移除多余的part_count参数并简化请求参数
简化团队分析请求逻辑,直接使用getRequestParams()获取参数,移除硬编码的part_count字段
2025-11-25 18:49:41 +08:00
b48b7749f2 feat(团队分析): 添加团队整体三阶分析报告功能
- 在api/manager.js中添加获取团队分析报告的接口
- 在TeamReport组件中添加分析按钮并触发事件
- 在manager.vue中实现团队分析弹窗及相关逻辑
- 添加样式美化分析报告展示
2025-11-25 17:53:54 +08:00
2ba88eff08 feat(部门分析): 添加部门整体分析报告功能
实现部门分析弹窗的数据获取与展示功能,新增getTeamEntiretyReport API接口
调整多个组件高度以优化布局
2025-11-25 16:59:43 +08:00
6cf6829334 feat(团队分析): 添加团队和部门分析功能及弹窗
- 在api中添加获取团队各组分析报告的接口
- 在secondTop和seniorManager视图中添加团队分析弹窗组件
- 实现部门分析弹窗功能
- 添加样式和交互逻辑处理团队分析数据展示
2025-11-25 16:11:07 +08:00
9d52b99414 refactor: 移除调试用的console.log语句
清理多个组件和工具文件中用于调试的console.log语句,保持代码整洁
2025-11-25 15:31:40 +08:00
b9f74dc810 fix(统计指标): 修复数据未加载时显示异常问题
修改统计指标组件和父组件的数据处理逻辑,确保在数据未加载或返回null时显示默认值0。同时将props类型从Number改为Object,并添加默认空对象防止访问属性错误。
2025-11-25 15:26:34 +08:00
1647970bed fix(components): 为录音名称添加标题提示和文本溢出处理
添加标题提示以显示完整录音名称,并设置文本溢出样式防止布局问题
2025-11-21 19:36:34 +08:00
e696f768e6 feat(录音报告): 添加一键复制报告内容功能
添加一键复制按钮到录音报告模态框,支持现代Clipboard API和传统复制方法。同时将录音名称显示为客户姓名(如果存在),否则使用默认名称。
2025-11-21 16:35:00 +08:00
6b76d36946 feat: 更新应用图标、标题并调整弹窗尺寸
修改应用图标为NYC_logo,更新页面标题为"暖洋葱家庭教育数据看板"
调整sale.vue中弹窗的最大宽度和高度以适应更多内容
2025-11-19 15:21:36 +08:00
8e5f7335d8 feat(团队管理): 添加团队整体分析按钮并优化布局
在secondTop和seniorManager页面添加团队整体分析按钮
调整seniorManager页面的团队详情头部布局,使其更灵活
为按钮添加悬停和点击效果
2025-11-10 17:40:09 +08:00
5ff42dbbad fix(api): 修正二阶分析报告接口路径错误
refactor(views): 重构阶段分析报告展示逻辑,使用新接口数据格式

feat(analytics): 添加umami网站统计脚本

feat(api): 新增优秀录音文件获取接口

style(views): 优化分析报告样式和布局
2025-11-07 20:53:40 +08:00
b0c2f28d7f feat(录音管理): 添加优秀录音组件并优化API调用
refactor(性能优化): 使用Promise.all并行请求核心KPI接口

style(样式调整): 修改ProblemRanking组件高度和内边距

chore: 移除调试用的console.log语句
2025-10-29 17:11:57 +08:00
51091d097e 5555 2025-10-29 12:13:47 +08:00
86cf54b9de fix(router): 将首页重定向从/sale改为/login
refactor: 移除seniorManager.vue中多余的空白行
2025-10-27 12:11:25 +08:00
10fb9dd4f2 feat(销售时间线): 添加全部录音按钮及处理逻辑
新增全部录音按钮并实现其点击事件处理逻辑,同时将API端点从本地地址改为生产环境地址。处理函数结构与未归属录音类似,但调用不同的API接口获取数据。
2025-10-23 10:47:29 +08:00
2979e7e216 refactor(topOne): 优化优秀录音展示逻辑和样式
- 将excellentRecord和qualityCalls类型从Object改为Array以简化数据处理
- 添加录音分数显示并设置前三名特殊样式
- 调整录音列表的padding和布局
- 移除不必要的overflow-y属性
2025-10-22 18:03:59 +08:00
288a525537 refactor(ui): 调整多个组件的内边距和样式一致性
fix(GoodMusic): 修复录音列表数据结构和显示问题
style: 统一多个组件的头部内边距为10px 20px 10px
chore: 切换API基础路径为生产环境
2025-10-22 18:02:55 +08:00
db6433693a fix(topone): 修复获取优秀记录时返回数据格式不正确的问题
返回数据应从res.data.excellent_record_list改为直接返回res.data,以匹配接口返回格式
2025-10-22 17:38:05 +08:00
99efa8de75 fix(api): 修正获取优秀录音文件的API路径并实现功能
修复top.js和secondTop.js中获取优秀录音文件的API路径错误,将common路径改为正确的level_four和level_five路径
在secondTop.vue中实现获取优秀录音文件的功能,添加参数验证和错误处理
2025-10-22 17:34:34 +08:00
d75fd9beb8 fix: 修复表单数据处理逻辑并更新UI文本显示
修复表单数据从对象到数组的格式兼容处理,确保新旧数据格式都能正确显示
更新多处UI文本描述,包括"今日通话"改为"本期通话"、"月度总业绩"改为"本月成交单数"
调整客户阶段筛选逻辑,排除'待加微'客户对'待填表单'和'待到课'的影响
修复未分配通话记录API端点错误
2025-10-22 16:22:51 +08:00
094f655634 fix: 修复表单数据处理和API端点问题
修复RawDataCards和CustomerDetail组件中表单数据的默认值和类型定义
更新getTodayCall API端点路径为current_camp_call
调整sale.vue中表单数据处理逻辑以适应新API响应格式
优化PerformanceComparison组件的数据处理逻辑
修改开发环境API基础路径为本地测试地址
2025-10-22 14:57:28 +08:00
10c8c7b796 refactor(components): 重构通话记录卡片布局和样式
- 移除调试用的console.log语句
- 重新组织通话记录卡片的结构,将用户信息、标签和分数整合到更清晰的布局中
- 新增通话时长格式化方法和分数样式分类方法
- 优化移动端布局和交互效果
- 改进标签和按钮的视觉样式,增加悬停效果
2025-10-16 11:44:33 +08:00
ea32a16e5d fix: 将客户查询参数从name改为phone
修改客户聊天和通话记录的查询参数,从使用客户姓名改为使用电话号码,以匹配后端接口的变更需求
2025-10-15 21:38:19 +08:00
57be345996 feat(录音管理): 实现无归属录音API集成及报告查看功能
- 添加API请求获取真实录音数据并处理展示
- 实现录音报告弹窗展示详细信息
- 优化录音列表UI布局和响应式设计
2025-10-15 18:56:11 +08:00
3ed490d6dc fix: 将客户表单请求参数从name改为phone
客户表单接口现在需要使用手机号而非姓名作为查询参数,以更准确地识别客户
2025-10-15 17:48:31 +08:00
1fdd8fe12a feat(销售时间线): 添加无归属录音查看功能
添加无归属录音弹窗组件,包含录音列表展示和下载功能
2025-10-14 20:07:39 +08:00
a6f4c96f1f fix(图表): 修复组件卸载时图表内存泄漏问题
添加组件挂载状态跟踪,确保在组件卸载时正确清理图表实例
移除无用注释,修正描述文字
2025-10-14 18:58:30 +08:00
73c84f7b8d feat(PerformanceComparison): 优化性能对比表格样式和功能
- 重构表格样式,改进视觉层次和交互效果
- 调整变化值显示格式,将百分比和差值分开显示
- 增加表格行的悬停效果和斑马纹
- 改进数值格式化函数,添加单位显示
- 增强选择器交互效果和样式
- 添加NaN检查防止计算错误
2025-10-14 17:43:05 +08:00
3a529bafa8 feat(业绩对比): 添加业绩周期对比功能组件
新增业绩周期对比组件,支持与上周/上月/上季度数据对比展示。包含以下主要修改:
1. 添加PerformanceComparison.vue组件实现对比表格和周期选择
2. 在seniorManager.vue中集成该组件并添加相关计算属性
3. 新增API接口getHistoryCamps获取历史营期数据
4. 添加样式和状态管理逻辑
2025-10-14 16:58:52 +08:00
822afb422c feat(会员详情): 新增通话分类统计模块
添加通话分类统计功能,包括API接口调用和前端展示组件。该模块会显示不同通话标签的统计数据和平均时长,帮助分析会员的通话行为模式。同时优化了折叠动画和响应式布局,提升用户体验。
2025-10-14 11:20:53 +08:00
7e8f272dfe fix(GroupRanking): 添加组件卸载状态检查防止内存泄漏
在组件卸载时添加状态标记,避免组件卸载后仍执行图表更新操作导致内存泄漏
2025-10-13 17:55:26 +08:00
93febd0964 feat(销售漏斗): 添加团队销售漏斗功能并集成到组件中
- 在api模块添加getTeamSalesFunnel接口
- GroupRanking组件新增teamSalesFunnel属性监听
- seniorManager页面集成销售漏斗数据获取和传递
- 实现销售漏斗数据自动更新图表功能
2025-10-13 15:59:48 +08:00
9555bb66fd feat(FeedbackForm): 在反馈提交中添加项目字段
refactor(CustomerDetail): 重命名并启用总通话分析功能
重构客户详情组件,将"客户诉求分析"改为"总通话分析"并启用相关功能,优化分析逻辑和UI显示

style(sale): 移除冗余标题并调整布局样式
删除客户详情区域的冗余标题,调整主布局的宽度和边距

perf(CustomerDetail): 优化分析请求和错误处理
移除调试日志,优化API请求参数和错误处理逻辑
2025-10-13 11:45:27 +08:00
575a08ed3a feat(会员详情): 添加二阶分析报告功能并优化指导建议布局
重构会员详情组件,添加二阶分析报告功能,包括周期切换和报告加载状态显示。优化指导建议部分的布局结构,将分析报告与原有指导建议分开显示。调整样式以提升用户体验,并修复部分代码格式问题。
2025-10-10 21:56:02 +08:00
b3f5178470 feat(个人仪表盘): 添加实时分析报告功能并更新API基础路径
- 在PersonalDashboard组件中实现实时分析报告功能,包括数据为空和加载状态处理
- 添加SimpleChatService集成用于生成分析报告
- 将API基础路径从本地开发环境切换到生产环境
- 优化分析报告模态框样式和错误消息显示
2025-10-10 21:32:27 +08:00
859821dfb3 feat(分析报告): 添加二期分析报告功能并优化UI
- 在MemberDetails和PersonalDashboard组件中添加二期分析报告功能
- 统一分析周期参数为'day'、'camp'、'month'
- 优化模态框头部布局和按钮样式
- 添加获取二期分析报告的API调用
- 调整baseURL为本地开发环境
2025-10-09 22:02:20 +08:00
d661b77afa feat(销售分析): 添加二阶分析报告功能
- 在api.js和manager.js中添加获取二阶分析报告的API接口
- 在个人仪表板中添加阶段分析报告按钮和模态框
- 移除不再使用的周分析组件
- 添加分析报告数据结构和样式
2025-10-09 21:21:44 +08:00
676b213a7d feat(反馈表单): 在多个视图添加反馈表单功能及样式
为topOne、seniorManager和secondTop视图添加反馈表单控制逻辑和按钮样式
2025-10-09 20:17:21 +08:00
600684570a feat(反馈系统): 添加用户反馈功能组件
在多个视图页面中添加反馈按钮和FeedbackForm组件,允许用户提交反馈意见。主要变更包括:
1. 创建FeedbackForm.vue组件实现反馈表单
2. 在topone、seniorManager、secondTop等视图添加反馈按钮
3. 实现表单提交逻辑和样式
4. 修复manager.vue中Sale组件路径大小写问题
5. 将index.html语言设置为中文
2025-09-30 15:59:39 +08:00
6f0d10b881 fix: 修复导出功能并优化页面标题
- 将“销售驾驶舱”改为“分析师驾驶舱”以更准确反映功能
- 修复导出功能中移除自动导出和优化错误提示
- 清理不再使用的优秀录音获取代码
2025-09-24 20:54:28 +08:00
4885674f23 feat(销售管理): 优化团队成员详情展示和录音下载功能
- 在团队成员详情组件中添加memberDetails属性,展示更详细的数据统计
- 改进录音下载功能,处理HTTPS页面下载HTTP资源的情况并优化文件名获取
- 新增下载专用弹窗组件,防止与普通弹窗冲突
- 修复销售时间线中"点击未支付"阶段的显示文本
- 增强模态框的滚动控制和样式一致性
2025-09-17 10:56:11 +08:00
3033326def fix(客户详情): 修改通话数据检查逻辑和按钮提示文本
将通话数据检查逻辑从"是否有通话数据"改为"是否有20分钟通话数据",并相应更新按钮的禁用状态提示文本
2025-09-15 12:07:35 +08:00
11c1bcc626 fix(PerformanceRanking): 简化选中成员的判断逻辑
feat(RawDataCards): 添加通话记录标签样式和显示
refactor(MemberDetails): 启用指导建议并修复空成员检查
2025-09-15 12:01:10 +08:00
2447985cb2 refactor(views): 移除测试页面并清理模拟数据
移除不再使用的缓存测试页面 CacheTest.vue
清理 MemberDetails.vue 中的模拟录音数据,准备接入真实API
2025-09-15 11:29:37 +08:00
62a4eb0319 feat(录音下载): 重构录音下载功能并添加模态提示
- 将录音下载方法改为异步并使用fetch API
- 添加下载开始、成功和失败的模态提示
- 替换alert为统一模态框显示通话原文内容
- 在父组件中添加模态框组件及样式
2025-09-15 11:25:03 +08:00
1d63829ed6 fix(CustomerDetail): 移除SOP分析按钮的加载状态限制并更新API地址
refactor(RawDataCards): 重构通话记录卡片布局并添加时间格式化功能

- 在CustomerDetail组件中,简化SOP分析按钮的状态逻辑并更新API地址
- 在RawDataCards组件中,重新设计操作按钮布局,添加时间显示功能并优化样式
2025-09-12 17:19:41 +08:00
87c926ebb1 refactor(RawDataCards): 移除调试用的console.log语句
清理代码中用于调试的console.log输出,保持代码整洁
2025-09-10 15:24:37 +08:00
1e6f987172 fix: 修复通话记录数据处理和SOP分析逻辑
移除未使用的SOP分析处理函数
修正RawDataCards组件中通话记录数据的处理逻辑
简化录音下载和查看原文功能的实现
2025-09-10 15:23:13 +08:00
a00a20c4ee fix: 移除录音文件数量显示并修复API调用参数传递
移除RawDataCards.vue中不再需要的录音文件数量显示
在CustomerDetail.vue中修复axios调用参数传递方式
2025-09-10 15:00:20 +08:00
b030a201a7 feat(api): 添加电话接通率API接口
refactor(cache): 将缓存系统重构为独立模块并添加测试页面

fix: 修复客户详情中电话接通率显示格式问题

refactor: 移除各页面中的缓存逻辑,统一使用缓存store

feat: 在客户详情中添加通话分析API调用

fix: 修正导出客户API的URL路径

chore: 更新开发环境配置注释
2025-09-10 14:22:24 +08:00
36 changed files with 7518 additions and 3029 deletions

View File

@@ -1,10 +1,11 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="zh">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" href="/NYC_logo.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + Vue</title> <title>暖洋葱家庭教育数据看板</title>
<script defer src="https://umami.nycjy.cn/script.js" data-website-id="0d851950-9420-4c3e-a12a-c221fcf039b5"></script>
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>

View File

@@ -4664,7 +4664,7 @@
}, },
"node_modules/markdown-it": { "node_modules/markdown-it": {
"version": "14.1.0", "version": "14.1.0",
"resolved": "https://registry.npmmirror.com/markdown-it/-/markdown-it-14.1.0.tgz", "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz",
"integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==", "integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

View File

@@ -7,7 +7,7 @@ export const getProblemDistribution = (params) => {
// 今日通话 /api/v1/more_level_screening/today_call // 今日通话 /api/v1/more_level_screening/today_call
export const getTodayCall = (params) => { export const getTodayCall = (params) => {
return https.post('/api/v1/sales/today_call', params) return https.post('/api/v1/sales/current_camp_call', params)
} }
// 表格填写率 /api/v1/more_level_screening/table_filling_rate // 表格填写率 /api/v1/more_level_screening/table_filling_rate
@@ -77,5 +77,12 @@ export const getGoldContactTime = (params) => {
export const getAvgCallTime = (params) => { export const getAvgCallTime = (params) => {
return https.post('/api/v1/sales/get_avg_call_time', params) return https.post('/api/v1/sales/get_avg_call_time', params)
} }
// 电话接通率 /api/v1/sales/get_call_success_rate
export const getCallSuccessRate = (params) => {
return https.post('/api/v1/sales/get_call_success_rate', params)
}
// 二阶分析报告
export const getSecondOrderAnalysisReport = (params) => {
return https.post('/api/v1/sales/get_second_analysis_report', params)
}

View File

@@ -44,6 +44,22 @@ export const getGroupDetail = (params) => {
export const getGroupCallDuration = (params) => { export const getGroupCallDuration = (params) => {
return https.post('/api/v1/manager/group_call_duration', params) return https.post('/api/v1/manager/group_call_duration', params)
} }
// 二阶分析报告 /api/v1/sales/get_call_text
export const GetSecondOrderAnalysisReport = (params) => {
return https.post('/api/v1/manager/group_second_report', params)
}
// 通话分类数据 /api/v1/manager/get_member_call_classify
export const getMemberCallClassify = (params) => {
return https.post('/api/v1/manager/get_member_call_classify', params)
}
// 团队整体三阶分析报告 /api/v1/manager/group_entirety_third_report
export const getGroupEntiretyThirdReport = (params) => {
return https.post('/api/v1/manager/group_entirety_third_report', params)
}
// 获取优秀录音文件 /api/v1/level_five/overview/get_excellent_record_file
export const getExcellentRecordFile = (params) => {
return https.post('/api/v1/level_five/overview/get_excellent_record_file', params)
}

View File

@@ -66,7 +66,7 @@ export const getCampPeriodAdmin = (params) => {
} }
// 获取优秀录音文件 /api/v1/level_four/overview/get_excellent_record_file // 获取优秀录音文件 /api/v1/level_four/overview/get_excellent_record_file
export const getExcellentRecordFile = (params) => { export const getExcellentRecordFile = (params) => {
return https.post('/api/v1/common/get_excellent_record_file', params) return https.post('/api/v1/level_four/overview/get_excellent_record_file', params)
} }
// 修改营期 /api/v1/level_four/overview/change_camp_period // 修改营期 /api/v1/level_four/overview/change_camp_period
export const changeCampPeriod = (params) => { export const changeCampPeriod = (params) => {
@@ -87,7 +87,7 @@ export const cancelSwitchHistoryCampPeriod = (params) => {
// 一键导出 api/v1/level_four/overview/export_customers // 一键导出 api/v1/level_four/overview/export_customers
export const exportCustomers = (params) => { export const exportCustomers = (params) => {
return https.post('http://192.168.15.56:8890/api/v1/level_four/overview/export_customers', params) return https.post('/api/v1/level_four/overview/export_all_customers_under_sales', params)
} }

View File

@@ -45,14 +45,18 @@ export const getTimeoutRate = (params) => {
export const getTableFillingRate = (params) => { export const getTableFillingRate = (params) => {
return https.post('/api/v1/level_three/overview/table_filling_rate', params) return https.post('/api/v1/level_three/overview/table_filling_rate', params)
} }
// 销售漏斗
// 销售漏斗 /api/v1/level_three/overview/team_sales_funnel
export const getTeamSalesFunnel = (params) => {
return https.post('/api/v1/level_three/overview/team_sales_funnel', params)
}
// 客户迫切解决的问题 /api/v1/level_three/overview/urgent_need_to_address // 客户迫切解决的问题 /api/v1/level_three/overview/urgent_need_to_address
export const getUrgentNeedToAddress = (params) => { export const getUrgentNeedToAddress = (params) => {
return https.post('/api/v1/level_three/overview/urgent_need_to_address', params) return https.post('/api/v1/level_three/overview/urgent_need_to_address', params)
} }
// 团队业绩排名 /api/v1/level_three/overview/team_ranking // 团队业绩排名 /api/v1/level_three/overview/team_ranking
export const getTeamRanking = (params) => { export const getTeamRanking = (params) => {
return https.post('/api/v1/level_three/overview/team_ranking', params) return https.post('/api/v1/level_three/overview/team_ranking', params)
} }
@@ -66,8 +70,28 @@ export const getTeamRankingInfo = (params) => {
export const getAbnormalResponseRate = (params) => { export const getAbnormalResponseRate = (params) => {
return https.post('/api/v1/level_three/overview/abnormal_response_rate', params) return https.post('/api/v1/level_three/overview/abnormal_response_rate', params)
} }
// 历史营期 /api/v1/level_three/overview/get_history_camps
export const getHistoryCamps = (params) => {
return https.post('/api/v1/level_three/overview/get_history_camps', params)
}
// 数据对比 /api/v1/level_three/overview/get_team_many_target
export const getTeamManyTarget = (params) => {
return https.post('/api/v1/level_three/overview/get_team_many_target', params)
}
// 优秀录音 /api/v1/level_three/overview/get_current_center_excellent_record_file
export const getExcellentRecordFile = (params) => {
return https.post('/api/v1/level_three/overview/get_current_center_excellent_record_file', params)
}
// 团队下各组分析报告 /api/v1/level_three/overview/team_every_group_report
export const getTeamEveryGroupReport = (params) => {
return https.post('/api/v1/level_three/overview/team_every_group_report', params)
}
// 部门整体分析报告 /api/v1/level_three/overview/team_entirety_report
export const getTeamEntiretyReport = (params) => {
return https.post('/api/v1/level_three/overview/team_entirety_report', params)
}

View File

@@ -69,8 +69,8 @@ export const getDetailedDataTable = (params) => {
export const getPeriodStage = (params) => { export const getPeriodStage = (params) => {
return https.get('/api/v1/level_five/overview/get_period_stage', params) return https.get('/api/v1/level_five/overview/get_period_stage', params)
} }
// 获取优秀录音文件 /api/v1/level_four/overview/get_excellent_record_file // 获取优秀录音文件 /api/v1/level_five/overview/get_excellent_record_file
export const getExcellentRecordFile = (params) => { export const getExcellentRecordFile = (params) => {
return https.post('/api/v1/common/get_excellent_record_file', params) return https.post('/api/v1/level_five/overview/get_excellent_record_file', params)
} }

View File

@@ -0,0 +1,334 @@
<template>
<div v-if="isVisible" class="modal-overlay" @click="closeModal">
<div class="modal-container" @click.stop>
<div class="modal-header">
<h3>意见反馈</h3>
<button class="modal-close-btn" @click="closeModal">×</button>
</div>
<div class="feedback-form-container">
<h2>意见反馈</h2>
<p class="subtitle">我们期待听到您的声音不断改进我们的产品</p>
<!-- 提交成功或失败的提示信息 -->
<div v-if="submitStatus === 'success'" class="feedback-message success">
感谢您的反馈我们已经收到您的宝贵意见
</div>
<div v-if="submitStatus === 'error'" class="feedback-message error">
提交失败请检查网络或稍后重试
</div>
<form v-if="submitStatus !== 'success'" @submit.prevent="handleSubmit">
<!-- 反馈类型 -->
<div class="form-group">
<label for="feedback-type">反馈类型</label>
<select id="feedback-type" v-model="formData.type">
<option>功能建议</option>
<option>界面优化</option>
<option>Bug 反馈</option>
<option>其他</option>
</select>
</div>
<!-- 反馈内容 -->
<div class="form-group">
<label for="feedback-content">反馈内容 (必填)</label>
<textarea
id="feedback-content"
v-model="formData.content"
placeholder="请详细描述您的问题或建议..."
rows="6"
required
></textarea>
</div>
<!-- 提交按钮 -->
<div class="form-actions">
<button type="submit" :disabled="isSubmitting">
<span v-if="isSubmitting">正在提交...</span>
<span v-else>提交反馈</span>
</button>
</div>
</form>
</div>
</div>
</div>
</template>
<script setup>
import axios from 'axios';
import { ref, reactive } from 'vue';
// 定义组件的props
const props = defineProps({
isVisible: {
type: Boolean,
default: false
}
});
// 定义组件要触发的事件
const emit = defineEmits(['submit-feedback', 'close']);
// 使用 reactive 创建响应式表单数据对象
const formData = reactive({
type: '功能建议', // 默认值
content: '',
contact: '',
screenshot: null, // 存储文件对象
});
// ref 用于独立的响应式值
const isSubmitting = ref(false); // 是否正在提交
const submitStatus = ref(null); // 'success', 'error', or null
const imagePreviewUrl = ref(null); // 图片预览 URL
const fileInputRef = ref(null); // 用于引用文件输入元素
// 文件选择变化时的处理函数
const handleFileChange = (event) => {
const file = event.target.files[0];
if (file) {
formData.screenshot = file;
// 创建一个临时的 URL 用于图片预览
imagePreviewUrl.value = URL.createObjectURL(file);
}
};
// 移除已选图片
const removeImage = () => {
formData.screenshot = null;
imagePreviewUrl.value = null;
// 清空文件输入框的值,以便用户可以再次选择相同的文件
if (fileInputRef.value) {
fileInputRef.value.value = '';
}
};
// 关闭模态框
const closeModal = () => {
emit('close');
};
// 表单提交处理函数
const handleSubmit = async () => {
// 简单验证
if (!formData.content.trim()) {
alert('反馈内容不能为空!');
return;
}
isSubmitting.value = true;
submitStatus.value = null;
try {
// 创建 FormData 对象
// 获取 token (假设存储在 localStorage 中)
const token = localStorage.getItem('token') || '';
// 发送 POST 请求到后端接口
const response = await axios.post('https://feedback.api.nycjy.cn/api/v1/feedback/submit_feedback', {project:'mldash',type: formData.type, content: formData.content}, {
headers: {
'Authorization': `Bearer ${token}`
}
});
// console.log('响应状态8888:', response.data.message);
// 提交成功
submitStatus.value = 'success';
// 触发父组件的事件,并传递数据
emit('submit-feedback', { ...formData });
} catch (error) {
console.error('提交反馈失败:', error);
// 提交失败
submitStatus.value = 'error';
} finally {
// 请求结束
isSubmitting.value = false;
}
};
</script>
<style scoped>
.modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.modal-container {
background-color: #ffffff;
border-radius: 10px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
max-width: 600px;
width: 90%;
max-height: 90vh;
overflow-y: auto;
position: relative;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 2rem;
border-bottom: 1px solid #e2e8f0;
}
.modal-header h3 {
margin: 0;
color: #1a202c;
font-size: 1.25rem;
}
.modal-close-btn {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: #718096;
padding: 0;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
}
.modal-close-btn:hover {
color: #1a202c;
}
.feedback-form-container {
padding: 2rem;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
}
h2 {
text-align: center;
color: #1a202c;
margin-top: 0;
margin-bottom: 0.5rem;
}
.subtitle {
text-align: center;
color: #718096;
margin-bottom: 2rem;
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 600;
color: #4a5568;
}
input[type="text"],
select,
textarea {
width: 100%;
padding: 0.75rem;
border: 1px solid #cbd5e0;
border-radius: 6px;
font-size: 1rem;
color: #2d3748;
transition: border-color 0.2s, box-shadow 0.2s;
}
input[type="text"]:focus,
select:focus,
textarea:focus {
outline: none;
border-color: #4299e1;
box-shadow: 0 0 0 3px rgba(66, 153, 225, 0.5);
}
input[type="file"] {
width: 100%;
padding: 0.5rem;
}
.image-preview {
position: relative;
margin-top: 1rem;
max-width: 200px;
}
.image-preview img {
width: 100%;
height: auto;
border-radius: 6px;
border: 1px solid #e2e8f0;
}
.remove-image-btn {
position: absolute;
top: -10px;
right: -10px;
background-color: #e53e3e;
color: white;
border: none;
border-radius: 50%;
width: 24px;
height: 24px;
font-size: 16px;
line-height: 24px;
text-align: center;
cursor: pointer;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
.form-actions {
text-align: center;
}
button[type="submit"] {
padding: 0.75rem 2rem;
background-color: #4299e1;
color: white;
border: none;
border-radius: 6px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: background-color 0.2s;
width: 100%;
}
button[type="submit"]:hover:not(:disabled) {
background-color: #3182ce;
}
button[type="submit"]:disabled {
background-color: #a0aec0;
cursor: not-allowed;
}
.feedback-message {
padding: 1rem;
border-radius: 6px;
text-align: center;
margin-bottom: 1.5rem;
font-weight: 500;
}
.feedback-message.success {
background-color: #c6f6d5;
color: #2f855a;
border: 1px solid #9ae6b4;
}
.feedback-message.error {
background-color: #fed7d7;
color: #c53030;
border: 1px solid #feb2b2;
}
</style>

View File

@@ -11,7 +11,7 @@ const routes = [
{ {
path: '/', path: '/',
name: 'Home', name: 'Home',
redirect: '/sale' redirect: '/login'
}, },
{ {
path: '/login', path: '/login',

View File

@@ -0,0 +1,135 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useCacheStore = defineStore('cache', () => {
// 缓存Map
const cache = ref(new Map())
const CACHE_DURATION = 30 * 60 * 1000 // 30分钟缓存时长
// 生成缓存键
const getCacheKey = (apiName, params = {}) => {
const sortedParams = Object.keys(params)
.sort()
.reduce((result, key) => {
result[key] = params[key]
return result
}, {})
return `${apiName}_${JSON.stringify(sortedParams)}`
}
// 检查缓存是否有效
const isValidCache = (cacheData) => {
return cacheData && (Date.now() - cacheData.timestamp) < CACHE_DURATION
}
// 设置缓存
const setCache = (key, data) => {
cache.value.set(key, {
data,
timestamp: Date.now()
})
}
// 获取缓存
const getCache = (key) => {
const cacheData = cache.value.get(key)
if (isValidCache(cacheData)) {
return cacheData.data
}
// 如果缓存过期,删除它
if (cacheData) {
cache.value.delete(key)
}
return null
}
// 缓存包装器函数
const withCache = async (apiName, apiFunction, params = {}) => {
const cacheKey = getCacheKey(apiName, params)
const cachedData = getCache(cacheKey)
if (cachedData) {
console.log(`[缓存命中] ${apiName}:`, cachedData)
return cachedData
}
console.log(`[API调用] ${apiName}`)
const result = await apiFunction(params)
setCache(cacheKey, result)
return result
}
// 清除所有缓存
const clearCache = () => {
cache.value.clear()
console.log('所有缓存已清除')
}
// 清除特定缓存
const clearSpecificCache = (apiName, params = {}) => {
const key = getCacheKey(apiName, params)
cache.value.delete(key)
console.log(`已清除缓存: ${key}`)
}
// 获取缓存信息并清理过期缓存
const getCacheInfo = () => {
const now = Date.now()
const validCaches = []
const expiredCaches = []
for (const [key, data] of cache.value.entries()) {
if (isValidCache(data)) {
validCaches.push({
key,
timestamp: data.timestamp,
age: Math.round((now - data.timestamp) / 1000) + 's'
})
} else {
expiredCaches.push(key)
cache.value.delete(key)
}
}
return {
validCount: validCaches.length,
expiredCount: expiredCaches.length,
validCaches,
expiredCaches
}
}
// 清除过期缓存
const clearExpiredCache = () => {
const info = getCacheInfo()
console.log(`清理了 ${info.expiredCount} 个过期缓存`)
return info
}
// 获取缓存统计信息
const getCacheStats = () => {
return {
totalCount: cache.value.size,
duration: CACHE_DURATION / (1000 * 60) + '分钟',
...getCacheInfo()
}
}
return {
// 状态
cache,
CACHE_DURATION,
// 方法
getCacheKey,
isValidCache,
setCache,
getCache,
withCache,
clearCache,
clearSpecificCache,
getCacheInfo,
clearExpiredCache,
getCacheStats
}
})

View File

@@ -6,7 +6,7 @@ import { useUserStore } from '@/stores/user'
// 创建axios实例 // 创建axios实例
const service = axios.create({ const service = axios.create({
baseURL: 'https://mldash.nycjy.cn/' || '', // API基础路径支持完整URL baseURL: 'https://mldash.nycjy.cn/' || '', // API基础路径支持完整URL
// baseURL: 'http://192.168.15.121:8889' || '', // API基础路径支持完整URL // baseURL: 'http://192.168.15.121:8890' || '', // API基础路径支持完整URL
timeout: 100000, // 请求超时时间 timeout: 100000, // 请求超时时间
headers: { headers: {
'Content-Type': 'application/json;charset=UTF-8' 'Content-Type': 'application/json;charset=UTF-8'
@@ -31,7 +31,6 @@ service.interceptors.request.use(
} }
// 显示加载状态 // 显示加载状态
if (config.showLoading !== false) { if (config.showLoading !== false) {
console.log('显示加载中...')
} }
return config return config
}, },

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -14,7 +14,7 @@
v-for="(member, index) in displayMembers" v-for="(member, index) in displayMembers"
:key="member.user_name || member.id" :key="member.user_name || member.id"
class="table-row" class="table-row"
:class="{ active: selectedMember && (selectedMember.user_name === member.user_name || selectedMember.id === member.id) }" :class="{ active: selectedMember && selectedMember === member }"
@click="selectMember(member)" @click="selectMember(member)"
@dblclick="handleDoubleClick(member)" @dblclick="handleDoubleClick(member)"
> >

View File

@@ -1,6 +1,9 @@
<template> <template>
<div class="team-report"> <div class="team-report">
<h2>今日团队实时战报</h2> <div class="header-container">
<h2>今日团队实时战报</h2>
<button class="analysis-button" @click="showTeamAnalysis">团队分析</button>
</div>
<div class="report-grid"> <div class="report-grid">
<div class="report-card"> <div class="report-card">
<div class="card-header"> <div class="card-header">
@@ -32,7 +35,7 @@
</div> </div>
<div class="report-card"> <div class="report-card">
<div class="card-header"> <div class="card-header">
<span class="card-title">月度总业绩 <i class="info-icon" @mouseenter="showTooltip('monthlyRevenue', $event)" @mouseleave="hideTooltip"></i></span> <span class="card-title">本月成交单数 <i class="info-icon" @mouseenter="showTooltip('monthlyRevenue', $event)" @mouseleave="hideTooltip"></i></span>
<span class="card-trend positive">+8% vs 上月</span> <span class="card-trend positive">+8% vs 上月</span>
</div> </div>
<div class="card-value">{{ formatCurrency(weekTotalData.week_add_fee_total?.total_add_fee || 0) }} </div> <div class="card-value">{{ formatCurrency(weekTotalData.week_add_fee_total?.total_add_fee || 0) }} </div>
@@ -76,6 +79,9 @@ const props = defineProps({
} }
}) })
// 定义emit
const emit = defineEmits(['show-team-analysis'])
// 监听数据变化,用于调试 // 监听数据变化,用于调试
watch(() => props.weekTotalData, (newData) => { watch(() => props.weekTotalData, (newData) => {
console.log('TeamReport 收到的数据:', newData) console.log('TeamReport 收到的数据:', newData)
@@ -122,8 +128,8 @@ const metricDescriptions = {
description: '本期新增的成交订单数量,已确认付款或签约的客户订单。' description: '本期新增的成交订单数量,已确认付款或签约的客户订单。'
}, },
monthlyRevenue: { monthlyRevenue: {
title: '月度总业绩', title: '本月成交单数',
description: '本月团队累计完成的销售业绩总额,包括所有已确认的订单金额。' description: '本月团队累计完成的销售订单数量,包括所有已确认的订单。'
}, },
conversionRate: { conversionRate: {
title: '定金转化率', title: '定金转化率',
@@ -146,6 +152,11 @@ const showTooltip = (metricType, event) => {
const hideTooltip = () => { const hideTooltip = () => {
tooltip.visible = false tooltip.visible = false
} }
// 显示团队分析
const showTeamAnalysis = () => {
emit('show-team-analysis')
}
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@@ -156,11 +167,33 @@ const hideTooltip = () => {
padding: 1.5rem; padding: 1.5rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
.header-container {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
h2 { h2 {
font-size: 1.2rem; font-size: 1.2rem;
font-weight: 600; font-weight: 600;
color: #1e293b; color: #1e293b;
margin: 0 0 1.5rem 0; margin: 0;
}
.analysis-button {
background: #409eff;
color: white;
border: none;
border-radius: 4px;
padding: 8px 16px;
font-size: 0.9rem;
cursor: pointer;
transition: background-color 0.3s;
&:hover {
background: #337ecc;
}
} }
.report-grid { .report-grid {

View File

@@ -37,9 +37,11 @@
<!-- Top Section - Team Alerts and Today's Report --> <!-- Top Section - Team Alerts and Today's Report -->
<div class="top-section"> <div class="top-section">
<!-- Team Alerts --> <!-- Team Alerts -->
<TeamAlerts :abnormalData="groupAbnormalResponse" /> <!-- <TeamAlerts :abnormalData="groupAbnormalResponse" /> -->
<!-- Today's Team Report --> <GoodMusic :quality-calls="excellentRecord"
<TeamReport :weekTotalData="weekTotalData" /> />
<!-- Today's Team Report -->
<TeamReport :weekTotalData="weekTotalData" @show-team-analysis="fetchTeamAnalysis" />
</div> </div>
<!-- Sales Funnel Section --> <!-- Sales Funnel Section -->
@@ -60,73 +62,47 @@
<!-- Right Section --> <!-- Right Section -->
<div class="right-section"> <div class="right-section">
<!-- Member Details --> <!-- Member Details -->
<MemberDetails :selected-member="selectedMember" /> <MemberDetails :selected-member="selectedMember" :memberDetails="memberDetails" />
</div> </div>
</div> </div>
</main> </main>
</div> </div>
<!-- 团队分析弹窗 -->
<div v-if="showTeamAnalysisModal" class="modal-overlay" @click="showTeamAnalysisModal = false">
<div class="modal-content" @click.stop>
<div class="modal-header">
<h3>团队整体三阶分析报告</h3>
<button class="close-button" @click="showTeamAnalysisModal = false">×</button>
</div>
<div class="modal-body">
<div v-for="(report, index) in teamAnalysisData" :key="index" class="report-item">
<div class="report-meta">
<span class="time-range">{{ report.start_time }} {{ report.end_time }}</span>
<span class="created-at">生成时间: {{ report.created_at }}</span>
</div>
<div class="report-content" v-html="formatReportContent(report.report)"></div>
</div>
</div>
</div>
</div>
</template> </template>
<script setup> <script setup>
import { ref, onMounted, computed } from "vue"; import { ref, onMounted, computed } from "vue";
import TeamAlerts from "./components/TeamAlerts.vue"; import TeamAlerts from "./components/TeamAlerts.vue";
import GoodMusic from "./components/GoodMusic.vue";
import TeamReport from "./components/TeamReport.vue"; import TeamReport from "./components/TeamReport.vue";
import SalesFunnel from "./components/SalesFunnel.vue"; import SalesFunnel from "./components/SalesFunnel.vue";
import PerformanceRanking from "./components/PerformanceRanking.vue"; import PerformanceRanking from "./components/PerformanceRanking.vue";
import MemberDetails from "./components/MemberDetails.vue"; import MemberDetails from "./components/MemberDetails.vue";
import Sale from "../person/Sale.vue"; import Sale from "../person/sale.vue";
import SalesTimelineWithTaskList from "../person/components/SalesTimelineWithTaskList.vue"; import SalesTimelineWithTaskList from "../person/components/SalesTimelineWithTaskList.vue";
import RawDataCards from "../person/components/RawDataCards.vue"; import RawDataCards from "../person/components/RawDataCards.vue";
import CustomerDetail from "../person/components/CustomerDetail.vue"; import CustomerDetail from "../person/components/CustomerDetail.vue";
import { useUserStore } from "@/stores/user"; import { useUserStore } from "@/stores/user";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import {getGroupAbnormalResponse, getWeekTotalCall, getWeekAddCustomerTotal, getWeekAddDealTotal, import {getGroupAbnormalResponse, getWeekTotalCall, getWeekAddCustomerTotal, getWeekAddDealTotal,
getWeekAddFeeTotal, getGroupFunnel,getPayDepositToMoneyRate,getGroupRanking, getGroupCallDuration} from "@/api/manager.js"; getWeekAddFeeTotal, getGroupFunnel,getPayDepositToMoneyRate,getGroupRanking, getGroupCallDuration,getGroupDetail, getGroupEntiretyThirdReport,getExcellentRecordFile} from "@/api/manager.js";
// 缓存系统
const cache = new Map()
const CACHE_DURATION = 30 * 60 * 1000 // 30分钟缓存
// 缓存工具函数
function getCacheKey(apiName, params = {}) {
return `${apiName}_${JSON.stringify(params)}`
}
function isValidCache(cacheData) {
return cacheData && (Date.now() - cacheData.timestamp) < CACHE_DURATION
}
function setCache(key, data) {
cache.set(key, {
data,
timestamp: Date.now()
})
}
function getCache(key) {
const cacheData = cache.get(key)
if (isValidCache(cacheData)) {
return cacheData.data
}
cache.delete(key)
return null
}
// 通用缓存包装函数
async function withCache(apiName, apiCall, params = {}) {
const key = getCacheKey(apiName, params)
const cachedData = getCache(key)
if (cachedData) {
console.log(`使用缓存数据: ${key}`)
return cachedData
}
console.log(`调用API: ${key}`)
const result = await apiCall()
setCache(key, result)
return result
}
// 团队成员数据 // 团队成员数据
const teamMembers = [ const teamMembers = [
@@ -153,9 +129,10 @@ const userStore = useUserStore();
// 获取通用请求参数的函数 // 获取通用请求参数的函数
const getRequestParams = () => { const getRequestParams = () => {
const params = {} const params = {}
// 从路由参数获取 // 从路由参数获取
const routeUserLevel = router.currentRoute.value.query.user_level || router.currentRoute.value.params.user_level const routeUserLevel = router.currentRoute.value.query.user_level || router.currentRoute.value.params.user_level
const routeUserName = router.currentRoute.value.query.user_name || router.currentRoute.value.params.user_name const routeUserName = router.currentRoute.value.query.user_name || router.currentRoute.value.params.user_name
// 如果路由有参数,使用路由参数 // 如果路由有参数,使用路由参数
if (routeUserLevel) { if (routeUserLevel) {
params.user_level = routeUserLevel.toString() params.user_level = routeUserLevel.toString()
@@ -164,6 +141,14 @@ const getRequestParams = () => {
params.user_name = routeUserName params.user_name = routeUserName
} }
// 如果没有路由参数,使用当前登录用户的信息
if (!params.user_level && userStore.userInfo?.user_level) {
params.user_level = userStore.userInfo.user_level.toString()
}
if (!params.user_name && userStore.userInfo?.username) {
params.user_name = userStore.userInfo.username
}
return params return params
} }
@@ -191,14 +176,14 @@ const weekTotalData = ref({
pay_deposit_to_money_rate: {}, pay_deposit_to_money_rate: {},
group_funnel: {}, group_funnel: {},
group_call_duration: {}, group_call_duration: {},
}); })
// 团队异常预警 // 团队异常预警
const groupAbnormalResponse = ref({}) const groupAbnormalResponse = ref({})
async function TeamGetGroupAbnormalResponse() { async function TeamGetGroupAbnormalResponse() {
const params = getRequestParams() const params = getRequestParams()
const hasParams = params.user_name const hasParams = params.user_name
try { try {
const response = await withCache('getGroupAbnormalResponse', () => getGroupAbnormalResponse(hasParams ? params : undefined), hasParams ? params : {}) const response = await getGroupAbnormalResponse(hasParams ? params : undefined)
const rawData = response.data const rawData = response.data
// 转换数据格式,生成预警消息 // 转换数据格式,生成预警消息
@@ -206,9 +191,9 @@ async function TeamGetGroupAbnormalResponse() {
let alertId = 1 let alertId = 1
// 处理严重超时异常人员 // 处理严重超时异常人员
const timeoutPersons = rawData.erious_timeout_rate_abnorma || [] const timeoutPersons = rawData?.serious_timeout_rate_abnorma || []
// 处理表格填写异常人员 // 处理表格填写异常人员
const fillingPersons = rawData.table_filling_abnormal || [] const fillingPersons = rawData?.table_filling_abnormal || []
// 为每个异常人员生成独立的预警消息 // 为每个异常人员生成独立的预警消息
@@ -243,7 +228,7 @@ async function TeamGetGroupAbnormalResponse() {
async function TeamGetWeekTotalCall() { async function TeamGetWeekTotalCall() {
const params = getRequestParams() const params = getRequestParams()
const hasParams = params.user_name const hasParams = params.user_name
const res = await withCache('getWeekTotalCall', () => getWeekTotalCall(hasParams ? params : undefined), hasParams ? params : {}) const res = await getWeekTotalCall(hasParams ? params : undefined)
console.log(res) console.log(res)
if (res.code === 200) { if (res.code === 200) {
weekTotalData.value.week_total_call = res.data weekTotalData.value.week_total_call = res.data
@@ -253,7 +238,7 @@ async function TeamGetWeekTotalCall() {
async function TeamGetGroupCallDuration() { async function TeamGetGroupCallDuration() {
const params = getRequestParams() const params = getRequestParams()
const hasParams = params.user_name const hasParams = params.user_name
const res = await withCache('getGroupCallDuration', () => getGroupCallDuration(hasParams ? params : undefined), hasParams ? params : {}) const res = await getGroupCallDuration(hasParams ? params : undefined)
console.log(res) console.log(res)
if (res.code === 200) { if (res.code === 200) {
weekTotalData.value.group_call_duration = res.data weekTotalData.value.group_call_duration = res.data
@@ -263,7 +248,7 @@ async function TeamGetGroupCallDuration() {
async function TeamGetWeekAddCustomerTotal() { async function TeamGetWeekAddCustomerTotal() {
const params = getRequestParams() const params = getRequestParams()
const hasParams = params.user_name const hasParams = params.user_name
const res = await withCache('getWeekAddCustomerTotal', () => getWeekAddCustomerTotal(hasParams ? params : undefined), hasParams ? params : {}) const res = await getWeekAddCustomerTotal(hasParams ? params : undefined)
console.log(res) console.log(res)
if (res.code === 200) { if (res.code === 200) {
weekTotalData.value.week_add_customer_total = res.data weekTotalData.value.week_add_customer_total = res.data
@@ -273,20 +258,61 @@ async function TeamGetWeekAddCustomerTotal() {
async function TeamGetWeekAddDealTotal() { async function TeamGetWeekAddDealTotal() {
const params = getRequestParams() const params = getRequestParams()
const hasParams = params.user_name const hasParams = params.user_name
const res = await withCache('getWeekAddDealTotal', () => getWeekAddDealTotal(hasParams ? params : undefined), hasParams ? params : {}) const res = await getWeekAddDealTotal(hasParams ? params : undefined)
console.log(res) console.log(res)
if (res.code === 200) { if (res.code === 200) {
weekTotalData.value.week_add_deal_total = res.data weekTotalData.value.week_add_deal_total = res.data
} }
} }
// 月度总业绩 // 优秀录音
// 获取优秀录音
const excellentRecord = ref([]);
// 获取优秀录音文件
async function CentergetGoodRecord() {
console.log('CentergetGoodRecord 开始执行')
try {
const params = getRequestParams()
const params1 = {
user_level: userStore.userInfo?.user_level?.toString() || '',
user_name: userStore.userInfo?.username || ''
}
// 检查参数是否有效
const hasParams = params.user_name && params.user_level
const requestParams = hasParams ? {
...params,
} : params1
console.log('CentergetGoodRecord request params:', requestParams)
// 验证必要参数是否存在
if (!requestParams.user_name || !requestParams.user_level) {
console.error("缺少必要的请求参数:", requestParams);
return;
}
// 直接发送请求,不使用缓存
const res = await getExcellentRecordFile(requestParams)
console.log(972872132,res)
if (res && res.code === 200 && res.data) {
excellentRecord.value = res.data || []
console.log('获取优秀录音成功:', res.data)
} else {
console.error("获取优秀录音失败,响应数据不完整:", res);
excellentRecord.value = []
}
} catch (error) {
console.error("获取优秀录音失败:", error);
excellentRecord.value = []
}
}
// 定金转化 // 定金转化
async function TeamGetWeekAddFeeTotal() { async function TeamGetWeekAddFeeTotal() {
const params = getRequestParams() const params = getRequestParams()
const hasParams = params.user_name const hasParams = params.user_name
const res = await withCache('getPayDepositToMoneyRate', () => getPayDepositToMoneyRate(hasParams ? params : undefined), hasParams ? params : {}) const res = await getPayDepositToMoneyRate(hasParams ? params : undefined)
console.log(res) console.log(res)
if (res.code === 200) { if (res.code === 200) {
weekTotalData.value.pay_deposit_to_money_rate = res.data weekTotalData.value.pay_deposit_to_money_rate = res.data
@@ -296,7 +322,7 @@ async function TeamGetWeekAddFeeTotal() {
async function TeamGetGroupFunnel() { async function TeamGetGroupFunnel() {
const params = getRequestParams() const params = getRequestParams()
const hasParams = params.user_name const hasParams = params.user_name
const res = await withCache('getGroupFunnel', () => getGroupFunnel(hasParams ? params : undefined), hasParams ? params : {}) const res = await getGroupFunnel(hasParams ? params : undefined)
console.log(res) console.log(res)
if (res.code === 200) { if (res.code === 200) {
weekTotalData.value.group_funnel = res.data weekTotalData.value.group_funnel = res.data
@@ -321,7 +347,7 @@ const groupRanking = ref({})
async function TeamGetGroupRanking() { async function TeamGetGroupRanking() {
const params = getRequestParams() const params = getRequestParams()
const hasParams = params.user_name const hasParams = params.user_name
const res = await withCache('getGroupRanking', () => getGroupRanking(hasParams ? params : undefined), hasParams ? params : {}) const res = await getGroupRanking(hasParams ? params : undefined)
console.log(res) console.log(res)
if (res.code === 200) { if (res.code === 200) {
groupRanking.value = res.data groupRanking.value = res.data
@@ -331,107 +357,121 @@ async function TeamGetGroupRanking() {
const memberDetails = ref({}) const memberDetails = ref({})
// 团队分析数据
const teamAnalysisData = ref([])
const showTeamAnalysisModal = ref(false)
// 当前选中的成员,默认为空 // 当前选中的成员,默认为空
const selectedMember = ref(null); const selectedMember = ref(null);
// 选择成员函数 // 选择成员函数
const selectMember = (member) => { const selectMember = (member) => {
selectedMember.value = member; selectedMember.value = member;
console.log(122331,member)
TeamGetGroupDetail(member.user_name)
}; };
// 成员详细数据
// 缓存管理功能 async function TeamGetGroupDetail(member) {
// 清除所有缓存 const res = await getGroupDetail({user_name:member})
function clearCache() { console.log(res)
cache.clear() if (res.code === 200) {
console.log('所有缓存已清除') memberDetails.value = res.data
/**
* add_customer_count:32
call_count:96
month_order_count:5
total_call_duration_hour
:
1.92
user_name
:
"李晓雪"
week_order_count
:
2
*/
}
} }
// 清除特定缓存 // 获取团队分析数据
function clearSpecificCache(apiName, params = {}) { const fetchTeamAnalysis = async () => {
const key = getCacheKey(apiName, params) try {
cache.delete(key) showTeamAnalysisModal.value = true
console.log(`已清除缓存: ${key}`)
}
// 获取缓存信息并清理过期缓存 const params = getRequestParams()
function getCacheInfo() { const response = await getGroupEntiretyThirdReport(params)
const now = Date.now()
const validCaches = []
const expiredCaches = []
for (const [key, data] of cache.entries()) { // 根据API响应结构调整数据处理逻辑
if (isValidCache(data)) { if (response.data) {
validCaches.push({ if (Array.isArray(response.data)) {
key, // 如果response.data本身就是数组
timestamp: data.timestamp, teamAnalysisData.value = response.data
age: Math.round((now - data.timestamp) / 1000) + 's' } else if (response.data.data && Array.isArray(response.data.data)) {
}) // 如果response.data.data是数组
} else { teamAnalysisData.value = response.data.data
expiredCaches.push(key) } else {
cache.delete(key) // 其他情况,可能是单个对象
teamAnalysisData.value = [response.data]
}
} }
} } catch (error) {
console.error('获取团队分析数据失败:', error)
console.log('有效缓存:', validCaches) teamAnalysisData.value = []
console.log('已清理过期缓存:', expiredCaches)
return {
validCount: validCaches.length,
expiredCount: expiredCaches.length,
validCaches,
expiredCaches
} }
} }
// 强制刷新所有数据清除缓存并重新调用所有API // 格式化报告内容
async function forceRefreshAllData() { const formatReportContent = (content) => {
console.log('开始强制刷新所有数据...') if (!content || content === "None") {
clearCache() return "<p>暂无分析报告内容</p>";
}
// 重新调用所有API // 处理报告内容,保留换行和基本格式
await Promise.all([ let formattedContent = content
TeamGetGroupAbnormalResponse(), // 替换连续的换行符
TeamGetWeekTotalCall(), .replace(/\n\s*\n/g, '</p><p>')
TeamGetGroupCallDuration(), // 替换单个换行符为<br>
TeamGetWeekAddCustomerTotal(), .replace(/\n/g, '<br>')
TeamGetWeekAddDealTotal(), // 替换Markdown风格的标题为HTML标签
TeamGetWeekAddFeeTotal(), .replace(/^### (.*?)(<br>|$)/gim, '<h3>$1</h3>')
TeamGetGroupFunnel(), .replace(/^## (.*?)(<br>|$)/gim, '<h2>$1</h2>')
TeamGetGroupRanking() .replace(/^# (.*?)(<br>|$)/gim, '<h1>$1</h1>')
]) // 替换粗体
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
// 替换斜体
.replace(/\*(.*?)\*/g, '<em>$1</em>')
// 替换无序列表项
.replace(/^\* (.*?)(<br>|$)/gim, '<li>$1</li>');
console.log('所有数据刷新完成') // 包装列表项到<ul>标签中
formattedContent = formattedContent.replace(/(<li>.*?<\/li>)+/g, '<ul>$&</ul>');
// 处理段落
if (!formattedContent.startsWith('<p>')) {
formattedContent = '<p>' + formattedContent;
}
if (!formattedContent.endsWith('</p>')) {
formattedContent = formattedContent + '</p>';
}
// 清理多余的<br>标签
formattedContent = formattedContent.replace(/<br><\/p>/g, '</p>');
return formattedContent;
} }
// 团队异常预警 // 团队异常预警
onMounted(async () => { onMounted(async () => {
// 输出缓存状态信息 CentergetGoodRecord()
console.log('Manager页面缓存系统已初始化缓存时长:', CACHE_DURATION / (1000 * 60), '分钟') TeamGetGroupAbnormalResponse()
TeamGetWeekTotalCall()
await TeamGetGroupAbnormalResponse() TeamGetGroupCallDuration()
await TeamGetWeekTotalCall() TeamGetWeekAddCustomerTotal()
await TeamGetGroupCallDuration() TeamGetWeekAddDealTotal()
await TeamGetWeekAddCustomerTotal() TeamGetWeekAddFeeTotal()
await TeamGetWeekAddDealTotal() TeamGetGroupFunnel()
await TeamGetWeekAddFeeTotal() TeamGetGroupRanking()
await TeamGetGroupFunnel()
await TeamGetGroupRanking()
// 输出初始缓存信息
getCacheInfo()
// 开发环境下暴露缓存管理函数到全局对象,方便调试
if (process.env.NODE_ENV === 'development') {
window.managerCache = {
clearCache,
clearSpecificCache,
getCacheInfo,
forceRefreshAllData,
cache
}
console.log('开发模式:缓存管理函数已暴露到 window.managerCache')
}
}) })
</script> </script>
@@ -748,12 +788,12 @@ onMounted(async () => {
.top-section { .top-section {
display: grid; display: grid;
grid-template-columns: 1fr 3fr; grid-template-columns: 1fr 3fr;
gap: 1rem; gap: 0.5rem;
// PC端保持一致布局 // PC端保持一致布局
@media (min-width: 1024px) { @media (min-width: 1024px) {
grid-template-columns: 1fr 3fr; grid-template-columns: 1fr 3fr;
gap: 1.5rem; gap: 1rem;
} }
// 平板端适配 // 平板端适配
@@ -778,7 +818,7 @@ onMounted(async () => {
.analytics-section { .analytics-section {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
gap: 1rem; gap: 0.5rem;
margin-bottom: 1rem; margin-bottom: 1rem;
// PC端保持一致布局 // PC端保持一致布局
@@ -1952,5 +1992,267 @@ onMounted(async () => {
} }
} }
} }
/* 团队分析弹窗样式 */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.modal-content {
background: white;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
max-width: 90vw;
max-height: 90vh;
overflow: hidden;
display: flex;
flex-direction: column;
}
.modal-header {
padding: 1rem 1.5rem;
border-bottom: 1px solid #eee;
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-header h3 {
margin: 0;
font-size: 1.25rem;
color: #333;
}
.close-button {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: #999;
padding: 0;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
}
.close-button:hover {
color: #333;
}
.modal-body {
padding: 1.5rem;
overflow-y: auto;
max-height: calc(90vh - 80px);
}
.report-item {
margin-bottom: 2rem;
}
.report-item:last-child {
margin-bottom: 0;
}
.report-meta {
display: flex;
justify-content: space-between;
margin-bottom: 1rem;
font-size: 0.9rem;
color: #666;
}
.report-content {
line-height: 1.6;
}
.report-content :deep(h1),
.report-content :deep(h2),
.report-content :deep(h3),
.report-content :deep(h4),
.report-content :deep(h5),
.report-content :deep(h6) {
margin: 1.5rem 0 1rem 0;
font-weight: 600;
}
.report-content :deep(h1) {
font-size: 1.75rem;
border-bottom: 2px solid #eee;
padding-bottom: 0.5rem;
}
.report-content :deep(h2) {
font-size: 1.5rem;
border-bottom: 1px solid #eee;
padding-bottom: 0.5rem;
}
.report-content :deep(h3) {
font-size: 1.25rem;
}
.report-content :deep(p) {
margin: 0.75rem 0;
}
.report-content :deep(ul),
.report-content :deep(ol) {
margin: 0.75rem 0;
padding-left: 1.5rem;
}
.report-content :deep(li) {
margin: 0.25rem 0;
}
.report-content :deep(strong) {
font-weight: 600;
}
.report-content :deep(em) {
font-style: italic;
}
}
/* 团队分析弹窗 */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.modal-content {
background: white;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
max-width: 90vw;
max-height: 90vh;
overflow: hidden;
display: flex;
flex-direction: column;
}
.modal-header {
padding: 1rem 1.5rem;
border-bottom: 1px solid #eee;
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-header h3 {
margin: 0;
font-size: 1.25rem;
color: #333;
}
.close-button {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: #999;
padding: 0;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
}
.close-button:hover {
color: #333;
}
.modal-body {
padding: 1.5rem;
overflow-y: auto;
max-height: calc(90vh - 80px);
}
.report-item {
margin-bottom: 2rem;
}
.report-item:last-child {
margin-bottom: 0;
}
.report-meta {
display: flex;
justify-content: space-between;
margin-bottom: 1rem;
font-size: 0.9rem;
color: #666;
}
.report-content {
line-height: 1.6;
}
.report-content :deep(h1),
.report-content :deep(h2),
.report-content :deep(h3),
.report-content :deep(h4),
.report-content :deep(h5),
.report-content :deep(h6) {
margin: 1.5rem 0 1rem 0;
font-weight: 600;
}
.report-content :deep(h1) {
font-size: 1.75rem;
border-bottom: 2px solid #eee;
padding-bottom: 0.5rem;
}
.report-content :deep(h2) {
font-size: 1.5rem;
border-bottom: 1px solid #eee;
padding-bottom: 0.5rem;
}
.report-content :deep(h3) {
font-size: 1.25rem;
}
.report-content :deep(p) {
margin: 0.75rem 0;
}
.report-content :deep(ul),
.report-content :deep(ol) {
margin: 0.75rem 0;
padding-left: 1.5rem;
}
.report-content :deep(li) {
margin: 0.25rem 0;
}
.report-content :deep(strong) {
font-weight: 600;
}
.report-content :deep(em) {
font-style: italic;
} }
</style> </style>

View File

@@ -15,17 +15,17 @@
<button <button
@click="startSopAnalysis" @click="startSopAnalysis"
class="analysis-button sop-button" class="analysis-button sop-button"
:disabled="isSopAnalysisLoading || !hasCallData" :disabled="!hasCallData"
> >
{{ isSopAnalysisLoading ? 'SOP分析中...' : (hasCallData ? 'SOP通话分析' : '暂无通话数据') }} {{ hasCallData ? 'SOP通话分析' : '暂无20分钟通话数据'}}
</button> </button>
<!-- <button <button
@click="startDemandAnalysis" @click="startDemandAnalysis"
class="analysis-button demand-button" class="analysis-button demand-button"
:disabled="isDemandAnalysisLoading" :disabled="isDemandAnalysisLoading"
> >
{{ isDemandAnalysisLoading ? '诉求分析中...' : '客户诉求分析' }} {{ isDemandAnalysisLoading ? '数据分析中...' : '总通话分析' }}
</button> --> </button>
</div> </div>
</div> </div>
@@ -65,21 +65,21 @@
</div> </div>
<!-- 下方整行区域 --> <!-- 下方整行区域 -->
<!-- <div class="bottom-row"> <div class="bottom-row">
<div class="analysis-section demand-analysis"> <div class="analysis-section demand-analysis">
<div class="section-header"> <div class="section-header">
<h4>客户诉求分析</h4> <h4>总通话分析</h4>
</div> </div>
<div class="section-content"> <div class="section-content">
<div class="text-content" v-if="demandAnalysisResult"> <div class="text-content" v-if="demandAnalysisResult">
<div class="analysis-text" v-html="formattedDemandAnalysis"></div> <div class="analysis-text" v-html="formattedDemandAnalysis"></div>
</div> </div>
<div class="placeholder-text" v-else> <div class="placeholder-text" v-else>
<p>点击"客户诉求分析"按钮开始深度分析客户需求和诉求</p> <p>点击"总通话分析"按钮开始深度分析客户的所有通话</p>
</div> </div>
</div> </div>
</div> </div>
</div> --> </div>
</div> </div>
</div> </div>
@@ -94,6 +94,8 @@
import { ref, watch, computed } from 'vue'; import { ref, watch, computed } from 'vue';
import { SimpleChatService } from '@/utils/ChatService.js'; import { SimpleChatService } from '@/utils/ChatService.js';
import MarkdownIt from 'markdown-it'; import MarkdownIt from 'markdown-it';
import https from '@/utils/https'
import axios from 'axios'
// 定义props // 定义props
const props = defineProps({ const props = defineProps({
@@ -102,8 +104,8 @@ const props = defineProps({
default: null default: null
}, },
formInfo: { formInfo: {
type: Object, type: Array,
default: () => ({}) default: () => []
}, },
chatRecords: { chatRecords: {
type: Array, type: Array,
@@ -127,7 +129,7 @@ const isDemandAnalysisLoading = ref(false); // 诉求分析加载状态
// Dify API配置 // Dify API配置
const DIFY_API_KEY_01 = 'app-h4uBo5kOGoiYhjuBF1AHZi8b'; //基础信息分析 const DIFY_API_KEY_01 = 'app-h4uBo5kOGoiYhjuBF1AHZi8b'; //基础信息分析
const DIFY_API_KEY = 'app-ZIJSFWbcdZLufkwCp9RrvpUR'; const DIFY_API_KEY = 'app-ZIJSFWbcdZLufkwCp9RrvpUR'; // 总通话分析
// 初始化ChatService // 初始化ChatService
const chatService_01 = new SimpleChatService(DIFY_API_KEY_01); const chatService_01 = new SimpleChatService(DIFY_API_KEY_01);
const chatService = new SimpleChatService(DIFY_API_KEY); const chatService = new SimpleChatService(DIFY_API_KEY);
@@ -157,9 +159,9 @@ const formattedDemandAnalysis = computed(() => {
return md.render(demandAnalysisResult.value); return md.render(demandAnalysisResult.value);
}); });
// 计算属性:检查是否有通话数据 // 计算属性:检查是否有20分钟通话数据
const hasCallData = computed(() => { const hasCallData = computed(() => {
return props.callRecords && props.callRecords.length > 0; return props.callRecords && props.callRecords.some(call => call && call.record_tag === '20分钟通话');
}); });
// 监听selectedContact变化重置所有分析结果 // 监听selectedContact变化重置所有分析结果
@@ -184,181 +186,115 @@ watch(() => props.selectedContact, (newContact) => {
}, { immediate: true }); }, { immediate: true });
// 基础信息分析 // 基础信息分析
const startBasicAnalysis = async () => { // const startBasicAnalysis = async () => {
if (!props.selectedContact) return; // if (!props.selectedContact) return;
isBasicAnalysisLoading.value = true; // isBasicAnalysisLoading.value = true;
basicAnalysisResult.value = ''; // basicAnalysisResult.value = '';
// 构建表单信息 // // 构建表单信息
const formData = props.formInfo || {}; // const formData = props.formInfo || [];
let formInfoText = '暂无表单信息'; // let formInfoText = '暂无表单信息';
if (Object.keys(formData).length > 0) { // // ** 适配新的 formInfo 数组格式 **
console.log(888888,formData); // if (Array.isArray(formData) && formData.length > 0) {
const allInfo = []; // const allInfo = [];
// 处理第一种格式基础信息和additional_info // // 遍历新格式: [{ question_label: "...", answer: "..." }, ...]
if (formData.name || formData.mobile || formData.additional_info) { // formData.forEach(item => {
const basicInfo = []; // // 检查字段是否存在且答案有效
const additionalInfo = []; // if (
// item.question_label &&
// item.answer &&
// item.answer !== '暂无' &&
// item.answer !== ''
// ) {
// // 格式化为 "问题标签: 答案"
// allInfo.push(`${item.question_label.trim()}: ${item.answer.trim()}`);
// }
// });
// 处理基础信息字段 // // 格式化表单信息文本
const basicFields = { // formInfoText = allInfo.length > 0
name: '姓名', // ? `=== 问卷/表单信息 ===\n${allInfo.join('\n')}`
mobile: '手机号', // : '暂无有效问卷/表单信息';
occupation: '职业', // }
territory: '地区', // // ** 适配结束 **
child_name: '孩子姓名',
child_gender: '孩子性别',
child_education: '孩子教育阶段',
child_relation: '与孩子关系'
};
Object.entries(basicFields).forEach(([key, label]) => { // // 构建聊天记录信息
if (formData[key] && formData[key] !== '暂无' && formData[key] !== '') { // const chatData = props.chatRecords || [];
basicInfo.push(`${label}: ${formData[key]}`); // const chatInfoText = chatData.messages && chatData.messages.length > 0 ?
} // `聊天记录数量: ${chatData.messages.length}条\n最近聊天内容: ${JSON.stringify(chatData.messages.slice(-3), null, 2)}` :
}); // '暂无聊天记录';
// 处理 additional_info 数组 // // 构建通话记录信息
if (formData.additional_info && Array.isArray(formData.additional_info)) { // const callData = props.callRecords || [];
formData.additional_info.forEach(item => { // const callInfoText = callData.length > 0 ?
if (item.topic && item.answer) { // `通话记录数量: ${callData.length}次\n通话记录详情: ${JSON.stringify(callData, null, 2)}` :
additionalInfo.push(`${item.topic}\n答案: ${item.answer}`); // '暂无通话记录';
}
});
}
// 添加基础信息 // const query = `请对客户进行基础信息分析:
if (basicInfo.length > 0) { // 客户姓名:${props.selectedContact.name}
allInfo.push('=== 基础信息 ==='); // 联系电话:${props.selectedContact.phone || '未提供'}
allInfo.push(...basicInfo); // 销售阶段:${props.selectedContact.salesStage || '未知'}
}
// 添加问卷信息 // === 表单信息 ===
if (additionalInfo.length > 0) { // ${formInfoText}
allInfo.push('\n=== 问卷信息 ===');
allInfo.push(...additionalInfo);
}
}
// 处理第二种格式customerExpandFieldMap // === 聊天记录 ===
if (formData.customerExpandFieldMap) { // ${chatInfoText}
const expandInfo = [];
const map = formData.customerExpandFieldMap;
// 处理所有expand字段 // === 通话记录 ===
Object.entries(map).forEach(([key, value]) => { // ${callData.length > 0 && callData[0].record_context ? callData[0].record_context : callInfoText}
// 跳过原型链上的属性
if (!map.hasOwnProperty(key)) return;
// 如果是对象类型包含key和typeCode的字段 // 请基于以上客户的表单信息、聊天记录和通话记录,分析客户的基本情况、背景信息和初步画像。`;
if (value && typeof value === 'object' && value.key) {
const question = value.key;
let answer = '';
// 根据不同的类型处理答案 // try {
if (value.typeCode === 'SINGLE_SELECT' || value.typeCode === 'MULTIPLE_SELECT') { // await chatService_01.sendMessage(
if (value.expandValueList && value.expandValueList.length > 0) { // query,
answer = value.expandValueList.map(item => item.itemName).join('、'); // (update) => {
} // basicAnalysisResult.value = update.content;
} else if (value.typeCode === 'TEXT' || value.typeCode === 'TEXTAREA' || value.typeCode === 'NUMBER') { // },
// 直接从formData中获取对应的expand值 // () => {
answer = formData[key] || ''; // isBasicAnalysisLoading.value = false;
} // console.log('基础信息分析完成');
// }
// 只添加有答案的问题 // );
if (answer && answer !== '暂无' && answer !== '') { // } catch (error) {
expandInfo.push(`${question}\n答案: ${answer}`); // console.error('基础信息分析失败:', error);
} // basicAnalysisResult.value = `分析失败: ${error.message}`;
} // isBasicAnalysisLoading.value = false;
}); // }
// };
// 添加扩展信息 const startBasicAnalysis=async ()=>{
if (expandInfo.length > 0) { console.log("客户基础信息:", props.selectedContact);
if (allInfo.length > 0) allInfo.push('\n'); // 如果已有其他信息,添加分隔行 const res=await https.post('api/v1/sales_timeline/get_customer_basic_info',{
allInfo.push('=== 问卷详细信息 ==='); user_name:props.selectedContact.name,
allInfo.push(...expandInfo); phone:props.selectedContact.phone
} })
} if(res.data){
basicAnalysisResult.value = res.data;
formInfoText = allInfo.length > 0 ? allInfo.join('\n') : '暂无表单信息'; console.log("客户基础信息分析结果:", res);
}else{
basicAnalysisResult.value = '基础信息暂无数据'
} }
// 构建聊天记录信息 }
const chatData = props.chatRecords || [];
const chatInfoText = chatData.messages.length > 0 ?
`聊天记录数量: ${chatData.messages.length}\n最近聊天内容: ${JSON.stringify(chatData.messages.slice(-3), null, 2)}` :
'暂无聊天记录';
// 构建通话记录信息
const callData = props.callRecords || [];
const callInfoText = callData.length > 0 ?
`通话记录数量: ${callData.length}\n通话记录详情: ${JSON.stringify(callData, null, 2)}` :
'暂无通话记录';
const query = `请对客户进行基础信息分析:
客户姓名:${props.selectedContact.name}
联系电话:${props.selectedContact.phone || '未提供'}
销售阶段:${props.selectedContact.salesStage || '未知'}
=== 表单信息 ===
${formInfoText}
=== 聊天记录 ===
${chatInfoText}
=== 通话记录 ===
${callData.length > 0 && callData[0].record_context ? callData[0].record_context : callInfoText}
请基于以上客户的表单信息、聊天记录和通话记录,分析客户的基本情况、背景信息和初步画像。`;
console.log(888888,formInfoText);
try {
await chatService_01.sendMessage(
query,
(update) => {
basicAnalysisResult.value = update.content;
},
() => {
isBasicAnalysisLoading.value = false;
console.log('基础信息分析完成');
}
);
} catch (error) {
console.error('基础信息分析失败:', error);
basicAnalysisResult.value = `分析失败: ${error.message}`;
isBasicAnalysisLoading.value = false;
}
};
// SOP通话分析 // SOP通话分析
const startSopAnalysis = async () => { const startSopAnalysis = async () => {
if (!props.selectedContact) return; if (!props.selectedContact) return;
isSopAnalysisLoading.value = true; isSopAnalysisLoading.value = true;
sopAnalysisResult.value = ''; sopAnalysisResult.value = '';
// 构建通话记录信息
const callData = props.callRecords || [];
const callInfoText = callData.length > 0 ?
`通话记录数量: ${callData.length}\n通话记录详情: ${JSON.stringify(callData, null, 2)}` :
'暂无通话记录';
const query = `=== 通话记录 ===
${callData.length > 0 && callData[0].record_context ? callData[0].record_context : callInfoText}`;
try { try {
await chatService.sendMessage( const res = await axios.get('https://analysis.api.nycjy.cn/api/v1/call', {
query, params: {
(update) => { wechat_id: props.selectedContact.wechat_id
sopAnalysisResult.value = update.content;
},
() => {
isSopAnalysisLoading.value = false;
console.log('SOP通话分析完成');
} }
); });
sopAnalysisResult.value = res.data.report_content;
isSopAnalysisLoading.value = false;
} catch (error) { } catch (error) {
console.error('SOP通话分析失败:', error); console.error('SOP通话分析失败:', error);
sopAnalysisResult.value = `分析失败: ${error.message}`; sopAnalysisResult.value = `分析失败: ${error.message}`;
@@ -366,30 +302,31 @@ ${callData.length > 0 && callData[0].record_context ? callData[0].record_context
} }
}; };
// 客户诉求分析 // 总通话分析
const startDemandAnalysis = async () => { const startDemandAnalysis = async () => {
console.log("所有通话记录:", props.callRecords);
if (!props.selectedContact) return; if (!props.selectedContact) return;
isDemandAnalysisLoading.value = true; isDemandAnalysisLoading.value = true;
demandAnalysisResult.value = ''; demandAnalysisResult.value = '';
const query = `请对客户 ${props.selectedContact.name} 进行深度诉求分析: // 1. 检查并拼接所有通话记录,为每次通话添加清晰的标签和区分
let allCallTexts = '暂无通话记录';
if (props.callRecords && props.callRecords.length > 0) {
allCallTexts = props.callRecords
.map((call, index) => {
// 为每段通话记录构建一个结构化的“信息头”,包含序号、标签和时间
return `--- 通话记录 ${index + 1} ---\n标签: ${call.record_tag || '无'}\n时间: ${call.record_create_time || '未知'}\n\n内容:\n${call.record_context}`;
})
.join('\n\n'); // 使用两个换行符清晰地分隔不同的通话记录
}
请从以下维度分析客户的真实需求和诉求: // 2. 构建一个详细的、结构化的分析指令
1. 显性需求分析(客户明确表达的需求) const query = `
2. 隐性需求挖掘(潜在的、未明确表达的需求) 请基于以下该客户的所有通话记录,进行全面深入的分析:
3. 痛点识别(客户面临的主要问题和挑战) === 全部通话记录 ===
4. 决策因素分析(影响客户决策的关键因素) ${allCallTexts}
5. 价值期望(客户期望获得的价值和收益) `;
6. 风险顾虑(客户可能的担忧和顾虑)
7. 个性化建议(针对性的解决方案建议)
客户信息:
姓名:${props.selectedContact.name}
公司:${props.selectedContact.company || '未提供'}
职位:${props.selectedContact.position || '未提供'}
销售阶段:${props.selectedContact.salesStage || '未知'}
健康度:${props.selectedContact.health || '未知'}%`;
try { try {
await chatService.sendMessage( await chatService.sendMessage(
@@ -399,11 +336,11 @@ const startDemandAnalysis = async () => {
}, },
() => { () => {
isDemandAnalysisLoading.value = false; isDemandAnalysisLoading.value = false;
console.log('客户诉求分析完成'); console.log('总通话分析完成');
} }
); );
} catch (error) { } catch (error) {
console.error('客户诉求分析失败:', error); console.error('总通话分析失败:', error);
demandAnalysisResult.value = `分析失败: ${error.message}`; demandAnalysisResult.value = `分析失败: ${error.message}`;
isDemandAnalysisLoading.value = false; isDemandAnalysisLoading.value = false;
} }
@@ -876,12 +813,18 @@ $purple: #a855f7;
} }
} }
} }
} }
// Markdown样式 // Markdown样式
.analysis-text { .analysis-text {
// Markdown样式 // Markdown样式
h1, h2, h3, h4, h5, h6 { :deep(h1),
:deep(h2),
:deep(h3),
:deep(h4),
:deep(h5),
:deep(h6) {
margin: 1rem 0 0.5rem 0; margin: 1rem 0 0.5rem 0;
font-weight: 600; font-weight: 600;
color: $slate-800; color: $slate-800;
@@ -891,14 +834,31 @@ $purple: #a855f7;
} }
} }
h1 { font-size: 1.25rem; } :deep(h1) {
h2 { font-size: 1.125rem; } font-size: 1.25rem;
h3 { font-size: 1rem; } }
h4 { font-size: 0.875rem; }
h5 { font-size: 0.75rem; }
h6 { font-size: 0.75rem; }
p { :deep(h2) {
font-size: 1.125rem;
}
:deep(h3) {
font-size: 1rem;
}
:deep(h4) {
font-size: 0.875rem;
}
:deep(h5) {
font-size: 0.75rem;
}
:deep(h6) {
font-size: 0.75rem;
}
:deep(p) {
margin: 0.5rem 0; margin: 0.5rem 0;
&:first-child { &:first-child {
@@ -910,7 +870,8 @@ $purple: #a855f7;
} }
} }
ul, ol { :deep(ul),
:deep(ol) {
margin: 0.5rem 0; margin: 0.5rem 0;
padding-left: 1.5rem; padding-left: 1.5rem;
@@ -919,23 +880,24 @@ $purple: #a855f7;
} }
} }
blockquote { :deep(blockquote) {
margin: 1rem 0; margin: 1rem 0;
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
border-left: 4px solid $blue; border-left: 4px solid $blue;
background: rgba(59, 130, 246, 0.05); background: rgba(59, 130, 246, 0.05);
font-style: italic; color: $slate-600;
} }
code { :deep(code) {
background: $slate-100; background: $slate-100;
padding: 0.125rem 0.25rem; padding: 0.125rem 0.25rem;
border-radius: 0.25rem; border-radius: 0.25rem;
font-family: 'Courier New', monospace; font-family: 'Courier New', monospace;
font-size: 0.8rem; font-size: 0.8rem;
color: $indigo;
} }
pre { :deep(pre) {
background: $slate-100; background: $slate-100;
padding: 1rem; padding: 1rem;
border-radius: 0.5rem; border-radius: 0.5rem;
@@ -948,16 +910,16 @@ $purple: #a855f7;
} }
} }
strong { :deep(strong) {
font-weight: 600; font-weight: 600;
color: $slate-800; color: $slate-800;
} }
em { :deep(em) {
font-style: italic; font-style: italic;
} }
a { :deep(a) {
color: $blue; color: $blue;
text-decoration: none; text-decoration: none;
@@ -966,18 +928,19 @@ $purple: #a855f7;
} }
} }
hr { :deep(hr) {
margin: 1.5rem 0; margin: 1.5rem 0;
border: none; border: none;
border-top: 1px solid $slate-200; border-top: 1px solid $slate-200;
} }
table { :deep(table) {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
margin: 1rem 0; margin: 1rem 0;
th, td { th,
td {
padding: 0.5rem; padding: 0.5rem;
border: 1px solid $slate-200; border: 1px solid $slate-200;
text-align: left; text-align: left;
@@ -1064,9 +1027,9 @@ h2.section-title {
} }
h4 { h4 {
font-weight: 600; font-weight: 600;
color: $slate-700; color: $slate-700;
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
} }
// Context Panel // Context Panel
@@ -1074,8 +1037,6 @@ h4 {
background-color: $white; background-color: $white;
border-radius: 0.5rem; border-radius: 0.5rem;
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06); box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
// padding: 1rem;
// margin-top: 12px;
} }
// 分析区域布局优化 // 分析区域布局优化
@@ -1114,5 +1075,4 @@ h4 {
min-height: 250px; min-height: 250px;
} }
} }
</style> </style>

View File

@@ -1,8 +1,9 @@
<template> <template>
<div class="personal-dashboard"> <div class="personal-dashboard">
<!-- 头部标题 --> <!-- 头部标题 -->
<div class="dashboard-header"> <div class="dashboard-header" style="display: flex; justify-content: space-between; align-items: center;">
<h2>个人工作仪表板</h2> <h2>个人工作仪表板</h2>
<button @click="showSecondOrderAnalysisReport">阶段分析报告</button>
</div> </div>
<!-- 核心KPI & 统计卡片 --> <!-- 核心KPI & 统计卡片 -->
@@ -13,10 +14,10 @@
<div class="kpi-grid"> <div class="kpi-grid">
<div class="kpi-item"> <div class="kpi-item">
<div class="kpi-value">{{ props.kpiData.totalCalls }}</div> <div class="kpi-value">{{ props.kpiData.totalCalls }}</div>
<p>今日通话 <i class="info-icon" @mouseenter="showTooltip('totalCalls', $event)" @mouseleave="hideTooltip"></i></p> <p>本期通话 <i class="info-icon" @mouseenter="showTooltip('totalCalls', $event)" @mouseleave="hideTooltip"></i></p>
</div> </div>
<div class="kpi-item"> <div class="kpi-item">
<div class="kpi-value">{{ props.kpiData.successRate }}%</div> <div class="kpi-value">{{ props.kpiData.successRate }}</div>
<p>电话接通率 <i class="info-icon" @mouseenter="showTooltip('successRate', $event)" @mouseleave="hideTooltip"></i></p> <p>电话接通率 <i class="info-icon" @mouseenter="showTooltip('successRate', $event)" @mouseleave="hideTooltip"></i></p>
</div> </div>
<div class="kpi-item"> <div class="kpi-item">
@@ -105,6 +106,29 @@
:description="tooltip.description" :description="tooltip.description"
/> />
<!-- 阶段分析报告弹框 -->
<div v-if="showAnalysisModal" class="modal-overlay" @click.self="closeAnalysisModal">
<div class="modal-container">
<div class="modal-header">
<h3 class="modal-title">阶段分析报告</h3>
<button class="modal-close-btn" @click="closeAnalysisModal">&times;</button>
</div>
<div class="modal-body">
<div class="analysis-content">
<div v-if="!analysisReport || Object.keys(analysisReport).length === 0" class="loading-message">正在生成分析报告...</div>
<div v-else-if="Array.isArray(analysisReport) && analysisReport.length === 0" class="error-message">数据为空</div>
<div v-else-if="Array.isArray(analysisReport)">
<div v-for="(report, index) in analysisReport" :key="index" class="report-section">
<h4>{{ report.name }} ({{ report.start_time }} {{ report.end_time }})</h4>
<div v-html="report.report.replace(/\n/g, '<br>')"></div>
</div>
</div>
<div v-else class="error-message">数据格式错误</div>
</div>
</div>
</div>
</div>
</div> </div>
</template> </template>
@@ -114,11 +138,34 @@ import { ref, reactive, onMounted, onBeforeUnmount, computed, watch } from 'vue'
import StatisticData from './StatisticData.vue'; import StatisticData from './StatisticData.vue';
import * as echarts from 'echarts'; import * as echarts from 'echarts';
import Chart from 'chart.js/auto'; import Chart from 'chart.js/auto';
import {getTableFillingRate,getAverageResponseTime,getWeeklyActiveCommunicationRate,getTimeoutResponseRate} from "@/api/api.js" import {getSecondOrderAnalysisReport} from "@/api/api.js"
import { useUserStore } from "@/stores/user"; import { useUserStore } from "@/stores/user";
import { useRouter } from "vue-router";
import { SimpleChatService } from '@/utils/ChatService.js';
// 用户store // 用户store
const userStore = useUserStore(); const userStore = useUserStore();
// 路由实例
const router = useRouter();
const Dify_API_Key_02 = 'app-MGaBOx5QFblsMZ7dSkxKJDKm'
const chatService_02= new SimpleChatService(Dify_API_Key_02)
// 获取通用请求参数的函数
const getRequestParams = () => {
const params = {}
// 只从路由参数获取
const routeUserLevel = router.currentRoute.value.query.user_level || router.currentRoute.value.params.user_level
const routeUserName = router.currentRoute.value.query.user_name || router.currentRoute.value.params.user_name
// 如果路由有参数,使用路由参数
if (routeUserLevel) {
params.user_level = routeUserLevel.toString()
}
if (routeUserName) {
params.user_name = routeUserName
}
return params
}
// 定义props // 定义props
const props = defineProps({ const props = defineProps({
kpiData: { kpiData: {
@@ -159,9 +206,20 @@ const props = defineProps({
} }
}); });
async function CenterGetSecondOrderAnalysisReport() {
const params = getRequestParams()
const res = await getSecondOrderAnalysisReport(params)
if (res.code === 200) {
console.log(11111,res.data)
analysisReport.value = res.data
}
}
// Chart.js 实例 // Chart.js 实例
const chartInstances = {}; const chartInstances = {};
// 添加组件挂载状态跟踪
const isComponentMounted = ref(true);
// DOM 元素引用 // DOM 元素引用
const personalFunnelChartCanvas = ref(null); const personalFunnelChartCanvas = ref(null);
const contactTimeChartCanvas = ref(null); const contactTimeChartCanvas = ref(null);
@@ -177,18 +235,26 @@ const tooltip = reactive({
// 指标说明配置 // 指标说明配置
const kpiDescriptions = { const kpiDescriptions = {
totalCalls: {
title: '本期通话',
description: '本期总共通话的次数。'
},
successRate: { successRate: {
title: '电话接通率', title: '电话接通率',
description: '拨通电话 ÷ 拨打的电话' description: '拨通电话 ÷ 拨打的电话'
}, },
avgDuration: { avgDuration: {
title: '平均通话时长', title: '平均通话时长',
description: '所有通话总时长 ÷ 拨打电话次数。' description: '所有通话总时长 ÷ 拨打电话次数。'
}, },
conversionRate: { conversionRate: {
title: '成交转化率', title: '成交转化率',
description: '成交人数 ÷ 本期总数据。' description: '成交人数 ÷ 本期总数据。'
}, },
assignedData: {
title: '本期分配数据',
description: '本期内分配到的数据总量。'
},
wechatAddRate: { wechatAddRate: {
title: '加微率', title: '加微率',
description: '加微人数 ÷ 本期数据总人数' description: '加微人数 ÷ 本期数据总人数'
@@ -218,7 +284,8 @@ const createOrUpdateChart = (chartId, canvasRef, config) => {
if (chartInstances[chartId]) { if (chartInstances[chartId]) {
chartInstances[chartId].destroy(); chartInstances[chartId].destroy();
} }
if (canvasRef.value) { // 确保组件仍然挂载且canvas引用存在
if (isComponentMounted.value && canvasRef.value) {
const ctx = canvasRef.value.getContext('2d'); const ctx = canvasRef.value.getContext('2d');
chartInstances[chartId] = new Chart(ctx, config); chartInstances[chartId] = new Chart(ctx, config);
} }
@@ -226,6 +293,9 @@ const createOrUpdateChart = (chartId, canvasRef, config) => {
// Chart.js: 渲染销售漏斗图 // Chart.js: 渲染销售漏斗图
const renderPersonalFunnelChart = () => { const renderPersonalFunnelChart = () => {
// 确保组件仍然挂载
if (!isComponentMounted.value) return;
const config = { const config = {
type: 'bar', type: 'bar',
data: { data: {
@@ -250,6 +320,9 @@ const renderPersonalFunnelChart = () => {
// Chart.js: 渲染黄金联络时段图 // Chart.js: 渲染黄金联络时段图
const renderContactTimeChart = () => { const renderContactTimeChart = () => {
// 确保组件仍然挂载
if (!isComponentMounted.value) return;
if (!props.contactTimeData || !props.contactTimeData.gold_contact_success_rate) { if (!props.contactTimeData || !props.contactTimeData.gold_contact_success_rate) {
return; return;
} }
@@ -306,7 +379,20 @@ const hideTooltip = () => {
tooltip.visible = false; tooltip.visible = false;
}; };
// 阶段分析报告模态框状态
const showAnalysisModal = ref(false);
// 阶段分析报告数据
const analysisReport = ref({});
// 显示阶段分析报告模态框
const showSecondOrderAnalysisReport = () => {
showAnalysisModal.value = true;
CenterGetSecondOrderAnalysisReport()
};
// 关闭阶段分析报告模态框
const closeAnalysisModal = () => {
showAnalysisModal.value = false;
};
watch(() => props.contactTimeData, () => { watch(() => props.contactTimeData, () => {
renderContactTimeChart(); renderContactTimeChart();
@@ -315,11 +401,13 @@ watch(() => props.contactTimeData, () => {
// --- 生命周期钩子 --- // --- 生命周期钩子 ---
onMounted(() => { onMounted(() => {
isComponentMounted.value = true;
renderPersonalFunnelChart(); renderPersonalFunnelChart();
renderContactTimeChart(); renderContactTimeChart();
}); });
onBeforeUnmount(() => { onBeforeUnmount(() => {
isComponentMounted.value = false;
Object.values(chartInstances).forEach(chart => chart.destroy()); Object.values(chartInstances).forEach(chart => chart.destroy());
}); });
</script> </script>
@@ -511,7 +599,7 @@ $white: #ffffff;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: 20px 20px 16px; padding: 10px 20px 10px;
border-bottom: 1px solid #ebeef5; border-bottom: 1px solid #ebeef5;
h3 { margin: 0; color: $slate-900; font-size: 18px; font-weight: 600; } h3 { margin: 0; color: $slate-900; font-size: 18px; font-weight: 600; }
} }
@@ -705,7 +793,14 @@ $white: #ffffff;
gap: 16px; gap: 16px;
} }
.modal-header {
padding-left: 15px;
padding-right: 15px;
}
.modal-title {
font-size: 16px;
}
} }
@@ -735,4 +830,163 @@ $white: #ffffff;
position: relative; position: relative;
transition: all 0.2s ease; transition: all 0.2s ease;
} }
/* 模态框样式 */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.modal-container {
background: white;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
width: 90%;
max-width: 600px;
max-height: 60vh;
overflow: hidden;
display: flex;
flex-direction: column;
}
.modal-header {
display: flex;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid #ebeef5;
flex-shrink: 0;
}
.modal-title {
margin: 0;
font-size: 18px;
font-weight: 600;
color: #303133;
margin-right: auto; // 将标题推到最左边
}
.modal-close-btn {
background: none;
border: none;
font-size: 24px;
cursor: pointer;
color: #909399;
padding: 0;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
margin-left: 16px; // 与按钮组保持间距
flex-shrink: 0;
&:hover {
color: #303133;
}
}
.modal-body {
padding: 20px;
flex: 1;
overflow-y: auto;
}
.period-switcher {
display: flex;
flex-shrink: 0; // 防止按钮组在空间不足时被压缩
}
.period-switcher button {
padding: 6px 14px;
border: 1px solid #dcdfe6;
background: white;
border-radius: 0;
cursor: pointer;
font-size: 13px;
transition: all 0.3s ease;
margin-left: -1px; // 让边框重叠,形成一体化效果
&:first-child {
border-radius: 4px 0 0 4px;
margin-left: 0;
}
&:last-child {
border-radius: 0 4px 4px 0;
}
&:hover {
border-color: #a0cfff;
color: #409eff;
z-index: 1;
position: relative;
}
&.active {
background: #409eff;
border-color: #409eff;
color: white;
z-index: 2;
position: relative;
}
}
.analysis-content h4 {
margin-top: 0;
margin-bottom: 15px;
color: #303133;
font-size: 16px;
}
.analysis-content p {
color: #606266;
line-height: 1.6;
}
.error-message {
color: #f56c6c;
font-weight: bold;
text-align: center;
padding: 20px;
border: 1px solid #f56c6c;
border-radius: 4px;
background-color: #fef0f0;
}
.loading-message {
text-align: center;
padding: 20px;
color: #909399;
}
.report-section {
margin-bottom: 20px;
padding: 15px;
border: 1px solid #ebeef5;
border-radius: 4px;
background-color: #f9fafc;
}
.report-section:last-child {
margin-bottom: 0;
}
.report-section h4 {
margin-top: 0;
margin-bottom: 15px;
color: #303133;
font-size: 16px;
border-bottom: 1px solid #ebeef5;
padding-bottom: 8px;
}
</style> </style>

View File

@@ -14,12 +14,26 @@
</svg> </svg>
</div> </div>
<h3 class="card-title">表单信息</h3> <h3 class="card-title">表单信息</h3>
<div class="form-filter">
<button
v-for="option in formFilterOptions"
:key="option"
class="form-filter-btn"
:class="{ active: formFilter === option }"
@click="formFilter = option"
>
{{ option }}
</button>
</div>
</div> </div>
<div class="card-content"> <div class="card-content">
<div class="form-data-list"> <div v-for="(section, sectionIndex) in displayedFormSections" :key="sectionIndex" class="form-section">
<div v-for="(field, index) in formFields" :key="index" class="form-field"> <div v-if="section.title" class="form-section-title">{{ section.title }}</div>
<span class="field-label">{{ field.label }}:</span> <div class="form-data-list">
<span class="field-value">{{ field.value }}</span> <div v-for="(field, index) in section.fields" :key="index" class="form-field">
<span class="field-label">{{ field.label }}:</span>
<span class="field-value">{{ field.value }}</span>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -70,7 +84,7 @@
<span class="content-time">最新: {{ chatData.lastMessage }}</span> <span class="content-time">最新: {{ chatData.lastMessage }}</span>
</div> </div>
<div class="message-list"> <div class="message-list">
<div v-for="(message, index) in props.chatInfo.messages" :key="index" class="message-item"> <div v-for="(message, index) in props.chatInfo?.messages" :key="index" class="message-item">
<div class="message-header"> <div class="message-header">
<span class="message-sender">{{ message.format_direction }}</span> <span class="message-sender">{{ message.format_direction }}</span>
<span class="message-time">{{ message.format_add_time }}</span> <span class="message-time">{{ message.format_add_time }}</span>
@@ -89,19 +103,36 @@
<div class="call-list"> <div class="call-list">
<div v-for="(call, index) in callRecords" :key="index" class="call-item"> <div v-for="(call, index) in callRecords" :key="index" class="call-item">
<div class="call-header"> <div class="call-header">
<span class="call-type">用户: {{ call.user_name }}</span> <div class="header-main" style="display: flex; flex-direction: row;">
<span class="call-duration">客户: {{ call.customer_name }}</span> <div class="user-info">
<span class="call-time">录音文件: {{ call.record_file_addr_list?.length || 0 }} </span> <span class="call-type">用户: {{ call.user_name }}</span>
</div>
<div class="header-tags">
<span class="call-tag" :class="{
'tag-20min': call.record_tag === '20分钟通话',
'tag-invalid': call.record_tag === '无效通话',
'tag-other': call.record_tag !== '20分钟通话' && call.record_tag !== '无效通话'
}" v-if="call.record_tag">{{ call.record_tag }}</span>
<!-- 分数移到类型标签后面 -->
<span class="stat-value" :class="getScoreClass(call.score)">{{ call.score }}</span>
</div>
</div>
<div class="call-time">{{ formatDateTime(call.record_create_time) }}</div>
</div> </div>
<div class="call-actions"> <div class="call-actions">
<button class="action-btn download-btn" @click="downloadRecording(call)"> <div class="action-buttons">
<i class="icon-download"></i> <button class="action-btn download-btn" @click="downloadRecording(call)">
录音下载 <i class="icon-download"></i>
</button> 录音下载
<button class="action-btn view-btn" @click="viewTranscript(call)"> </button>
<i class="icon-view"></i> <button class="action-btn view-btn" @click="viewTranscript(call)">
查看原文 <i class="icon-view"></i>
</button> 查看原文
</button>
<!-- 时长移到操作按钮后面 -->
<span class="call-duration">{{ formatCallDuration(call.call_duration) }}</span>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -115,7 +146,7 @@
</template> </template>
<script setup> <script setup>
import { ref, computed } from 'vue' import { ref, computed, watch } from 'vue'
import axios from 'axios' import axios from 'axios'
// Props // Props
@@ -125,8 +156,8 @@ const props = defineProps({
default: () => ({}) default: () => ({})
}, },
formInfo: { formInfo: {
type: Object, type: Array,
default: () => ({}) default: () => []
}, },
chatInfo: { chatInfo: {
type: Object, type: Object,
@@ -139,7 +170,7 @@ const props = defineProps({
}) })
// Emits // Emits
const emit = defineEmits(['analyze-sop']) const emit = defineEmits(['analyze-sop', 'show-modal', 'show-download-modal'])
// 当前激活的tab // 当前激活的tab
const activeTab = ref('chat') const activeTab = ref('chat')
@@ -149,63 +180,108 @@ const chatMessages = computed(() => {
return props.chatInfo?.messages || [] return props.chatInfo?.messages || []
}) })
// 表单字段数据 const formFilter = ref('全部')
const formFields = computed(() => {
const formSections = computed(() => {
const formData = props.formInfo const formData = props.formInfo
if (!formData || Object.keys(formData).length === 0) { const emptyFields = [
return [ { label: '姓名', value: '暂无数据' },
{ label: '姓名', value: '暂无数据' }, { label: '联系方式', value: '暂无数据' },
{ label: '联系方式', value: '暂无数据' }, { label: '孩子信息', value: '暂无数据' },
{ label: '孩子信息', value: '暂无数据' }, { label: '地区', value: '暂无数据' }
{ label: '地区', value: '暂无数据' } ]
]
const makeSection = (fields, title = '表单信息') => [{ title, fields }]
const buildFieldsFromAnswers = (answers = []) => {
return answers.map(item => ({
label: item.question_label,
value: item.answer || '暂无回答'
}))
}
let dataArray = null
if (Array.isArray(formData)) {
dataArray = formData
} else if (formData && Array.isArray(formData.data)) {
dataArray = formData.data
}
if (Array.isArray(dataArray) && dataArray.length > 0) {
if (dataArray[0].form_title && Array.isArray(dataArray[0].answers)) {
return dataArray.map(form => ({
title: form.form_title || '表单信息',
fields: buildFieldsFromAnswers(form.answers || [])
}))
}
if (dataArray[0].question_label && Object.prototype.hasOwnProperty.call(dataArray[0], 'answer')) {
return makeSection(buildFieldsFromAnswers(dataArray))
}
}
if (!formData || (typeof formData === 'object' && Object.keys(formData).length === 0)) {
return makeSection(emptyFields)
} }
let fields = [] let fields = []
if (formData.name || formData.mobile || formData.child_name) {
const customerInfo = [formData.name, formData.mobile, formData.child_relation, formData.occupation].filter(item => item && item !== '暂无').join(' | ')
const childInfo = [formData.child_name, formData.child_gender, formData.child_education].filter(item => item && item !== '暂无').join(' | ')
// 检查是否为第一种格式包含name, mobile等字段 fields = [
if (formData.name || formData.mobile || formData.child_name) { { label: '客户信息', value: customerInfo || '暂无' },
const customerInfo = [formData.name, formData.mobile, formData.child_relation, formData.occupation].filter(item => item && item !== '暂无').join(' | ') { label: '孩子信息', value: childInfo || '暂无' },
const childInfo = [formData.child_name, formData.child_gender, formData.child_education].filter(item => item && item !== '暂无').join(' | ') { label: '地区', value: formData.territory || '暂无' }
]
fields = [ if (formData.additional_info && Array.isArray(formData.additional_info)) {
{ label: '客户信息', value: customerInfo || '暂无' }, formData.additional_info.forEach((item) => {
{ label: '孩子信息', value: childInfo || '暂无' }, fields.push({
{ label: '地区', value: formData.territory || '暂无' } label: item.topic,
] value: item.answer
})
})
}
} else {
const customerInfo = [formData.expandTwentyOne, formData.expandOne].filter(item => item && item !== '暂无').join(' | ')
const childInfo = [formData.expandTwentyNine, formData.expandTwentyFive, formData.expandTwo].filter(item => item && item !== '暂无').join(' | ')
// 如果有additional_info添加所有问题 fields = [
if (formData.additional_info && Array.isArray(formData.additional_info)) { { label: '客户信息', value: customerInfo || '暂无' },
formData.additional_info.forEach((item) => { { label: '孩子信息', value: childInfo || '暂无' },
fields.push({ { label: '学习状态', value: formData.expandFive || '暂无' },
label: item.topic, { label: '沟通情况', value: formData.expandEight || '暂无' },
value: item.answer { label: '主要问题', value: formData.expandTwentySeven || '暂无' },
}) { label: '关注领域', value: formData.expandFifteen || '暂无' },
}) { label: '学习成绩', value: formData.expandFourteen || '暂无' },
} { label: '孩子数量', value: formData.expandTwenty || '暂无' },
} else { { label: '预期时间', value: formData.expandThirty || '暂无' }
// 第二种格式expandXXX字段 ]
const customerInfo = [formData.expandTwentyOne, formData.expandOne].filter(item => item && item !== '暂无').join(' | ') }
const childInfo = [formData.expandTwentyNine, formData.expandTwentyFive, formData.expandTwo].filter(item => item && item !== '暂无').join(' | ')
fields = [ return makeSection(fields)
{ label: '客户信息', value: customerInfo || '暂无' },
{ label: '孩子信息', value: childInfo || '暂无' },
{ label: '学习状态', value: formData.expandFive || '暂无' },
{ label: '沟通情况', value: formData.expandEight || '暂无' },
{ label: '主要问题', value: formData.expandTwentySeven || '暂无' },
{ label: '关注领域', value: formData.expandFifteen || '暂无' },
{ label: '学习成绩', value: formData.expandFourteen || '暂无' },
{ label: '孩子数量', value: formData.expandTwenty || '暂无' },
{ label: '预期时间', value: formData.expandThirty || '暂无' }
]
}
// 合并表单数据和聊天数据
const allFields = [...fields]
return allFields
}) })
const formFilterOptions = computed(() => {
const titles = formSections.value.map(section => section.title).filter(Boolean)
const uniqueTitles = Array.from(new Set(titles))
if (uniqueTitles.length <= 1) return ['全部']
return ['全部', ...uniqueTitles]
})
const displayedFormSections = computed(() => {
if (formFilter.value === '全部') return formSections.value
const filtered = formSections.value.filter(section => section.title === formFilter.value)
return filtered.length > 0 ? filtered : formSections.value
})
watch(formFilterOptions, (options) => {
if (!options.includes(formFilter.value)) {
formFilter.value = options[0] || '全部'
}
}, { immediate: true })
// 聊天数据 // 聊天数据
const chatData = computed(() => ({ const chatData = computed(() => ({
count: props.chatInfo?.messages?.length || 0, count: props.chatInfo?.messages?.length || 0,
@@ -220,74 +296,169 @@ const callData = computed(() => ({
// 通话记录列表 // 通话记录列表
const callRecords = computed(() => { const callRecords = computed(() => {
console.log('RawDataCards - props.callInfo:', props.callInfo)
// 从 props.callInfo 中获取真实的通话记录数据 // 从 props.callInfo 中获取真实的通话记录数据
if (props.callInfo && Array.isArray(props.callInfo)) { if (props.callInfo && Array.isArray(props.callInfo)) {
console.log('RawDataCards - callInfo is array:', props.callInfo) console.log('通话记录:', props.callInfo)
return props.callInfo return props.callInfo
} }
// 如果 callInfo 是单个对象API返回的数据格式 // 如果 callInfo 是单个对象API返回的数据格式
if (props.callInfo && typeof props.callInfo === 'object' && props.callInfo.user_name) { if (props.callInfo && typeof props.callInfo === 'object' && props.callInfo.user_name) {
console.log('RawDataCards - callInfo is single object:', props.callInfo)
return [props.callInfo] // 将单个对象包装成数组 return [props.callInfo] // 将单个对象包装成数组
} }
// 如果 callInfo 是对象且包含数据数组 // 如果 callInfo 是对象且包含数据数组
if (props.callInfo && props.callInfo.data && Array.isArray(props.callInfo.data)) { if (props.callInfo && props.callInfo && Array.isArray(props.callInfo)) {
console.log('RawDataCards - callInfo.data is array:', props.callInfo.data) return props.callInfo
return props.callInfo.data
} }
console.log('RawDataCards - no valid call data found, returning empty array')
// 如果没有数据,返回空数组 // 如果没有数据,返回空数组
return [] return []
}) })
// 新增:格式化通话时长的方法
const formatCallDuration = (durationInMinutes) => {
if (typeof durationInMinutes !== 'number' || durationInMinutes < 0) {
return '暂无';
}
const totalSeconds = Math.round(durationInMinutes * 60);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${minutes}${seconds}`;
};
// 新增根据分数获取CSS类的方法
const getScoreClass = (score) => {
if (score >= 80) {
return 'score-high';
} else if (score >= 60) {
return 'score-medium';
} else {
return 'score-low';
}
};
// 录音下载方法 // 录音下载方法
const downloadRecording = (call) => { const downloadRecording = async (call) => {
console.log('下载录音:', call) console.log('下载录音:', call)
// 检查是否有录音文件地址 // 检查是否有录音文件地址
if (call.record_file_addr_list && call.record_file_addr_list.length > 0) { if (call.record_file_addr) {
const recordingUrl = call.record_file_addr_list[0] const recordingUrl = call.record_file_addr
// 从URL中提取文件名 try {
const urlParts = recordingUrl.split('/') // 显示下载开始提示
const fileName = urlParts[urlParts.length - 1] emit('show-download-modal', '下载提示', '正在下载录音文件,请稍候...')
// 创建下载链接 // 若为 HTTPS 页面请求 HTTP 资源,浏览器会拦截,回退为在新标签页打开
const link = document.createElement('a') if (window.location.protocol === 'https:' && recordingUrl.startsWith('http://')) {
link.href = recordingUrl const parts = recordingUrl.split('/')
link.download = fileName const fallbackName = parts[parts.length - 1] || 'recording'
link.target = '_blank' const link = document.createElement('a')
link.href = recordingUrl
link.target = '_blank'
link.rel = 'noopener'
link.download = fallbackName
link.style.display = 'none'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
emit('show-download-modal', '提示', '目标为不安全的HTTP资源已在新标签页打开下载链接。')
return
}
// 触发下载 // 通过请求的方式下载
document.body.appendChild(link) const response = await fetch(recordingUrl, {
link.click() method: 'GET',
document.body.removeChild(link) mode: 'cors',
credentials: 'omit',
redirect: 'follow',
referrerPolicy: 'no-referrer'
})
console.log(`正在下载录音文件: ${fileName}`) if (!response.ok) {
throw new Error(`下载失败,状态码: ${response.status}`)
}
// 从响应头或URL中提取文件名
let fileName = 'recording'
const disposition = response.headers.get('content-disposition')
if (disposition) {
const match = disposition.match(/filename\*=UTF-8''([^;]+)|filename="?([^";]+)"?/i)
if (match) fileName = decodeURIComponent(match[1] || match[2])
}
if (!fileName || fileName === 'recording') {
try {
const urlObj = new URL(recordingUrl, window.location.href)
const segments = urlObj.pathname.split('/')
fileName = segments[segments.length - 1] || 'recording'
} catch (e) {
const parts = recordingUrl.split('/')
fileName = parts[parts.length - 1] || 'recording'
}
}
const blob = await response.blob()
const objectUrl = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = objectUrl
a.download = fileName
a.style.display = 'none'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
setTimeout(() => URL.revokeObjectURL(objectUrl), 1000)
// 下载成功提示
emit('show-download-modal', '下载成功', '录音文件下载完成!')
} catch (error) {
console.error('下载录音文件失败:', error)
// 回退尝试在新标签页直接打开原始链接适用于CORS或其他限制
try {
const link = document.createElement('a')
link.href = recordingUrl
link.target = '_blank'
link.rel = 'noopener'
link.style.display = 'none'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
emit('show-download-modal', '提示', '无法直接下载,已在新标签页打开录音链接。')
} catch (e) {
emit('show-download-modal', '下载失败', '下载录音文件失败,请检查网络连接或文件是否存在')
}
}
} else { } else {
alert('该通话记录暂无录音文件') emit('show-download-modal', '提示', '该通话记录暂无录音文件')
} }
} }
// 查看原文方法 // 查看原文方法
const viewTranscript = async (call) => { const viewTranscript = async (call) => {
// 触发SOP分析 const title = '通话原文内容'
emit('analyze-sop', { const content = call.record_context || '该通话记录暂无原文内容'
type: 'call',
data: call,
content: call.record_context || ''
})
// 显示通话记录内容 emit('show-modal', { title, content })
if (call.record_context) { }
alert(call.record_context)
} else { // 时间格式化方法
alert('该通话记录暂无原文内容') const formatDateTime = (dateTimeString) => {
if (!dateTimeString) return '暂无时间'
try {
const date = new Date(dateTimeString)
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
const seconds = String(date.getSeconds()).padStart(2, '0')
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
} catch (error) {
console.error('时间格式化错误:', error)
return dateTimeString
} }
} }
</script> </script>
@@ -378,6 +549,58 @@ const viewTranscript = async (call) => {
flex: 1; flex: 1;
} }
.form-filter {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.form-filter-btn {
padding: 6px 12px;
border-radius: 999px;
border: 1px solid #e5e7eb;
background: #ffffff;
font-size: 12px;
font-weight: 600;
color: #6b7280;
cursor: pointer;
transition: all 0.2s ease;
}
.form-filter-btn.active {
border-color: #10b981;
color: #059669;
background: #ecfdf5;
}
.form-filter-btn:hover {
color: #059669;
border-color: #a7f3d0;
}
.form-section {
display: flex;
flex-direction: column;
gap: 8px;
}
.form-section + .form-section {
margin-top: 16px;
padding-top: 12px;
border-top: 1px dashed #e5e7eb;
}
.form-section-title {
font-size: 14px;
font-weight: 600;
color: #111827;
background: #f0fdf4;
border: 1px solid #dcfce7;
padding: 4px 10px;
border-radius: 999px;
width: fit-content;
}
// 表单字段样式 // 表单字段样式
.form-data-list { .form-data-list {
.form-field { .form-field {
@@ -509,163 +732,199 @@ const viewTranscript = async (call) => {
.call-list { .call-list {
.call-item { .call-item {
margin-bottom: 16px; margin-bottom: 16px;
padding: 16px; padding: 20px;
border-radius: 8px; border-radius: 12px;
background: #f9fafb; background: #ffffff;
border-left: 4px solid #3b82f6; border: 1px solid #e5e7eb;
transition: all 0.3s ease;
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
&:hover {
box-shadow: 0 6px 16px rgba(0,0,0,0.1);
transform: translateY(-2px);
}
.call-header { .call-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: flex-start;
margin-bottom: 8px; .header-main {
display: flex;
flex-direction: column;
gap: 8px;
flex: 1;
.call-type { .user-info {
font-size: 12px; display: flex;
font-weight: 600; gap: 12px;
padding: 4px 8px; flex-wrap: wrap;
border-radius: 4px;
background: #dbeafe;
color: #3b82f6;
}
.call-duration { .call-type {
font-size: 12px; font-size: 13px;
font-weight: 500; font-weight: 600;
color: #6b7280; padding: 5px 10px;
border-radius: 20px;
background: #dbeafe;
color: #3b82f6;
}
.call-customer {
font-size: 13px;
font-weight: 500;
color: #6b7280;
align-self: center;
}
}
.header-tags {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
.call-tag {
font-size: 12px;
font-weight: 600;
padding: 4px 10px;
border-radius: 20px;
&.tag-20min {
background: #dcfce7;
color: #16a34a;
border: 1px solid #bbf7d0;
}
&.tag-invalid {
background: #fee2e2;
color: #dc2626;
border: 1px solid #fecaca;
}
&.tag-other {
background: #fef3c7;
color: #d97706;
border: 1px solid #fed7aa;
}
}
// 分数样式
.stat-value {
font-size: 14px;
font-weight: 700;
padding: 4px 10px;
border-radius: 20px;
&.score-high {
color: #16a34a; // 绿色
background-color: #dcfce7;
}
&.score-medium {
color: #d97706; // 橙色
background-color: #fef3c7;
}
&.score-low {
color: #dc2626; // 红色
background-color: #fee2e2;
}
}
}
} }
.call-time { .call-time {
font-size: 12px; font-size: 12px;
color: #9ca3af; color: #9ca3af;
font-weight: 500;
white-space: nowrap;
padding: 4px 8px;
background: #f9fafb;
border-radius: 4px;
align-self: flex-start;
} }
} }
.call-actions { .call-actions {
display: flex; display: flex;
gap: 12px; justify-content: space-between;
align-items: center;
margin-top: 12px; margin-top: 12px;
.action-btn { .action-buttons {
display: flex; display: flex;
gap: 12px;
align-items: center; align-items: center;
gap: 6px;
padding: 8px 12px;
border: none;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
&.download-btn { .action-btn {
background: #dbeafe; display: flex;
color: #3b82f6; align-items: center;
gap: 6px;
padding: 8px 14px;
border: none;
border-radius: 8px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
&:hover { &.download-btn {
background: #bfdbfe; background: #dbeafe;
transform: translateY(-1px); color: #3b82f6;
&:hover {
background: #bfdbfe;
transform: translateY(-2px);
box-shadow: 0 4px 6px rgba(59, 130, 246, 0.2);
}
&:active {
transform: translateY(0);
}
}
&.view-btn {
background: #d1fae5;
color: #059669;
&:hover {
background: #a7f3d0;
transform: translateY(-2px);
box-shadow: 0 4px 6px rgba(5, 150, 105, 0.2);
}
&:active {
transform: translateY(0);
}
}
i {
font-style: normal;
font-size: 14px;
&.icon-download::before {
content: '⬇';
}
&.icon-view::before {
content: '👁';
}
} }
} }
&.view-btn { // 通话时长样式
background: #d1fae5; .call-duration {
color: #059669; font-size: 13px;
color: #6b7280;
&:hover { font-weight: 500;
background: #a7f3d0; background: #f9fafb;
transform: translateY(-1px); padding: 6px 12px;
} border-radius: 20px;
} border: 1px solid #e5e7eb;
i {
width: 14px;
height: 14px;
&.icon-download::before {
content: '⬇';
}
&.icon-view::before {
content: '👁';
}
} }
} }
} }
} }
} }
.card-content {
margin-bottom: 20px;
}
.card-description {
color: #6b7280;
font-size: 14px;
margin: 0 0 16px 0;
line-height: 1.5;
}
.card-stats {
display: flex;
gap: 20px;
@media (max-width: 480px) {
flex-direction: column;
gap: 12px;
}
}
.stat-item {
display: flex;
flex-direction: column;
gap: 4px;
}
.stat-label {
font-size: 12px;
color: #9ca3af;
font-weight: 500;
}
.stat-value {
font-size: 14px;
color: #111827;
font-weight: 600;
}
.card-action {
border-top: 1px solid #f3f4f6;
padding-top: 16px;
margin-top: 16px;
}
.view-btn {
display: flex;
align-items: center;
gap: 8px;
background: none;
border: none;
color: #6b7280;
font-size: 14px;
font-weight: 500;
cursor: pointer;
padding: 8px 0;
transition: color 0.2s ease;
&:hover {
color: #111827;
}
svg {
transition: transform 0.2s ease;
}
&:hover svg {
transform: translateX(2px);
}
}
@media (max-width: 768px) { @media (max-width: 768px) {
.raw-data-cards { .raw-data-cards {
margin: 20px 0; margin: 20px 0;
@@ -713,8 +972,10 @@ const viewTranscript = async (call) => {
font-size: 14px; font-size: 14px;
} }
.card-description { .call-actions {
font-size: 13px; flex-direction: column;
align-items: flex-start;
gap: 10px;
} }
} }
</style> </style>

View File

@@ -23,16 +23,26 @@
<!-- 自己登录时的顶栏原有样式 --> <!-- 自己登录时的顶栏原有样式 -->
<template v-else> <template v-else>
<div style="display: flex; justify-content: space-between; align-items: center; width: 100%;"> <div style="display: flex; justify-content: space-between; align-items: center; width: 100%;">
<h1 class="app-title">销售驾驶舱</h1> <h1 class="app-title">分析师驾驶舱</h1>
<div <div
class="quick-stats" class="quick-stats"
style="display: flex; align-items: center; gap: 30px" style="display: flex; align-items: center; gap: 30px"
> >
</div> </div>
<div style="display: flex; align-items: center; gap: 20px;">
<button @click="showFeedbackFormModal" class="feedback-btn">意见反馈</button>
<FeedbackForm
:is-visible="showFeedbackForm"
@close="closeFeedbackFormModal"
@submit-feedback="closeFeedbackFormModal"
/>
<UserDropdown <UserDropdown
:card-visibility="cardVisibility" :card-visibility="cardVisibility"
@update-card-visibility="updateCardVisibility" @update-card-visibility="updateCardVisibility"
/> />
</div>
</div> </div>
</template> </template>
</div> </div>
@@ -76,7 +86,9 @@
@view-form-data="handleViewFormData" @view-form-data="handleViewFormData"
@view-chat-data="handleViewChatData" @view-chat-data="handleViewChatData"
@view-call-data="handleViewCallData" @view-call-data="handleViewCallData"
@analyze-sop="handleAnalyzeSop" /> @analyze-sop="handleAnalyzeSop"
@show-modal="handleShowModal"
@show-download-modal="handleShowDownloadModal" />
</div> </div>
</section> </section>
@@ -86,9 +98,6 @@
<main class="main-content"> <main class="main-content">
<!-- 客户详情区域 --> <!-- 客户详情区域 -->
<section v-if="cardVisibility.customerDetail && selectedContact" class="detail-section"> <section v-if="cardVisibility.customerDetail && selectedContact" class="detail-section">
<div class="section-header">
<h2>客户详情</h2>
</div>
<div class="section-content"> <div class="section-content">
<CustomerDetail <CustomerDetail
ref="customerDetailRef" ref="customerDetailRef"
@@ -120,12 +129,45 @@
</div> </div>
</section> </section>
<!-- 周期分析区域 --> <!-- 自定义弹框 -->
<section v-if="cardVisibility.weekAnalysis===false" class="week-analysis-section" style="width: 100%; margin-top: 24px;"> <div v-if="showModal" class="modal-overlay" @click="closeModal" @wheel.prevent @touchmove.prevent>
<div class="section-content"> <div class="modal-container" @click.stop @wheel.stop @touchmove.stop>
<WeekAnalize :week-data="weekAnalysisData" /> <div class="modal-header">
<h3 class="modal-title">{{ modalTitle }}</h3>
<button class="modal-close-btn" @click="closeModal">
<i class="icon-close">×</i>
</button>
</div>
<div class="modal-body">
<div class="modal-content">
{{ modalContent }}
</div>
</div>
<div class="modal-footer">
<button class="modal-btn modal-btn-primary" @click="closeModal">确定</button>
</div>
</div> </div>
</section> </div>
<!-- 下载弹框 -->
<div v-if="showDownloadModal" class="modal-overlay" @click="closeDownloadModal" @wheel.prevent @touchmove.prevent>
<div class="modal-container" @click.stop @wheel.stop @touchmove.stop>
<div class="modal-header">
<h3 class="modal-title">{{ downloadModalTitle }}</h3>
<button class="modal-close-btn" @click="closeDownloadModal">
<i class="icon-close">×</i>
</button>
</div>
<div class="modal-body">
<div class="modal-content">
{{ downloadModalContent }}
</div>
</div>
<div class="modal-footer">
<button class="modal-btn modal-btn-primary" @click="closeDownloadModal">确定</button>
</div>
</div>
</div>
</div> </div>
</template> </template>
@@ -140,66 +182,11 @@ import RawDataCards from "./components/RawDataCards.vue";
import WeekAnalize from "./components/WeekAnalize.vue"; import WeekAnalize from "./components/WeekAnalize.vue";
import UserDropdown from "@/components/UserDropdown.vue"; import UserDropdown from "@/components/UserDropdown.vue";
import Loading from "@/components/Loading.vue"; import Loading from "@/components/Loading.vue";
import FeedbackForm from "@/components/FeedbackForm.vue";
import {getCustomerAttendance,getTodayCall,getProblemDistribution,getTableFillingRate,getAverageResponseTime, import {getCustomerAttendance,getTodayCall,getProblemDistribution,getTableFillingRate,getAverageResponseTime,
getWeeklyActiveCommunicationRate,getTimeoutResponseRate,getCustomerCallInfo,getCustomerChatInfo,getCustomerFormInfo, getWeeklyActiveCommunicationRate,getTimeoutResponseRate,getCustomerCallInfo,getCustomerChatInfo,getCustomerFormInfo,
getConversionRateAndAllocatedData,getCustomerAttendanceAfterClass4,getPayMoneyCustomers,getSalesFunnel,getGoldContactTime,getAvgCallTime} from "@/api/api.js" getConversionRateAndAllocatedData,getCustomerAttendanceAfterClass4,getPayMoneyCustomers,getSalesFunnel,getGoldContactTime,
getAvgCallTime,getCallSuccessRate,getSecondOrderAnalysisReport} from "@/api/api.js"
// 缓存系统
const cache = new Map();
const CACHE_DURATION = 30 * 60 * 1000; // 30分钟缓存时长
// 生成缓存键
const getCacheKey = (apiName, params = {}) => {
const sortedParams = Object.keys(params)
.sort()
.reduce((result, key) => {
result[key] = params[key];
return result;
}, {});
return `${apiName}_${JSON.stringify(sortedParams)}`;
};
// 检查缓存是否有效
const isValidCache = (cacheData) => {
return cacheData && (Date.now() - cacheData.timestamp) < CACHE_DURATION;
};
// 设置缓存
const setCache = (key, data) => {
cache.set(key, {
data,
timestamp: Date.now()
});
};
// 获取缓存
const getCache = (key) => {
const cacheData = cache.get(key);
if (isValidCache(cacheData)) {
return cacheData.data;
}
// 如果缓存过期,删除它
if (cacheData) {
cache.delete(key);
}
return null;
};
// 缓存包装器函数
const withCache = async (apiName, apiFunction, params = {}) => {
const cacheKey = getCacheKey(apiName, params);
const cachedData = getCache(cacheKey);
if (cachedData) {
console.log(`[缓存命中] ${apiName}:`, cachedData);
return cachedData;
}
console.log(`[API调用] ${apiName}`);
const result = await apiFunction(params);
setCache(cacheKey, result);
return result;
};
// 路由实例 // 路由实例
const router = useRouter(); const router = useRouter();
@@ -220,7 +207,6 @@ const getRequestParams = () => {
if (routeUserName) { if (routeUserName) {
params.user_name = routeUserName params.user_name = routeUserName
} }
return params return params
} }
@@ -304,6 +290,19 @@ const statisticsData = reactive({
// 客户迫切解决的问题数据 // 客户迫切解决的问题数据
const urgentProblemData = ref([]); const urgentProblemData = ref([]);
// 弹框状态
const showModal = ref(false)
const modalContent = ref('')
const modalTitle = ref('')
// FeedbackForm 弹框状态
const showFeedbackForm = ref(false)
// 下载弹框状态
const showDownloadModal = ref(false)
const downloadModalContent = ref('')
const downloadModalTitle = ref('')
// 时间线数据 // 时间线数据
const timelineData = ref({}); const timelineData = ref({});
@@ -324,11 +323,13 @@ const payMoneyCustomersList = ref([]);
const payMoneyCustomersCount = ref(0); const payMoneyCustomersCount = ref(0);
// 表单信息 // 表单信息
const formInfo = ref({}); const formInfo = ref([]);
// 通话记录 // 通话记录
const callRecords = ref([]); const callRecords = ref([]);
// 聊天记录 // 聊天记录
const chatRecords = ref([]); const chatRecords = ref([]);
// 电话接通率
const callSuccessRate = ref(0)
// MOCK DATA (Should ideally come from a store or API) // MOCK DATA (Should ideally come from a store or API)
const MOCK_DATA = reactive({ const MOCK_DATA = reactive({
@@ -365,14 +366,25 @@ async function getCoreKpi() {
const params = getRequestParams() const params = getRequestParams()
const hasParams = params.user_name const hasParams = params.user_name
// 并发请求所有KPI接口
const [
todayCallRes,
conversionRes,
avgCallTimeRes,
callSuccessRateRes
] = await Promise.all([
getTodayCall(hasParams ? params : undefined),
getConversionRateAndAllocatedData(hasParams ? params : undefined),
getAvgCallTime(hasParams ? params : undefined),
getCallSuccessRate(hasParams ? params : undefined)
])
// 今日通话数据 // 今日通话数据
const res = await withCache('getTodayCall', () => getTodayCall(hasParams ? params : undefined), hasParams ? params : {}) if (todayCallRes.code === 200) {
if (res.code === 200) { kpiDataState.totalCalls = todayCallRes.data.call_count
kpiDataState.totalCalls = res.data.today_call
} }
// 转化率、分配数据量、加微率 // 转化率、分配数据量、加微率
const conversionRes = await withCache('getConversionRateAndAllocatedData', () => getConversionRateAndAllocatedData(hasParams ? params : undefined), hasParams ? params : {})
if (conversionRes.code === 200) { if (conversionRes.code === 200) {
kpiDataState.conversionRate = conversionRes.data.conversion_rate || 0 kpiDataState.conversionRate = conversionRes.data.conversion_rate || 0
kpiDataState.assignedData = conversionRes.data.all_count || 0 kpiDataState.assignedData = conversionRes.data.all_count || 0
@@ -380,10 +392,14 @@ async function getCoreKpi() {
} }
// 平均通话时长 // 平均通话时长
const avgCallTimeRes = await withCache('getAvgCallTime', () => getAvgCallTime(hasParams ? params : undefined), hasParams ? params : {})
if (avgCallTimeRes.code === 200) { if (avgCallTimeRes.code === 200) {
kpiDataState.avgDuration = avgCallTimeRes.data.call_time || 0 kpiDataState.avgDuration = avgCallTimeRes.data.call_time || 0
} }
// 电话接通率
if (callSuccessRateRes.code === 200) {
kpiDataState.successRate = callSuccessRateRes.data.call_success_rate || 0
}
} catch (error) { } catch (error) {
console.error('获取核心KPI数据失败:', error) console.error('获取核心KPI数据失败:', error)
} finally { } finally {
@@ -397,26 +413,35 @@ async function getStatisticsData() {
const params = getRequestParams() const params = getRequestParams()
const hasParams = params.user_name const hasParams = params.user_name
// 获取表单填写率 // 并发请求所有统计数据
const fillingRateRes = await withCache('getTableFillingRate', () => getTableFillingRate(hasParams ? params : undefined), hasParams ? params : {}) const [
fillingRateRes,
avgResponseRes,
communicationRes,
timeoutRes
] = await Promise.all([
getTableFillingRate(hasParams ? params : undefined),
getAverageResponseTime(hasParams ? params : undefined),
getWeeklyActiveCommunicationRate(hasParams ? params : undefined),
getTimeoutResponseRate(hasParams ? params : undefined)
])
// 处理表单填写率
if (fillingRateRes.code === 200) { if (fillingRateRes.code === 200) {
statisticsData.formCompletionRate = fillingRateRes.data.filling_rate statisticsData.formCompletionRate = fillingRateRes.data.filling_rate
} }
// 获取平均响应时间 // 处理平均响应时间
const avgResponseRes = await withCache('getAverageResponseTime', () => getAverageResponseTime(hasParams ? params : undefined), hasParams ? params : {})
if (avgResponseRes.code === 200) { if (avgResponseRes.code === 200) {
statisticsData.averageResponseTime = avgResponseRes.data.average_minutes statisticsData.averageResponseTime = avgResponseRes.data.average_minutes
} }
// 获取客户沟通率 // 处理客户沟通率
const communicationRes = await withCache('getWeeklyActiveCommunicationRate', () => getWeeklyActiveCommunicationRate(hasParams ? params : undefined), hasParams ? params : {})
if (communicationRes.code === 200) { if (communicationRes.code === 200) {
statisticsData.customerCommunicationRate = communicationRes.data.communication_rate statisticsData.customerCommunicationRate = communicationRes.data.communication_rate
} }
// 获取超时响应率 // 处理超时响应率
const timeoutRes = await withCache('getTimeoutResponseRate', () => getTimeoutResponseRate(hasParams ? params : undefined), hasParams ? params : {})
if (timeoutRes.code === 200) { if (timeoutRes.code === 200) {
statisticsData.timeoutResponseRate = timeoutRes.data.overtime_rate_600 statisticsData.timeoutResponseRate = timeoutRes.data.overtime_rate_600
statisticsData.severeTimeoutRate = timeoutRes.data.overtime_rate_800 statisticsData.severeTimeoutRate = timeoutRes.data.overtime_rate_800
@@ -434,7 +459,7 @@ async function getUrgentProblem() {
const params = getRequestParams() const params = getRequestParams()
const hasParams = params.user_name const hasParams = params.user_name
const res = await withCache('getProblemDistribution', () => getProblemDistribution(hasParams ? params : undefined), hasParams ? params : {}) const res = await getProblemDistribution(hasParams ? params : undefined)
if(res.code === 200) { if(res.code === 200) {
// 将API返回的对象格式转换为数组格式 // 将API返回的对象格式转换为数组格式
const problemDistributionCount = res.data.problem_distribution_count const problemDistributionCount = res.data.problem_distribution_count
@@ -453,7 +478,7 @@ async function getTimeline() {
const params = getRequestParams() const params = getRequestParams()
const hasParams = params.user_name const hasParams = params.user_name
// 前6个阶段 // 前6个阶段
const res = await withCache('getCustomerAttendance', () => getCustomerAttendance(hasParams ? params : undefined), hasParams ? params : {}) const res = await getCustomerAttendance(hasParams ? params : undefined)
if(res.code === 200) { if(res.code === 200) {
// 处理时间线数据 // 处理时间线数据
if (res.data.timeline) { if (res.data.timeline) {
@@ -471,7 +496,7 @@ async function getTimeline() {
} }
} }
// 后4个阶段 // 后4个阶段
const classRes = await withCache('getCustomerAttendanceAfterClass4', () => getCustomerAttendanceAfterClass4(hasParams ? params : undefined), hasParams ? params : {}) const classRes = await getCustomerAttendanceAfterClass4(hasParams ? params : undefined)
if(classRes.code === 200) { if(classRes.code === 200) {
// 处理课1-4阶段的客户数据 // 处理课1-4阶段的客户数据
if (classRes.data.class_customers_list) { if (classRes.data.class_customers_list) {
@@ -547,7 +572,7 @@ async function getTimeline() {
} }
} }
// 成交阶段 // 成交阶段
const payRes = await withCache('getPayMoneyCustomers', () => getPayMoneyCustomers(hasParams ? params : undefined), hasParams ? params : {}) const payRes = await getPayMoneyCustomers(hasParams ? params : undefined)
if(payRes.code === 200) { if(payRes.code === 200) {
// 处理成交阶段的客户数据 // 处理成交阶段的客户数据
if (payRes.data.pay_money_customers_list) { if (payRes.data.pay_money_customers_list) {
@@ -574,12 +599,13 @@ async function getCustomerForm() {
const routeParams = getRequestParams() const routeParams = getRequestParams()
const params = { const params = {
user_name: routeParams.user_name || userStore.userInfo.username, user_name: routeParams.user_name || userStore.userInfo.username,
customer_name: selectedContact.value.name, phone: selectedContact.value.phone,
} }
try { try {
const res = await withCache('getCustomerFormInfo', () => getCustomerFormInfo(params), params) const res = await getCustomerFormInfo(params)
console.log('获取客户表单数据:', res)
if(res.code === 200) { if(res.code === 200) {
formInfo.value = res.data formInfo.value = res.data || []
} }
} catch (error) { } catch (error) {
// 静默处理错误 // 静默处理错误
@@ -593,10 +619,10 @@ async function getCustomerChat() {
const routeParams = getRequestParams() const routeParams = getRequestParams()
const params = { const params = {
user_name: routeParams.user_name || userStore.userInfo.username, user_name: routeParams.user_name || userStore.userInfo.username,
customer_name: selectedContact.value.name, phone: selectedContact.value.phone,
} }
try { try {
const res = await withCache('getCustomerChatInfo', () => getCustomerChatInfo(params), params) const res = await getCustomerChatInfo(params)
if(res.code === 200) { if(res.code === 200) {
chatRecords.value = res.data chatRecords.value = res.data
} else { } else {
@@ -614,14 +640,12 @@ async function getCustomerCall() {
const routeParams = getRequestParams() const routeParams = getRequestParams()
const params = { const params = {
user_name: routeParams.user_name || userStore.userInfo.username, user_name: routeParams.user_name || userStore.userInfo.username,
customer_name: selectedContact.value.name, phone: selectedContact.value.phone,
} }
try { try {
const res = await withCache('getCustomerCallInfo', () => getCustomerCallInfo(params), params) const res = await getCustomerCallInfo(params)
if(res.code === 200) { if(res.code === 200) {
callRecords.value = res.data callRecords.value = res.data
console.log('Call Records Data from API:', res.data)
console.log('callRecords.value after assignment:', callRecords.value)
} }
} catch (error) { } catch (error) {
// 静默处理错误 // 静默处理错误
@@ -672,6 +696,7 @@ const formattedCustomersList = computed(() => {
} }
return customersList.value?.map(customer => ({ return customersList.value?.map(customer => ({
wechat_id: customer.customer_wechat_id,
id: customer.customer_name, // 使用客户姓名作为唯一标识 id: customer.customer_name, // 使用客户姓名作为唯一标识
name: customer.customer_name, name: customer.customer_name,
phone: customer.phone, phone: customer.phone,
@@ -860,33 +885,61 @@ const handleViewCallData = (contact) => {
// TODO: 实现通话录音查看逻辑 // TODO: 实现通话录音查看逻辑
}; };
// 处理SOP分析事件 // 处理弹框显示事件
const handleAnalyzeSop = (analyzeData) => { const handleShowModal = (title, content) => {
if (customerDetailRef.value && analyzeData.content) { console.log('handleShowModal0000', title)
customerDetailRef.value.startSopAnalysis(analyzeData.content); modalTitle.value = title.title
} modalContent.value = title.content
}; showModal.value = true
}
// 关闭弹框
const closeModal = () => {
showModal.value = false
modalContent.value = ''
modalTitle.value = ''
}
// 处理下载弹框显示
const handleShowDownloadModal = (title, content) => {
downloadModalTitle.value = title
downloadModalContent.value = content
showDownloadModal.value = true
}
// 关闭下载弹框
const closeDownloadModal = () => {
showDownloadModal.value = false
downloadModalContent.value = ''
downloadModalTitle.value = ''
}
// 显示 FeedbackForm
const showFeedbackFormModal = () => {
showFeedbackForm.value = true
}
// 关闭 FeedbackForm
const closeFeedbackFormModal = () => {
showFeedbackForm.value = false
}
// // 处理SOP分析事件
// const handleAnalyzeSop = (analyzeData) => {
// console.log('handleAnalyzeSop', analyzeData)
// console.log('analyzeData.content', customerDetailRef.value)
// if (customerDetailRef.value && analyzeData.content) {
// customerDetailRef.value.startSopAnalysis(analyzeData.content);
// }
// };
// 销售漏斗 // 销售漏斗
const SalesFunnel = ref([]) const SalesFunnel = ref([])
async function CenterGetSalesFunnel() { async function CenterGetSalesFunnel() {
const params = getRequestParams() const params = getRequestParams()
const hasParams = params.user_name const hasParams = params.user_name
const res = await withCache('getSalesFunnel', () => getSalesFunnel(hasParams ? params : undefined), hasParams ? params : {}) const res = await getSalesFunnel(hasParams ? params : undefined)
if(res.code === 200){ if(res.code === 200){
SalesFunnel.value = res.data SalesFunnel.value = res.data
/**
* "data": {
"user_name": "常琳",
"user_level": 1,
"sale_funnel": {
"线索总数": 11,
"有效沟通": 9,
"到课数据": 8,
"预付定金": 0,
"成功签单": 0
}
}
*/
} }
} }
// 黄金联络时间段 // 黄金联络时间段
@@ -894,56 +947,15 @@ const goldContactTime = ref([])
async function CenterGetGoldContactTime() { async function CenterGetGoldContactTime() {
const params = getRequestParams() const params = getRequestParams()
const hasParams = params.user_name const hasParams = params.user_name
const res = await withCache('getGoldContactTime', () => getGoldContactTime(hasParams ? params : undefined), hasParams ? params : {}) const res = await getGoldContactTime(hasParams ? params : undefined)
if(res.code === 200){ if(res.code === 200){
goldContactTime.value = res.data goldContactTime.value = res.data
} }
} }
// 缓存管理功能 // 强制刷新所有数据重新调用所有API
// 清除所有缓存
function clearCache() {
cache.clear()
}
// 清除特定缓存
function clearSpecificCache(apiName, params = {}) {
const key = getCacheKey(apiName, params)
cache.delete(key)
console.log(`已清除缓存: ${key}`)
}
// 获取缓存信息并清理过期缓存
function getCacheInfo() {
const now = Date.now()
const validCaches = []
const expiredCaches = []
for (const [key, data] of cache.entries()) {
if (isValidCache(data)) {
validCaches.push({
key,
timestamp: data.timestamp,
age: Math.round((now - data.timestamp) / 1000) + 's'
})
} else {
expiredCaches.push(key)
cache.delete(key)
}
}
return {
validCount: validCaches.length,
expiredCount: expiredCaches.length,
validCaches,
expiredCaches
}
}
// 强制刷新所有数据清除缓存并重新调用所有API
async function forceRefreshAllData() { async function forceRefreshAllData() {
console.log('开始强制刷新所有数据...') console.log('开始强制刷新所有数据...')
clearCache()
// 重新调用所有API // 重新调用所有API
await Promise.all([ await Promise.all([
@@ -951,7 +963,6 @@ async function forceRefreshAllData() {
getStatisticsData(), getStatisticsData(),
getUrgentProblem(), getUrgentProblem(),
getTimeline(), getTimeline(),
getCustomerPayMoney(),
CenterGetSalesFunnel(), CenterGetSalesFunnel(),
CenterGetGoldContactTime(), CenterGetGoldContactTime(),
// 客户相关数据需要在选中客户后才能获取 // 客户相关数据需要在选中客户后才能获取
@@ -966,28 +977,20 @@ async function forceRefreshAllData() {
onMounted(async () => { onMounted(async () => {
try { try {
isPageLoading.value = true isPageLoading.value = true
await getStatisticsData() getStatisticsData()
await getCoreKpi() getCoreKpi()
await CenterGetGoldContactTime() CenterGetGoldContactTime()
await CenterGetSalesFunnel() CenterGetSalesFunnel()
await getCustomerForm() getCustomerForm()
await getCustomerChat() getCustomerChat()
await getUrgentProblem() getUrgentProblem()
await getCustomerCall() getCustomerCall()
await getTimeline() getTimeline()
await getCustomerPayMoney()
// 输出初始缓存信息 // 开发环境下暴露数据刷新函数到全局对象,方便调试
getCacheInfo()
// 开发环境下暴露缓存管理函数到全局对象,方便调试
if (process.env.NODE_ENV === 'development') { if (process.env.NODE_ENV === 'development') {
window.saleCache = { window.saleData = {
clearCache, forceRefreshAllData
clearSpecificCache,
getCacheInfo,
forceRefreshAllData,
cache
} }
} }
@@ -1077,9 +1080,9 @@ $primary: #3b82f6;
} }
// 主要布局 // 主要布局
.main-layout { .main-layout {
width: 100vw; width: 99vw;
margin: 0 auto; margin-bottom: 1rem;
padding: 1rem; // padding: 1rem;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1rem; gap: 1rem;
@@ -1817,4 +1820,191 @@ $primary: #3b82f6;
} }
} }
// 弹框样式
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
// 使用 Flexbox 实现垂直和水平居中
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
backdrop-filter: blur(4px);
animation: fadeIn 0.2s ease-out;
}
.modal-container {
background: #ffffff;
border-radius: 12px;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
max-width: 1200px;
width: 90%;
// 设置最大高度,防止弹窗超出屏幕
max-height: 80vh;
// 防止内容溢出容器,配合内部滚动
overflow: hidden;
// 使用 Flexbox 布局,让 .modal-body 可以伸缩
display: flex;
flex-direction: column;
animation: slideIn 0.3s ease-out;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 24px;
border-bottom: 1px solid #e5e7eb;
}
.modal-title {
font-size: 18px;
font-weight: 600;
color: #111827;
margin: 0;
}
.modal-close-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
background: #f3f4f6;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: #e5e7eb;
transform: scale(1.05);
}
.icon-close {
font-size: 18px;
color: #6b7280;
font-weight: bold;
}
}
.modal-body {
// 关键:让内容区域占据所有剩余空间
flex: 1;
// 关键:当内容超出时,只在垂直方向显示滚动条
overflow-y: auto;
// 防止滚动链传递到页面,仅在弹框内滚动
overscroll-behavior: contain;
// 为内容提供统一内边距
padding: 24px;
// 配合 flex: 1 使用,防止 flex item 在某些浏览器中无法正确收缩
min-height: 0;
}
.modal-content {
font-size: 14px;
line-height: 1.6;
color: #374151;
// 支持长文本和换行
white-space: pre-wrap;
word-wrap: break-word;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
padding: 16px 24px;
border-top: 1px solid #e5e7eb;
// flex-shrink: 0; // 确保 footer 不会被压缩
}
.modal-btn {
padding: 8px 16px;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
&.modal-btn-primary {
background: #3b82f6;
color: #ffffff;
&:hover {
background: #2563eb;
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(59, 130, 246, 0.3);
}
}
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(-20px) scale(0.95);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
// 意见反馈按钮样式
.feedback-btn {
background-color: #4299e1;
color: white;
border: none;
border-radius: 6px;
padding: 0.5rem 1rem;
font-size: 0.9rem;
cursor: pointer;
transition: background-color 0.2s;
}
.feedback-btn:hover {
background-color: #3182ce;
}
// 弹框响应式样式
@media (max-width: 768px) {
.modal-container {
width: 95%;
max-height: 85vh;
}
.modal-header {
padding: 16px 20px;
}
.modal-title {
font-size: 16px;
}
.modal-body {
padding: 20px;
}
.modal-content {
font-size: 13px;
}
.modal-footer {
padding: 12px 20px;
}
}
</style> </style>

View File

@@ -311,7 +311,7 @@ onBeforeUnmount(() => {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: 20px 20px 16px; padding: 10px 20px 10px;
border-bottom: 1px solid #ebeef5; border-bottom: 1px solid #ebeef5;
h3 { margin: 0; color: #303133; font-size: 18px; font-weight: 600; } h3 { margin: 0; color: #303133; font-size: 18px; font-weight: 600; }
} }

View File

@@ -18,6 +18,7 @@
:class="{ active: selectedRecording === index }" :class="{ active: selectedRecording === index }"
@click="selectRecording(index)" @click="selectRecording(index)"
> >
<span class="recording-index">{{ recording.score}}</span>
<div class="recording-info"> <div class="recording-info">
<div class="recording-name" :title="recording.name">{{ recording.name.length > 10 ? recording.name.substring(0, 10) + '...' : recording.name }}</div> <div class="recording-name" :title="recording.name">{{ recording.name.length > 10 ? recording.name.substring(0, 10) + '...' : recording.name }}</div>
<div class="recording-meta"> <div class="recording-meta">
@@ -172,8 +173,8 @@ import MarkdownIt from 'markdown-it'
// Props定义 // Props定义
const props = defineProps({ const props = defineProps({
qualityCalls: { qualityCalls: {
type: Object, type: Array,
default: () => ({}) default: () => []
} }
}) })
@@ -221,24 +222,22 @@ const recordings = computed(() => {
if (!props.qualityCalls ) { if (!props.qualityCalls ) {
return staticRecordings.value; return staticRecordings.value;
} }
const recordingsList = []; const recordingsList = [];
Object.keys(props.qualityCalls).forEach(userName => { props.qualityCalls.forEach((record, index) => {
props.qualityCalls[userName].forEach((record, index) => {
recordingsList.push({ recordingsList.push({
id: recordingsList.length + 1, id: recordingsList.length + 1,
name: record.obj_file_name ? record.obj_file_name.split('/').pop() : `${record.sale_name}-录音-${index + 1}`, name: record.record_name ? record.record_name : `${record.sale_name}-录音-${index + 1}`,
date: new Date().toISOString().split('T')[0], date: new Date().toISOString().split('T')[0],
url: record.obj_file_name, url: record.record_file_addr,
transcription: record.context || null, transcription: record.record_context || null,
score: record.score, score: record.record_score,
sop: record.sop, sop: record.record_report,
sale_name: record.sale_name, sale_name: record.record_name,
size: 2048576, // 默认文件大小 2MB size: 2048576, // 默认文件大小 2MB
uploadTime: new Date().toLocaleDateString('zh-CN') uploadTime: record.created_at,
}); });
}); });
});
return recordingsList; return recordingsList;
}) })
@@ -505,14 +504,14 @@ const downloadRecording = (index) => {
background: white; background: white;
border-radius: 8px; border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
height: 400px; height: 420px;
} }
.chart-header { .chart-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: 20px 20px 0; padding: 10px 20px 0;
border-bottom: 1px solid #ebeef5; border-bottom: 1px solid #ebeef5;
} }
@@ -549,7 +548,7 @@ const downloadRecording = (index) => {
} }
.chart-content { .chart-content {
padding: 20px; padding: 10px;
} }
.recording-section { .recording-section {
@@ -562,7 +561,6 @@ const downloadRecording = (index) => {
.recording-list { .recording-list {
margin-bottom: 20px; margin-bottom: 20px;
max-height: 400px; max-height: 400px;
overflow-y: auto;
} }
.recording-item { .recording-item {
@@ -602,6 +600,39 @@ const downloadRecording = (index) => {
display: inline-block; display: inline-block;
} }
.recording-index {
/* 基础分数样式 */
padding: 2px 8px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
background-color: #e9ecef;
color: #495057;
margin-right: 10px;
}
/* 第一名样式 */
.recording-item:first-child .recording-index {
background: linear-gradient(135deg, #FFD700, #FFA500);
color: #fff;
box-shadow: 0 2px 4px rgba(255, 215, 0, 0.3);
}
/* 第二名样式 */
.recording-item:nth-child(2) .recording-index {
background: linear-gradient(135deg, #C0C0C0, #A9A9A9);
color: #fff;
box-shadow: 0 2px 4px rgba(192, 192, 192, 0.3);
}
/* 第三名样式 */
.recording-item:nth-child(3) .recording-index {
background: linear-gradient(135deg, #CD7F32, #A0522D);
color: #fff;
box-shadow: 0 2px 4px rgba(205, 127, 50, 0.3);
}
.recording-meta { .recording-meta {
display: flex; display: flex;
gap: 12px; gap: 12px;

View File

@@ -35,7 +35,7 @@
<script setup> <script setup>
import { computed,onMounted } from 'vue'; import { computed,onMounted } from 'vue';
import { exportCustomers } from '@/api/secondTop'; import { exportCustomers, getExcellentRecordFile } from '@/api/secondTop';
import { useUserStore } from "@/stores/user"; import { useUserStore } from "@/stores/user";
import { ElMessage } from 'element-plus'; import { ElMessage } from 'element-plus';
import * as XLSX from 'xlsx'; import * as XLSX from 'xlsx';
@@ -89,13 +89,15 @@ async function exportData() {
try { try {
ElMessage.info('正在导出数据,请稍候...') ElMessage.info('正在导出数据,请稍候...')
const res = await exportCustomers(params) console.log('导出参数:', params)
const res = await exportCustomers()
if (res.code === 200 && res.data && res.data.length > 0) { if (res.code === 200 && res.data && res.data.length > 0) {
ElMessage.success('数据导出成功')
// 处理数据,将复杂的嵌套对象展平 // 处理数据,将复杂的嵌套对象展平
const exportData = res.data.map(customer => { const exportData = res.data.map(customer => {
const flatData = { const flatData = {
'昵称': customer.nickname || '', '昵称': customer.nickname || '',
'客户姓名': customer.customer_name || '',
'性别': customer.gender || '', '性别': customer.gender || '',
'跟进人': customer.follow_up_name || '', '跟进人': customer.follow_up_name || '',
'手机号': customer.phone || '', '手机号': customer.phone || '',
@@ -103,18 +105,104 @@ async function exportData() {
'用户ID': customer.mantis_user_id || '', '用户ID': customer.mantis_user_id || '',
} }
// 处理微信表单信息 const parseFormData = (formData) => {
if (customer.wechat_form) { if (typeof formData === 'string') {
flatData['家长姓名'] = customer.wechat_form.name || '' try {
flatData['孩子姓名'] = customer.wechat_form.child_name || '' return JSON.parse(formData)
flatData['孩子性别'] = customer.wechat_form.child_gender || '' } catch (e) {
flatData['职业'] = customer.wechat_form.occupation || '' return null
flatData['孩子教育阶段'] = customer.wechat_form.child_education || '' }
flatData['与孩子关系'] = customer.wechat_form.child_relation || '' }
flatData['联系电话'] = customer.wechat_form.mobile || '' return formData || null
flatData['地区'] = customer.wechat_form.territory || '' }
flatData['创建时间'] = customer.wechat_form.created_at ? new Date(customer.wechat_form.created_at).toLocaleString() : ''
flatData['更新时间'] = customer.wechat_form.updated_at ? new Date(customer.wechat_form.updated_at).toLocaleString() : '' const normalizeFormArray = (formData) => {
const data = parseFormData(formData)
console.log('解析后的表单数据:', data)
if (Array.isArray(data)) return data
if (data && Array.isArray(data.data)) return data.data
if (data && typeof data === 'object' && !data.answers) {
const numericKeys = Object.keys(data).filter(key => String(Number(key)) === key)
if (numericKeys.length > 0) {
return numericKeys
.sort((a, b) => Number(a) - Number(b))
.map(key => data[key])
.filter(Boolean)
}
}
if (data && data.answers) return [data]
return data ? [data] : []
}
const ensureUniqueKey = (key) => {
if (!flatData[key]) return key
let index = 2
while (flatData[`${key}(${index})`]) {
index += 1
}
return `${key}(${index})`
}
const parsedFormData = parseFormData(customer.wechat_form)
const formArray = normalizeFormArray(parsedFormData)
if (formArray.length > 0) {
console.log('表单数组:', formArray)
const isAnswerList = formArray.every(item => item && item.question_label && Object.prototype.hasOwnProperty.call(item, 'answer'))
const isFormWithAnswers = formArray.every(item => item && Array.isArray(item.answers))
console.log('是否为答案列表:', isAnswerList)
console.log('是否为表单含 answers:', isFormWithAnswers)
if (isAnswerList) {
let formAnswerCount = 0
formArray.forEach((answerItem) => {
const label = String(answerItem.question_label || '').trim()
if (!label) return
const key = ensureUniqueKey(label)
flatData[key] = answerItem.answer ?? ''
formAnswerCount += 1
})
if (formAnswerCount === 0 && formArray.length > 0) {
const fallbackKey = ensureUniqueKey('表单答案')
flatData[fallbackKey] = formArray.map(item => `${item.question_label || ''}: ${item.answer || ''}`).join(' | ')
}
} else if (isFormWithAnswers) {
console.log('表单含 answers:', formArray)
formArray.forEach((formItem) => {
const formTitle = formItem.form_title || '表单'
if (Array.isArray(formItem.answers)) {
let formAnswerCount = 0
formItem.answers.forEach((answerItem) => {
const label = String(answerItem.question_label || '').trim()
if (!label) return
const key = ensureUniqueKey(`${formTitle}-${label}`)
flatData[key] = answerItem.answer ?? ''
formAnswerCount += 1
})
if (formAnswerCount === 0 && formItem.answers.length > 0) {
const fallbackKey = ensureUniqueKey(`${formTitle}-表单答案`)
flatData[fallbackKey] = formItem.answers.map(item => `${item.question_label || ''}: ${item.answer || ''}`).join(' | ')
}
}
if (formItem.created_at) {
const key = ensureUniqueKey(`${formTitle}-创建时间`)
flatData[key] = new Date(formItem.created_at).toLocaleString()
}
if (formItem.updated_at) {
const key = ensureUniqueKey(`${formTitle}-更新时间`)
flatData[key] = new Date(formItem.updated_at).toLocaleString()
}
})
}
} else if (parsedFormData && typeof parsedFormData === 'object') {
flatData['家长姓名'] = parsedFormData.name || ''
flatData['孩子姓名'] = parsedFormData.child_name || ''
flatData['孩子性别'] = parsedFormData.child_gender || ''
flatData['职业'] = parsedFormData.occupation || ''
flatData['孩子教育阶段'] = parsedFormData.child_education || ''
flatData['与孩子关系'] = parsedFormData.child_relation || ''
flatData['联系电话'] = parsedFormData.mobile || ''
flatData['地区'] = parsedFormData.territory || ''
flatData['创建时间'] = parsedFormData.created_at ? new Date(parsedFormData.created_at).toLocaleString() : ''
flatData['更新时间'] = parsedFormData.updated_at ? new Date(parsedFormData.updated_at).toLocaleString() : ''
} }
// 处理到课情况 // 处理到课情况
@@ -126,9 +214,12 @@ async function exportData() {
} }
// 处理问卷调查信息 // 处理问卷调查信息
if (customer.wechat_form && customer.wechat_form.additional_info) { if (parsedFormData && parsedFormData.additional_info) {
customer.wechat_form.additional_info.forEach((item) => { parsedFormData.additional_info.forEach((item) => {
flatData[item.topic || ''] = item.answer || '' const key = ensureUniqueKey(item.topic || '')
if (key) {
flatData[key] = item.answer || ''
}
}) })
} }
@@ -137,27 +228,18 @@ async function exportData() {
// 创建工作簿 // 创建工作簿
const wb = XLSX.utils.book_new() const wb = XLSX.utils.book_new()
const ws = XLSX.utils.json_to_sheet(exportData) const allKeys = Array.from(new Set(exportData.flatMap(item => Object.keys(item))))
const ws = XLSX.utils.json_to_sheet(exportData, { header: allKeys })
// 设置列宽 // 设置列宽
const colWidths = [ const colWidths = allKeys.map(key => {
{ wch: 10 }, // 昵称 const maxCellLength = exportData.reduce((max, row) => {
{ wch: 6 }, // 性别 const value = row[key]
{ wch: 12 }, // 跟进人 const length = value === null || value === undefined ? 0 : String(value).length
{ wch: 15 }, // 手机号 return Math.max(max, length)
{ wch: 10 }, // 是否入群 }, 0)
{ wch: 20 }, // 用户ID return { wch: Math.min(50, Math.max(10, key.length, maxCellLength)) }
{ wch: 12 }, // 家长姓名 })
{ wch: 12 }, // 孩子姓名
{ wch: 8 }, // 孩子性别
{ wch: 12 }, // 职业
{ wch: 12 }, // 孩子教育阶段
{ wch: 15 }, // 与孩子关系
{ wch: 15 }, // 联系电话
{ wch: 20 }, // 地区
{ wch: 20 }, // 创建时间
{ wch: 20 }, // 更新时间
]
ws['!cols'] = colWidths ws['!cols'] = colWidths
// 添加工作表到工作簿 // 添加工作表到工作簿
@@ -172,7 +254,7 @@ async function exportData() {
ElMessage.success(`导出成功!共导出 ${exportData.length} 条数据`) ElMessage.success(`导出成功!共导出 ${exportData.length} 条数据`)
} else { } else {
ElMessage.warning('暂无数据可导出') alert('暂无数据可导出')
} }
} catch (error) { } catch (error) {
console.error('导出失败:', error) console.error('导出失败:', error)
@@ -181,9 +263,7 @@ async function exportData() {
} }
onMounted(async ()=>{
await exportData()
})
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@@ -198,7 +278,7 @@ onMounted(async ()=>{
} }
.chart-header { .chart-header {
padding: 20px 20px 16px; padding: 10px 20px 10px;
border-bottom: 1px solid #ebeef5; border-bottom: 1px solid #ebeef5;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;

View File

@@ -28,10 +28,18 @@
<div v-if="!isRouteNavigation"> <div v-if="!isRouteNavigation">
<!-- 用户下拉菜单 --> <!-- 用户下拉菜单 -->
<div style="display: flex; align-items: center; gap: 20px;">
<button @click="showFeedbackFormModal" class="feedback-btn">意见反馈</button>
<FeedbackForm
:is-visible="showFeedbackForm"
@close="closeFeedbackFormModal"
@submit-feedback="closeFeedbackFormModal"
/>
<UserDropdown <UserDropdown
:card-visibility="cardVisibility" :card-visibility="cardVisibility"
@update-card-visibility="updateCardVisibility" @update-card-visibility="updateCardVisibility"
/> />
</div>
</div> </div>
</div> </div>
@@ -104,6 +112,22 @@
<span class="value">{{ selectedGroup.conversionRate }}%</span> <span class="value">{{ selectedGroup.conversionRate }}%</span>
</div> </div>
</div> </div>
<div class="group-performance">
<button @click="showTeamAnalysisModal">团队整体分析</button>
</div>
</div>
<!-- 团队整体分析弹窗 -->
<div v-if="showTeamAnalysis" class="team-analysis-modal" @click.self="closeTeamAnalysisModal">
<div class="modal-content">
<div class="modal-header">
<h3>团队整体分析</h3>
<button class="close-btn" @click="closeTeamAnalysisModal">×</button>
</div>
<div class="modal-body">
<p>这里是团队整体分析的内容</p>
</div>
</div>
</div> </div>
<div class="members-grid"> <div class="members-grid">
@@ -170,13 +194,13 @@
</main> </main>
<!-- Loading 组件 --> <!-- Loading 组件 -->
<Loading :visible="isLoading" text="数据加载中..." /> <!-- <Loading :visible="isLoading" text="数据加载中..." /> -->
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, onMounted, computed,reactive } from 'vue' import { ref, onMounted, computed,reactive } from 'vue'
import FeedbackForm from "@/components/FeedbackForm.vue";
// 30分钟数据缓存系统 // 30分钟数据缓存系统
const cache = new Map() const cache = new Map()
const CACHE_DURATION = 30 * 60 * 1000 // 30分钟 const CACHE_DURATION = 30 * 60 * 1000 // 30分钟
@@ -261,22 +285,46 @@
const CheckType = ref('month') const CheckType = ref('month')
// 卡片显示状态 // 卡片显示状态
const cardVisibility = ref({ const cardVisibility = ref({
centerOverview: true, centerOverview: true,
actionItems: true, actionItems: true,
customerType: true, customerType: true,
goodMusic: true, goodMusic: true,
problemRanking: true, problemRanking: true,
groupRanking: true, groupRanking: true,
groupComparison: true, groupComparison: true,
teamDetail: true teamDetail: true
}) })
// 更新卡片显示状态 // FeedbackForm 控制变量
const updateCardVisibility = (newVisibility) => { const showFeedbackForm = ref(false)
Object.assign(cardVisibility.value, newVisibility)
console.log('卡片显示状态已更新:', cardVisibility.value) // 团队整体分析弹窗控制变量
} const showTeamAnalysis = ref(false)
// 更新卡片显示状态
const updateCardVisibility = (newVisibility) => {
Object.assign(cardVisibility.value, newVisibility)
console.log('卡片显示状态已更新:', cardVisibility.value)
}
// FeedbackForm 控制方法
const showFeedbackFormModal = () => {
showFeedbackForm.value = true
}
const closeFeedbackFormModal = () => {
showFeedbackForm.value = false
}
// 团队整体分析弹窗控制方法
const showTeamAnalysisModal = () => {
showTeamAnalysis.value = true
}
const closeTeamAnalysisModal = () => {
showTeamAnalysis.value = false
}
// 营期调控逻辑 // 营期调控逻辑
// This would ideally come from a prop or API call based on the logged-in user // This would ideally come from a prop or API call based on the logged-in user
const centerData = ref({ const centerData = ref({
@@ -840,29 +888,44 @@ const conversionRateVsAverage = ref({})
}) })
} }
// 获取优秀录音 // 获取优秀录音
const excellentRecord = ref({}); const excellentRecord = ref([]);
// 获取优秀录音文件 // 获取优秀录音文件
async function CentergetGoodRecord() { async function CentergetGoodRecord() {
const params = getRequestParams() console.log('CentergetGoodRecord 开始执行')
const params1 = {
user_level:userStore.userInfo.user_level.toString(),
user_name:userStore.userInfo.username
}
const hasParams = params.user_name
const requestParams = hasParams ? {
...params,
} : params1
console.log(188811111,requestParams)
try { try {
const res = await withCache('CentergetGoodRecord', const params = getRequestParams()
() => getExcellentRecordFile(requestParams), const params1 = {
requestParams user_level: userStore.userInfo?.user_level?.toString() || '',
) user_name: userStore.userInfo?.username || ''
excellentRecord.value = res.data.excellent_record_list }
console.log(111111,res.data.excellent_record_list)
// 检查参数是否有效
const hasParams = params.user_name && params.user_level
const requestParams = hasParams ? {
...params,
} : params1
console.log('CentergetGoodRecord request params:', requestParams)
// 验证必要参数是否存在
if (!requestParams.user_name || !requestParams.user_level) {
console.error("缺少必要的请求参数:", requestParams);
return;
}
// 直接发送请求,不使用缓存
const res = await getExcellentRecordFile(requestParams)
if (res && res.code === 200 && res.data) {
excellentRecord.value = res.data || []
console.log('获取优秀录音成功:', res.data)
} else {
console.error("获取优秀录音失败,响应数据不完整:", res);
excellentRecord.value = []
}
} catch (error) { } catch (error) {
console.error("获取优秀录音失败:", error); console.error("获取优秀录音失败:", error);
excellentRecord.value = []
} }
} }
@@ -919,21 +982,21 @@ const excellentRecord = ref({});
(currentQuery.user_name && currentQuery.user_level) (currentQuery.user_name && currentQuery.user_level)
if (!isFromRoute) { if (!isFromRoute) {
await CenterCampPeriodAdmin() CenterCampPeriodAdmin()
} }
CentergetGoodRecord()
CenterOverallCenterPerformance()
CenterTotalGroupCount()
CenterConversionRate()
CenterTotalCallCount()
CenterNewCustomer()
CenterDepositConversionRate()
CenterCustomerType()
CenterUrgentNeedToAddress()
CenterConversionRateVsAverage()
await CenterOverallCenterPerformance() CenterSeniorManagerList()
await CenterTotalGroupCount() CenterGroupList('all')
await CenterConversionRate()
await CenterTotalCallCount()
await CenterNewCustomer()
await CenterDepositConversionRate()
await CenterCustomerType()
await CenterUrgentNeedToAddress()
await CenterConversionRateVsAverage()
await CenterSeniorManagerList()
// await CentergetGoodRecord()
await CenterGroupList('all')
console.log('[强制刷新] 所有数据已重新加载') console.log('[强制刷新] 所有数据已重新加载')
} catch (error) { } catch (error) {
@@ -961,14 +1024,13 @@ const excellentRecord = ref({});
await CenterTotalGroupCount() await CenterTotalGroupCount()
await CenterConversionRate() await CenterConversionRate()
await CenterTotalCallCount() await CenterTotalCallCount()
await CentergetGoodRecord()
await CenterNewCustomer() await CenterNewCustomer()
await CenterDepositConversionRate() await CenterDepositConversionRate()
await CenterCustomerType() await CenterCustomerType()
await CenterUrgentNeedToAddress() await CenterUrgentNeedToAddress()
await CenterConversionRateVsAverage() await CenterConversionRateVsAverage()
await CenterSeniorManagerList() await CenterSeniorManagerList()
// 获取优秀录音
await CentergetGoodRecord()
await CenterGroupList('all') // 初始化加载全部高级经理数据 await CenterGroupList('all') // 初始化加载全部高级经理数据
// 输出缓存信息 // 输出缓存信息
@@ -1602,6 +1664,76 @@ const hideTooltip = () => {
} }
} }
/* 团队分析弹窗样式 */
.team-analysis-modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.team-analysis-modal .modal-content {
background-color: white;
border-radius: 8px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
width: 80%;
max-width: 600px;
max-height: 80vh;
overflow: hidden;
display: flex;
flex-direction: column;
}
.team-analysis-modal .modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
border-bottom: 1px solid #e2e8f0;
}
.team-analysis-modal .modal-header h3 {
margin: 0;
color: #1a202c;
font-size: 1.25rem;
}
.team-analysis-modal .close-btn {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: #718096;
padding: 0;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
}
.team-analysis-modal .close-btn:hover {
color: #1a202c;
}
.team-analysis-modal .modal-body {
padding: 1rem;
overflow-y: auto;
flex: 1;
}
.team-analysis-modal .modal-body p {
margin: 0;
color: #4a5568;
line-height: 1.5;
}
// 路由导航顶栏样式 // 路由导航顶栏样式
.route-header { .route-header {
display: flex; display: flex;
@@ -1649,6 +1781,22 @@ const hideTooltip = () => {
border-radius: 0.5rem; border-radius: 0.5rem;
border: 1px solid rgba(0, 0, 0, 0.1); border: 1px solid rgba(0, 0, 0, 0.1);
} }
/* 意见反馈按钮样式 */
.feedback-btn {
background-color: #4299e1;
color: white;
border: none;
border-radius: 6px;
padding: 0.5rem 1rem;
font-size: 0.9rem;
cursor: pointer;
transition: background-color 0.2s;
&:hover {
background-color: #3182ce;
}
}
} }
.stage-control { .stage-control {
margin-left: 20px; margin-left: 20px;

View File

@@ -24,10 +24,10 @@
团队总业绩 团队总业绩
<span class="info-icon" @mouseenter="showTooltip($event, 'teamPerformance')" @mouseleave="hideTooltip"></span> <span class="info-icon" @mouseenter="showTooltip($event, 'teamPerformance')" @mouseleave="hideTooltip"></span>
</span> </span>
<span class="card-trend positive">{{ totalPerformance.team_current_vs_previous_deals }} vs 上期</span> <span class="card-trend positive">{{ totalPerformance.team_current_vs_previous_period_deals_comparison }} vs 上期</span>
</div> </div>
<div class="card-value">{{ totalPerformance.current_team_odd_numbers||0 }}</div> <div class="card-value">{{ totalPerformance.current_team_odd_numbers||0 }}</div>
<div class="card-subtitle">月目标完成率: {{ totalPerformance.team_monthly_performance }}</div> <div class="card-subtitle">月目标完成率: {{ totalPerformance.team_monthly_target_completion_rate }}</div>
</div> </div>
<div class="overview-card"> <div class="overview-card">
@@ -48,7 +48,7 @@
团队转化率 团队转化率
<span class="info-icon" @mouseenter="showTooltip($event, 'conversionRate')" @mouseleave="hideTooltip"></span> <span class="info-icon" @mouseenter="showTooltip($event, 'conversionRate')" @mouseleave="hideTooltip"></span>
</span> </span>
<span class="card-trend positive">{{ conversionRate.team_current_vs_previous_deals }} vs 上期</span> <span class="card-trend positive">{{ conversionRate.team_current_vs_previous_conversion_rate }} vs 上期</span>
</div> </div>
<div class="card-value">{{ conversionRate.center_conversion_rate }}</div> <div class="card-value">{{ conversionRate.center_conversion_rate }}</div>
<div class="card-subtitle">团队平均转化率: {{ conversionRate.average_conversion_rate }}</div> <div class="card-subtitle">团队平均转化率: {{ conversionRate.average_conversion_rate }}</div>
@@ -135,7 +135,6 @@ const props = defineProps({
}) })
} }
}) })
console.log(99999,props.overallTeamPerformance)
// 计算属性 // 计算属性
const totalPerformance = computed(() => { const totalPerformance = computed(() => {
return props.overallTeamPerformance.totalPerformance return props.overallTeamPerformance.totalPerformance
@@ -158,7 +157,6 @@ const newCustomers = computed(() => {
}) })
const depositConversions = computed(() => { const depositConversions = computed(() => {
console.log(999991111,props.overallTeamPerformance.depositConversions)
return props.overallTeamPerformance.depositConversions return props.overallTeamPerformance.depositConversions
}) })

View File

@@ -27,18 +27,25 @@ const contextPanelRef = ref(null);
const sentimentChartCanvas = ref(null); const sentimentChartCanvas = ref(null);
const chartInstances = {}; const chartInstances = {};
// 添加组件挂载状态跟踪
const isComponentMounted = ref(true);
// CHARTING // CHARTING
const createOrUpdateChart = (chartId, canvasRef, config) => { const createOrUpdateChart = (chartId, canvasRef, config) => {
if (chartInstances[chartId]) { if (chartInstances[chartId]) {
chartInstances[chartId].destroy(); chartInstances[chartId].destroy();
} }
if (canvasRef.value) { // 确保组件仍然挂载且canvas引用存在
if (isComponentMounted.value && canvasRef.value) {
const ctx = canvasRef.value.getContext('2d'); const ctx = canvasRef.value.getContext('2d');
chartInstances[chartId] = new Chart(ctx, config); chartInstances[chartId] = new Chart(ctx, config);
} }
}; };
const renderSentimentChart = (history) => { const renderSentimentChart = (history) => {
// 确保组件仍然挂载
if (!isComponentMounted.value) return;
if (!sentimentChartCanvas.value) return; if (!sentimentChartCanvas.value) return;
const ctx = sentimentChartCanvas.value.getContext('2d'); const ctx = sentimentChartCanvas.value.getContext('2d');
const gradient = ctx.createLinearGradient(0, 0, 0, 120); const gradient = ctx.createLinearGradient(0, 0, 0, 120);
@@ -126,6 +133,21 @@ watch(() => props.selectedContact, (newContact) => {
}); });
} }
}, { immediate: true }); }, { immediate: true });
// LIFECYCLE HOOKS
onMounted(() => {
isComponentMounted.value = true;
});
onBeforeUnmount(() => {
isComponentMounted.value = false;
// 清理所有图表实例
Object.values(chartInstances).forEach(chart => {
if (chart) {
chart.destroy();
}
});
});
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

File diff suppressed because it is too large Load Diff

View File

@@ -13,16 +13,23 @@
</template> </template>
<script setup> <script setup>
import { ref, reactive, onMounted, onBeforeUnmount } from 'vue' import { ref, reactive, onMounted, onBeforeUnmount, watch, nextTick } from 'vue'
import Chart from 'chart.js/auto' import Chart from 'chart.js/auto'
const props = defineProps({ const props = defineProps({
selectedGroup: { selectedGroup: {
type: Object, type: Object,
default: null default: null
},
teamSalesFunnel: {
type: Object,
default: () => ({})
} }
}) })
// 组件状态跟踪
const isComponentMounted = ref(true)
// Chart.js 实例 // Chart.js 实例
const chartInstances = {} const chartInstances = {}
@@ -35,6 +42,21 @@ const funnelData = reactive({
data: [120, 90, 45, 18, 10], data: [120, 90, 45, 18, 10],
}) })
// 监听teamSalesFunnel变化并更新图表数据
watch(() => props.teamSalesFunnel, (newVal) => {
if (newVal && Object.keys(newVal).length > 0 && isComponentMounted.value) {
// 按照固定顺序提取数据
const order = ['线索', '加微', '到课', '定金', '成交']
funnelData.data = order.map(key => newVal[key] || 0)
// 确保在DOM更新后再更新图表
nextTick(() => {
if (isComponentMounted.value) {
renderPersonalFunnelChart()
}
})
}
}, { deep: true })
// Chart.js: 创建或更新图表 // Chart.js: 创建或更新图表
const createOrUpdateChart = (chartId, canvasRef, config) => { const createOrUpdateChart = (chartId, canvasRef, config) => {
if (chartInstances[chartId]) { if (chartInstances[chartId]) {
@@ -72,10 +94,19 @@ const renderPersonalFunnelChart = () => {
// 生命周期钩子 // 生命周期钩子
onMounted(() => { onMounted(() => {
renderPersonalFunnelChart() isComponentMounted.value = true
// 处理初始传入的teamSalesFunnel数据
if (props.teamSalesFunnel && Object.keys(props.teamSalesFunnel).length > 0) {
const order = ['线索', '加微', '到课', '定金', '成交']
funnelData.data = order.map(key => props.teamSalesFunnel[key] || 0)
}
if (isComponentMounted.value) {
renderPersonalFunnelChart()
}
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {
isComponentMounted.value = false
Object.values(chartInstances).forEach(chart => chart.destroy()) Object.values(chartInstances).forEach(chart => chart.destroy())
}) })
</script> </script>
@@ -120,7 +151,7 @@ $white: #ffffff;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: 0px 20px 16px; padding: 0px 20px 10px;
border-bottom: 1px solid #ebeef5; border-bottom: 1px solid #ebeef5;
h3 { h3 {
margin: 0; margin: 0;

View File

@@ -0,0 +1,445 @@
<template>
<div class="performance-comparison">
<div class="comparison-header">
<h2>业绩周期对比</h2>
<div class="period-selector-wrapper">
<label for="period-select">对比周期</label>
<select id="period-select" v-model="selectedPeriod" @change="fetchComparisonData" class="period-select">
<option value="last_week">与上周对比</option>
<option value="last_month">与上月对比</option>
<option value="last_quarter">与上季度对比</option>
</select>
</div>
</div>
<div v-if="isLoading" class="loading-state">
<div class="loading-spinner"></div>
<p>正在加载对比数据...</p>
</div>
<div v-else-if="!previousPeriodData" class="empty-state">
<p>暂无对比周期的数据</p>
</div>
<div v-else class="comparison-table-wrapper">
<table class="comparison-table">
<thead>
<tr>
<th class="metric-col">核心指标</th>
<th>本期数据</th>
<th>{{ selectedPeriodLabel }}数据</th>
<th class="change-col">变化情况</th>
</tr>
</thead>
<tbody>
<tr v-for="metric in comparedMetrics" :key="metric.key">
<td class="metric-col">
<span class="metric-label">{{ metric.label }}</span>
</td>
<td>{{ formatValue(metric.current, metric.unit) }}</td>
<td>{{ formatValue(metric.previous, metric.unit) }}</td>
<td class="change-col">
<div class="change-cell">
<span class="change-value" :class="getChangeClass(metric.change.trend)">
<span v-if="metric.change.trend === 'up'" class="trend-icon"></span>
<span v-if="metric.change.trend === 'down'" class="trend-icon"></span>
{{ metric.change.percentage }}
</span>
<span class="change-diff">{{ formatChange(metric.change.diff, metric.unit) }}</span>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch } from 'vue';
// 假设你有一个API服务来获取对比数据
// import { getPerformanceComparisonData } from '@/api/senorManger.js';
// **MODIFIED**: 更新模拟API以返回新的数据结构
const getPerformanceComparisonData = async (params) => {
console.log('模拟API请求 (新数据结构):', params);
return new Promise(resolve => {
setTimeout(() => {
let mockData;
if (params.period === 'last_week') {
mockData = {
"allocated_data_volume": 650,
"wechat_added_volume": 410,
"call_volume_after_classification": { "加微通话": 10, "20分钟通话": 50, "未分类": 150, "无效通话": 100, "促到课": 40 },
"call_avg_duration_after_classification": { "加微通话": 4.5, "20分钟通话": 15.0, "未分类": 1.5, "无效通话": 1.0, "促到课": 2.0 },
"deposit_volume": 40,
"transaction_volume": 52,
"conversion_rate": "8.00%"
};
} else if (params.period === 'last_month') {
mockData = {
"allocated_data_volume": 2800,
"wechat_added_volume": 1800,
"call_volume_after_classification": { "加微通话": 40, "20分钟通话": 220, "未分类": 600, "视频通话": 50, "无效通话": 450, "促到课": 180 },
"call_avg_duration_after_classification": { "加微通话": 5.0, "20分钟通话": 16.0, "未分类": 1.8, "视频通话": 6.0, "无效通话": 1.1, "促到课": 1.5 },
"deposit_volume": 180,
"transaction_volume": 230,
"conversion_rate": "8.21%"
};
} else { // last_quarter
mockData = {
"allocated_data_volume": 8500,
"wechat_added_volume": 5500,
"call_volume_after_classification": { "加微通话": 120, "20分钟通话": 650, "未分类": 1800, "视频通话": 150, "无效通话": 1200, "促到课": 500 },
"call_avg_duration_after_classification": { "加微通话": 4.8, "20分钟通话": 16.5, "未分类": 1.7, "视频通话": 5.5, "无效通话": 1.0, "促到课": 1.8 },
"deposit_volume": 550,
"transaction_volume": 700,
"conversion_rate": "8.24%"
};
}
resolve({ code: 200, data: mockData });
}, 800);
});
};
const props = defineProps({
currentPeriodData: {
type: Object,
required: true,
},
});
const selectedPeriod = ref('last_month');
const previousPeriodData = ref(null);
const isLoading = ref(false);
const periodLabels = {
last_week: '上周',
last_month: '上月',
last_quarter: '上季度',
};
const selectedPeriodLabel = computed(() => periodLabels[selectedPeriod.value]);
const fetchComparisonData = async () => {
isLoading.value = true;
try {
const res = await getPerformanceComparisonData({ period: selectedPeriod.value });
if (res.code === 200) {
previousPeriodData.value = res.data;
} else {
previousPeriodData.value = null;
}
} catch (error) {
console.error('获取对比数据失败:', error);
previousPeriodData.value = null;
} finally {
isLoading.value = false;
}
};
onMounted(() => {
fetchComparisonData();
});
watch(() => props.currentPeriodData, () => {
fetchComparisonData();
}, { deep: true });
// **NEW**: 新增一个工具函数用于处理API返回的复杂数据结构
// 并将其转换为表格渲染所需的扁平化结构
const processRawData = (rawData) => {
if (!rawData) return null;
// 1. 计算通话总量
const totalCalls = Object.values(rawData.call_volume_after_classification || {})
.reduce((sum, count) => sum + count, 0);
// 2. 计算通话总时长 (分钟)
// 逻辑: (分类通话数 * 分类平均时长) 的总和
const callVolumes = rawData.call_volume_after_classification || {};
const avgDurations = rawData.call_avg_duration_after_classification || {};
const totalDurationInMinutes = Object.keys(callVolumes).reduce((sum, key) => {
const volume = callVolumes[key] || 0;
const avgDuration = avgDurations[key] || 0;
return sum + (volume * avgDuration);
}, 0);
// 3. 将转化率字符串 "7.98%" 转为数字 7.98
const conversionRateValue = parseFloat(rawData.conversion_rate) || 0;
// 4. 返回一个与旧版组件兼容的对象结构
return {
assignedLeads: rawData.allocated_data_volume,
wechatAdds: rawData.wechat_added_volume,
calls: totalCalls,
// 组件内部格式化时会除以60因此这里需要乘以60将分钟转为秒
callDuration: totalDurationInMinutes * 60,
deposits: rawData.deposit_volume,
deals: rawData.transaction_volume,
conversionRate: conversionRateValue,
};
};
const comparedMetrics = computed(() => {
// **MODIFIED**: 在计算前,先使用 processRawData 对数据进行处理
const processedCurrentData = processRawData(props.currentPeriodData);
const processedPreviousData = processRawData(previousPeriodData.value);
if (!processedCurrentData || !processedPreviousData) return [];
// 指标配置保持不变,因为数据已经被处理成它期望的格式
const metricsConfig = [
{ key: 'assignedLeads', label: '分配数据量', unit: '个' },
{ key: 'wechatAdds', label: '加微量', unit: '个' },
{ key: 'calls', label: '通话量', unit: '次' },
{ key: 'callDuration', label: '通话总时长', unit: '分钟' },
{ key: 'deposits', label: '定金量', unit: '单' },
{ key: 'deals', label: '成交量', unit: '单' },
{ key: 'conversionRate', label: '转化率', unit: '%' },
];
return metricsConfig.map(metric => {
const current = processedCurrentData[metric.key];
const previous = processedPreviousData[metric.key];
return {
...metric,
current,
previous,
change: calculateChange(current, previous),
};
});
});
const calculateChange = (current, previous) => {
if (previous === null || previous === undefined || isNaN(current) || isNaN(previous)) {
return { diff: 'N/A', percentage: 'N/A', trend: 'neutral' };
}
const diff = current - previous;
let percentage;
if (previous === 0) {
percentage = current > 0 ? '+100.0%' : '0.0%';
} else {
const percentageValue = (diff / previous) * 100;
percentage = `${percentageValue > 0 ? '+' : ''}${percentageValue.toFixed(1)}%`;
}
let trend = 'neutral';
if (diff > 0) trend = 'up';
if (diff < 0) trend = 'down';
return { diff, percentage, trend };
};
const formatValue = (value, unit) => {
if (value === null || value === undefined) return '-';
if (unit === '分钟') {
// 假设传入的value是秒转换为分钟显示
return `${Math.round(value / 60)} 分钟`;
}
if (unit === '%') {
return `${value.toFixed(1)}%`;
}
return value.toLocaleString();
};
const formatChange = (diff, unit) => {
if (typeof diff !== 'number') return '';
const prefix = diff > 0 ? '+' : '-';
const absDiff = Math.abs(diff);
if (unit === '分钟') {
// 假设diff是秒转换为分钟显示
return `${prefix}${Math.round(absDiff / 60)} 分钟`;
}
if (unit === '%') {
return `${prefix}${absDiff.toFixed(1)}%`;
}
return `${prefix}${absDiff.toLocaleString()}`;
};
const getChangeClass = (trend) => {
if (trend === 'up') return 'positive';
if (trend === 'down') return 'negative';
return 'neutral';
};
</script>
<style lang="scss" scoped>
/* 样式部分无需改动,因此省略以保持简洁 */
.performance-comparison {
background: #ffffff;
border-radius: 12px;
padding: 2rem;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
border: 1px solid #e2e8f0;
}
.comparison-header {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 1rem;
border-bottom: 1px solid #f1f5f9;
h2 {
font-size: 1.5rem;
font-weight: 700;
color: #1a202c;
margin: 0;
}
.period-selector-wrapper {
display: flex;
align-items: center;
gap: 0.75rem;
label {
font-size: 0.9rem;
color: #718096;
}
.period-select {
padding: 0.6rem 1rem;
border-radius: 8px;
border: 1px solid #cbd5e0;
background-color: #ffffff;
font-size: 0.9rem;
cursor: pointer;
transition: border-color 0.2s, box-shadow 0.2s;
&:hover {
border-color: #a0aec0;
}
&:focus {
outline: none;
border-color: #4299e1;
box-shadow: 0 0 0 3px rgba(66, 153, 225, 0.5);
}
}
}
}
.comparison-table-wrapper {
overflow-x: auto;
}
.comparison-table {
width: 100%;
border-collapse: collapse;
th, td {
padding: 1rem 1.25rem;
text-align: left;
vertical-align: middle;
}
thead th {
font-size: 0.8rem;
color: #718096;
font-weight: 600;
text-transform: uppercase;
background-color: #f7fafc;
border-bottom: 2px solid #e2e8f0;
}
tbody tr {
transition: background-color 0.15s ease-in-out;
&:nth-child(odd) {
background-color: #fdfdff;
}
&:not(:last-child) {
border-bottom: 1px solid #f1f5f9;
}
&:hover {
background-color: #f0f5ff;
}
}
tbody td {
font-size: 0.95rem;
color: #2d3748;
}
.metric-col {
min-width: 150px;
font-weight: 500;
}
.change-col {
width: 180px;
}
}
.change-cell {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.25rem;
.change-value {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-weight: 600;
padding: 0.25rem 0.6rem;
border-radius: 9999px;
font-size: 0.85rem;
&.positive {
background-color: #ecfdf5;
color: #065f46;
}
&.negative {
background-color: #fff1f2;
color: #9f1239;
}
&.neutral {
background-color: #f8fafc;
color: #475569;
}
.trend-icon {
font-size: 1rem;
}
}
.change-diff {
font-size: 0.8rem;
color: #718096;
margin-left: 0.25rem;
}
}
.loading-state, .empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 4rem;
color: #a0aec0;
font-size: 1rem;
background-color: #f7fafc;
border-radius: 8px;
.loading-spinner {
width: 40px;
height: 40px;
border: 4px solid #e2e8f0;
border-top: 4px solid #4299e1;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 1.5rem;
}
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
</style>

View File

@@ -87,7 +87,7 @@ $white: #ffffff;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 26rem !important; height: 21.5rem !important;
max-height: 26rem; max-height: 26rem;
// flex: 1; // flex: 1;
} }
@@ -96,7 +96,7 @@ $white: #ffffff;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: 20px 20px 16px; padding: 10px 20px 10px;
border-bottom: 1px solid #ebeef5; border-bottom: 1px solid #ebeef5;
h3 { h3 {

View File

@@ -5,35 +5,35 @@
<div class="stat-icon customer-rate"> <div class="stat-icon customer-rate">
<i class="el-icon-chat-dot-round"></i> <i class="el-icon-chat-dot-round"></i>
</div> </div>
<div class="kpi-value">{{ customerCommunicationRate.active_customer_communication_rate||0 }}</div> <div class="kpi-value">{{ (customerCommunicationRate && customerCommunicationRate.active_customer_communication_rate) || 0 }}</div>
<p>活跃客户沟通率 <i class="el-icon-warning info-icon" @mouseenter="showTooltip($event, 'customerCommunicationRate')" @mouseleave="hideTooltip"></i></p> <p>活跃客户沟通率 <i class="el-icon-warning info-icon" @mouseenter="showTooltip($event, 'customerCommunicationRate')" @mouseleave="hideTooltip"></i></p>
</div> </div>
<div class="kpi-item stat-item"> <div class="kpi-item stat-item">
<div class="stat-icon response-time"> <div class="stat-icon response-time">
<i class="el-icon-timer"></i> <i class="el-icon-timer"></i>
</div> </div>
<div class="kpi-value">{{ averageResponseTime.average_answer_time||0 }}<span class="kpi-unit">分钟</span></div> <div class="kpi-value">{{ (averageResponseTime && averageResponseTime.average_answer_time)||0 }}<span class="kpi-unit">分钟</span></div>
<p>平均应答时间 <i class="el-icon-warning info-icon" @mouseenter="showTooltip($event, 'averageResponseTime')" @mouseleave="hideTooltip"></i></p> <p>平均应答时间 <i class="el-icon-warning info-icon" @mouseenter="showTooltip($event, 'averageResponseTime')" @mouseleave="hideTooltip"></i></p>
</div> </div>
<div class="kpi-item stat-item"> <div class="kpi-item stat-item">
<div class="stat-icon timeout-rate"> <div class="stat-icon timeout-rate">
<i class="el-icon-warning"></i> <i class="el-icon-warning"></i>
</div> </div>
<div class="kpi-value">{{ timeoutResponseRate.timeout_rate||0 }}</div> <div class="kpi-value">{{ (timeoutResponseRate && timeoutResponseRate.timeout_rate)||0 }}</div>
<p>超时应答率 <i class="el-icon-warning info-icon" @mouseenter="showTooltip($event, 'timeoutResponseRate')" @mouseleave="hideTooltip"></i></p> <p>超时应答率 <i class="el-icon-warning info-icon" @mouseenter="showTooltip($event, 'timeoutResponseRate')" @mouseleave="hideTooltip"></i></p>
</div> </div>
<div class="kpi-item stat-item"> <div class="kpi-item stat-item">
<div class="stat-icon severe-timeout-rate"> <div class="stat-icon severe-timeout-rate">
<i class="el-icon-warning-outline"></i> <i class="el-icon-warning-outline"></i>
</div> </div>
<div class="kpi-value">{{ timeoutResponseRate.serious_timeout_rate||0 }}</div> <div class="kpi-value">{{ (timeoutResponseRate && timeoutResponseRate.serious_timeout_rate)||0 }}</div>
<p>严重超时应答率 <i class="el-icon-warning info-icon" @mouseenter="showTooltip($event, 'severeTimeoutRate')" @mouseleave="hideTooltip"></i></p> <p>严重超时应答率 <i class="el-icon-warning info-icon" @mouseenter="showTooltip($event, 'severeTimeoutRate')" @mouseleave="hideTooltip"></i></p>
</div> </div>
<div class="kpi-item stat-item"> <div class="kpi-item stat-item">
<div class="stat-icon form-rate"> <div class="stat-icon form-rate">
<i class="el-icon-document"></i> <i class="el-icon-document"></i>
</div> </div>
<div class="kpi-value">{{ formCompletionRate.table_filling_rate||0 }}</div> <div class="kpi-value">{{ (formCompletionRate && formCompletionRate.table_filling_rate)||0 }}</div>
<p>表格填写率 <i class="el-icon-warning info-icon" @mouseenter="showTooltip($event, 'formCompletionRate')" @mouseleave="hideTooltip"></i></p> <p>表格填写率 <i class="el-icon-warning info-icon" @mouseenter="showTooltip($event, 'formCompletionRate')" @mouseleave="hideTooltip"></i></p>
</div> </div>
</div> </div>
@@ -53,24 +53,24 @@ import Tooltip from '@/components/Tooltip.vue';
defineProps({ defineProps({
customerCommunicationRate: { customerCommunicationRate: {
type: Number, type: Object,
default: 0 default: () => ({})
}, },
averageResponseTime: { averageResponseTime: {
type: Number, type: Object,
default: 0 default: () => ({})
}, },
timeoutResponseRate: { timeoutResponseRate: {
type: Number, type: Object,
default: 0 default: () => ({})
}, },
severeTimeoutRate: { severeTimeoutRate: {
type: Number, type: Object,
default: 0 default: () => ({})
}, },
formCompletionRate: { formCompletionRate: {
type: Number, type: Object,
default: 0 default: () => ({})
} }
}); });

View File

@@ -27,12 +27,42 @@
<div v-if="!isRouteNavigation"> <div v-if="!isRouteNavigation">
<!-- 用户下拉菜单 --> <!-- 用户下拉菜单 -->
<UserDropdown <div style="display: flex; align-items: center; gap: 20px;">
class="header-ringht" <button @click="showDepartmentAnalysisModal" class="feedback-btn">部门分析</button>
style="margin-left: auto;" <button @click="showFeedbackFormModal" class="feedback-btn">意见反馈</button>
:card-visibility="cardVisibility" <FeedbackForm
@update-card-visibility="updateCardVisibility" :is-visible="showFeedbackForm"
/> @close="closeFeedbackFormModal"
@submit-feedback="closeFeedbackFormModal"
/>
<!-- 部门分析弹窗 -->
<div v-if="showDepartmentAnalysis" class="department-analysis-modal" @click.self="closeDepartmentAnalysisModal">
<div class="modal-content">
<div class="modal-header">
<h3>部门分析</h3>
<button class="close-btn" @click="closeDepartmentAnalysisModal">×</button>
</div>
<div class="modal-body">
<div v-if="departmentAnalysisData && departmentAnalysisData.length > 0">
<div v-for="(report, index) in departmentAnalysisData" :key="index" class="report-item">
<h4>报告时间: {{ report.start_time }} {{ report.end_time }}</h4>
<div v-if="report.report && report.report !== 'None' && report.report.trim() !== ''" class="report-content" v-html="formatReportContent(report.report)"></div>
<div v-else class="no-report">
<p>暂无分析报告</p>
</div>
</div>
</div>
<div v-else>
<p>暂无部门分析数据</p>
</div>
</div>
</div>
</div>
<UserDropdown
:card-visibility="cardVisibility"
@update-card-visibility="updateCardVisibility"
/>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -47,7 +77,12 @@
@update-check-type="updateCheckType" @update-check-type="updateCheckType"
/> />
<div v-if="cardVisibility.teamAlerts" class="action-items-compact"> <div v-if="cardVisibility.teamAlerts" class="action-items-compact">
<TeamAlerts style="height: 300px;" :abnormalData="teamAlerts" /> <!-- <TeamAlerts style="height: 300px;" :abnormalData="teamAlerts" /> -->
<div v-if="cardVisibility.problemRanking" class="problem-ranking">
<!-- 客户迫切解决的问题 -->
<ProblemRanking :problemRanking="problemRanking" />
</div>
</div> </div>
</div> </div>
<StatisticalIndicators <StatisticalIndicators
@@ -58,20 +93,22 @@
:severeTimeoutRate="statisticalIndicators.severeTimeoutRate" :severeTimeoutRate="statisticalIndicators.severeTimeoutRate"
:formCompletionRate="statisticalIndicators.formCompletionRate" :formCompletionRate="statisticalIndicators.formCompletionRate"
/> />
<!-- 新增业绩周期对比组件 -->
<div v-if="false" class="performance-comparison-section">
<PerformanceComparison :current-period-data="currentPeriodMetrics" />
</div>
<!-- Bottom Section --> <!-- Bottom Section -->
<div class="bottom-section"> <div class="bottom-section">
<!-- Left Section - Group Performance Ranking --> <!-- Left Section - Group Performance Ranking -->
<div v-if="cardVisibility.groupRanking" class="left-section"> <div v-if="cardVisibility.groupRanking" class="left-section">
<GroupRanking <GroupRanking
:groups="groups" :groups="groups"
:teamSalesFunnel="teamSalesFunnel"
:selected-group="selectedGroup" :selected-group="selectedGroup"
@select-group="selectGroup" @select-group="selectGroup"
/> />
</div> </div>
<div v-if="cardVisibility.problemRanking" class="problem-ranking"> <GoodMusic style="height: 300px;" :qualityCalls="excellentRecord" />
<!-- 客户迫切解决的问题 -->
<ProblemRanking :problemRanking="problemRanking" />
</div>
<!-- Right Section - Group Comparison --> <!-- Right Section - Group Comparison -->
<div v-if="cardVisibility.groupComparison" class="right-section"> <div v-if="cardVisibility.groupComparison" class="right-section">
<GroupComparison <GroupComparison
@@ -82,6 +119,9 @@
/> />
</div> </div>
</div> </div>
<!-- Team Members Detail Section --> <!-- Team Members Detail Section -->
<div class="team-detail-section" v-if="selectedGroup && cardVisibility.teamDetail"> <div class="team-detail-section" v-if="selectedGroup && cardVisibility.teamDetail">
<!-- 团队详情加载状态 --> <!-- 团队详情加载状态 -->
@@ -92,7 +132,8 @@
<!-- 团队详情内容 --> <!-- 团队详情内容 -->
<div v-else> <div v-else>
<div class="team-detail-header"> <div class="team-detail-header">
<h2>{{ selectedGroup.name }} - 团队成员详情</h2> <div>
<h2>{{ selectedGroup.name }} - 团队成员详情</h2>
<div class="team-summary"> <div class="team-summary">
<div class="summary-item"> <div class="summary-item">
<span class="label">组长:</span> <span class="label">组长:</span>
@@ -111,6 +152,35 @@
<span class="value">{{ selectedGroup.conversionRate }}%</span> <span class="value">{{ selectedGroup.conversionRate }}%</span>
</div> </div>
</div> </div>
</div>
<div class="group-performance">
<button @click="showTeamAnalysisModal">团队整体分析</button>
</div>
</div>
<!-- 团队分析弹窗 -->
<div v-if="showTeamAnalysis" class="team-analysis-modal" @click.self="closeTeamAnalysisModal">
<div class="modal-content">
<div class="modal-header">
<h3>团队整体分析</h3>
<button class="close-btn" @click="closeTeamAnalysisModal">×</button>
</div>
<div class="modal-body">
<div v-if="teamAnalysisData && teamAnalysisData.length > 0">
<div v-for="(report, index) in teamAnalysisData" :key="index" class="report-item">
<h4>报告时间: {{ report.start_time }} {{ report.end_time }}</h4>
<div v-if="report.report && report.report !== 'None' && report.report.trim() !== ''" class="report-content" v-html="formatReportContent(report.report)"></div>
<div v-else class="no-report">
<p>暂无分析报告</p>
</div>
</div>
</div>
<div v-else>
<p>暂无团队分析数据</p>
</div>
</div>
</div>
</div> </div>
<div class="members-grid"> <div class="members-grid">
@@ -181,23 +251,26 @@
</template> </template>
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { ref, onMounted, computed, reactive } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { computed, reactive } from 'vue'
import Tooltip from '@/components/Tooltip.vue' import Tooltip from '@/components/Tooltip.vue'
import CenterOverview from './components/CenterOverview.vue' import CenterOverview from './components/CenterOverview.vue'
import GroupComparison from './components/GroupComparison.vue' import GroupComparison from './components/GroupComparison.vue'
import GroupRanking from './components/GroupRanking.vue' import GroupRanking from './components/GroupRanking.vue'
import GoodMusic from './components/GoodMusic.vue'
import TeamAlerts from '../maneger/components/TeamAlerts.vue' import TeamAlerts from '../maneger/components/TeamAlerts.vue'
import ProblemRanking from './components/ProblemRanking.vue' import ProblemRanking from './components/ProblemRanking.vue'
import StatisticalIndicators from './components/StatisticalIndicators.vue' import StatisticalIndicators from './components/StatisticalIndicators.vue'
import UserDropdown from '@/components/UserDropdown.vue' import UserDropdown from '@/components/UserDropdown.vue'
import Loading from '@/components/Loading.vue' import Loading from '@/components/Loading.vue'
import PerformanceComparison from './components/PerformanceComparison.vue'; // 1. 导入新组件
import { getOverallTeamPerformance,getTotalGroupCount,getConversionRate,getTotalCallCount, import { getOverallTeamPerformance,getTotalGroupCount,getConversionRate,getTotalCallCount,
getNewCustomer,getDepositConversionRate,getActiveCustomerCommunicationRate,getAverageAnswerTime, getNewCustomer,getDepositConversionRate,getActiveCustomerCommunicationRate,getAverageAnswerTime,
getTimeoutRate,getTableFillingRate,getUrgentNeedToAddress,getTeamRanking,getTeamRankingInfo,getAbnormalResponseRate } from '@/api/senorManger.js' getTimeoutRate,getTableFillingRate,getUrgentNeedToAddress,getTeamRanking,getTeamRankingInfo,
getAbnormalResponseRate,getTeamSalesFunnel,getExcellentRecordFile,getTeamEveryGroupReport,
getTeamEntiretyReport } from '@/api/senorManger.js'
import { useUserStore } from '@/stores/user.js' import { useUserStore } from '@/stores/user.js'
import FeedbackForm from "@/components/FeedbackForm.vue";
// 缓存系统 // 缓存系统
const cache = new Map() const cache = new Map()
@@ -233,7 +306,6 @@ const withCache = async (functionName, apiCall, params = {}) => {
return cachedData return cachedData
} }
console.log(`调用API获取数据: ${functionName}`, params)
const result = await apiCall() const result = await apiCall()
setCache(cacheKey, result) setCache(cacheKey, result)
return result return result
@@ -245,6 +317,25 @@ const clearCache = () => {
console.log('所有缓存已清除') console.log('所有缓存已清除')
} }
// 优秀录音
const excellentRecord = ref([]);
async function CenterExcellentRecord() {
const params={
user_level:userStore.userInfo.user_level.toString(),
user_name:userStore.userInfo.username
}
try {
const cacheKey = getCacheKey('CenterExcellentRecord', params);
const result = await withCache(cacheKey, async () => {
const res = await getExcellentRecordFile(params);
return res.data;
});
excellentRecord.value = result;
} catch (error) {
console.error("获取优秀录音失败:", error);
}
}
const clearSpecificCache = (functionName, params = {}) => { const clearSpecificCache = (functionName, params = {}) => {
const cacheKey = getCacheKey(functionName, params) const cacheKey = getCacheKey(functionName, params)
cache.delete(cacheKey) cache.delete(cacheKey)
@@ -280,19 +371,19 @@ const forceRefreshAllData = async () => {
try { try {
isLoading.value = true isLoading.value = true
await fetchOverallTeamPerformance() fetchOverallTeamPerformance()
await fetchActiveGroups() fetchActiveGroups()
await fetchConversionRate() fetchConversionRate()
await fetchTotalCallCount() fetchTotalCallCount()
await fetchNewCustomers() fetchNewCustomers()
await fetchDepositConversions() fetchDepositConversions()
await fetchAbnormalResponseRate() fetchAbnormalResponseRate()
await fetchCustomerCommunicationRate() fetchCustomerCommunicationRate()
await fetchAverageResponseTime() fetchAverageResponseTime()
await fetchTimeoutRate() fetchTimeoutRate()
await fetchTableFillingRate() fetchTableFillingRate()
await fetchUrgentNeedToAddress() fetchUrgentNeedToAddress()
await fetchTeamRanking() fetchTeamRanking()
console.log('所有数据已强制刷新完成') console.log('所有数据已强制刷新完成')
} catch (error) { } catch (error) {
console.error('强制刷新数据失败:', error) console.error('强制刷新数据失败:', error)
@@ -307,6 +398,16 @@ const timeoutResponseRate = ref(5)
const severeTimeoutRate = ref(2) const severeTimeoutRate = ref(2)
const formCompletionRate = ref(90) const formCompletionRate = ref(90)
const CheckType = ref('month') const CheckType = ref('month')
// FeedbackForm 控制变量
const showFeedbackForm = ref(false)
// 部门分析弹窗控制变量
const showDepartmentAnalysis = ref(false)
// 团队分析弹窗控制变量
const showTeamAnalysis = ref(false)
// 团队分析数据
const teamAnalysisData = ref([])
// 部门分析数据
const departmentAnalysisData = ref([])
// 更新CheckType的方法 // 更新CheckType的方法
const updateCheckType = async (newValue) => { const updateCheckType = async (newValue) => {
@@ -318,6 +419,99 @@ const updateCheckType = async (newValue) => {
console.log('数据已根据新的统计模式重新加载') console.log('数据已根据新的统计模式重新加载')
} }
// FeedbackForm 控制方法
const showFeedbackFormModal = () => {
showFeedbackForm.value = true
}
const closeFeedbackFormModal = () => {
showFeedbackForm.value = false
}
// 部门分析弹窗控制方法
const showDepartmentAnalysisModal = async () => {
showDepartmentAnalysis.value = true
// 获取部门分析数据
try {
// 获取当前登录的高级经理信息
const currentUser = userStore.userInfo;
const params = {
user_name: currentUser.username,
user_level: currentUser.user_level.toString(),
part_count: 1 // 默认获取最近1份报告
}
const response = await getTeamEntiretyReport(params)
// 根据API响应结构调整数据处理逻辑
if (response.data) {
if (Array.isArray(response.data)) {
// 如果response.data本身就是数组
departmentAnalysisData.value = response.data
} else if (response.data.data && Array.isArray(response.data.data)) {
// 如果response.data.data是数组
departmentAnalysisData.value = response.data.data
} else {
// 其他情况,可能是单个对象
departmentAnalysisData.value = [response.data]
}
}
} catch (error) {
console.error('获取部门分析数据失败:', error)
departmentAnalysisData.value = []
}
}
const closeDepartmentAnalysisModal = () => {
showDepartmentAnalysis.value = false
}
// 团队分析弹窗控制方法
const showTeamAnalysisModal = async () => {
showTeamAnalysis.value = true
// 获取团队分析数据
try {
const params = {
department_name: selectedGroup.value.name + '-' + selectedGroup.value.leader,
part_count: 1 // 默认获取最近1份报告
}
const response = await getTeamEveryGroupReport(params)
// 根据API响应结构调整数据处理逻辑
if (response.data) {
if (Array.isArray(response.data)) {
// 如果response.data本身就是数组
teamAnalysisData.value = response.data
} else if (response.data.data && Array.isArray(response.data.data)) {
// 如果response.data.data是数组
teamAnalysisData.value = response.data.data
} else {
// 其他情况,可能是单个对象
teamAnalysisData.value = [response.data]
}
}
} catch (error) {
console.error('获取团队分析数据失败:', error)
}
}
const closeTeamAnalysisModal = () => {
showTeamAnalysis.value = false
}
// 格式化报告内容
const formatReportContent = (content) => {
if (!content) return ''
// 将Markdown格式的标题转换为HTML标签
return content
.replace(/### (.*?)(?=\n|$)/g, '<h3>$1</h3>')
.replace(/## (.*?)(?=\n|$)/g, '<h2>$1</h2>')
.replace(/# (.*?)(?=\n|$)/g, '<h1>$1</h1>')
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.*?)\*/g, '<em>$1</em>')
.replace(/\n\n/g, '</p><p>')
.replace(/\n/g, '<br>')
}
// 卡片显示状态 // 卡片显示状态
const cardVisibility = ref({ const cardVisibility = ref({
centerOverview: true, centerOverview: true,
@@ -326,7 +520,8 @@ const cardVisibility = ref({
groupRanking: true, groupRanking: true,
problemRanking: true, problemRanking: true,
groupComparison: true, groupComparison: true,
teamDetail: true teamDetail: true,
performanceComparison: true, // 2. 新增组件的可见性控制
}) })
// 更新卡片显示状态 // 更新卡片显示状态
@@ -421,7 +616,6 @@ async function fetchActiveGroups() {
requestParams requestParams
) )
overallTeamPerformance.value.activeGroups = response.data overallTeamPerformance.value.activeGroups = response.data
console.log('活跃组数:', response.data)
} catch (error) { } catch (error) {
console.error('获取活跃组数失败:', error) console.error('获取活跃组数失败:', error)
} }
@@ -502,20 +696,34 @@ async function fetchDepositConversions() {
requestParams requestParams
) )
overallTeamPerformance.value.depositConversions = response.data overallTeamPerformance.value.depositConversions = response.data
console.log(99888999,response.data)
} catch (error) { } catch (error) {
console.error('获取定金转化失败:', error) console.error('获取定金转化失败:', error)
} }
} }
const statisticalIndicators = ref({ const statisticalIndicators = ref({
customerCommunicationRate: 0, customerCommunicationRate: {},
averageResponseTime: 0, averageResponseTime: {},
timeoutResponseRate: 0, timeoutResponseRate: {},
severeTimeoutRate: 0, severeTimeoutRate: {},
formCompletionRate: 0, formCompletionRate: {},
}) })
// 销售漏斗
const teamSalesFunnel = ref({})
async function GetTeamSalesFunnel() {
const params = getRequestParams()
const hasParams = params.user_name
const requestParams = hasParams ? {
...params,
check_type: CheckType.value
} : {check_type: CheckType.value}
const res = await getTeamSalesFunnel(requestParams)
if (res.code === 200) {
teamSalesFunnel.value = res.data
}
}
// 团队异常 // 团队异常
const teamAlerts = ref({}) const teamAlerts = ref({})
// 异常预警 // 异常预警
@@ -530,33 +738,30 @@ async function fetchAbnormalResponseRate() {
() => getAbnormalResponseRate(hasParams ? params : undefined), () => getAbnormalResponseRate(hasParams ? params : undefined),
requestParams requestParams
) )
const rawData = response.data const rawData = response.data || {} // 添加默认值防止null访问
// 转换数据格式,按团队分组生成预警消息
const processedAlerts = [] const processedAlerts = []
const teamData = new Map() const teamData = new Map()
// 收集严重超时异常数据 // 添加安全检查防止访问null属性
if (rawData.team_serious_timeout_abnormal_counts_by_group) { if (rawData.team_serious_timeout_abnormal_counts_by_group) {
Object.entries(rawData.team_serious_timeout_abnormal_counts_by_group).forEach(([teamName, data]) => { Object.entries(rawData.team_serious_timeout_abnormal_counts_by_group).forEach(([teamName, data]) => {
if (!teamData.has(teamName)) { if (!teamData.has(teamName)) {
teamData.set(teamName, { timeoutCount: 0, fillingCount: 0 }) teamData.set(teamName, { timeoutCount: 0, fillingCount: 0 })
} }
teamData.get(teamName).timeoutCount = data.count teamData.get(teamName).timeoutCount = data.count || 0
}) })
} }
// 收集表格填写异常数据
if (rawData.team_table_filling_abnormal_counts_by_group) { if (rawData.team_table_filling_abnormal_counts_by_group) {
Object.entries(rawData.team_table_filling_abnormal_counts_by_group).forEach(([teamName, data]) => { Object.entries(rawData.team_table_filling_abnormal_counts_by_group).forEach(([teamName, data]) => {
if (!teamData.has(teamName)) { if (!teamData.has(teamName)) {
teamData.set(teamName, { timeoutCount: 0, fillingCount: 0 }) teamData.set(teamName, { timeoutCount: 0, fillingCount: 0 })
} }
teamData.get(teamName).fillingCount = data.count teamData.get(teamName).fillingCount = data.count || 0
}) })
} }
// 生成按团队分组的预警消息
let alertId = 1 let alertId = 1
teamData.forEach((counts, teamName) => { teamData.forEach((counts, teamName) => {
const messages = [] const messages = []
@@ -577,7 +782,6 @@ async function fetchAbnormalResponseRate() {
} }
}) })
// 设置处理后的数据
teamAlerts.value = { processedAlerts } teamAlerts.value = { processedAlerts }
} catch (error) { } catch (error) {
@@ -596,9 +800,12 @@ async function fetchCustomerCommunicationRate() {
() => getActiveCustomerCommunicationRate(hasParams ? params : undefined), () => getActiveCustomerCommunicationRate(hasParams ? params : undefined),
requestParams requestParams
) )
statisticalIndicators.value.customerCommunicationRate = response.data // 确保响应数据不为null
statisticalIndicators.value.customerCommunicationRate = response.data || {}
} catch (error) { } catch (error) {
console.error('获取活跃客户沟通率失败:', error) console.error('获取活跃客户沟通率失败:', error)
// 出错时设置为空对象
statisticalIndicators.value.customerCommunicationRate = {}
} }
} }
// 统计指标--平均应答时间 // 统计指标--平均应答时间
@@ -613,9 +820,12 @@ async function fetchAverageResponseTime() {
() => getAverageAnswerTime(hasParams ? params : undefined), () => getAverageAnswerTime(hasParams ? params : undefined),
requestParams requestParams
) )
statisticalIndicators.value.averageResponseTime = response.data // 确保响应数据不为null
statisticalIndicators.value.averageResponseTime = response.data || {}
} catch (error) { } catch (error) {
console.error('获取平均应答时间失败:', error) console.error('获取平均应答时间失败:', error)
// 出错时设置为空对象
statisticalIndicators.value.averageResponseTime = {}
} }
} }
// 统计指标--超时应答率、严重超时应答率 // 统计指标--超时应答率、严重超时应答率
@@ -630,9 +840,15 @@ async function fetchTimeoutRate() {
() => getTimeoutRate(hasParams ? params : undefined), () => getTimeoutRate(hasParams ? params : undefined),
requestParams requestParams
) )
statisticalIndicators.value.timeoutResponseRate = response.data // 确保响应数据不为null
statisticalIndicators.value.timeoutResponseRate = response.data || {}
// severeTimeoutRate使用相同的数据源
statisticalIndicators.value.severeTimeoutRate = response.data || {}
} catch (error) { } catch (error) {
console.error('获取超时应答率失败:', error) console.error('获取超时应答率失败:', error)
// 出错时设置为空对象
statisticalIndicators.value.timeoutResponseRate = {}
statisticalIndicators.value.severeTimeoutRate = {}
} }
} }
// 统计指标--表格填写率 // 统计指标--表格填写率
@@ -647,9 +863,12 @@ async function fetchTableFillingRate() {
() => getTableFillingRate(hasParams ? params : undefined), () => getTableFillingRate(hasParams ? params : undefined),
requestParams requestParams
) )
statisticalIndicators.value.formCompletionRate = response.data // 确保响应数据不为null
statisticalIndicators.value.formCompletionRate = response.data || {}
} catch (error) { } catch (error) {
console.error('获取表格填写率失败:', error) console.error('获取表格填写率失败:', error)
// 出错时设置为空对象
statisticalIndicators.value.formCompletionRate = {}
} }
} }
const problemRanking = ref({}) const problemRanking = ref({})
@@ -724,6 +943,7 @@ onMounted(async ()=>{
await fetchOverallTeamPerformance() await fetchOverallTeamPerformance()
await fetchActiveGroups() await fetchActiveGroups()
await fetchConversionRate() await fetchConversionRate()
await GetTeamSalesFunnel()
await fetchTotalCallCount() await fetchTotalCallCount()
await fetchNewCustomers() await fetchNewCustomers()
await fetchDepositConversions() await fetchDepositConversions()
@@ -734,11 +954,10 @@ onMounted(async ()=>{
await fetchTableFillingRate() await fetchTableFillingRate()
await fetchUrgentNeedToAddress() await fetchUrgentNeedToAddress()
await fetchTeamRanking() await fetchTeamRanking()
await CenterExcellentRecord()
// 输出缓存信息
console.log('缓存状态:', getCacheInfo()) console.log('缓存状态:', getCacheInfo())
// 开发环境下暴露缓存管理函数到全局
if (import.meta.env.DEV) { if (import.meta.env.DEV) {
window.seniorManagerCache = { window.seniorManagerCache = {
clearCache, clearCache,
@@ -756,6 +975,19 @@ onMounted(async ()=>{
} }
}) })
// 3. 新增计算属性,为新组件聚合本期数据
const currentPeriodMetrics = computed(() => {
return {
assignedLeads: overallTeamPerformance.value.newCustomers?.count_this_period || 0,
wechatAdds: teamSalesFunnel.value?.['加微'] || 0,
calls: overallTeamPerformance.value.totalCalls?.count_this_period || 0,
callDuration: 12580, // 假设数据来自API这里使用模拟值
deposits: teamSalesFunnel.value?.['定金'] || 0,
deals: teamSalesFunnel.value?.['成交'] || 0,
conversionRate: parseFloat(overallTeamPerformance.value.conversionRate?.conversion_rate_this_period) || 0,
};
});
// 组别数据 // 组别数据
const groups=[] const groups=[]
// 当前选中的组别,默认为第一个 // 当前选中的组别,默认为第一个
@@ -765,14 +997,10 @@ const selectedGroup = ref(groups[0])
const selectGroup = async (group) => { const selectGroup = async (group) => {
console.log('选择的组别:', group) console.log('选择的组别:', group)
selectedGroup.value = group selectedGroup.value = group
// 获取部门名称并调用团队业绩详情接口
// 从teamRanking数据中查找对应的原始部门名称
let department = group.name let department = group.name
if (teamRanking.value && teamRanking.value.formal_plural) { if (teamRanking.value && teamRanking.value.formal_plural) {
// 在formal_plural中查找匹配的部门名称
const departmentKeys = Object.keys(teamRanking.value.formal_plural) const departmentKeys = Object.keys(teamRanking.value.formal_plural)
const matchedDepartment = departmentKeys.find(key => { const matchedDepartment = departmentKeys.find(key => {
// 提取部门名称的主要部分进行匹配
const mainName = key.split('-')[0] || key const mainName = key.split('-')[0] || key
return group.name.includes(mainName) || mainName.includes(group.name) return group.name.includes(mainName) || mainName.includes(group.name)
}) })
@@ -782,7 +1010,6 @@ const selectGroup = async (group) => {
} }
console.log('选中的部门:', group.name, '-> 发送的部门名称:', department) console.log('选中的部门:', group.name, '-> 发送的部门名称:', department)
// 设置团队详情加载状态
isTeamDetailLoading.value = true isTeamDetailLoading.value = true
try { try {
await fetchTeamPerformanceDetail(department) await fetchTeamPerformanceDetail(department)
@@ -796,8 +1023,6 @@ const selectGroup = async (group) => {
// 处理团队双击事件 // 处理团队双击事件
const handleTeamDoubleClick = (group) => { const handleTeamDoubleClick = (group) => {
console.log('团队双击事件触发,团队数据:', group) console.log('团队双击事件触发,团队数据:', group)
// 跳转到manager页面携带团队负责人和用户等级
router.push({ router.push({
path: '/manager', path: '/manager',
query: { query: {
@@ -810,12 +1035,9 @@ const handleTeamDoubleClick = (group) => {
// 处理成员双击事件 // 处理成员双击事件
const handleMemberDoubleClick = (member) => { const handleMemberDoubleClick = (member) => {
console.log('双击事件触发,成员数据:', member) console.log('双击事件触发,成员数据:', member)
// 将成员等级写死为1所有成员都可以跳转
const memberLevel = 1 const memberLevel = 1
console.log('等级设置为1准备跳转到Sale页面') console.log('等级设置为1准备跳转到Sale页面')
// 跳转到Sale页面携带成员姓名和等级
router.push({ router.push({
name: 'Sale', name: 'Sale',
query: { query: {
@@ -850,7 +1072,6 @@ const getStatusText = (status) => {
return statusMap[status] || '未知' return statusMap[status] || '未知'
} }
// 工具提示状态 // 工具提示状态
const tooltip = reactive({ const tooltip = reactive({
visible: false, visible: false,
@@ -960,7 +1181,6 @@ const hideTooltip = () => {
} }
.action-items-compact { .action-items-compact {
overflow: hidden; overflow: hidden;
@@ -1080,12 +1300,19 @@ const hideTooltip = () => {
.team-detail-header { .team-detail-header {
margin-bottom: 2rem; margin-bottom: 2rem;
display: flex;
justify-content: space-between;
align-items: flex-start;
flex-wrap: wrap;
gap: 1rem;
h2 { h2 {
font-size: 1.4rem; font-size: 1.4rem;
font-weight: 600; font-weight: 600;
color: #1e293b; color: #1e293b;
margin: 0 0 1rem 0; margin: 0;
flex: 1;
min-width: 300px;
} }
.team-summary { .team-summary {
@@ -1111,6 +1338,32 @@ const hideTooltip = () => {
} }
} }
} }
.group-performance {
button {
background-color: #3b82f6;
color: white;
border: none;
border-radius: 6px;
padding: 0.5rem 1rem;
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
height: fit-content;
&:hover {
background-color: #2563eb;
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
&:active {
transform: translateY(0);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
}
}
} }
.members-grid { .members-grid {
@@ -1490,4 +1743,215 @@ const hideTooltip = () => {
border: 1px solid rgba(0, 0, 0, 0.1); border: 1px solid rgba(0, 0, 0, 0.1);
} }
} }
// 意见反馈按钮样式
.feedback-btn {
background-color: #4299e1;
color: white;
border: none;
border-radius: 6px;
padding: 0.5rem 1rem;
font-size: 0.9rem;
cursor: pointer;
transition: background-color 0.2s;
}
.feedback-btn:hover {
background-color: #3182ce;
}
/* 部门分析弹窗样式 */
.department-analysis-modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.modal-content {
background-color: white;
border-radius: 8px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
width: 80%;
max-width: 600px;
max-height: 80vh;
overflow: hidden;
display: flex;
flex-direction: column;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
border-bottom: 1px solid #e2e8f0;
}
.modal-header h3 {
margin: 0;
color: #1a202c;
font-size: 1.25rem;
}
.close-btn {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: #718096;
padding: 0;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
}
.close-btn:hover {
color: #1a202c;
}
.modal-body {
padding: 1rem;
overflow-y: auto;
flex: 1;
}
.modal-body p {
margin: 0;
color: #4a5568;
line-height: 1.5;
}
/* 团队分析弹窗样式 */
.team-analysis-modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.team-analysis-modal .modal-content {
background-color: white;
border-radius: 8px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
width: 80%;
max-width: 600px;
max-height: 80vh;
overflow: hidden;
display: flex;
flex-direction: column;
}
.team-analysis-modal .modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
border-bottom: 1px solid #e2e8f0;
}
.team-analysis-modal .modal-header h3 {
margin: 0;
color: #1a202c;
font-size: 1.25rem;
}
.team-analysis-modal .close-btn {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: #718096;
padding: 0;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
}
.team-analysis-modal .close-btn:hover {
color: #1a202c;
}
.team-analysis-modal .modal-body {
padding: 1rem;
overflow-y: auto;
flex: 1;
}
.team-analysis-modal .modal-body p {
margin: 0;
color: #4a5568;
line-height: 1.5;
}
.team-analysis-modal .report-item {
margin-bottom: 20px;
padding: 15px;
border: 1px solid #e2e8f0;
border-radius: 5px;
background-color: #f8fafc;
}
.team-analysis-modal .report-item h4 {
margin-top: 0;
color: #1a202c;
border-bottom: 1px solid #e2e8f0;
padding-bottom: 5px;
}
.team-analysis-modal .report-content {
margin-top: 10px;
color: #4a5568;
line-height: 1.6;
}
.team-analysis-modal .report-content h1,
.team-analysis-modal .report-content h2,
.team-analysis-modal .report-content h3 {
margin-top: 15px;
margin-bottom: 10px;
color: #1a202c;
}
.team-analysis-modal .report-content h1 {
font-size: 1.5rem;
}
.team-analysis-modal .report-content h2 {
font-size: 1.3rem;
}
.team-analysis-modal .report-content h3 {
font-size: 1.1rem;
}
.team-analysis-modal .report-content strong {
font-weight: bold;
}
.team-analysis-modal .report-content em {
font-style: italic;
}
.team-analysis-modal .no-report {
text-align: center;
color: #718096;
font-style: italic;
}
</style> </style>

View File

@@ -18,6 +18,7 @@
:class="{ active: selectedRecording === index }" :class="{ active: selectedRecording === index }"
@click="selectRecording(index)" @click="selectRecording(index)"
> >
<span class="recording-index">{{ recording.score}}</span>
<div class="recording-info"> <div class="recording-info">
<div class="recording-name" :title="recording.name">{{ recording.name.length > 10 ? recording.name.substring(0, 10) + '...' : recording.name }}</div> <div class="recording-name" :title="recording.name">{{ recording.name.length > 10 ? recording.name.substring(0, 10) + '...' : recording.name }}</div>
<div class="recording-meta"> <div class="recording-meta">
@@ -172,8 +173,8 @@ import MarkdownIt from 'markdown-it'
// Props定义 // Props定义
const props = defineProps({ const props = defineProps({
qualityCalls: { qualityCalls: {
type: Object, type: Array,
default: () => ({}) default: () => []
} }
}) })
@@ -221,24 +222,22 @@ const recordings = computed(() => {
if (!props.qualityCalls ) { if (!props.qualityCalls ) {
return staticRecordings.value; return staticRecordings.value;
} }
const recordingsList = []; const recordingsList = [];
Object.keys(props.qualityCalls).forEach(userName => { props.qualityCalls.forEach((record, index) => {
props.qualityCalls[userName].forEach((record, index) => {
recordingsList.push({ recordingsList.push({
id: recordingsList.length + 1, id: recordingsList.length + 1,
name: record.obj_file_name ? record.obj_file_name.split('/').pop() : `${record.sale_name}-录音-${index + 1}`, name: record.record_name ? record.record_name : `${record.sale_name}-录音-${index + 1}`,
date: new Date().toISOString().split('T')[0], date: new Date().toISOString().split('T')[0],
url: record.obj_file_name, url: record.record_file_addr,
transcription: record.context || null, transcription: record.record_context || null,
score: record.score, score: record.record_score,
sop: record.sop, sop: record.record_report,
sale_name: record.sale_name, sale_name: record.record_name,
size: 2048576, // 默认文件大小 2MB size: 2048576, // 默认文件大小 2MB
uploadTime: new Date().toLocaleDateString('zh-CN') uploadTime: record.created_at,
}); });
}); });
});
return recordingsList; return recordingsList;
}) })
@@ -512,7 +511,7 @@ const downloadRecording = (index) => {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: 20px 20px 0; padding: 10px 20px 0;
border-bottom: 1px solid #ebeef5; border-bottom: 1px solid #ebeef5;
} }
@@ -549,7 +548,7 @@ const downloadRecording = (index) => {
} }
.chart-content { .chart-content {
padding: 20px; padding: 10px;
} }
.recording-section { .recording-section {
@@ -562,7 +561,6 @@ const downloadRecording = (index) => {
.recording-list { .recording-list {
margin-bottom: 20px; margin-bottom: 20px;
max-height: 400px; max-height: 400px;
overflow-y: auto;
} }
.recording-item { .recording-item {
@@ -602,6 +600,39 @@ const downloadRecording = (index) => {
display: inline-block; display: inline-block;
} }
.recording-index {
/* 基础分数样式 */
padding: 2px 8px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
background-color: #e9ecef;
color: #495057;
margin-right: 10px;
}
/* 第一名样式 */
.recording-item:first-child .recording-index {
background: linear-gradient(135deg, #FFD700, #FFA500);
color: #fff;
box-shadow: 0 2px 4px rgba(255, 215, 0, 0.3);
}
/* 第二名样式 */
.recording-item:nth-child(2) .recording-index {
background: linear-gradient(135deg, #C0C0C0, #A9A9A9);
color: #fff;
box-shadow: 0 2px 4px rgba(192, 192, 192, 0.3);
}
/* 第三名样式 */
.recording-item:nth-child(3) .recording-index {
background: linear-gradient(135deg, #CD7F32, #A0522D);
color: #fff;
box-shadow: 0 2px 4px rgba(205, 127, 50, 0.3);
}
.recording-meta { .recording-meta {
display: flex; display: flex;
gap: 12px; gap: 12px;

View File

@@ -4,10 +4,18 @@
<div class="dashboard-header"> <div class="dashboard-header">
<h1>管理者数据看板</h1> <h1>管理者数据看板</h1>
<!-- 头像 --> <!-- 头像 -->
<UserDropdown <div style="display: flex; align-items: center; gap: 20px;">
:card-visibility="cardVisibility" <button @click="showFeedbackFormModal" class="feedback-btn">意见反馈</button>
@update-card-visibility="updateCardVisibility" <FeedbackForm
/> :is-visible="showFeedbackForm"
@close="closeFeedbackFormModal"
@submit-feedback="closeFeedbackFormModal"
/>
<UserDropdown
:card-visibility="cardVisibility"
@update-card-visibility="updateCardVisibility"
/>
</div>
</div> </div>
<!-- 第一行核心业绩指标销售实时进度 --> <!-- 第一行核心业绩指标销售实时进度 -->
@@ -111,6 +119,7 @@ import PeriodStage from "./components/PeriodStage.vue";
import { getOverallCompanyPerformance,getCompanyDepositConversionRate,getCompanyTotalCallCount,getCompanyNewCustomer,getCompanyConversionRate,getCompanyRealTimeProgress import { getOverallCompanyPerformance,getCompanyDepositConversionRate,getCompanyTotalCallCount,getCompanyNewCustomer,getCompanyConversionRate,getCompanyRealTimeProgress
,getCompanyConversionRateVsLast,getSalesMonthlyPerformance,getCustomerTypeDistribution,getUrgentNeedToAddress,getLevelTree,getDetailedDataTable,getExcellentRecordFile } from "@/api/top"; ,getCompanyConversionRateVsLast,getSalesMonthlyPerformance,getCustomerTypeDistribution,getUrgentNeedToAddress,getLevelTree,getDetailedDataTable,getExcellentRecordFile } from "@/api/top";
import { useUserStore } from "@/stores/user.js"; import { useUserStore } from "@/stores/user.js";
import FeedbackForm from "@/components/FeedbackForm.vue";
// 缓存系统 // 缓存系统
const cache = new Map(); const cache = new Map();
@@ -236,6 +245,18 @@ const cardVisibility = ref({
detailedDataTable: true detailedDataTable: true
}); });
// FeedbackForm 控制变量
const showFeedbackForm = ref(false);
// FeedbackForm 控制方法
const showFeedbackFormModal = () => {
showFeedbackForm.value = true;
};
const closeFeedbackFormModal = () => {
showFeedbackForm.value = false;
};
// 更新卡片显示状态 // 更新卡片显示状态
const updateCardVisibility = (newVisibility) => { const updateCardVisibility = (newVisibility) => {
Object.assign(cardVisibility.value, newVisibility); Object.assign(cardVisibility.value, newVisibility);
@@ -504,7 +525,6 @@ async function getConversionComparison(data) {
const res = await getCompanyConversionRateVsLast(params); const res = await getCompanyConversionRateVsLast(params);
return res.data; return res.data;
}); });
console.log(111111,result);
conversionComparison.value = result; conversionComparison.value = result;
} catch (error) { } catch (error) {
console.error("获取转化对比失败:", error); console.error("获取转化对比失败:", error);
@@ -644,7 +664,7 @@ const handleFilterChange = (filterParams) => {
getDetailData(filterParams) getDetailData(filterParams)
} }
// 优秀录音 // 优秀录音
const excellentRecord = ref({}); const excellentRecord = ref([]);
async function CenterExcellentRecord() { async function CenterExcellentRecord() {
const params={ const params={
user_level:userStore.userInfo.user_level.toString(), user_level:userStore.userInfo.user_level.toString(),
@@ -654,10 +674,9 @@ const params={
const cacheKey = getCacheKey('CenterExcellentRecord', params); const cacheKey = getCacheKey('CenterExcellentRecord', params);
const result = await withCache(cacheKey, async () => { const result = await withCache(cacheKey, async () => {
const res = await getExcellentRecordFile(params); const res = await getExcellentRecordFile(params);
return res.data.excellent_record_list; return res.data;
}); });
excellentRecord.value = result; excellentRecord.value = result;
console.log(111111,result);
} catch (error) { } catch (error) {
console.error("获取优秀录音失败:", error); console.error("获取优秀录音失败:", error);
} }
@@ -666,15 +685,15 @@ onMounted(async() => {
// 页面初始化逻辑 // 页面初始化逻辑
console.log('页面初始化,开始加载数据...'); console.log('页面初始化,开始加载数据...');
await getRealTimeProgress() getRealTimeProgress()
await getTotalDeals() getTotalDeals()
await getConversionComparison('month') getConversionComparison('month')
await getCompanySalesRank('red') getCompanySalesRank('red')
await getCustomerTypeRatio('child_education') getCustomerTypeRatio('child_education')
await getCustomerUrgency() getCustomerUrgency()
await CusotomGetLevelTree() CusotomGetLevelTree()
await getDetailData() getDetailData()
await CenterExcellentRecord() CenterExcellentRecord()
// 输出缓存状态信息 // 输出缓存状态信息
const cacheInfo = getCacheInfo(); const cacheInfo = getCacheInfo();
@@ -2164,4 +2183,20 @@ button {
-ms-user-select: text; -ms-user-select: text;
user-select: text; user-select: text;
} }
/* 意见反馈按钮样式 */
.feedback-btn {
background-color: #4299e1;
color: white;
border: none;
border-radius: 6px;
padding: 0.5rem 1rem;
font-size: 0.9rem;
cursor: pointer;
transition: background-color 0.2s;
}
.feedback-btn:hover {
background-color: #3182ce;
}
</style> </style>