Compare commits

..

129 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
5a930ac084 fix: 修复优秀录音获取和导出按钮权限问题
- 取消注释CentergetGoodRecord调用以获取优秀录音
- 为问题排行榜导出按钮添加用户等级4的权限控制
- 将排行榜默认周期改为month并移除冗余注释
2025-09-04 12:28:53 +08:00
be3c724a5e refactor(views): 移除排行榜和漏斗图中本期选项的代码
清理不再使用的"本期"选项代码,保持界面选项一致性
2025-09-04 12:21:21 +08:00
f47211b0b0 feat: 实现卡片可见性管理并优化多个组件功能
- 在UserDropdown组件中添加卡片名称映射
- 为sale.vue、seniorManager.vue、topone.vue和secondTop.vue添加卡片可见性控制
- 在CustomerDetail.vue中添加通话数据检查逻辑
- 将https.js中的API基础路径切换为生产环境
2025-09-03 11:41:09 +08:00
e94ea6b592 feat(销售页面): 添加模块显示控制功能及周期分析组件
refactor(StatisticData): 简化指标名称显示
style(UserDropdown): 添加显示设置弹窗样式
2025-09-02 11:18:05 +08:00
328ae8cd55 fix: 修改axios实例的baseURL为本地开发环境地址
将生产环境API地址注释掉,启用本地开发环境地址以便于调试
2025-09-02 10:45:50 +08:00
e9a8605073 fix(sale): 修复客户列表数据处理和统计数据获取逻辑
移除冗余注释并优化客户列表数据处理,防止空值异常
将统计数据的获取移至页面加载时执行,确保数据完整性
2025-09-02 10:27:28 +08:00
d5792be702 feat(销售时间轴): 添加time_and_camp_stage字段支持并优化课程阶段筛选逻辑
- 在客户数据中添加time_and_camp_stage字段用于记录付款行为发生的课程阶段
- 重构课程阶段筛选逻辑,优先使用time_and_camp_stage字段进行精确匹配
- 移除已注释的旧代码
- 更新API基础路径为生产环境
2025-09-01 21:14:52 +08:00
e7f9abcc19 fix: 修复全部阶段客户数量计算逻辑
当customersList为空时,使用props.data中的全部阶段数据作为后备值
2025-09-01 17:32:11 +08:00
7af2ee9e25 fix: 修正待入群和待联系阶段的百分比计算逻辑 2025-09-01 17:14:07 +08:00
c261594a25 fix(ProblemRanking): 优化问卷调查信息导出逻辑
refactor(UserDropdown): 改进退出登录处理流程,增加错误处理

style(SalesTimelineWithTaskList): 统一"点击未支付"显示文本为"点击未付"
2025-09-01 16:40:27 +08:00
fa2754e124 feat(客户数据): 添加一键导出功能并集成xlsx库
- 在ProblemRanking组件中添加导出按钮和功能
- 新增导出API接口并修改axios基础URL
- 添加xlsx依赖用于Excel文件生成
- 实现客户数据展平处理和Excel导出逻辑
2025-09-01 11:36:26 +08:00
c10b514779 feat(销售时间轴): 添加子时间轴阶段选择功能
实现子时间轴各阶段的点击选择功能,将筛选后的客户数据转换为统一格式并传递给父组件
2025-08-30 17:19:53 +08:00
d204c7befe feat(销售时间轴): 添加课程1-4子时间轴并优化健康度显示
添加课程1-4的详细子时间轴组件,展示各课程阶段的转化情况
重构健康度显示逻辑,使用新的CSS类名系统
移除按钮中的SVG图标,调整按钮字体大小
2025-08-30 16:25:07 +08:00
beec8c6cfb refactor(views): 优化客户阶段显示逻辑和业绩单位显示
- 合并课1-4阶段显示,简化客户阶段逻辑
- 修改业绩显示单位为"单"而非货币
- 调整警告提示样式增加底部间距
- 完善组业绩详情请求参数处理
2025-08-30 14:54:50 +08:00
4c06067dd4 feat(Calendar): 添加未来营期显示功能并优化样式
- 新增未来3个营期的预期安排显示功能
- 为未来营期事件添加新的类型和样式
- 优化现有营期事件的样式和显示逻辑
- 修复用户名称动态获取的问题
2025-08-30 14:20:09 +08:00
f87c6b8252 feat(营期管理): 添加取消切换历史营期功能并优化统计模式
- 在API中添加cancelSwitchHistoryCampPeriod方法用于取消切换历史营期
- 将统计模式从'monthly'改为'month'以保持命名一致性
- 优化Calendar组件中历史营期按钮的显示逻辑
- 修复统计模式切换时checkType值的逻辑错误
2025-08-30 12:20:32 +08:00
d6db489f80 fix(calendar): 优化历史营期显示和交互体验
- 移除顶部状态指示器,改为通过事件点样式区分历史营期
- 添加鼠标悬停延迟显示和离开隐藏功能
- 实现历史营期数据映射到日历的功能
- 调整历史营期切换时的日期跳转逻辑
- 优化历史营期事件点的视觉样式
2025-08-29 17:18:44 +08:00
4ad91cabe3 feat(营期管理): 添加历史营期查看与切换功能
- 在API层新增获取历史营期和切换历史营期的接口方法
- 在日历组件中添加历史营期查看界面和状态指示器
- 实现历史营期列表展示、选中切换和返回当前营期功能
- 调整统计模式切换按钮顺序并修正参数传递逻辑
- 添加相关样式优化选中状态和操作按钮的交互效果
2025-08-29 16:04:36 +08:00
d0ad4e56dd feat(营期管理): 添加修改营期API并优化营期设置逻辑
- 新增changeCampPeriod API接口用于修改营期参数
- 重构CenterCampPeriodAdmin方法支持新的参数格式
- 优化saveCampSettings和saveNextCampSettings方法调用逻辑
2025-08-29 15:07:26 +08:00
04b19bc45d feat(Calendar): 添加法定节假日显示功能并优化交互
- 新增节假日API获取及显示功能
- 将点击事件改为鼠标悬停触发
- 添加节假日样式和名称显示
- 优化事件点颜色和样式
- 移除secondTop.vue中不再使用的录音获取方法
2025-08-29 11:59:20 +08:00
2827d70f65 feat(销售时间线): 添加待填表单和待到课阶段的特殊筛选逻辑
为待填表单阶段添加筛选客户职业或子女教育为'未知'的逻辑
为待到课阶段添加筛选没有有效课程数据的客户逻辑
2025-08-28 22:00:04 +08:00
2f380b1fe5 fix(登录): 修复登录流程问题并优化样式
- 在登录前清除本地存储的用户数据
- 允许直接访问登录页面无需重定向
- 调整销售时间线组件的字体大小和间距
2025-08-28 21:37:50 +08:00
21ef158ce4 refactor(https): 切换基础URL并清理请求拦截器注释
移除生产环境URL注释并启用本地开发URL
清理请求拦截器中不必要的注释和空行
2025-08-28 20:21:50 +08:00
0627caf37c fix(Calendar): 添加保存按钮防抖并移除营期开始时间字段
添加isSaving状态防止重复提交保存请求
移除不再需要的campStartDate字段及相关逻辑
保存按钮在提交时显示加载状态
2025-08-28 15:34:51 +08:00
6d22aecc29 Merge branch 'Breach' of https://git.yinlihupo.cn/LiuRui/DJKB into Breach 2025-08-28 15:03:41 +08:00
4eea8f8a8a feat(Calendar): 添加历史营期查看功能并优化排序逻辑
- 在Calendar组件中添加历史营期查看功能,包括弹窗和样式
- 修改secondTop组件中的成员排序逻辑,从按成交单数改为按排名排序
- 启用之前注释掉的优秀录音数据获取功能
2025-08-28 15:03:40 +08:00
2a52e8f0f6 fix: 恢复生产环境API基础路径配置
将开发环境IP地址注释掉,使用正式生产环境的API基础路径
2025-08-27 21:21:24 +08:00
2ba97b83ec feat(登录): 增加通过URL参数直接登录的功能
在路由守卫和登录逻辑中添加对URL参数的处理,当URL中包含token、username和level时,可以直接登录并跳转到对应页面,无需API验证。同时启用路由的认证元信息配置。
2025-08-27 20:24:31 +08:00
71d2432df5 refactor(Calendar): 移除调试用的console.log语句
清理组件中用于调试的console.log输出,保持代码整洁
2025-08-27 18:18:45 +08:00
14a536bd1c feat(Calendar): 添加休息天数输入并改进营期设置逻辑
- 在营期设置弹窗中添加休息天数输入字段
- 修改营期结束判断逻辑,不再仅依赖休息日
- 改进用户参数获取逻辑,优先使用路由参数
- 添加测试数据以便在没有营期数据时测试功能
- 优化API请求参数处理,确保总是传递必要参数
2025-08-27 18:13:46 +08:00
787703fa12 feat(Calendar): 添加结束营期确认和下一营期设置弹窗
添加结束营期确认弹窗和强制设置下一营期弹窗,确保结束当前营期前必须设置下一营期
2025-08-27 17:38:48 +08:00
f79fa1dd3d refactor(secondTop): 移除未使用的结束营期功能并重命名缓存键
移除未使用的finishCamp函数和相关变量padding111
将getGoodRecord缓存键重命名为CentergetGoodRecord以保持命名一致性
2025-08-27 17:31:29 +08:00
41eeb55815 feat(登录): 添加token验证登录功能并优化优秀录音获取
在登录页面添加token验证登录功能,支持通过路由参数自动登录
将secondTop页面的getGoodRecord函数重命名为CentergetGoodRecord并暂时注释调用
2025-08-27 17:16:41 +08:00
865ee46334 build: 添加 pinia-plugin-persistedstate 依赖以支持状态持久化
升级 pinia-plugin-persistedstate 至 v4.5.0 版本,并添加相关依赖 deep-pick-omit、defu 和 destr 以支持新功能
2025-08-27 16:57:26 +08:00
4069ae3a91 chore: 移除未使用的 pinia-plugin-persistedstate 依赖 2025-08-27 16:57:08 +08:00
152f5c2b4a feat(api): 添加获取平均通话时长和有效通话时长的接口
添加了获取平均通话时长的接口 getAvgCallTime 和获取有效通话时长的接口 getGroupCallDuration
更新了相关视图组件以使用新接口数据
2025-08-27 15:35:54 +08:00
ecf63b74cb feat: 为多个视图添加API缓存系统
为manager、sale和secondTop视图添加30分钟缓存机制,减少API调用次数
包含缓存管理功能,支持清除缓存、获取缓存信息和强制刷新数据
在开发环境下暴露缓存管理函数到全局对象方便调试
2025-08-27 14:44:44 +08:00
d4daed2ec1 feat(api): 更新优秀录音文件接口路径并添加缓存系统
refactor(views): 在多个视图组件中实现数据缓存机制

为API接口更新路径并添加全面的缓存系统,包括:
1. 修改优秀录音文件接口路径
2. 实现30分钟有效期的缓存机制
3. 添加缓存管理功能(清除、查看状态)
4. 在topOne、secondTop和seniorManager视图组件中应用缓存
5. 开发环境下暴露缓存管理函数方便调试
2025-08-27 14:04:04 +08:00
dd913f4d14 fix(GoodMusic): 修复优秀录音组件数据展示问题
refactor(GoodMusic): 重构组件为Composition API风格
style(PeriodStage): 移除无用图例代码
chore(https): 更新API基础路径为本地测试地址
2025-08-27 11:13:48 +08:00
d0b8159274 refactor(QualityCalls): 重构组件为Composition API并简化UI
fix(https): 将API基础路径从http改为https
fix(topone): 修正优秀录音数据处理逻辑
fix(CenterOverview): 修改默认显示值为0
2025-08-26 22:10:43 +08:00
abadcf2494 fix: 修复多个组件的数据处理和API调用问题
修复QualityCalls组件录音数据处理逻辑,确保正确显示动态数据
修正sale.vue中选中客户后获取统计数据的调用顺序
更新API基础路径为生产环境地址
优化CenterOverview组件默认值和显示逻辑
修复SalesTimelineWithTaskList组件课程显示和阶段计数问题
2025-08-26 20:54:37 +08:00
14ee188856 refactor(manager): 优化团队成员详情和预警处理逻辑
- 移除硬编码的团队成员数据,改为从API获取
- 添加可选链操作符处理可能为空的成员数据
- 重构异常预警处理逻辑,动态生成预警消息
- 调整UI组件间距和样式
- 清理无用注释和代码
2025-08-26 14:55:05 +08:00
6779db176c refactor(导航): 优化团队导航逻辑和指标描述
- 统一从高级经理到经理页面的导航路径和参数传递
- 添加团队双击事件处理并跳转到经理页面
- 简化指标描述文本,移除冗余解释
2025-08-26 14:13:26 +08:00
11e686d4d9 feat(topOne): 添加各中心营期阶段组件并优化API调用
- 新增PeriodStage组件展示各中心营期阶段信息
- 移除任务管理相关代码,替换为营期阶段展示
- 修改API端点路径,优化优秀录音文件接口调用
- 调整TeamAlerts组件样式,减小最大高度
2025-08-26 13:55:55 +08:00
b7d46c3dde refactor(secondTop): 优化营期设置和结束逻辑
使用getRequestParams统一处理请求参数
将硬编码的字符串值改为变量引用
2025-08-26 13:12:35 +08:00
d7c8a9e173 fix(metrics): 更新多个视图中的指标计算方式描述
refactor(header): 重构高级经理指挥台的头部组件,支持路由导航显示
2025-08-26 12:00:02 +08:00
58c6ec1c53 feat(统计模式): 添加按月/按期统计切换功能并优化客户阶段显示
- 在CenterOverview组件中添加统计模式切换按钮
- 实现统计模式切换逻辑并触发数据重新加载
- 优化SalesTimelineWithTaskList的客户阶段显示和计算逻辑
- 更新API调用参数以支持不同统计模式
- 调整样式和布局以适应新功能
2025-08-26 11:34:50 +08:00
87cc0e4976 feat(销售阶段): 拆分课1-4阶段为单独课程阶段并优化逻辑
- 将课1-4阶段拆分为课1、课2、课3、课4四个独立阶段
- 修改客户类型和销售阶段处理逻辑,使用当前选中阶段作为默认值
- 添加课程阶段筛选功能,支持按具体课程筛选客户
- 更新销售时间线组件以支持新的课程阶段显示
2025-08-25 21:05:07 +08:00
41058a7ab9 fix(销售页面): 简化客户类型判断逻辑
移除对"未支付"状态的单独判断,仅保留"点击未支付"作为未支付状态的标识
2025-08-25 20:26:43 +08:00
962b430a75 feat(calendar): 添加日历组件并替换待办事项列表
重构待办事项列表为日历视图,添加@fullcalendar/core依赖
支持营期设置、日期选择和事件展示功能
2025-08-25 15:47:19 +08:00
0347da9cdc feat(CustomerDetail): 添加对客户扩展字段的处理逻辑
支持处理两种不同格式的表单数据:基础信息+additional_info格式和customerExpandFieldMap格式。当表单数据包含customerExpandFieldMap时,会解析其中的扩展字段并根据不同类型(单选、多选、文本等)提取答案,组合到最终的分析文本中。
2025-08-25 11:23:37 +08:00
f1fe585fc4 refactor(person/sale): 简化紧急问题数据转换逻辑
移除百分比转换步骤,直接使用API返回的数值格式
2025-08-25 11:05:24 +08:00
d385d22cf5 refactor(views): 移除Loading组件并简化指标描述
更新API基础URL为192.168.15.53
调整漏斗图时间选择器默认值和选项顺序
优化KPI卡片显示,移除部分提示图标并简化描述文本
2025-08-23 21:01:06 +08:00
5e29aa77d6 feat(视图组件): 添加指标提示功能
在secondTop.vue和DetailedDataTable.vue中添加工具提示组件,当用户悬停在指标标签上时显示计算方式的详细说明
2025-08-22 21:59:58 +08:00
af07a1e175 feat(组件): 为KPI指标添加工具提示功能并优化业绩显示
1. 在KpiMetrics和CenterOverview组件中添加工具提示功能,显示各指标的计算说明
2. 修改GroupComparison组件中业绩数据的显示方式,移除货币格式化
3. 添加Tooltip组件用于显示指标说明
4. 优化工具提示的样式和交互效果
2025-08-22 21:39:36 +08:00
0e0d297da7 feat(GoodMusic): 添加外部录音数据支持并优化显示
- 新增recordData prop接收外部录音数据
- 实现processedRecordings计算属性处理外部数据
- 添加销售员姓名和评分显示
- 优化转录文本和录音分析功能
2025-08-22 20:56:04 +08:00
570f59f93f feat(团队指标): 为团队指标添加工具提示说明功能
为转化率、通话次数等团队指标添加工具提示功能,显示各指标的计算方式和定义
取消secondTop.vue中两处被注释的初始化数据加载调用
2025-08-22 20:25:28 +08:00
d53dd95c4e feat(会员详情): 添加指标说明工具提示功能
为会员详情页面的各项指标添加信息图标和工具提示功能,当用户悬停在ⓘ图标上时显示该指标的计算方式和说明。包含总通话次数、通话时长、新增客户、成交单数和转化率五个指标的详细说明。

新增Tooltip组件用于显示提示信息,并实现鼠标跟随定位功能。调整了样式以适配新的信息图标布局。
2025-08-22 18:20:02 +08:00
9b61620b86 feat(组件): 添加指标说明工具提示功能
在个人仪表盘、团队报表和统计指标组件中添加工具提示功能,当用户悬停在指标信息图标上时显示详细说明
创建新的Tooltip组件用于显示指标说明
更新API基础路径配置
2025-08-22 18:13:42 +08:00
8b63ab6123 fix: 更新API端点IP地址从192.168.15.56到192.168.15.60
将多个API请求的IP地址从192.168.15.56更新为192.168.15.60,确保应用连接到正确的后端服务
2025-08-22 10:04:17 +08:00
350a065863 feat(api): 添加获取优秀录音文件的接口和方法
添加了获取优秀录音文件的API接口`getExcellentRecordFile`,并在secondTop.vue中实现了相关调用逻辑。同时恢复了之前注释掉的其他中心数据获取方法的调用。
2025-08-21 13:55:47 +08:00
3b1c1c03f3 feat: 实现任务管理功能并优化界面显示
- 添加任务列表获取和状态更新API调用
- 修改任务列表组件显示格式和状态标签
- 优化日期格式化处理逻辑
- 调整任务列表样式和交互效果
- 注释掉部分不需要的API调用
2025-08-21 12:57:50 +08:00
8780a94f82 feat(topone): 实现任务下发功能并优化界面布局
- 添加任务下发API接口并在任务列表组件中引入
- 修改任务创建逻辑,对接后端API
- 获取下属人员列表用于任务分配
- 优化表格布局,移除总业绩列
- 删除不必要的指导建议模块
2025-08-21 11:43:59 +08:00
544a66b8fa feat(导航): 添加双击导航功能并优化数据展示
- 在GroupComparison组件中添加双击部门跳转到经理页面的功能
- 在secondTop组件中添加双击成员跳转到销售页面的功能
- 优化topOne组件中客户迫切问题排行榜的数据格式转换
- 在RankingList组件中增加展示条目并添加排序功能
- 在SalesTimelineWithTaskList组件中替换alert弹窗为自定义模态框
- 优化secondTop组件路由跳转逻辑,避免重复请求
2025-08-21 10:47:41 +08:00
c340f6c870 fix: 恢复被注释的中心绩效相关数据获取调用
恢复之前被注释掉的中心绩效相关API调用,确保页面能获取完整数据
2025-08-21 01:20:07 +08:00
18d1c74a2c feat(营期管控): 添加营期结束功能及营期设置保存逻辑
- 新增getCampPeriodAdmin API接口用于营期管控
- 在非接数据阶段添加结束营期按钮及相关处理逻辑
- 实现营期设置保存功能并与后端API对接
- 添加营期数据初始化逻辑,从API获取当前营期信息
2025-08-20 23:17:33 +08:00
5635bcd4be feat(secondTop): 添加营期阶段调控功能并优化KPI显示
- 在secondTop页面添加营期阶段调控UI,支持修改"接数据"天数
- 计算并显示当前营期阶段及日期范围
- 优化PersonalDashboard中的KPI指标名称显示
- 隐藏topOne页面中不需要的CampManagement组件
2025-08-20 21:02:49 +08:00
80bb9784ae fix(客户详情): 修复SOP分析功能并优化通话记录显示逻辑
重构SOP分析功能,移除不必要的recordContext参数,改为使用组件内部数据。优化通话记录显示逻辑,当存在通话记录时优先显示record_context内容。在sale.vue中添加对SOP分析事件的处理,通过ref调用CustomerDetail组件的方法。
2025-08-20 15:33:59 +08:00
ae579d637f fix: 更新API基础路径并优化SOP分析功能
- 将API基础路径从192.168.15.54更新为192.168.15.60
- 优化CustomerDetail组件中的SOP分析按钮状态控制
- 在SalesTimelineWithTaskList组件中添加直播发言展示功能
- 重构RawDataCards组件的查看原文逻辑,触发SOP分析并显示通话记录
2025-08-20 15:20:51 +08:00
5973039d4a feat(通话记录): 实现真实通话记录展示及录音转录功能
- 在sale.vue中添加调试日志以跟踪API返回数据
- 修改RawDataCards组件以显示真实通话记录数据
- 实现录音文件转录功能,通过ASR API获取通话原文
- 调整通话记录卡片UI以显示用户、客户信息和录音文件数量
2025-08-19 22:06:34 +08:00
2b75f1b568 feat(api): 新增销售漏斗和黄金联络时段API接口
feat(views): 添加销售漏斗和黄金联络时段数据展示功能
refactor(views): 优化客户详情组件的数据处理逻辑
fix(views): 修复业绩数据显示字段不一致问题
style(views): 调整路由导航顶栏样式
2025-08-19 21:45:15 +08:00
56 changed files with 21758 additions and 5013 deletions

View File

@@ -1,10 +1,11 @@
<!doctype html>
<html lang="en">
<html lang="zh">
<head>
<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" />
<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>
<body>
<div id="app"></div>

6386
my-vue-app/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -10,6 +10,7 @@
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.1",
"@fullcalendar/core": "^6.1.19",
"axios": "^1.10.0",
"chart.js": "^4.5.0",
"dompurify": "^3.2.6",
@@ -18,11 +19,12 @@
"markdown-it": "^14.1.0",
"marked": "^16.1.1",
"pinia": "^3.0.2",
"pinia-plugin-persistedstate": "^3.2.3",
"pinia-plugin-persistedstate": "^4.5.0",
"vue": "^3.5.17",
"vue-chartjs": "^5.3.2",
"vue-echarts": "^7.0.3",
"vue-router": "^4.5.0"
"vue-router": "^4.5.0",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@tsconfig/node22": "^22.0.1",

1486
my-vue-app/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

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
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
@@ -58,7 +58,6 @@ export const getCustomerChatInfo = (params) => {
// 客户表单详情 /api/v1/sales/get_customer_form_info
export const getCustomerFormInfo = (params) => {
return https.post('/api/v1/sales_timeline/get_customer_form_info', params)
}
// 客户通话录音 /api/v1/sales/get_customer_call_info
@@ -66,4 +65,24 @@ export const getCustomerCallInfo = (params) => {
return https.post('/api/v1/sales_timeline/get_customer_call_info', params)
}
// 销售漏斗 /api/v1/sales/sales_funnel
export const getSalesFunnel = (params) => {
return https.post('/api/v1/sales/sales_funnel', params)
}
// 黄金联络 /api/v1/sales/get_gold_contact_time
export const getGoldContactTime = (params) => {
return https.post('/api/v1/sales/get_gold_contact_time', params)
}
// 平均通话时长 /api/v1/sales/get_avg_call_time
export const getAvgCallTime = (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

@@ -10,8 +10,6 @@ export const getWeekTotalCall = (params) => {
return https.post('/api/v1/manager/week_total_call', params)
}
// 有效通话时长
// 新增意向客户 /api/v1/manager/week_add_customer_total
export const getWeekAddCustomerTotal = (params) => {
return https.post('/api/v1/manager/week_add_customer_total', params)
@@ -38,11 +36,30 @@ export const getGroupFunnel = (params) => {
export const getGroupRanking = (params) => {
return https.post('/api/v1/manager/group_ranking', params)
}
// 团队成员业绩详情 /api/v1/manager/group_detail
export const getGroupDetail = (params) => {
return https.post('/api/v1/manager/group_detail', params)
}
// 有效通话时长 /api/v1/manager/group_call_duration
export const getGroupCallDuration = (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

@@ -60,6 +60,35 @@ export const getConversionRateVsAverage = (params) => {
return https.post('/api/v1/level_four/overview/conversion_rate_vs_average', params)
}
// 营期管控 /api/v1/level_four/overview/camp_period_admin
export const getCampPeriodAdmin = (params) => {
return https.post('/api/v1/level_four/overview/camp_period_admin', params)
}
// 获取优秀录音文件 /api/v1/level_four/overview/get_excellent_record_file
export const getExcellentRecordFile = (params) => {
return https.post('/api/v1/level_four/overview/get_excellent_record_file', params)
}
// 修改营期 /api/v1/level_four/overview/change_camp_period
export const changeCampPeriod = (params) => {
return https.post('/api/v1/level_four/overview/change_camp_period', params)
}
// 获取历史营期 /api/v1/level_four/overview/get_history_camp_period
export const getHistoryCampPeriod = (params) => {
return https.post('/api/v1/level_four/overview/get_history_camp_period', params)
}
// 切换历史营期 /api/v1/level_four/overview/switch_history_camp_period
export const switchHistoryCampPeriod = (params) => {
return https.post('/api/v1/level_four/overview/switch_history_camp_period', params)
}
// 返回当前营期 /api/v1/level_four/overview/cancel_switch_history_camp_period
export const cancelSwitchHistoryCampPeriod = (params) => {
return https.post('/api/v1/level_four/overview/cancel_switch_history_camp_period', params)
}
// 一键导出 api/v1/level_four/overview/export_customers
export const exportCustomers = (params) => {
return https.post('/api/v1/level_four/overview/export_all_customers_under_sales', params)
}

View File

@@ -45,7 +45,11 @@ export const getTimeoutRate = (params) => {
export const getTableFillingRate = (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
export const getUrgentNeedToAddress = (params) => {
@@ -66,8 +70,28 @@ export const getTeamRankingInfo = (params) => {
export const getAbnormalResponseRate = (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

@@ -65,4 +65,12 @@ export const getDetailedDataTable = (params) => {
return https.post('/api/v1/level_five/overview/detailed_data_table', params)
}
// 获取各中心营期阶段 /api/v1/level_five/overview/get_period_stage
export const getPeriodStage = (params) => {
return https.get('/api/v1/level_five/overview/get_period_stage', 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

@@ -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

@@ -0,0 +1,53 @@
<template>
<Teleport to="body">
<div v-if="visible" class="stat-tooltip" :style="{ left: x + 'px', top: y + 'px' }">
<div class="tooltip-title">
{{ title }}
</div>
<div class="tooltip-description">
<p>{{ description }}</p>
</div>
</div>
</Teleport>
</template>
<script setup>
defineProps({
visible: Boolean,
x: Number,
y: Number,
title: String,
description: String
});
</script>
<style scoped>
.stat-tooltip {
position: fixed;
z-index: 9999;
background: rgba(0, 0, 0, 0.9);
color: white;
padding: 12px 16px;
border-radius: 8px;
font-size: 14px;
max-width: 300px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
pointer-events: none;
}
.tooltip-title {
font-weight: 600;
margin-bottom: 4px;
color: #fff;
}
.tooltip-description {
font-size: 13px;
color: #e0e0e0;
line-height: 1.4;
}
.tooltip-description p {
margin: 0;
}
</style>

View File

@@ -25,6 +25,13 @@
</svg>
修改密码
</div>
<div class="dropdown-item" @click="handleDisplaySettings">
<svg width="16" height="16" viewBox="0 0 16 16" style="margin-right: 8px;">
<path d="M8 4.754a3.246 3.246 0 1 0 0 6.492 3.246 3.246 0 0 0 0-6.492zM5.754 8a2.246 2.246 0 1 1 4.492 0 2.246 2.246 0 0 1-4.492 0z" fill="currentColor"/>
<path d="M9.796 1.343c-.527-1.79-3.065-1.79-3.592 0l-.094.319a.873.873 0 0 1-1.255.52l-.292-.16c-1.64-.892-3.433.902-2.54 2.541l.159.292a.873.873 0 0 1-.52 1.255l-.319.094c-1.79.527-1.79 3.065 0 3.592l.319.094a.873.873 0 0 1 .52 1.255l-.16.292c-.892 1.64.901 3.434 2.541 2.54l.292-.159a.873.873 0 0 1 1.255.52l.094.319c.527 1.79 3.065 1.79 3.592 0l.094-.319a.873.873 0 0 1 1.255-.52l.292.16c1.64.893 3.434-.902 2.54-2.541l-.159-.292a.873.873 0 0 1 .52-1.255l.319-.094c1.79-.527 1.79-3.065 0-3.592l-.319-.094a.873.873 0 0 1-.52-1.255l.16-.292c.893-1.64-.902-3.433-2.541-2.54l-.292.159a.873.873 0 0 1-1.255-.52l-.094-.319zm-2.633.283c.246-.835 1.428-.835 1.674 0l.094.319a1.873 1.873 0 0 0 2.693 1.115l.291-.16c.764-.415 1.6.42 1.184 1.185l-.159.292a1.873 1.873 0 0 0 1.116 2.692l.318.094c.835.246.835 1.428 0 1.674l-.319.094a1.873 1.873 0 0 0-1.115 2.693l.16.291c.415.764-.42 1.6-1.185 1.184l-.291-.159a1.873 1.873 0 0 0-2.693 1.116l-.094.318c-.246.835-1.428.835-1.674 0l-.094-.319a1.873 1.873 0 0 0-2.692-1.115l-.292.16c-.764.415-1.6-.42-1.184-1.185l.159-.291A1.873 1.873 0 0 0 1.945 8.93l-.319-.094c-.835-.246-.835-1.428 0-1.674l.319-.094A1.873 1.873 0 0 0 3.06 4.377l-.16-.292c-.415-.764.42-1.6 1.185-1.184l.292.159a1.873 1.873 0 0 0 2.692-1.115l.094-.319z" fill="currentColor"/>
</svg>
显示设置
</div>
<div class="dropdown-item logout-item" @click="handleLogout">
<svg width="16" height="16" viewBox="0 0 16 16" style="margin-right: 8px;">
<path d="M6 2a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h4a1 1 0 0 0 1-1V3a1 1 0 0 0-1-1H6zM5 3a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V3z" fill="currentColor"/>
@@ -148,6 +155,49 @@
</div>
</div>
<!-- 显示设置弹窗 -->
<div v-if="showDisplayModal" class="display-modal-overlay" @click="cancelDisplaySettings">
<div class="display-modal" @click.stop>
<div class="display-modal-header">
<h2>显示设置</h2>
<p>选择要显示的模块</p>
</div>
<div class="display-modal-body">
<div class="checkbox-group">
<label v-for="(visible, key) in localCardVisibility" :key="key" class="checkbox-item">
<input
type="checkbox"
v-model="localCardVisibility[key]"
:disabled="displayLoading"
/>
<span class="checkbox-label">{{ getCardDisplayName(key) }}</span>
</label>
</div>
</div>
<div class="display-modal-footer">
<button
type="button"
class="btn-cancel"
@click="cancelDisplaySettings"
:disabled="displayLoading"
>
取消
</button>
<button
type="button"
class="btn-confirm"
@click="handleDisplaySubmit"
:disabled="displayLoading"
>
<span v-if="displayLoading" class="loading-spinner"></span>
{{ displayLoading ? '应用中...' : '确认应用' }}
</button>
</div>
</div>
</div>
<!-- 退出登录确认弹窗 -->
<div v-if="showLogoutModal" class="logout-modal-overlay" @click="cancelLogout">
<div class="logout-modal" @click.stop>
@@ -166,11 +216,28 @@
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import { ref, reactive, onMounted, onUnmounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useUserStore } from '@/stores/user'
import http from '@/utils/https'
// Props
const props = defineProps({
cardVisibility: {
type: Object,
default: () => ({
timeline: true,
rawData: true,
customerDetail: true,
analytics: true,
weekAnalysis: true
})
}
})
// Emits
const emit = defineEmits(['update-card-visibility'])
// 路由实例
const router = useRouter()
@@ -192,6 +259,50 @@ const passwordForm = ref({
newPassword: ''
}) // 修改密码表单数据
const passwordLoading = ref(false) // 修改密码加载状态
const showDisplayModal = ref(false) // 显示设置弹窗显示状态
const displayLoading = ref(false) // 显示设置加载状态
const localCardVisibility = reactive({}) // 本地卡片显示状态
// 监听props变化同步到本地状态
watch(() => props.cardVisibility, (newVal) => {
Object.assign(localCardVisibility, newVal)
}, { immediate: true, deep: true })
// 获取卡片显示名称
const getCardDisplayName = (key) => {
const nameMap = {
// sale.vue 页面的卡片
timeline: '销售时间线',
rawData: '原始数据',
customerDetail: '客户详情',
analytics: '数据分析',
weekAnalysis: '周期分析',
// seniorManager.vue 页面的卡片
centerOverview: '中心概览',
teamAlerts: '团队预警',
statisticalIndicators: '统计指标',
groupRanking: '组别排名',
problemRanking: '问题排名',
groupComparison: '组别对比',
teamDetail: '团队详情',
// secondTop.vue 页面的卡片
actionItems: '行动项目',
customerType: '客户类型',
goodMusic: '优秀录音',
// topone.vue 页面的卡片
kpiMetrics: '核心业绩指标',
salesProgress: '销售实时进度',
periodStage: '各中心营期阶段',
funnelChart: '转化漏斗',
personalSalesRanking: '销售个人业绩排行榜',
qualityCalls: '优质通话',
rankingList: '业绩排行榜',
problemRanking: '客户迫切解决的问题排行榜',
campManagement: '营期管理',
detailedDataTable: '详细数据表格'
}
return nameMap[key] || key
}
// 切换下拉菜单显示状态
const toggleDropdown = () => {
@@ -309,6 +420,39 @@ const cancelPasswordChange = () => {
passwordForm.value.newPassword = ''
}
// 显示设置
const handleDisplaySettings = () => {
console.log('显示设置')
showDropdown.value = false
showDisplayModal.value = true
}
// 显示设置处理函数
const handleDisplaySubmit = async () => {
displayLoading.value = true
try {
// 发送事件给父组件更新卡片显示状态
emit('update-card-visibility', { ...localCardVisibility })
// 模拟异步操作
await new Promise(resolve => setTimeout(resolve, 300))
showDisplayModal.value = false
} catch (error) {
console.error('显示设置失败:', error)
} finally {
displayLoading.value = false
}
}
// 取消显示设置
const cancelDisplaySettings = () => {
showDisplayModal.value = false
// 恢复到原始状态
Object.assign(localCardVisibility, props.cardVisibility)
}
// 退出登录
const handleLogout = () => {
showDropdown.value = false
@@ -319,15 +463,21 @@ const handleLogout = () => {
const confirmLogout = () => {
console.log('用户确认退出登录')
// 清除用户信息(如果有的话)
// localStorage.removeItem('token')
// sessionStorage.clear()
try {
// 清除用户状态
userStore.logout()
// 关闭弹窗
showLogoutModal.value = false
// 跳转到登录页面
router.push('/login')
} catch (error) {
console.error('退出登录失败:', error)
// 即使出错也要关闭弹窗并跳转
showLogoutModal.value = false
router.push('/login')
}
}
// 取消退出登录
@@ -649,6 +799,142 @@ const cancelLogout = () => {
}
}
/* 显示设置弹窗样式 */
.display-modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 9999;
backdrop-filter: blur(4px);
}
.display-modal {
background: white;
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);
min-width: 400px;
max-width: 500px;
animation: modalSlideIn 0.3s ease-out;
}
.display-modal-header {
padding: 24px 24px 16px;
border-bottom: 1px solid #f1f5f9;
}
.display-modal-header h2 {
font-size: 20px;
font-weight: 600;
color: #1e293b;
margin: 0 0 8px 0;
}
.display-modal-header p {
font-size: 14px;
color: #64748b;
margin: 0;
}
.display-modal-body {
padding: 24px;
}
.checkbox-group {
display: flex;
flex-direction: column;
gap: 16px;
}
.checkbox-item {
display: flex;
align-items: center;
cursor: pointer;
padding: 8px;
border-radius: 6px;
transition: background-color 0.2s;
}
.checkbox-item:hover {
background-color: #f8fafc;
}
.checkbox-item input[type="checkbox"] {
width: 18px;
height: 18px;
margin-right: 12px;
cursor: pointer;
accent-color: #667eea;
}
.checkbox-item input[type="checkbox"]:disabled {
cursor: not-allowed;
opacity: 0.6;
}
.checkbox-label {
font-size: 14px;
color: #374151;
font-weight: 500;
cursor: pointer;
}
.display-modal-footer {
padding: 16px 24px 24px 24px;
display: flex;
gap: 12px;
justify-content: flex-end;
}
.display-modal-footer .btn-cancel,
.display-modal-footer .btn-confirm {
padding: 12px 24px;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
border: none;
min-width: 80px;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.display-modal-footer .btn-cancel {
background: #f8fafc;
color: #64748b;
border: 1px solid #e2e8f0;
}
.display-modal-footer .btn-cancel:hover:not(:disabled) {
background: #f1f5f9;
transform: translateY(-1px);
}
.display-modal-footer .btn-confirm {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.display-modal-footer .btn-confirm:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
}
.display-modal-footer .btn-cancel:disabled,
.display-modal-footer .btn-confirm:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
/* 修改密码弹窗样式 */
.password-modal-overlay {
position: fixed;

View File

@@ -11,43 +11,43 @@ const routes = [
{
path: '/',
name: 'Home',
redirect: '/sale'
redirect: '/login'
},
{
path: '/login',
name: 'Login',
component: Login,
// meta: { requiresAuth: false }
meta: { requiresAuth: false }
},
{
path: '/sale',
name: 'Sale',
component: Sale,
// meta: { requiresAuth: true, minLevel: 1 }
meta: { requiresAuth: true, minLevel: 1 }
},
{
path: '/manager',
name: 'Manager',
component: Manager,
// meta: { requiresAuth: true, minLevel: 2 }
meta: { requiresAuth: true, minLevel: 2 }
},
{
path: '/senior-manager',
name: 'SeniorManager',
component: SeniorManager,
// meta: { requiresAuth: true, minLevel: 3 }
meta: { requiresAuth: true, minLevel: 3 }
},
{
path: '/second-top',
name: 'SecondTop',
component: SecondTop,
// meta: { requiresAuth: true, minLevel: 4 }
meta: { requiresAuth: true, minLevel: 4 }
},
{
path: '/top',
name: 'Top',
component: TopOne,
// meta: { requiresAuth: true, minLevel: 5 }
meta: { requiresAuth: true, minLevel: 5 }
}
]
@@ -57,15 +57,59 @@ const router = createRouter({
})
// 路由守卫
router.beforeEach((to, from, next) => {
router.beforeEach(async (to, from, next) => {
const userStore = useUserStore()
// 检查URL参数中是否包含token和用户信息来自其他页面的跳转
const urlToken = to.query.token
const urlUsername = to.query.username
const urlLevel = to.query.level
// 如果URL中包含token和用户信息进行自动登录
if (urlToken && urlUsername && urlLevel) {
try {
// 解码用户名URL编码的中文
const decodedUsername = decodeURIComponent(urlUsername)
// 直接设置用户信息到store跳过API验证因为token来自可信源
userStore.login(urlToken, decodedUsername, parseInt(urlLevel), '', '')
// 根据用户等级跳转到对应页面
const defaultRoutes = {
1: '/sale',
2: '/manager',
3: '/senior-manager',
4: '/second-top',
5: '/top'
}
const targetRoute = defaultRoutes[parseInt(urlLevel)] || '/sale'
// 如果当前路由就是目标路由,直接通过;否则重定向
if (to.path === targetRoute) {
next()
} else {
next(targetRoute)
}
return
} catch (error) {
console.error('自动登录失败:', error)
// 如果自动登录失败,继续正常的路由守卫逻辑
}
}
// 如果路由不需要认证,直接通过
if (!to.meta.requiresAuth) {
next()
return
}
// 如果访问登录页面,始终允许访问(允许重新登录)
if (to.path === '/login') {
next()
return
}
// 检查是否已登录
if (!userStore.isLoggedIn || !userStore.userInfo) {
next('/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

@@ -5,7 +5,8 @@ import { useUserStore } from '@/stores/user'
// 创建axios实例
const service = axios.create({
baseURL: 'http://192.168.15.54:8890' || '', // API基础路径支持完整URL
baseURL: 'https://mldash.nycjy.cn/' || '', // API基础路径支持完整URL
// baseURL: 'http://192.168.15.121:8890' || '', // API基础路径支持完整URL
timeout: 100000, // 请求超时时间
headers: {
'Content-Type': 'application/json;charset=UTF-8'
@@ -15,9 +16,6 @@ const service = axios.create({
// 请求拦截器
service.interceptors.request.use(
config => {
// 在发送请求之前做些什么
// console.log('发送请求:', config)
// 添加token到请求头
const userStore = useUserStore()
const token = userStore.token
@@ -31,13 +29,9 @@ service.interceptors.request.use(
_t: Date.now()
}
}
// 显示加载状态
if (config.showLoading !== false) {
// 可以在这里添加全局loading
console.log('显示加载中...')
}
return config
},
error => {
@@ -47,15 +41,9 @@ service.interceptors.request.use(
return Promise.reject(error)
}
)
// 响应拦截器
service.interceptors.response.use(
response => {
// 隐藏加载状态
// console.log('隐藏加载中...')
// 对响应数据做点什么
// console.log('收到响应:', response)
const { data, status } = response

View File

@@ -260,12 +260,15 @@
</template>
<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { ref, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import http from '@/utils/https'
import { useUserStore } from '@/stores/user'
const router = useRouter()
const route = useRoute()
const userStore = useUserStore()
// 响应式数据
@@ -392,8 +395,12 @@ const handleLogin = async () => {
loading.value = true
errorMessage.value = ''
// 清除本地存储的用户数据,确保使用最新的登录信息
userStore.logout()
try {
// 调用登录API
// token检测
const response = await http.post('/api/v1/login', {
username: loginForm.value.username,
password: loginForm.value.password
@@ -546,6 +553,64 @@ const handleSetSecurity = async () => {
const cancelSecuritySetup = () => {
alert('首次登录必须设置密保问题')
}
// Token验证登录函数
const handleTokenLogin = async (token, username = null, userLevel = null) => {
loading.value = true
errorMessage.value = ''
try {
// 如果URL中包含用户信息直接使用跳过API验证
if (username && userLevel) {
// 解码用户名
const decodedUsername = decodeURIComponent(username)
// 直接设置用户信息到store
userStore.login(token, decodedUsername, parseInt(userLevel), '', '')
// 根据用户等级跳转到对应页面
navigateToUserPage(parseInt(userLevel))
return
}
// 使用token进行API验证登录
const response = await http.post('/api/v1/token_login', {
token: token
})
if (response.code === 200 || response.success) {
// 保存登录响应数据
loginResponseData = response
// 使用Pinia存储用户信息和token
if (response && response.token) {
userStore.login(response.token, response.name, response.user_level, response.department, response.department_id)
}
// 检查用户是否重名
await checkUserDuplicate()
} else {
errorMessage.value = response.message || 'Token验证失败'
}
} catch (error) {
errorMessage.value = error.message || 'Token验证失败请重试'
} finally {
loading.value = false
}
}
// 组件挂载时检查路由参数中的token
onMounted(() => {
const token = route.query.token
const username = route.query.username
const userLevel = route.query.level
if (token) {
// 如果路由参数中有token进行token验证登录
// 如果同时有用户信息直接使用否则通过API验证
handleTokenLogin(token, username, userLevel)
}
})
</script>
<style scoped>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,6 @@
<div class="table-header">
<span>排名</span>
<span>姓名</span>
<span>总业绩</span>
<span>转化率</span>
<span>加微率</span>
<span>入群率</span>
@@ -15,13 +14,12 @@
v-for="(member, index) in displayMembers"
:key="member.user_name || member.id"
class="table-row"
:class="{ active: selectedMember && (selectedMember.user_name === member.user_name || selectedMember.id === member.id) }"
:class="{ active: selectedMember && selectedMember === member }"
@click="selectMember(member)"
@dblclick="handleDoubleClick(member)"
>
<span class="rank">{{ index + 1 }}</span>
<span class="name">{{ member.user_name || member.name }}</span>
<span class="performance">¥{{ formatAmount(member.week_amount || member.performance) }}</span>
<span class="conversion">{{ member.conversion_rate || member.conversion || '0%' }}</span>
<span class="wechat-rate">{{ member.plus_v_rate || member.wechatRate || '0%' }}</span>
<span class="group-rate">{{ member.group_rate || member.groupRate || '0%' }}</span>
@@ -164,7 +162,7 @@ const handleDoubleClick = (member) => {
.table-header {
display: grid;
grid-template-columns: 60px 1fr 120px 80px 90px 90px;
grid-template-columns: 60px 1fr 80px 90px 90px;
gap: 0.8rem;
padding: 0.75rem 0;
border-bottom: 1px solid #e2e8f0;
@@ -176,7 +174,7 @@ const handleDoubleClick = (member) => {
.table-row {
display: grid;
grid-template-columns: 60px 1fr 120px 80px 90px 90px;
grid-template-columns: 60px 1fr 80px 90px 90px;
gap: 0.8rem;
padding: 0.75rem 0;
border-bottom: 1px solid #f1f5f9;
@@ -243,7 +241,7 @@ const handleDoubleClick = (member) => {
.ranking-table {
.table-header {
grid-template-columns: 40px 1fr 70px 55px 55px 55px;
grid-template-columns: 40px 1fr 70px 55px 55px;
gap: 0.3rem;
font-size: 0.75rem;
padding: 0.5rem 0;
@@ -251,7 +249,7 @@ const handleDoubleClick = (member) => {
}
.table-row {
grid-template-columns: 40px 1fr 70px 55px 55px 55px;
grid-template-columns: 40px 1fr 70px 55px 55px;
gap: 0.3rem;
font-size: 0.8rem;
padding: 0.5rem 0;
@@ -292,14 +290,14 @@ const handleDoubleClick = (member) => {
.ranking-table {
.table-header {
grid-template-columns: 30px 1fr 55px 45px 45px 45px;
grid-template-columns: 30px 1fr 55px 45px 45px;
gap: 0.2rem;
font-size: 0.7rem;
white-space: nowrap;
}
.table-row {
grid-template-columns: 30px 1fr 55px 45px 45px 45px;
grid-template-columns: 30px 1fr 55px 45px 45px;
gap: 0.2rem;
font-size: 0.75rem;
white-space: nowrap;

View File

@@ -62,8 +62,7 @@ const aggregatedAlerts = computed(() => {
.alert-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
max-height: 300px;
max-height: 270px;
overflow-y: auto;
// 自定义滚动条样式
@@ -85,14 +84,14 @@ const aggregatedAlerts = computed(() => {
}
}
}
.alert-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem;
gap: 0.2rem;
padding: 0.25rem;
border-radius: 8px;
font-size: 0.9rem;
margin-bottom: 0.2rem;
&.warning {
background: #fef3c7;

View File

@@ -1,55 +1,68 @@
<template>
<div class="team-report">
<div class="header-container">
<h2>今日团队实时战报</h2>
<button class="analysis-button" @click="showTeamAnalysis">团队分析</button>
</div>
<div class="report-grid">
<div class="report-card">
<div class="card-header">
<span class="card-title">团队总通话</span>
<span class="card-title">团队总通话 <i class="info-icon" @mouseenter="showTooltip('totalCalls', $event)" @mouseleave="hideTooltip"></i></span>
<span class="card-trend positive">{{ weekTotalData.week_total_call?.team_data?.current_rate_last_current || '0%' }} vs 上期</span>
</div>
<div class="card-value">{{ weekTotalData.week_total_call?.team_data?.total_current_week_call || 0 }} </div>
</div>
<div class="report-card">
<div class="card-header">
<span class="card-title">有效通话时长</span>
<span class="card-trend negative">{{ weekTotalData.week_total_call?.team_data?.current_rate_last_current || '0%' }} vs 上期</span>
<span class="card-title">有效通话时长 <i class="info-icon" @mouseenter="showTooltip('callDuration', $event)" @mouseleave="hideTooltip"></i></span>
<span class="card-trend negative">{{ weekTotalData.group_call_duration?.group_data?.current_rate_last_current || '0%' }} vs 上期</span>
</div>
<div class="card-value">{{ formatDuration(weekTotalData.week_total_call?.team_data?.total_call_duration)||0 }} 小时</div>
<div class="card-value">{{ formatDuration(weekTotalData.group_call_duration.group_data?.current_total_call_time_hour)||0 }} 小时</div>
</div>
<div class="report-card">
<div class="card-header">
<span class="card-title">新增意向客户</span>
<span class="card-title">新增意向客户 <i class="info-icon" @mouseenter="showTooltip('newCustomers', $event)" @mouseleave="hideTooltip"></i></span>
<span class="card-trend positive">{{ weekTotalData.week_add_customer_total?.team_data?.week_rate_last_week || '0%' }} vs 上期</span>
</div>
<div class="card-value">{{ weekTotalData.week_add_customer_total?.team_data?.total_week_add_customer || 0 }} </div>
</div>
<div class="report-card">
<div class="card-header">
<span class="card-title">新增成交</span>
<span class="card-title">新增成交 <i class="info-icon" @mouseenter="showTooltip('newDeals', $event)" @mouseleave="hideTooltip"></i></span>
<span class="card-trend positive">{{ weekTotalData.week_add_deal_total?.team_data?.week_rate_last_week || '0%' }} vs 上期</span>
</div>
<div class="card-value">{{ weekTotalData.week_add_deal_total?.team_data?.total_week_add_deal || 0 }} </div>
</div>
<div class="report-card">
<div class="card-header">
<span class="card-title">月度总业绩</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>
</div>
<div class="card-value">{{ formatCurrency(weekTotalData.week_add_fee_total?.total_add_fee || 0) }} </div>
</div>
<div class="report-card">
<div class="card-header">
<span class="card-title">定金转化率</span>
<span class="card-title">定金转化率 <i class="info-icon" @mouseenter="showTooltip('conversionRate', $event)" @mouseleave="hideTooltip"></i></span>
<span class="card-trend positive">{{ weekTotalData.pay_deposit_to_money_rate?.team_data?.week_vs_last_week || '0%' }} vs 上期</span>
</div>
<div class="card-value">{{ weekTotalData.pay_deposit_to_money_rate?.team_data?.week_pay_deposit_to_money_rate || '0%' }} </div>
</div>
</div>
<!-- Tooltip 组件 -->
<Tooltip
:visible="tooltip.visible"
:x="tooltip.x"
:y="tooltip.y"
:title="tooltip.title"
:description="tooltip.description"
/>
</div>
</template>
<script setup>
import { watch } from 'vue'
import { watch, reactive } from 'vue'
import Tooltip from '@/components/Tooltip.vue'
// 定义props
const props = defineProps({
@@ -60,11 +73,15 @@ const props = defineProps({
week_add_customer_total: {},
week_add_deal_total: {},
week_add_fee_total: {},
pay_deposit_to_money_rate: {}
pay_deposit_to_money_rate: {},
group_call_duration: {}
})
}
})
// 定义emit
const emit = defineEmits(['show-team-analysis'])
// 监听数据变化,用于调试
watch(() => props.weekTotalData, (newData) => {
console.log('TeamReport 收到的数据:', newData)
@@ -82,6 +99,64 @@ const formatCurrency = (amount) => {
if (!amount) return '0'
return amount.toLocaleString()
}
// Tooltip 相关数据
const tooltip = reactive({
visible: false,
x: 0,
y: 0,
title: '',
description: ''
})
// 指标说明配置
const metricDescriptions = {
totalCalls: {
title: '团队总通话',
description: '团队所有成员在本期内的通话总次数,包括拨出和接听的所有电话。'
},
callDuration: {
title: '有效通话时长',
description: '团队所有成员有效通话的总时长,不包括未接通和短时间通话。'
},
newCustomers: {
title: '新增意向客户',
description: '本期新增的有明确购买意向的客户数量,通过沟通确认有需求的客户。'
},
newDeals: {
title: '新增成交',
description: '本期新增的成交订单数量,已确认付款或签约的客户订单。'
},
monthlyRevenue: {
title: '本月成交单数',
description: '本月团队累计完成的销售订单数量,包括所有已确认的订单。'
},
conversionRate: {
title: '定金转化率',
description: '支付定金的客户数 ÷ 意向客户总数'
}
}
// Tooltip 相关方法
const showTooltip = (metricType, event) => {
const description = metricDescriptions[metricType]
if (description) {
tooltip.title = description.title
tooltip.description = description.description
tooltip.x = event.clientX + 10
tooltip.y = event.clientY - 10
tooltip.visible = true
}
}
const hideTooltip = () => {
tooltip.visible = false
}
// 显示团队分析
const showTeamAnalysis = () => {
emit('show-team-analysis')
}
</script>
<style lang="scss" scoped>
@@ -92,11 +167,33 @@ const formatCurrency = (amount) => {
padding: 1.5rem;
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 {
font-size: 1.2rem;
font-weight: 600;
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 {
@@ -188,4 +285,30 @@ const formatCurrency = (amount) => {
border-radius: 8px;
}
}
// 感叹号图标样式
.info-icon {
font-style: normal;
color: #409eff;
font-size: 12px;
margin-left: 4px;
opacity: 0.7;
cursor: pointer;
transition: all 0.3s ease;
&:hover {
opacity: 1;
color: #007bff;
transform: scale(1.2);
}
}
.card-title:hover .info-icon {
opacity: 1;
}
.report-card {
position: relative;
transition: all 0.2s ease;
}
</style>

View File

@@ -37,9 +37,11 @@
<!-- Top Section - Team Alerts and Today's Report -->
<div class="top-section">
<!-- Team Alerts -->
<TeamAlerts :abnormalData="groupAbnormalResponse" />
<!-- <TeamAlerts :abnormalData="groupAbnormalResponse" /> -->
<GoodMusic :quality-calls="excellentRecord"
/>
<!-- Today's Team Report -->
<TeamReport :weekTotalData="weekTotalData" />
<TeamReport :weekTotalData="weekTotalData" @show-team-analysis="fetchTeamAnalysis" />
</div>
<!-- Sales Funnel Section -->
@@ -60,27 +62,47 @@
<!-- Right Section -->
<div class="right-section">
<!-- Member Details -->
<MemberDetails :selected-member="selectedMember" />
<MemberDetails :selected-member="selectedMember" :memberDetails="memberDetails" />
</div>
</div>
</main>
</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>
<script setup>
import { ref, onMounted, computed } from "vue";
import TeamAlerts from "./components/TeamAlerts.vue";
import GoodMusic from "./components/GoodMusic.vue";
import TeamReport from "./components/TeamReport.vue";
import SalesFunnel from "./components/SalesFunnel.vue";
import PerformanceRanking from "./components/PerformanceRanking.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 RawDataCards from "../person/components/RawDataCards.vue";
import CustomerDetail from "../person/components/CustomerDetail.vue";
import { useUserStore } from "@/stores/user";
import { useRouter } from "vue-router";
import {getGroupAbnormalResponse, getWeekTotalCall, getWeekAddCustomerTotal, getWeekAddDealTotal, getWeekAddFeeTotal, getGroupFunnel,getPayDepositToMoneyRate,getGroupRanking } from "@/api/manager.js";
import {getGroupAbnormalResponse, getWeekTotalCall, getWeekAddCustomerTotal, getWeekAddDealTotal,
getWeekAddFeeTotal, getGroupFunnel,getPayDepositToMoneyRate,getGroupRanking, getGroupCallDuration,getGroupDetail, getGroupEntiretyThirdReport,getExcellentRecordFile} from "@/api/manager.js";
// 团队成员数据
const teamMembers = [
@@ -95,115 +117,7 @@ const teamMembers = [
newClients: 12,
deals: 5,
avgDealValue: 24000,
},
{
id: 2,
name: "张明",
rank: 2,
performance: 85000,
conversion: 5.0,
calls: 142,
callTime: 6.2,
newClients: 8,
deals: 3,
avgDealValue: 28333,
},
{
id: 3,
name: "王强",
rank: 3,
performance: 65000,
conversion: 4.0,
calls: 128,
callTime: 5.8,
newClients: 6,
deals: 2,
avgDealValue: 32500,
},
{
id: 4,
name: "赵静",
rank: 4,
performance: 0,
conversion: 0.0,
calls: 89,
callTime: 3.2,
newClients: 2,
deals: 0,
avgDealValue: 0,
},
{
id: 5,
name: "刘洋",
rank: 5,
performance: 0,
conversion: 0.0,
calls: 76,
callTime: 2.8,
newClients: 1,
deals: 0,
avgDealValue: 0,
},
{
id: 6,
name: "陈雨",
rank: 6,
performance: 45000,
conversion: 3.2,
calls: 98,
callTime: 4.1,
newClients: 4,
deals: 1,
avgDealValue: 45000,
},
{
id: 7,
name: "周杰",
rank: 7,
performance: 38000,
conversion: 2.8,
calls: 115,
callTime: 5.3,
newClients: 5,
deals: 1,
avgDealValue: 38000,
},
{
id: 8,
name: "吴梅",
rank: 8,
performance: 22000,
conversion: 1.9,
calls: 87,
callTime: 3.7,
newClients: 3,
deals: 1,
avgDealValue: 22000,
},
{
id: 9,
name: "孙涛",
rank: 9,
performance: 15000,
conversion: 1.2,
calls: 92,
callTime: 4.0,
newClients: 2,
deals: 1,
avgDealValue: 15000,
},
{
id: 10,
name: "马丽",
rank: 10,
performance: 8000,
conversion: 0.8,
calls: 68,
callTime: 2.5,
newClients: 1,
deals: 1,
avgDealValue: 8000,
},
}
];
// 路由实例
@@ -215,9 +129,10 @@ const userStore = useUserStore();
// 获取通用请求参数的函数
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()
@@ -226,6 +141,14 @@ const getRequestParams = () => {
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
}
@@ -252,17 +175,53 @@ const weekTotalData = ref({
week_add_fee_total: {},
pay_deposit_to_money_rate: {},
group_funnel: {},
week_add_fee_total: {},
});
group_call_duration: {},
})
// 团队异常预警
const groupAbnormalResponse = ref({})
async function TeamGetGroupAbnormalResponse() {
const params = getRequestParams()
const hasParams = params.user_name
const res = await getGroupAbnormalResponse(hasParams ? params : undefined)
console.log(res)
if (res.code === 200) {
groupAbnormalResponse.value = res.data
try {
const response = await getGroupAbnormalResponse(hasParams ? params : undefined)
const rawData = response.data
// 转换数据格式,生成预警消息
const processedAlerts = []
let alertId = 1
// 处理严重超时异常人员
const timeoutPersons = rawData?.serious_timeout_rate_abnorma || []
// 处理表格填写异常人员
const fillingPersons = rawData?.table_filling_abnormal || []
// 为每个异常人员生成独立的预警消息
// 处理严重超时率异常人员
timeoutPersons.forEach(person => {
processedAlerts.push({
id: `timeout-${alertId++}`,
type: 'warning',
icon: '⚠',
message: `${person}严重超时率过高`
})
})
// 处理表格填写率异常人员
fillingPersons.forEach(person => {
processedAlerts.push({
id: `filling-${alertId++}`,
type: 'warning',
icon: '⚠',
message: `${person}表格填写率过低`
})
})
// 设置处理后的数据
groupAbnormalResponse.value = { processedAlerts }
} catch (error) {
console.error('获取团队异常预警失败:', error)
}
}
// 团队总通话
@@ -275,6 +234,16 @@ async function TeamGetWeekTotalCall() {
weekTotalData.value.week_total_call = res.data
}
}
// 有效通话时长
async function TeamGetGroupCallDuration() {
const params = getRequestParams()
const hasParams = params.user_name
const res = await getGroupCallDuration(hasParams ? params : undefined)
console.log(res)
if (res.code === 200) {
weekTotalData.value.group_call_duration = res.data
}
}
// 新增客户
async function TeamGetWeekAddCustomerTotal() {
const params = getRequestParams()
@@ -295,7 +264,48 @@ async function TeamGetWeekAddDealTotal() {
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 = []
}
}
// 定金转化
@@ -341,66 +351,127 @@ async function TeamGetGroupRanking() {
console.log(res)
if (res.code === 200) {
groupRanking.value = res.data
/**
* "data": {
"user_name": "马然",
"user_level": 2,
"team_data": {
"group_list": [
{
"user_name": "马然",
"week_amount": 0,
"conversion_rate": "0%",
"plus_v_rate": "0%",
"group_rate": "0%"
},
{
"user_name": "程慧仟",
"week_amount": 7100.0,
"conversion_rate": "0.00%",
"plus_v_rate": "0.00%",
"group_rate": "0.00%"
},
{
"user_name": "常琳",
"week_amount": 14500.0,
"conversion_rate": "3.51%",
"plus_v_rate": "54.39%",
"group_rate": "49.12%"
},
{
"user_name": "王娟娟",
"week_amount": 600.0,
"conversion_rate": "0.00%",
"plus_v_rate": "3.08%",
"group_rate": "0.00%"
}
]
}
}
*/
}
}
// 成员详细数据
const memberDetails = ref({})
// 当前选中的成员,默认为第一名
const selectedMember = ref(teamMembers[0]);
// 团队分析数据
const teamAnalysisData = ref([])
const showTeamAnalysisModal = ref(false)
// 当前选中的成员,默认为空
const selectedMember = ref(null);
// 选择成员函数
const selectMember = (member) => {
selectedMember.value = member;
console.log(122331,member)
TeamGetGroupDetail(member.user_name)
};
onMounted(async () => {
await TeamGetGroupAbnormalResponse()
await TeamGetWeekTotalCall()
await TeamGetWeekAddCustomerTotal()
await TeamGetWeekAddDealTotal()
await TeamGetWeekAddFeeTotal()
await TeamGetGroupFunnel()
await TeamGetGroupRanking()
// 成员详细数据
async function TeamGetGroupDetail(member) {
const res = await getGroupDetail({user_name:member})
console.log(res)
if (res.code === 200) {
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
*/
}
}
// 获取团队分析数据
const fetchTeamAnalysis = async () => {
try {
showTeamAnalysisModal.value = true
const params = getRequestParams()
const response = await getGroupEntiretyThirdReport(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)
teamAnalysisData.value = []
}
}
// 格式化报告内容
const formatReportContent = (content) => {
if (!content || content === "None") {
return "<p>暂无分析报告内容</p>";
}
// 处理报告内容,保留换行和基本格式
let formattedContent = content
// 替换连续的换行符
.replace(/\n\s*\n/g, '</p><p>')
// 替换单个换行符为<br>
.replace(/\n/g, '<br>')
// 替换Markdown风格的标题为HTML标签
.replace(/^### (.*?)(<br>|$)/gim, '<h3>$1</h3>')
.replace(/^## (.*?)(<br>|$)/gim, '<h2>$1</h2>')
.replace(/^# (.*?)(<br>|$)/gim, '<h1>$1</h1>')
// 替换粗体
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
// 替换斜体
.replace(/\*(.*?)\*/g, '<em>$1</em>')
// 替换无序列表项
.replace(/^\* (.*?)(<br>|$)/gim, '<li>$1</li>');
// 包装列表项到<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 () => {
CentergetGoodRecord()
TeamGetGroupAbnormalResponse()
TeamGetWeekTotalCall()
TeamGetGroupCallDuration()
TeamGetWeekAddCustomerTotal()
TeamGetWeekAddDealTotal()
TeamGetWeekAddFeeTotal()
TeamGetGroupFunnel()
TeamGetGroupRanking()
})
</script>
@@ -717,12 +788,12 @@ onMounted(async () => {
.top-section {
display: grid;
grid-template-columns: 1fr 3fr;
gap: 1rem;
gap: 0.5rem;
// PC端保持一致布局
@media (min-width: 1024px) {
grid-template-columns: 1fr 3fr;
gap: 1.5rem;
gap: 1rem;
}
// 平板端适配
@@ -747,7 +818,7 @@ onMounted(async () => {
.analytics-section {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
gap: 0.5rem;
margin-bottom: 1rem;
// PC端保持一致布局
@@ -1348,7 +1419,7 @@ onMounted(async () => {
// PC端保持一致布局
@media (min-width: 1024px) {
grid-template-columns: 50px 1fr 100px 80px 90px 90px;
grid-template-columns: 50px 1fr 80px 90px 90px;
gap: 1rem;
font-size: 0.875rem;
padding: 1rem 0;
@@ -1356,7 +1427,7 @@ onMounted(async () => {
// 平板端适配
@media (max-width: 1023px) and (min-width: 769px) {
grid-template-columns: 45px 1fr 90px 70px 90px 90px;
grid-template-columns: 45px 1fr 70px 90px 90px;
gap: 0.75rem;
font-size: 0.8125rem;
@@ -1365,7 +1436,7 @@ onMounted(async () => {
// 移动端适配
@media (max-width: 768px) {
grid-template-columns: 40px 1fr 80px 60px 90px 90px;
grid-template-columns: 40px 1fr 60px 90px 90px;
gap: 0.5rem;
font-size: 0.75rem;
@@ -1403,7 +1474,7 @@ onMounted(async () => {
// PC端保持一致布局
@media (min-width: 1024px) {
grid-template-columns: 50px 1fr 100px 80px 90px 90px;
grid-template-columns: 50px 1fr 80px 90px 90px;
gap: 1rem;
font-size: 0.875rem;
@@ -1921,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>

View File

@@ -15,16 +15,16 @@
<button
@click="startSopAnalysis"
class="analysis-button sop-button"
:disabled="isSopAnalysisLoading"
:disabled="!hasCallData"
>
{{ isSopAnalysisLoading ? 'SOP分析中...' : 'SOP通话分析' }}
{{ hasCallData ? 'SOP通话分析' : '暂无20分钟通话数据'}}
</button>
<button
@click="startDemandAnalysis"
class="analysis-button demand-button"
:disabled="isDemandAnalysisLoading"
>
{{ isDemandAnalysisLoading ? '诉求分析中...' : '客户诉求分析' }}
{{ isDemandAnalysisLoading ? '数据分析中...' : '总通话分析' }}
</button>
</div>
</div>
@@ -66,17 +66,16 @@
<!-- 下方整行区域 -->
<div class="bottom-row">
<!-- 客户诉求分析 -->
<div class="analysis-section demand-analysis">
<div class="section-header">
<h4>客户诉求分析</h4>
<h4>总通话分析</h4>
</div>
<div class="section-content">
<div class="text-content" v-if="demandAnalysisResult">
<div class="analysis-text" v-html="formattedDemandAnalysis"></div>
</div>
<div class="placeholder-text" v-else>
<p>点击"客户诉求分析"按钮开始深度分析客户需求和诉求</p>
<p>点击"总通话分析"按钮开始深度分析客户的所有通话</p>
</div>
</div>
</div>
@@ -95,12 +94,26 @@
import { ref, watch, computed } from 'vue';
import { SimpleChatService } from '@/utils/ChatService.js';
import MarkdownIt from 'markdown-it';
import https from '@/utils/https'
import axios from 'axios'
// 定义props
const props = defineProps({
selectedContact: {
type: Object,
default: null
},
formInfo: {
type: Array,
default: () => []
},
chatRecords: {
type: Array,
default: () => []
},
callRecords: {
type: Array,
default: () => []
}
});
@@ -115,8 +128,8 @@ const isSopAnalysisLoading = ref(false); // SOP分析加载状态
const isDemandAnalysisLoading = ref(false); // 诉求分析加载状态
// Dify API配置
const DIFY_API_KEY_01 = 'app-wbR1P1j6kvdBK8Q1qXzdswzP';
const DIFY_API_KEY = 'app-37VXHRieOnq17BSury9ONavG';
const DIFY_API_KEY_01 = 'app-h4uBo5kOGoiYhjuBF1AHZi8b'; //基础信息分析
const DIFY_API_KEY = 'app-ZIJSFWbcdZLufkwCp9RrvpUR'; // 总通话分析
// 初始化ChatService
const chatService_01 = new SimpleChatService(DIFY_API_KEY_01);
const chatService = new SimpleChatService(DIFY_API_KEY);
@@ -146,6 +159,11 @@ const formattedDemandAnalysis = computed(() => {
return md.render(demandAnalysisResult.value);
});
// 计算属性检查是否有20分钟通话数据
const hasCallData = computed(() => {
return props.callRecords && props.callRecords.some(call => call && call.record_tag === '20分钟通话');
});
// 监听selectedContact变化重置所有分析结果
watch(() => props.selectedContact, (newContact) => {
if (newContact) {
@@ -168,41 +186,100 @@ watch(() => props.selectedContact, (newContact) => {
}, { immediate: true });
// 基础信息分析
// const startBasicAnalysis = async () => {
// if (!props.selectedContact) return;
// isBasicAnalysisLoading.value = true;
// basicAnalysisResult.value = '';
// // 构建表单信息
// const formData = props.formInfo || [];
// let formInfoText = '暂无表单信息';
// // ** 适配新的 formInfo 数组格式 **
// if (Array.isArray(formData) && formData.length > 0) {
// const allInfo = [];
// // 遍历新格式: [{ question_label: "...", answer: "..." }, ...]
// formData.forEach(item => {
// // 检查字段是否存在且答案有效
// if (
// item.question_label &&
// item.answer &&
// item.answer !== '暂无' &&
// item.answer !== ''
// ) {
// // 格式化为 "问题标签: 答案"
// allInfo.push(`${item.question_label.trim()}: ${item.answer.trim()}`);
// }
// });
// // 格式化表单信息文本
// formInfoText = allInfo.length > 0
// ? `=== 问卷/表单信息 ===\n${allInfo.join('\n')}`
// : '暂无有效问卷/表单信息';
// }
// // ** 适配结束 **
// // 构建聊天记录信息
// const chatData = props.chatRecords || [];
// const chatInfoText = chatData.messages && 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}
// 请基于以上客户的表单信息、聊天记录和通话记录,分析客户的基本情况、背景信息和初步画像。`;
// 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;
// }
// };
const startBasicAnalysis=async ()=>{
if (!props.selectedContact) return;
isBasicAnalysisLoading.value = true;
basicAnalysisResult.value = '';
const query = `请对客户进行基础信息分析:
客户姓名:${props.selectedContact.name}
联系电话:${props.selectedContact.phone || '未提供'}
邮箱:${props.selectedContact.email || '未提供'}
公司:${props.selectedContact.company || '未提供'}
职位:${props.selectedContact.position || '未提供'}
销售阶段:${props.selectedContact.salesStage || '未知'}
健康度:${props.selectedContact.health || '未知'}%
请分析客户的基本情况、背景信息和初步画像。`;
try {
await chatService_01.sendMessage(
query,
(update) => {
basicAnalysisResult.value = update.content;
},
() => {
isBasicAnalysisLoading.value = false;
console.log('基础信息分析完成');
console.log("客户基础信息:", props.selectedContact);
const res=await https.post('api/v1/sales_timeline/get_customer_basic_info',{
user_name:props.selectedContact.name,
phone:props.selectedContact.phone
})
if(res.data){
basicAnalysisResult.value = res.data;
console.log("客户基础信息分析结果:", res);
}else{
basicAnalysisResult.value = '基础信息暂无数据'
}
);
} catch (error) {
console.error('基础信息分析失败:', error);
basicAnalysisResult.value = `分析失败: ${error.message}`;
isBasicAnalysisLoading.value = false;
}
};
}
// SOP通话分析
const startSopAnalysis = async () => {
if (!props.selectedContact) return;
@@ -210,29 +287,14 @@ const startSopAnalysis = async () => {
isSopAnalysisLoading.value = true;
sopAnalysisResult.value = '';
const query = `请对客户 ${props.selectedContact.name} 进行SOP通话分析
基于标准销售流程(SOP),分析以下方面:
1. 通话质量评估
2. 销售流程执行情况
3. 客户响应度分析
4. 沟通效果评价
5. 改进建议
客户当前状态:${props.selectedContact.salesStage || '未知'}
健康度:${props.selectedContact.health || '未知'}%`;
try {
await chatService.sendMessage(
query,
(update) => {
sopAnalysisResult.value = update.content;
},
() => {
isSopAnalysisLoading.value = false;
console.log('SOP通话分析完成');
const res = await axios.get('https://analysis.api.nycjy.cn/api/v1/call', {
params: {
wechat_id: props.selectedContact.wechat_id
}
);
});
sopAnalysisResult.value = res.data.report_content;
isSopAnalysisLoading.value = false;
} catch (error) {
console.error('SOP通话分析失败:', error);
sopAnalysisResult.value = `分析失败: ${error.message}`;
@@ -240,30 +302,31 @@ const startSopAnalysis = async () => {
}
};
// 客户诉求分析
// 总通话分析
const startDemandAnalysis = async () => {
console.log("所有通话记录:", props.callRecords);
if (!props.selectedContact) return;
isDemandAnalysisLoading.value = true;
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'); // 使用两个换行符清晰地分隔不同的通话记录
}
请从以下维度分析客户的真实需求和诉求:
1. 显性需求分析(客户明确表达的需求)
2. 隐性需求挖掘(潜在的、未明确表达的需求)
3. 痛点识别(客户面临的主要问题和挑战)
4. 决策因素分析(影响客户决策的关键因素)
5. 价值期望(客户期望获得的价值和收益)
6. 风险顾虑(客户可能的担忧和顾虑)
7. 个性化建议(针对性的解决方案建议)
客户信息:
姓名:${props.selectedContact.name}
公司:${props.selectedContact.company || '未提供'}
职位:${props.selectedContact.position || '未提供'}
销售阶段:${props.selectedContact.salesStage || '未知'}
健康度:${props.selectedContact.health || '未知'}%`;
// 2. 构建一个详细的、结构化的分析指令
const query = `
请基于以下该客户的所有通话记录,进行全面深入的分析:
=== 全部通话记录 ===
${allCallTexts}
`;
try {
await chatService.sendMessage(
@@ -273,11 +336,11 @@ const startDemandAnalysis = async () => {
},
() => {
isDemandAnalysisLoading.value = false;
console.log('客户诉求分析完成');
console.log('总通话分析完成');
}
);
} catch (error) {
console.error('客户诉求分析失败:', error);
console.error('总通话分析失败:', error);
demandAnalysisResult.value = `分析失败: ${error.message}`;
isDemandAnalysisLoading.value = false;
}
@@ -754,8 +817,14 @@ $purple: #a855f7;
// Markdown样式
.analysis-text {
// 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;
font-weight: 600;
color: $slate-800;
@@ -765,14 +834,31 @@ $purple: #a855f7;
}
}
h1 { font-size: 1.25rem; }
h2 { font-size: 1.125rem; }
h3 { font-size: 1rem; }
h4 { font-size: 0.875rem; }
h5 { font-size: 0.75rem; }
h6 { font-size: 0.75rem; }
:deep(h1) {
font-size: 1.25rem;
}
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;
&:first-child {
@@ -784,7 +870,8 @@ $purple: #a855f7;
}
}
ul, ol {
:deep(ul),
:deep(ol) {
margin: 0.5rem 0;
padding-left: 1.5rem;
@@ -793,23 +880,24 @@ $purple: #a855f7;
}
}
blockquote {
:deep(blockquote) {
margin: 1rem 0;
padding: 0.5rem 1rem;
border-left: 4px solid $blue;
background: rgba(59, 130, 246, 0.05);
font-style: italic;
color: $slate-600;
}
code {
:deep(code) {
background: $slate-100;
padding: 0.125rem 0.25rem;
border-radius: 0.25rem;
font-family: 'Courier New', monospace;
font-size: 0.8rem;
color: $indigo;
}
pre {
:deep(pre) {
background: $slate-100;
padding: 1rem;
border-radius: 0.5rem;
@@ -822,16 +910,16 @@ $purple: #a855f7;
}
}
strong {
:deep(strong) {
font-weight: 600;
color: $slate-800;
}
em {
:deep(em) {
font-style: italic;
}
a {
:deep(a) {
color: $blue;
text-decoration: none;
@@ -840,18 +928,19 @@ $purple: #a855f7;
}
}
hr {
:deep(hr) {
margin: 1.5rem 0;
border: none;
border-top: 1px solid $slate-200;
}
table {
:deep(table) {
width: 100%;
border-collapse: collapse;
margin: 1rem 0;
th, td {
th,
td {
padding: 0.5rem;
border: 1px solid $slate-200;
text-align: left;
@@ -948,8 +1037,6 @@ h4 {
background-color: $white;
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);
// padding: 1rem;
// margin-top: 12px;
}
// 分析区域布局优化
@@ -988,5 +1075,4 @@ h4 {
min-height: 250px;
}
}
</style>

View File

@@ -1,8 +1,9 @@
<template>
<div class="personal-dashboard">
<!-- 头部标题 -->
<div class="dashboard-header">
<div class="dashboard-header" style="display: flex; justify-content: space-between; align-items: center;">
<h2>个人工作仪表板</h2>
<button @click="showSecondOrderAnalysisReport">阶段分析报告</button>
</div>
<!-- 核心KPI & 统计卡片 -->
@@ -13,27 +14,27 @@
<div class="kpi-grid">
<div class="kpi-item">
<div class="kpi-value">{{ props.kpiData.totalCalls }}</div>
<p>今日通话</p>
<p>本期通话 <i class="info-icon" @mouseenter="showTooltip('totalCalls', $event)" @mouseleave="hideTooltip"></i></p>
</div>
<div class="kpi-item">
<div class="kpi-value">{{ props.kpiData.successRate }}%</div>
<p>成功率</p>
<div class="kpi-value">{{ props.kpiData.successRate }}</div>
<p>电话接通率 <i class="info-icon" @mouseenter="showTooltip('successRate', $event)" @mouseleave="hideTooltip"></i></p>
</div>
<div class="kpi-item">
<div class="kpi-value">{{ props.kpiData.avgDuration }}<span class="kpi-unit">min</span></div>
<p>平均时长</p>
<p>平均通话时长 <i class="info-icon" @mouseenter="showTooltip('avgDuration', $event)" @mouseleave="hideTooltip"></i></p>
</div>
<div class="kpi-item">
<div class="kpi-value">{{ props.kpiData.conversionRate }}</div>
<p>转化率</p>
<p>成交转化率 <i class="info-icon" @mouseenter="showTooltip('conversionRate', $event)" @mouseleave="hideTooltip"></i></p>
</div>
<div class="kpi-item">
<div class="kpi-value">{{ props.kpiData.assignedData }}</div>
<p>本期分配数据</p>
<p>本期分配数据 <i class="info-icon" @mouseenter="showTooltip('assignedData', $event)" @mouseleave="hideTooltip"></i></p>
</div>
<div class="kpi-item">
<div class="kpi-value">{{ props.kpiData.wechatAddRate }}</div>
<p>加微率</p>
<p>加微率 <i class="info-icon" @mouseenter="showTooltip('wechatAddRate', $event)" @mouseleave="hideTooltip"></i></p>
</div>
</div>
</div>
@@ -95,19 +96,76 @@
</div>
</div>
</div>
<!-- 指标说明 Tooltip -->
<Tooltip
:visible="tooltip.visible"
:x="tooltip.x"
:y="tooltip.y"
:title="tooltip.title"
: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>
</template>
<script setup>
import { ref, reactive, onMounted, onBeforeUnmount, computed } from 'vue';
import Tooltip from '@/components/Tooltip.vue';
import { ref, reactive, onMounted, onBeforeUnmount, computed, watch } from 'vue';
import StatisticData from './StatisticData.vue';
import * as echarts from 'echarts';
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 { useRouter } from "vue-router";
import { SimpleChatService } from '@/utils/ChatService.js';
// 用户store
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
const props = defineProps({
kpiData: {
@@ -130,10 +188,7 @@ const props = defineProps({
},
contactTimeData: {
type: Object,
default: () => ({
labels: ['9-10点', '10-11点', '11-12点', '14-15点', '15-16点', '16-17点'],
data: [65, 85, 80, 92, 75, 60]
})
default: () => ({})
},
statisticsData: {
type: Object,
@@ -151,13 +206,61 @@ 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 实例
const chartInstances = {};
// 添加组件挂载状态跟踪
const isComponentMounted = ref(true);
// DOM 元素引用
const personalFunnelChartCanvas = ref(null);
const contactTimeChartCanvas = ref(null);
// Tooltip 相关数据
const tooltip = reactive({
visible: false,
x: 0,
y: 0,
title: '',
description: ''
});
// 指标说明配置
const kpiDescriptions = {
totalCalls: {
title: '本期通话',
description: '本期总共通话的次数。'
},
successRate: {
title: '电话接通率',
description: '拨通电话 ÷ 拨打的电话'
},
avgDuration: {
title: '平均通话时长',
description: '所有通话总时长 ÷ 拨打的电话次数。'
},
conversionRate: {
title: '成交转化率',
description: '成交人数 ÷ 本期总数据。'
},
assignedData: {
title: '本期分配数据',
description: '本期内分配到的数据总量。'
},
wechatAddRate: {
title: '加微率',
description: '加微人数 ÷ 本期数据总人数'
}
};
// Chart.js 数据 - 使用props传递的数据
const funnelData = computed(() => props.funnelData);
const contactTimeData = computed(() => props.contactTimeData);
@@ -176,18 +279,13 @@ const totalProblemCount = computed(() => {
return props.urgentProblemData.reduce((sum, item) => sum + item.value, 0);
});
// --- 方法 ---
// Chart.js: 创建或更新图表
const createOrUpdateChart = (chartId, canvasRef, config) => {
if (chartInstances[chartId]) {
chartInstances[chartId].destroy();
}
if (canvasRef.value) {
// 确保组件仍然挂载且canvas引用存在
if (isComponentMounted.value && canvasRef.value) {
const ctx = canvasRef.value.getContext('2d');
chartInstances[chartId] = new Chart(ctx, config);
}
@@ -195,13 +293,16 @@ const createOrUpdateChart = (chartId, canvasRef, config) => {
// Chart.js: 渲染销售漏斗图
const renderPersonalFunnelChart = () => {
// 确保组件仍然挂载
if (!isComponentMounted.value) return;
const config = {
type: 'bar',
data: {
labels: funnelData.labels,
labels: funnelData.value.labels,
datasets: [{
label: '数量', data: funnelData.data,
backgroundColor: ['rgba(59, 130, 246, 0.8)', 'rgba(16, 185, 129, 0.8)', 'rgba(245, 158, 11, 0.8)', 'rgba(239, 68, 68, 0.8)'],
label: '数量', data: funnelData.value.data,
backgroundColor: ['rgba(59, 130, 246, 0.8)', 'rgba(16, 185, 129, 0.8)', 'rgba(245, 158, 11, 0.8)', 'rgba(239, 68, 68, 0.8)', 'rgba(168, 85, 247, 0.8)'],
borderWidth: 1
}]
},
@@ -219,12 +320,22 @@ const renderPersonalFunnelChart = () => {
// Chart.js: 渲染黄金联络时段图
const renderContactTimeChart = () => {
// 确保组件仍然挂载
if (!isComponentMounted.value) return;
if (!props.contactTimeData || !props.contactTimeData.gold_contact_success_rate) {
return;
}
const labels = Object.keys(props.contactTimeData.gold_contact_success_rate);
const data = Object.values(props.contactTimeData.gold_contact_success_rate).map(rate => parseFloat(rate));
const config = {
type: 'line',
data: {
labels: contactTimeData.labels,
labels: labels,
datasets: [{
label: '成功率', data: contactTimeData.data,
label: '成功率', data: data,
borderColor: '#10b981', backgroundColor: 'rgba(16, 185, 129, 0.1)',
borderWidth: 3, tension: 0.4, fill: true, pointRadius: 4,
pointBackgroundColor: '#10b981', pointBorderColor: '#ffffff', pointBorderWidth: 2
@@ -252,16 +363,51 @@ const getPercentage = (value) => {
const getRankingClass = (index) => ({ 'rank-first': index === 0, 'rank-second': index === 1, 'rank-third': index === 2, 'rank-other': index > 2 });
const getRankBadgeClass = (index) => ({ 'badge-gold': index === 0, 'badge-silver': index === 1, 'badge-bronze': index === 2, 'badge-default': index > 2 });
// Tooltip 相关方法
const showTooltip = (kpiType, event) => {
const description = kpiDescriptions[kpiType];
if (description) {
tooltip.title = description.title;
tooltip.description = description.description;
tooltip.x = event.clientX + 10;
tooltip.y = event.clientY - 10;
tooltip.visible = true;
}
};
const hideTooltip = () => {
tooltip.visible = false;
};
// 阶段分析报告模态框状态
const showAnalysisModal = ref(false);
// 阶段分析报告数据
const analysisReport = ref({});
// 显示阶段分析报告模态框
const showSecondOrderAnalysisReport = () => {
showAnalysisModal.value = true;
CenterGetSecondOrderAnalysisReport()
};
// 关闭阶段分析报告模态框
const closeAnalysisModal = () => {
showAnalysisModal.value = false;
};
watch(() => props.contactTimeData, () => {
renderContactTimeChart();
}, { deep: true });
// --- 生命周期钩子 ---
onMounted(() => {
isComponentMounted.value = true;
renderPersonalFunnelChart();
renderContactTimeChart();
});
onBeforeUnmount(() => {
isComponentMounted.value = false;
Object.values(chartInstances).forEach(chart => chart.destroy());
});
</script>
@@ -431,6 +577,8 @@ $white: #ffffff;
}
// --- 图表区域 ---
.charts-section {
display: grid;
@@ -451,7 +599,7 @@ $white: #ffffff;
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 20px 16px;
padding: 10px 20px 10px;
border-bottom: 1px solid #ebeef5;
h3 { margin: 0; color: $slate-900; font-size: 18px; font-weight: 600; }
}
@@ -645,6 +793,200 @@ $white: #ffffff;
gap: 16px;
}
.modal-header {
padding-left: 15px;
padding-right: 15px;
}
.modal-title {
font-size: 16px;
}
}
.info-icon {
font-style: normal;
color: $blue;
font-size: 12px;
margin-left: 4px;
opacity: 0.7;
cursor: pointer;
transition: all 0.3s ease;
&:hover {
opacity: 1;
color: #007bff;
transform: scale(1.2);
}
}
.kpi-item:hover .info-icon {
opacity: 1;
}
.kpi-item {
position: relative;
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>

View File

@@ -14,16 +14,30 @@
</svg>
</div>
<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 class="card-content">
<div v-for="(section, sectionIndex) in displayedFormSections" :key="sectionIndex" class="form-section">
<div v-if="section.title" class="form-section-title">{{ section.title }}</div>
<div class="form-data-list">
<div v-for="(field, index) in formFields" :key="index" class="form-field">
<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 class="data-card communication-card">
@@ -70,7 +84,7 @@
<span class="content-time">最新: {{ chatData.lastMessage }}</span>
</div>
<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">
<span class="message-sender">{{ message.format_direction }}</span>
<span class="message-time">{{ message.format_add_time }}</span>
@@ -83,17 +97,43 @@
<!-- 通话录音内容 -->
<div v-if="activeTab === 'call'" class="call-content">
<div class="content-header">
<span class="content-count"> {{ callData.count }} 次通话</span>
<span class="content-time">总时长: {{ callData.totalDuration }}</span>
<span class="content-count"> {{ callRecords.length }} 次通话</span>
<span class="content-time">通话记录</span>
</div>
<div class="call-list">
<div v-for="(call, index) in callRecords" :key="index" class="call-item">
<div class="call-header">
<span class="call-type">{{ call.type }}</span>
<span class="call-duration">{{ call.duration }}</span>
<span class="call-time">{{ call.time }}</span>
<div class="header-main" style="display: flex; flex-direction: row;">
<div class="user-info">
<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 class="call-actions">
<div class="action-buttons">
<button class="action-btn download-btn" @click="downloadRecording(call)">
<i class="icon-download"></i>
录音下载
</button>
<button class="action-btn view-btn" @click="viewTranscript(call)">
<i class="icon-view"></i>
查看原文
</button>
<!-- 时长移到操作按钮后面 -->
<span class="call-duration">{{ formatCallDuration(call.call_duration) }}</span>
</div>
</div>
<div class="call-summary">{{ call.summary }}</div>
</div>
</div>
</div>
@@ -106,7 +146,8 @@
</template>
<script setup>
import { ref, computed } from 'vue'
import { ref, computed, watch } from 'vue'
import axios from 'axios'
// Props
const props = defineProps({
@@ -115,15 +156,22 @@ const props = defineProps({
default: () => ({})
},
formInfo: {
type: Object,
default: () => ({})
type: Array,
default: () => []
},
chatInfo: {
type: Object,
default: () => ({})
},
callInfo: {
type: Object,
default: () => ({})
}
})
// Emits
const emit = defineEmits(['analyze-sop', 'show-modal', 'show-download-modal'])
// 当前激活的tab
const activeTab = ref('chat')
@@ -132,21 +180,50 @@ const chatMessages = computed(() => {
return props.chatInfo?.messages || []
})
// 表单字段数据
const formFields = computed(() => {
const formFilter = ref('全部')
const formSections = computed(() => {
const formData = props.formInfo
if (!formData || Object.keys(formData).length === 0) {
return [
const emptyFields = [
{ 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 = []
// 检查是否为第一种格式包含name, mobile等字段
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(' | ')
@@ -157,7 +234,6 @@ const formFields = computed(() => {
{ label: '地区', value: formData.territory || '暂无' }
]
// 如果有additional_info添加所有问题
if (formData.additional_info && Array.isArray(formData.additional_info)) {
formData.additional_info.forEach((item) => {
fields.push({
@@ -167,7 +243,6 @@ const formFields = computed(() => {
})
}
} else {
// 第二种格式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(' | ')
@@ -183,12 +258,30 @@ const formFields = computed(() => {
{ label: '预期时间', value: formData.expandThirty || '暂无' }
]
}
// 合并表单数据和聊天数据
const allFields = [...fields]
return allFields
return makeSection(fields)
})
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(() => ({
count: props.chatInfo?.messages?.length || 0,
@@ -203,33 +296,171 @@ const callData = computed(() => ({
// 通话记录列表
const callRecords = computed(() => {
return [
{
type: '呼出',
duration: '12分钟',
time: '今天 10:30',
summary: '初次沟通了解客户基本需求。客户对数学课程比较感兴趣孩子8岁希望提高数学成绩。约定发送详细资料。'
},
{
type: '呼入',
duration: '8分钟',
time: '昨天 16:45',
summary: '客户主动来电咨询价格和上课时间。解答了关于师资力量和教学方法的问题。客户表示需要和家人商量。'
},
{
type: '呼出',
duration: '15分钟',
time: '3天前 14:20',
summary: '跟进客户需求,详细介绍了课程体系和教学理念。客户对一对一辅导很感兴趣,但对价格有些犹豫。'
},
{
type: '呼出',
duration: '6分钟',
time: '5天前 11:15',
summary: '首次电话联系,简单介绍了公司和课程概况。客户表示有兴趣,约定后续详细沟通。'
// 从 props.callInfo 中获取真实的通话记录数据
if (props.callInfo && Array.isArray(props.callInfo)) {
console.log('通话记录:', props.callInfo)
return props.callInfo
}
]
// 如果 callInfo 是单个对象API返回的数据格式
if (props.callInfo && typeof props.callInfo === 'object' && props.callInfo.user_name) {
return [props.callInfo] // 将单个对象包装成数组
}
// 如果 callInfo 是对象且包含数据数组
if (props.callInfo && props.callInfo && Array.isArray(props.callInfo)) {
return props.callInfo
}
// 如果没有数据,返回空数组
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 = async (call) => {
console.log('下载录音:', call)
// 检查是否有录音文件地址
if (call.record_file_addr) {
const recordingUrl = call.record_file_addr
try {
// 显示下载开始提示
emit('show-download-modal', '下载提示', '正在下载录音文件,请稍候...')
// 若为 HTTPS 页面请求 HTTP 资源,浏览器会拦截,回退为在新标签页打开
if (window.location.protocol === 'https:' && recordingUrl.startsWith('http://')) {
const parts = recordingUrl.split('/')
const fallbackName = parts[parts.length - 1] || 'recording'
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
}
// 通过请求的方式下载
const response = await fetch(recordingUrl, {
method: 'GET',
mode: 'cors',
credentials: 'omit',
redirect: 'follow',
referrerPolicy: 'no-referrer'
})
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 {
emit('show-download-modal', '提示', '该通话记录暂无录音文件')
}
}
// 查看原文方法
const viewTranscript = async (call) => {
const title = '通话原文内容'
const content = call.record_context || '该通话记录暂无原文内容'
emit('show-modal', { title, content })
}
// 时间格式化方法
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>
<style lang="scss" scoped>
@@ -318,6 +549,58 @@ const callRecords = computed(() => {
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-field {
@@ -449,114 +732,196 @@ const callRecords = computed(() => {
.call-list {
.call-item {
margin-bottom: 16px;
padding: 16px;
border-radius: 8px;
background: #f9fafb;
border-left: 4px solid #3b82f6;
padding: 20px;
border-radius: 12px;
background: #ffffff;
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 {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
align-items: flex-start;
.header-main {
display: flex;
flex-direction: column;
gap: 8px;
flex: 1;
.user-info {
display: flex;
gap: 12px;
flex-wrap: wrap;
.call-type {
font-size: 12px;
font-size: 13px;
font-weight: 600;
padding: 4px 8px;
border-radius: 4px;
padding: 5px 10px;
border-radius: 20px;
background: #dbeafe;
color: #3b82f6;
}
.call-duration {
font-size: 12px;
.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 {
font-size: 12px;
color: #9ca3af;
}
}
.call-summary {
font-size: 14px;
color: #374151;
line-height: 1.5;
}
}
}
.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;
white-space: nowrap;
padding: 4px 8px;
background: #f9fafb;
border-radius: 4px;
align-self: flex-start;
}
}
.stat-value {
font-size: 14px;
color: #111827;
font-weight: 600;
}
.call-actions {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 12px;
.card-action {
border-top: 1px solid #f3f4f6;
padding-top: 16px;
margin-top: 16px;
}
.action-buttons {
display: flex;
gap: 12px;
align-items: center;
.view-btn {
.action-btn {
display: flex;
align-items: center;
gap: 8px;
background: none;
gap: 6px;
padding: 8px 14px;
border: none;
color: #6b7280;
font-size: 14px;
border-radius: 8px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
padding: 8px 0;
transition: color 0.2s ease;
transition: all 0.2s ease;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
&.download-btn {
background: #dbeafe;
color: #3b82f6;
&:hover {
color: #111827;
background: #bfdbfe;
transform: translateY(-2px);
box-shadow: 0 4px 6px rgba(59, 130, 246, 0.2);
}
svg {
transition: transform 0.2s ease;
&:active {
transform: translateY(0);
}
}
&:hover svg {
transform: translateX(2px);
&.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: '👁';
}
}
}
// 通话时长样式
.call-duration {
font-size: 13px;
color: #6b7280;
font-weight: 500;
background: #f9fafb;
padding: 6px 12px;
border-radius: 20px;
border: 1px solid #e5e7eb;
}
}
}
}
}
@@ -607,8 +972,10 @@ const callRecords = computed(() => {
font-size: 14px;
}
.card-description {
font-size: 13px;
.call-actions {
flex-direction: column;
align-items: flex-start;
gap: 10px;
}
}
</style>

View File

@@ -2,72 +2,95 @@
<div class="stat-card kpi-card">
<h3 class="card-title">统计指标</h3>
<div class="kpi-grid stats-grid-inner">
<!-- 所有 .kpi-item 保持不变 -->
<div class="kpi-item stat-item" >
<div class="stat-icon customer-rate">
<i class="el-icon-chat-dot-round"></i>
</div>
<div class="stat-icon customer-rate"><i class="el-icon-chat-dot-round"></i></div>
<div class="kpi-value">{{ customerCommunicationRate }}</div>
<p>活跃客户沟通率</p>
<p>客户沟通率 <i class="info-icon" @mouseenter="showTooltip('customerCommunicationRate', $event)" @mouseleave="hideTooltip"></i></p>
</div>
<div class="kpi-item stat-item" >
<div class="stat-icon response-time">
<i class="el-icon-timer"></i>
</div>
<div class="kpi-value">{{ averageResponseTime }}<span class="kpi-unit">分钟</span></div>
<p>平均应答时间</p>
<div class="stat-icon response-time"><i class="el-icon-timer"></i></div>
<div class="kpi-value">{{ averageResponseTime }}<span class="kpi-unit"></span></div>
<p>均响应时间 <i class="info-icon" @mouseenter="showTooltip('averageResponseTime', $event)" @mouseleave="hideTooltip"></i></p>
</div>
<div class="kpi-item stat-item" >
<div class="stat-icon timeout-rate">
<i class="el-icon-warning"></i>
</div>
<div class="stat-icon timeout-rate"><i class="el-icon-warning"></i></div>
<div class="kpi-value">{{ timeoutResponseRate }}</div>
<p>超时应答率</p>
<p>超时应答率 <i class="info-icon" @mouseenter="showTooltip('timeoutResponseRate', $event)" @mouseleave="hideTooltip"></i></p>
</div>
<div class="kpi-item stat-item">
<div class="stat-icon severe-timeout-rate">
<i class="el-icon-warning-outline"></i>
</div>
<div class="stat-icon severe-timeout-rate"><i class="el-icon-warning-outline"></i></div>
<div class="kpi-value">{{ severeTimeoutRate }}</div>
<p>严重超时应答</p>
<p>严重超时率 <i class="info-icon" @mouseenter="showTooltip('severeTimeoutRate', $event)" @mouseleave="hideTooltip"></i></p>
</div>
<div class="kpi-item stat-item">
<div class="stat-icon form-rate">
<i class="el-icon-document"></i>
</div>
<div class="stat-icon form-rate"><i class="el-icon-document"></i></div>
<div class="kpi-value">{{ formCompletionRate }}</div>
<p>表格填写率</p>
<p>表格填写率 <i class="info-icon" @mouseenter="showTooltip('formCompletionRate', $event)" @mouseleave="hideTooltip"></i></p>
</div>
</div>
<!-- 指标说明 Tooltip -->
<Tooltip
:visible="tooltip.visible"
:x="tooltip.x"
:y="tooltip.y"
:title="tooltip.title"
:description="tooltip.description"
/>
</div>
</template>
<script setup>
import { defineProps } from 'vue';
import { defineProps, reactive } from 'vue';
import Tooltip from '@/components/Tooltip.vue';
defineProps({
customerCommunicationRate: {
type: Number,
default: 0
},
averageResponseTime: {
type: Number,
default: 0
},
timeoutResponseRate: {
type: Number,
default: 0
},
severeTimeoutRate: {
type: Number,
default: 0
},
formCompletionRate: {
type: Number,
default: 0
}
customerCommunicationRate: { type: Number, default: 0 },
averageResponseTime: { type: Number, default: 0 },
timeoutResponseRate: { type: Number, default: 0 },
severeTimeoutRate: { type: Number, default: 0 },
formCompletionRate: { type: Number, default: 0 }
});
const tooltip = reactive({
visible: false, // 确保初始值为 false
x: 0,
y: 0,
title: '',
description: ''
});
const statDescriptions = {
customerCommunicationRate: { title: '活跃客户沟通率', description: '活跃沟通客户数 ÷ 总客户数' },
averageResponseTime: { title: '平均应答时间', description: '总应答时间 ÷ 应答次数' },
timeoutResponseRate: { title: '超时应答率', description: '超时应答次数 ÷ 总应答次数' },
severeTimeoutRate: { title: '严重超时应答率', description: '严重超时应答次数 ÷ 总应答次数' },
formCompletionRate: { title: '表格填写率', description: '已填写表格数 ÷ 总表格数' }
};
const showTooltip = (statKey, event) => {
console.log('Mouse entered! Firing showTooltip for:', statKey);
const description = statDescriptions[statKey];
if (description) {
tooltip.visible = true;
tooltip.x = event.clientX + 10;
tooltip.y = event.clientY + 15;
tooltip.title = description.title;
tooltip.description = description.description;
console.log('Tooltip state updated:', JSON.parse(JSON.stringify(tooltip))); // 使用 JSON 序列化来查看快照
}
};
const hideTooltip = () => {
console.log('Mouse left! Hiding tooltip.');
tooltip.visible = false;
};
</script>
<style lang="scss" scoped>
</style>
<style lang="scss" scoped>
// --- 颜色和变量定义 ---
$slate-50: #f8fafc;
@@ -157,6 +180,59 @@ $white: #ffffff;
}
}
// Info 图标样式
.info-icon {
font-size: 12px;
margin-left: 4px;
opacity: 0.7;
cursor: pointer;
transition: all 0.3s ease;
&:hover {
opacity: 1;
color: #007bff;
transform: scale(1.2);
}
}
// Tooltip 样式
.stat-tooltip {
position: fixed;
z-index: 9999;
background: rgba(0, 0, 0, 0.9);
color: white;
padding: 12px 16px;
border-radius: 8px;
font-size: 14px;
max-width: 300px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
pointer-events: none;
.tooltip-title {
font-weight: 600;
margin-bottom: 4px;
color: #fff;
}
.tooltip-description {
font-size: 13px;
color: #e0e0e0;
line-height: 1.4;
}
}
// 统计项悬停效果
.stat-item {
cursor: pointer;
transition: all 0.3s ease;
&:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
}
// --- 响应式设计 ---
@media (max-width: 768px) {
.stats-grid-inner {

View File

@@ -0,0 +1,475 @@
<template>
<div class="week-analyze">
<div class="analyze-header">
<h3>本周综合表现分析</h3>
<p class="analyze-subtitle">基于本周销售数据的综合分析报告</p>
</div>
<div class="analyze-content">
<!-- 周期表现概览 -->
<div class="performance-overview">
<div class="overview-card">
<div class="card-icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<path d="M12 2L15.09 8.26L22 9L17 14L18.18 21L12 17.77L5.82 21L7 14L2 9L8.91 8.26L12 2Z" fill="#FFD700"/>
</svg>
</div>
<div class="card-content">
<h4>综合评分</h4>
<div class="score">{{ overallScore }}<span class="score-unit">/100</span></div>
<div class="score-trend" :class="scoreTrend.type">
{{ scoreTrend.text }}
</div>
</div>
</div>
<div class="overview-card">
<div class="card-icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<path d="M16 6L18.29 8.29L13.41 13.17L9.41 9.17L2 16.59L3.41 18L9.41 12L13.41 16L19.71 9.71L22 12V6H16Z" fill="#4CAF50"/>
</svg>
</div>
<div class="card-content">
<h4>目标完成率</h4>
<div class="score">{{ targetCompletion }}<span class="score-unit">%</span></div>
<div class="progress-bar">
<div class="progress-fill" :style="{ width: targetCompletion + '%' }"></div>
</div>
</div>
</div>
<div class="overview-card">
<div class="card-icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<path d="M12 2C6.48 2 2 6.48 2 12S6.48 22 12 22 22 17.52 22 12 17.52 2 12 2ZM13 17H11V15H13V17ZM13 13H11V7H13V13Z" fill="#FF9800"/>
</svg>
</div>
<div class="card-content">
<h4>改进建议</h4>
<div class="suggestions-count">{{ suggestions.length }}</div>
<div class="suggestions-preview">{{ suggestions[0]?.title || '暂无建议' }}</div>
</div>
</div>
</div>
<!-- 详细分析 -->
<div class="detailed-analysis">
<div class="analysis-section">
<h4>关键指标表现</h4>
<div class="metrics-grid">
<div v-for="metric in keyMetrics" :key="metric.name" class="metric-item">
<div class="metric-header">
<span class="metric-name">{{ metric.name }}</span>
<span class="metric-trend" :class="metric.trend">
{{ metric.trendText }}
</span>
</div>
<div class="metric-value">
<span class="current-value">{{ metric.current }}</span>
<span class="target-value">/ {{ metric.target }}</span>
</div>
<div class="metric-progress">
<div class="progress-bar">
<div class="progress-fill" :style="{ width: (metric.current / metric.target * 100) + '%' }"></div>
</div>
<span class="progress-text">{{ Math.round(metric.current / metric.target * 100) }}%</span>
</div>
</div>
</div>
</div>
<div class="analysis-section">
<h4>改进建议</h4>
<div class="suggestions-list">
<div v-for="suggestion in suggestions" :key="suggestion.id" class="suggestion-item">
<div class="suggestion-priority" :class="suggestion.priority"></div>
<div class="suggestion-content">
<h5>{{ suggestion.title }}</h5>
<p>{{ suggestion.description }}</p>
<div class="suggestion-actions">
<span class="action-tag" v-for="action in suggestion.actions" :key="action">{{ action }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
// Props
const props = defineProps({
weekData: {
type: Object,
default: () => ({})
}
})
// 响应式数据
const overallScore = ref(78)
const targetCompletion = ref(65)
const scoreTrend = computed(() => {
const score = overallScore.value
if (score >= 80) {
return { type: 'positive', text: '表现优秀' }
} else if (score >= 60) {
return { type: 'neutral', text: '表现良好' }
} else {
return { type: 'negative', text: '需要改进' }
}
})
const keyMetrics = ref([
{
name: '通话量',
current: 85,
target: 100,
trend: 'positive',
trendText: '↗ +12%'
},
{
name: '接通率',
current: 68,
target: 80,
trend: 'neutral',
trendText: '→ 持平'
},
{
name: '转化率',
current: 12,
target: 15,
trend: 'negative',
trendText: '↘ -3%'
},
{
name: '客户满意度',
current: 4.2,
target: 4.5,
trend: 'positive',
trendText: '↗ +0.2'
}
])
const suggestions = ref([
{
id: 1,
title: '提升通话转化率',
description: '当前转化率低于目标,建议优化话术和跟进策略',
priority: 'high',
actions: ['话术优化', '跟进策略', '客户画像分析']
},
{
id: 2,
title: '增加客户互动频次',
description: '客户响应时间较长,建议增加主动联系频次',
priority: 'medium',
actions: ['主动联系', '内容营销', '社群运营']
},
{
id: 3,
title: '优化时间管理',
description: '通话时长分布不均,建议优化时间分配',
priority: 'low',
actions: ['时间规划', '效率工具', '任务优先级']
}
])
// 生命周期
onMounted(() => {
// 可以在这里根据传入的数据计算分析结果
console.log('WeekAnalize组件已挂载', props.weekData)
})
</script>
<style scoped>
.week-analyze {
background: #fff;
border-radius: 12px;
padding: 24px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.analyze-header {
margin-bottom: 24px;
}
.analyze-header h3 {
font-size: 20px;
font-weight: 600;
color: #1a1a1a;
margin: 0 0 8px 0;
}
.analyze-subtitle {
color: #666;
font-size: 14px;
margin: 0;
}
.performance-overview {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 20px;
margin-bottom: 32px;
}
.overview-card {
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
border-radius: 12px;
padding: 20px;
display: flex;
align-items: center;
gap: 16px;
border: 1px solid #e9ecef;
}
.card-icon {
width: 48px;
height: 48px;
background: #fff;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.card-content h4 {
font-size: 14px;
color: #666;
margin: 0 0 8px 0;
font-weight: 500;
}
.score {
font-size: 28px;
font-weight: 700;
color: #1a1a1a;
line-height: 1;
}
.score-unit {
font-size: 16px;
color: #666;
font-weight: 400;
}
.score-trend {
font-size: 12px;
margin-top: 4px;
}
.score-trend.positive {
color: #4CAF50;
}
.score-trend.neutral {
color: #FF9800;
}
.score-trend.negative {
color: #f44336;
}
.progress-bar {
width: 100%;
height: 6px;
background: #e9ecef;
border-radius: 3px;
overflow: hidden;
margin-top: 8px;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #4CAF50 0%, #8BC34A 100%);
border-radius: 3px;
transition: width 0.3s ease;
}
.suggestions-count {
font-size: 24px;
font-weight: 700;
color: #FF9800;
line-height: 1;
}
.suggestions-preview {
font-size: 12px;
color: #666;
margin-top: 4px;
}
.detailed-analysis {
display: grid;
gap: 32px;
}
.analysis-section h4 {
font-size: 16px;
font-weight: 600;
color: #1a1a1a;
margin: 0 0 16px 0;
}
.metrics-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 16px;
}
.metric-item {
background: #f8f9fa;
border-radius: 8px;
padding: 16px;
border: 1px solid #e9ecef;
}
.metric-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.metric-name {
font-size: 14px;
color: #1a1a1a;
font-weight: 500;
}
.metric-trend {
font-size: 12px;
font-weight: 500;
}
.metric-trend.positive {
color: #4CAF50;
}
.metric-trend.neutral {
color: #FF9800;
}
.metric-trend.negative {
color: #f44336;
}
.metric-value {
margin-bottom: 8px;
}
.current-value {
font-size: 20px;
font-weight: 700;
color: #1a1a1a;
}
.target-value {
font-size: 14px;
color: #666;
margin-left: 4px;
}
.metric-progress {
display: flex;
align-items: center;
gap: 8px;
}
.metric-progress .progress-bar {
flex: 1;
margin: 0;
}
.progress-text {
font-size: 12px;
color: #666;
min-width: 35px;
text-align: right;
}
.suggestions-list {
display: grid;
gap: 16px;
}
.suggestion-item {
display: flex;
gap: 12px;
padding: 16px;
background: #f8f9fa;
border-radius: 8px;
border: 1px solid #e9ecef;
}
.suggestion-priority {
width: 4px;
border-radius: 2px;
flex-shrink: 0;
}
.suggestion-priority.high {
background: #f44336;
}
.suggestion-priority.medium {
background: #FF9800;
}
.suggestion-priority.low {
background: #4CAF50;
}
.suggestion-content h5 {
font-size: 14px;
font-weight: 600;
color: #1a1a1a;
margin: 0 0 8px 0;
}
.suggestion-content p {
font-size: 13px;
color: #666;
margin: 0 0 12px 0;
line-height: 1.4;
}
.suggestion-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.action-tag {
background: #e3f2fd;
color: #1976d2;
font-size: 11px;
padding: 4px 8px;
border-radius: 12px;
font-weight: 500;
}
@media (max-width: 768px) {
.week-analyze {
padding: 16px;
}
.performance-overview {
grid-template-columns: 1fr;
}
.metrics-grid {
grid-template-columns: 1fr;
}
.overview-card {
padding: 16px;
}
}
</style>

View File

@@ -1,34 +1,48 @@
<template>
<div class="sales-dashboard">
<!-- 页面加载状态 -->
<!-- <Loading :visible="isPageLoading" text="正在加载数据..." /> -->
<Loading :visible="isPageLoading" text="正在加载数据..." />
<!-- 顶部导航栏 -->
<!-- 销售时间线区域 -->
<section class="timeline-section">
<section v-if="cardVisibility.timeline" class="timeline-section">
<div class="section-header">
<!-- 动态顶栏根据是否有路由参数显示不同内容 -->
<!-- 路由跳转时的顶栏面包屑 + 姓名 -->
<div v-if="isRouteNavigation" class="route-header">
<div class="breadcrumb">
<span class="breadcrumb-item" @click="goBack">团队管理</span>
<span class="breadcrumb-separator">></span>
<span class="breadcrumb-item current"> {{ routeUserName }}数据驾驶舱</span>
<div v-if="isRouteNavigation" class="route-header" style="display: flex; justify-content: space-between; align-items: center;">
<div class="breadcrumb" style="display: flex; flex-direction: column;">
<span class="breadcrumb-item" @click="goBack">团队管理 >{{ routeUserName }}</span>
<span class="breadcrumb-item current"> 数据驾驶舱</span>
</div>
<div style="display: flex; align-items: center; gap: 20px;">
<div class="user-name">
{{ routeUserName }}
</div>
</div>
</div>
<!-- 自己登录时的顶栏原有样式 -->
<template v-else>
<div style="display: flex; justify-content: space-between; align-items: center; width: 100%;">
<h1 class="app-title">销售驾驶舱</h1>
<h1 class="app-title">分析师驾驶舱</h1>
<div
class="quick-stats"
style="display: flex; align-items: center; gap: 30px"
>
</div>
<UserDropdown />
<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
:card-visibility="cardVisibility"
@update-card-visibility="updateCardVisibility"
/>
</div>
</div>
</template>
</div>
@@ -43,6 +57,7 @@
v-else
:data="timelineData"
@stage-select="handleStageSelect"
@sub-stage-select="handleSubStageSelect"
:selected-stage="selectedStage"
:contacts="filteredContacts"
:selected-contact-id="selectedContactId"
@@ -57,7 +72,7 @@
</section>
<!-- 原始数据卡片区域 -->
<section class="raw-data-section">
<section v-if="cardVisibility.rawData && selectedContact" class="raw-data-section">
<div class="section-header">
<h2>原始数据</h2>
<p class="section-subtitle">客户互动的原始记录和数据</p>
@@ -67,9 +82,13 @@
:selected-contact="selectedContact"
:form-info="formInfo"
:chat-info="chatRecords"
:call-info="callRecords"
@view-form-data="handleViewFormData"
@view-chat-data="handleViewChatData"
@view-call-data="handleViewCallData" />
@view-call-data="handleViewCallData"
@analyze-sop="handleAnalyzeSop"
@show-modal="handleShowModal"
@show-download-modal="handleShowDownloadModal" />
</div>
</section>
@@ -78,17 +97,19 @@
<!-- 主要工作区域 -->
<main class="main-content">
<!-- 客户详情区域 -->
<section class="detail-section">
<div class="section-header">
<h2>客户详情</h2>
</div>
<section v-if="cardVisibility.customerDetail && selectedContact" class="detail-section">
<div class="section-content">
<CustomerDetail :selected-contact="selectedContact" />
<CustomerDetail
ref="customerDetailRef"
:selected-contact="selectedContact"
:form-info="formInfo"
:chat-records="chatRecords"
:call-records="callRecords" />
</div>
</section>
</main>
</div>
<section class="analytics-section-full" style="width: 100%;">
<section v-if="cardVisibility.analytics" class="analytics-section-full" style="width: 100%;">
<div class="section-content">
<!-- 数据分析区域加载状态 -->
@@ -101,13 +122,52 @@
v-else
:kpi-data="kpiData"
:funnel-data="funnelData"
:contact-time-data="contactTimeData"
:contact-time-data="goldContactTime"
:statistics-data="statisticsData"
:urgent-problem-data="urgentProblemData"
/>
</div>
</section>
<!-- 自定义弹框 -->
<div v-if="showModal" class="modal-overlay" @click="closeModal" @wheel.prevent @touchmove.prevent>
<div class="modal-container" @click.stop @wheel.stop @touchmove.stop>
<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>
<!-- 下载弹框 -->
<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>
</template>
@@ -119,11 +179,14 @@ import CustomerDetail from "./components/CustomerDetail.vue";
import PersonalDashboard from "./components/PersonalDashboard.vue";
import SalesTimelineWithTaskList from "./components/SalesTimelineWithTaskList.vue";
import RawDataCards from "./components/RawDataCards.vue";
import WeekAnalize from "./components/WeekAnalize.vue";
import UserDropdown from "@/components/UserDropdown.vue";
import Loading from "@/components/Loading.vue";
import FeedbackForm from "@/components/FeedbackForm.vue";
import {getCustomerAttendance,getTodayCall,getProblemDistribution,getTableFillingRate,getAverageResponseTime,
getWeeklyActiveCommunicationRate,getTimeoutResponseRate,getCustomerCallInfo,getCustomerChatInfo,getCustomerFormInfo,
getConversionRateAndAllocatedData,getCustomerAttendanceAfterClass4,getPayMoneyCustomers} from "@/api/api.js"
getConversionRateAndAllocatedData,getCustomerAttendanceAfterClass4,getPayMoneyCustomers,getSalesFunnel,getGoldContactTime,
getAvgCallTime,getCallSuccessRate,getSecondOrderAnalysisReport} from "@/api/api.js"
// 路由实例
const router = useRouter();
@@ -144,7 +207,6 @@ const getRequestParams = () => {
if (routeUserName) {
params.user_name = routeUserName
}
return params
}
@@ -166,6 +228,7 @@ const goBack = () => {
// STATE
const selectedContactId = ref(null);
const contextPanelRef = ref(null);
const customerDetailRef = ref(null);
const selectedStage = ref('全部'); // 选中的销售阶段
const isPageLoading = ref(true); // 页面整体加载状态
@@ -174,6 +237,37 @@ const isStatisticsLoading = ref(false); // 统计数据加载状态
const isUrgentProblemLoading = ref(false); // 紧急问题数据加载状态
const isTimelineLoading = ref(false); // 时间线数据加载状态
// 卡片显示隐藏控制
const cardVisibility = reactive({
timeline: true, // 销售时间线
rawData: true, // 原始数据卡片
customerDetail: true, // 客户详情
analytics: true, // 数据分析
weekAnalysis: true // 周期分析
});
// 切换卡片显示状态
const toggleCardVisibility = (cardName) => {
cardVisibility[cardName] = !cardVisibility[cardName];
};
// 更新卡片显示状态从UserDropdown组件接收
const updateCardVisibility = (newVisibility) => {
Object.assign(cardVisibility, newVisibility);
};
// 获取卡片显示名称
const getCardDisplayName = (key) => {
const nameMap = {
timeline: '销售时间线',
rawData: '原始数据',
customerDetail: '客户详情',
analytics: '数据分析',
weekAnalysis: '周期分析'
};
return nameMap[key] || key;
};
// KPI数据
const kpiDataState = reactive({
totalCalls: 85,
@@ -196,9 +290,25 @@ const statisticsData = reactive({
// 客户迫切解决的问题数据
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 weekAnalysisData = ref({});
// 客户列表数据
const customersList = ref([]);
@@ -213,11 +323,13 @@ const payMoneyCustomersList = ref([]);
const payMoneyCustomersCount = ref(0);
// 表单信息
const formInfo = ref({});
const formInfo = ref([]);
// 通话记录
const callRecords = ref([]);
// 聊天记录
const chatRecords = ref([]);
// 电话接通率
const callSuccessRate = ref(0)
// MOCK DATA (Should ideally come from a store or API)
const MOCK_DATA = reactive({
@@ -254,20 +366,40 @@ async function getCoreKpi() {
const params = getRequestParams()
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 getTodayCall(hasParams ? params : undefined)
if (res.code === 200) {
kpiDataState.totalCalls = res.data.today_call
if (todayCallRes.code === 200) {
kpiDataState.totalCalls = todayCallRes.data.call_count
}
// 转化率、分配数据量、加微率
const conversionRes = await getConversionRateAndAllocatedData(hasParams ? params : undefined)
if (conversionRes.code === 200) {
kpiDataState.conversionRate = conversionRes.data.conversion_rate || 0
kpiDataState.assignedData = conversionRes.data.all_count || 0
kpiDataState.wechatAddRate = conversionRes.data.plus_v_conversion_rate || 0
}
// 平均通话时长
if (avgCallTimeRes.code === 200) {
kpiDataState.avgDuration = avgCallTimeRes.data.call_time || 0
}
// 电话接通率
if (callSuccessRateRes.code === 200) {
kpiDataState.successRate = callSuccessRateRes.data.call_success_rate || 0
}
} catch (error) {
console.error('获取核心KPI数据失败:', error)
} finally {
@@ -281,26 +413,35 @@ async function getStatisticsData() {
const params = getRequestParams()
const hasParams = params.user_name
// 获取表单填写率
const fillingRateRes = await getTableFillingRate(hasParams ? params : undefined)
// 并发请求所有统计数据
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) {
statisticsData.formCompletionRate = fillingRateRes.data.filling_rate
}
// 获取平均响应时间
const avgResponseRes = await getAverageResponseTime(hasParams ? params : undefined)
// 处理平均响应时间
if (avgResponseRes.code === 200) {
statisticsData.averageResponseTime = avgResponseRes.data.average_minutes
}
// 获取客户沟通率
const communicationRes = await getWeeklyActiveCommunicationRate(hasParams ? params : undefined)
// 处理客户沟通率
if (communicationRes.code === 200) {
statisticsData.customerCommunicationRate = communicationRes.data.communication_rate
}
// 获取超时响应率
const timeoutRes = await getTimeoutResponseRate(hasParams ? params : undefined)
// 处理超时响应率
if (timeoutRes.code === 200) {
statisticsData.timeoutResponseRate = timeoutRes.data.overtime_rate_600
statisticsData.severeTimeoutRate = timeoutRes.data.overtime_rate_800
@@ -321,11 +462,8 @@ async function getUrgentProblem() {
const res = await getProblemDistribution(hasParams ? params : undefined)
if(res.code === 200) {
// 将API返回的对象格式转换为数组格式
const problemDistribution = res.data.problem_distribution
urgentProblemData.value = Object.entries(problemDistribution).map(([name, percentage]) => ({
name: name,
value: parseInt(percentage.replace('%', '')) || 0
}))
const problemDistributionCount = res.data.problem_distribution_count
urgentProblemData.value = Object.entries(problemDistributionCount).map(([name, value]) => ({ name, value }))
}
} catch (error) {
console.error('获取紧急问题数据失败:', error)
@@ -349,7 +487,6 @@ async function getTimeline() {
value: parseInt(count) || 0
}))
}
// 处理客户列表数据
if (res.data.all_customers_list) {
customersList.value = res.data.all_customers_list
}
@@ -365,10 +502,10 @@ async function getTimeline() {
if (classRes.data.class_customers_list) {
// 存储课1-4阶段的原始数据根据pay_status设置正确的type
courseCustomers.value['课1-4'] = classRes.data.class_customers_list.map(customer => {
let customerType = '课1-4'; // 默认类型
let customerType = ''; // 默认类型
// 根据pay_status设置具体的type
if (customer.pay_status === '未支付' || customer.pay_status === '点击未支付') {
if (customer.pay_status === '点击未支付') {
customerType = '点击未支付';
} else if (customer.pay_status === '付定金') {
customerType = '付定金';
@@ -394,7 +531,8 @@ async function getTimeline() {
class_situation: customer.class_situation,
class_num: Object.keys(customer.class_situation || {}), // 添加class_num字段
pay_status: customer.pay_status,
records: []
records: [],
time_and_camp_stage: customer.time_and_camp_stage || []
};
})
@@ -427,12 +565,10 @@ async function getTimeline() {
class_situation: customer.class_situation,
scrm_user_main_code: customer.scrm_user_main_code,
weChat_avatar: customer.weChat_avatar,
pay_status: customer.pay_status
pay_status: customer.pay_status,
time_and_camp_stage: customer.time_and_camp_stage || []
})
// 后三个阶段的客户数据已存储在courseCustomers['课1-4']中不需要合并到customersList
}
}
// 成交阶段
@@ -463,12 +599,13 @@ async function getCustomerForm() {
const routeParams = getRequestParams()
const params = {
user_name: routeParams.user_name || userStore.userInfo.username,
customer_name: selectedContact.value.name,
phone: selectedContact.value.phone,
}
try {
const res = await getCustomerFormInfo(params)
console.log('获取客户表单数据:', res)
if(res.code === 200) {
formInfo.value = res.data
formInfo.value = res.data || []
}
} catch (error) {
// 静默处理错误
@@ -477,20 +614,17 @@ async function getCustomerForm() {
// 聊天记录
async function getCustomerChat() {
if (!selectedContact.value || !selectedContact.value.name) {
console.warn('无法获取客户聊天记录:客户信息不完整');
return;
}
const routeParams = getRequestParams()
const params = {
user_name: routeParams.user_name || userStore.userInfo.username,
customer_name: selectedContact.value.name,
phone: selectedContact.value.phone,
}
try {
const res = await getCustomerChatInfo(params)
if(res.code === 200) {
chatRecords.value = res.data
console.log('聊天数据获取成功:', res.data)
console.log('chatRecords.value:', chatRecords.value)
} else {
console.log('聊天数据获取失败:', res)
}
@@ -501,19 +635,17 @@ async function getCustomerChat() {
// 通话记录
async function getCustomerCall() {
if (!selectedContact.value || !selectedContact.value.name) {
console.warn('无法获取客户通话记录:客户信息不完整');
return;
}
const routeParams = getRequestParams()
const params = {
user_name: routeParams.user_name || userStore.userInfo.username,
customer_name: selectedContact.value.name,
phone: selectedContact.value.phone,
}
try {
const res = await getCustomerCallInfo(params)
if(res.code === 200) {
callRecords.value = res.data
}
} catch (error) {
// 静默处理错误
@@ -531,10 +663,26 @@ const selectedContact = computed(() => {
return MOCK_DATA.contacts.find((c) => c.id === selectedContactId.value) || null;
});
const funnelData = computed(() => ({
labels: ["线索", "沟通", "意向", "预约", "成交"],
data: MOCK_DATA.personalFunnel,
}));
const funnelData = computed(() => {
if (!SalesFunnel.value || !SalesFunnel.value.sale_funnel) {
return {
labels: ["线索总数", "有效沟通", "到课数据", "预付定金", "成功签单"],
data: [0, 0, 0, 0, 0]
};
}
const funnel = SalesFunnel.value.sale_funnel;
return {
labels: ["线索总数", "有效沟通", "到课数据", "预付定金", "成功签单"],
data: [
funnel.线索总数 || 0,
funnel.有效沟通 || 0,
funnel.到课数据 || 0,
funnel.预付定金 || 0,
funnel.成功签单 || 0
]
};
});
const contactTimeData = computed(() => ({
labels: MOCK_DATA.contactTimeAnalysis.labels,
@@ -547,7 +695,8 @@ const formattedCustomersList = computed(() => {
return [];
}
return customersList.value.map(customer => ({
return customersList.value?.map(customer => ({
wechat_id: customer.customer_wechat_id,
id: customer.customer_name, // 使用客户姓名作为唯一标识
name: customer.customer_name,
phone: customer.phone,
@@ -597,6 +746,7 @@ const selectContact = (id) => {
// 当选中客户后,获取客户表单数据
nextTick(async () => {
if (selectedContact.value && selectedContact.value.name) {
await getCustomerForm();
await getCustomerChat();
await getCustomerCall();
@@ -643,7 +793,8 @@ const handleStageSelect = (stage, extraData = null) => {
scrm_user_main_code: customer.scrm_user_main_code,
weChat_avatar: customer.weChat_avatar,
class_situation: customer.class_situation,
records: customer.records
records: customer.records,
time_and_camp_stage: customer.time_and_camp_stage || []
}));
// 更新当前筛选的客户数据
@@ -651,7 +802,7 @@ const handleStageSelect = (stage, extraData = null) => {
} else if (extraData && extraData.isCourseStage) {
// 处理课1-4阶段的课程数据(保持原有逻辑
// 处理课阶段的数据(课1-4、课1、课2、课3、课4
const courseContacts = extraData.courseData.map(customer => ({
@@ -661,8 +812,8 @@ const handleStageSelect = (stage, extraData = null) => {
profession: customer.profession,
education: customer.education,
avatar: customer.avatar,
type: customer.type || '课1-4', // 保持原有type字段如果没有则默认为课1-4
salesStage: customer.type || '课1-4', // 使用customer.type作为salesStage
type: customer.type || stage, // 使用当前选中的阶段作为type
salesStage: customer.type || stage, // 使用customer.type或当前阶段作为salesStage
health: customer.health,
customer_name: customer.customer_name,
customer_occupation: customer.customer_occupation,
@@ -672,7 +823,8 @@ const handleStageSelect = (stage, extraData = null) => {
class_situation: customer.class_situation,
class_num: customer.class_num, // 添加class_num字段
pay_status: customer.pay_status, // 添加pay_status字段
records: customer.records
records: customer.records,
time_and_camp_stage: customer.time_and_camp_stage || []
}));
currentFilteredCustomers.value = courseContacts;
@@ -683,35 +835,165 @@ const handleStageSelect = (stage, extraData = null) => {
}
};
// 处理子时间轴阶段选择
const handleSubStageSelect = (eventData) => {
// 将筛选后的客户数据转换为contacts格式
const filteredContacts = eventData.filteredCustomers.map(customer => ({
id: customer.customer_name || customer.id,
name: customer.customer_name || customer.name,
phone: customer.phone,
profession: customer.customer_occupation || customer.profession,
education: customer.customer_child_education || customer.education,
lastMessageTime: customer.latest_message_time || customer.time,
avatarUrl: customer.customer_avatar_url || customer.avatar,
avatar: customer.customer_avatar_url || customer.avatar || '/default-avatar.svg',
type: customer.type || eventData.originalStageType,
classNum: customer.class_num,
class_num: customer.class_num,
salesStage: eventData.stageType,
priority: customer.type === '待联系' ? 'high' : 'normal',
time: customer.latest_message_time || customer.time || '未知',
health: customer.health || 75,
// 保留原始数据
customer_name: customer.customer_name,
customer_occupation: customer.customer_occupation,
customer_child_education: customer.customer_child_education,
scrm_user_main_code: customer.scrm_user_main_code,
weChat_avatar: customer.weChat_avatar,
class_situation: customer.class_situation,
records: customer.records,
time_and_camp_stage: customer.time_and_camp_stage || []
}));
// 更新当前筛选的客户数据但保持selectedStage不变保持子时间轴显示
currentFilteredCustomers.value = filteredContacts;
};
const handleViewFormData = async (contact) => {
// 获取客户表单数据
await getCustomerForm();
console.log('表单数据已加载:', formInfo.value);
};
const handleViewChatData = async (contact) => {
console.log('查看聊天数据:', contact)
await getCustomerChatInfo({
customerId: selectedContact.value?.customerId || 1
})
console.log('聊天数据已更新:', chatRecords.value)
};
const handleViewCallData = (contact) => {
// TODO: 实现通话录音查看逻辑
};
// 处理弹框显示事件
const handleShowModal = (title, content) => {
console.log('handleShowModal0000', title)
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([])
async function CenterGetSalesFunnel() {
const params = getRequestParams()
const hasParams = params.user_name
const res = await getSalesFunnel(hasParams ? params : undefined)
if(res.code === 200){
SalesFunnel.value = res.data
}
}
// 黄金联络时间段
const goldContactTime = ref([])
async function CenterGetGoldContactTime() {
const params = getRequestParams()
const hasParams = params.user_name
const res = await getGoldContactTime(hasParams ? params : undefined)
if(res.code === 200){
goldContactTime.value = res.data
}
}
// 强制刷新所有数据重新调用所有API
async function forceRefreshAllData() {
console.log('开始强制刷新所有数据...')
// 重新调用所有API
await Promise.all([
getCoreKpi(),
getStatisticsData(),
getUrgentProblem(),
getTimeline(),
CenterGetSalesFunnel(),
CenterGetGoldContactTime(),
// 客户相关数据需要在选中客户后才能获取
selectedContact.value ? getCustomerForm() : Promise.resolve(),
selectedContact.value ? getCustomerChat() : Promise.resolve(),
selectedContact.value ? getCustomerCall() : Promise.resolve()
])
console.log('所有数据刷新完成')
}
// LIFECYCLE HOOKS
onMounted(async () => {
try {
isPageLoading.value = true
await getCoreKpi()
await getCustomerForm()
await getCustomerChat()
await getUrgentProblem()
await getCustomerCall()
await getTimeline()
await getCustomerPayMoney()
getStatisticsData()
getCoreKpi()
CenterGetGoldContactTime()
CenterGetSalesFunnel()
getCustomerForm()
getCustomerChat()
getUrgentProblem()
getCustomerCall()
getTimeline()
// 开发环境下暴露数据刷新函数到全局对象,方便调试
if (process.env.NODE_ENV === 'development') {
window.saleData = {
forceRefreshAllData
}
}
// 等待数据加载完成后选择默认客户
await nextTick();
@@ -798,9 +1080,9 @@ $primary: #3b82f6;
}
// 主要布局
.main-layout {
width: 100vw;
margin: 0 auto;
padding: 1rem;
width: 99vw;
margin-bottom: 1rem;
// padding: 1rem;
display: flex;
flex-direction: column;
gap: 1rem;
@@ -1323,10 +1605,8 @@ $primary: #3b82f6;
// 路由导航顶栏样式
.route-header {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
padding: 0 2rem;
.breadcrumb {
display: flex;
@@ -1540,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>

View File

@@ -1,912 +1,31 @@
<template>
<div class="action-items">
<div class="actions-header">
<h2>待处理事项</h2>
<div class="header-controls">
<select v-model="filterPriority" class="priority-filter">
<option value="all">全部优先级</option>
<option value="urgent">紧急</option>
<option value="high"></option>
<option value="medium"></option>
<option value="low"></option>
</select>
<button class="add-btn" @click="showAddForm = true">+ 新增</button>
</div>
</div>
<!-- 统计概览 -->
<div class="actions-summary">
<div class="summary-item urgent">
<div class="summary-count">{{ getCountByPriority('urgent') }}</div>
<div class="summary-label">紧急事项</div>
</div>
<div class="summary-item high">
<div class="summary-count">{{ getCountByPriority('high') }}</div>
<div class="summary-label">高优先级</div>
</div>
<div class="summary-item medium">
<div class="summary-count">{{ getCountByPriority('medium') }}</div>
<div class="summary-label">中优先级</div>
</div>
<div class="summary-item completed">
<div class="summary-count">{{ completedCount }}</div>
<div class="summary-label">已完成</div>
</div>
</div>
<!-- 事项列表 -->
<div class="actions-list">
<div
v-for="action in filteredActions"
:key="action.id"
class="action-item"
:class="[action.priority, { completed: action.completed, overdue: isOverdue(action.dueDate) }]"
>
<div class="action-checkbox">
<input
type="checkbox"
:checked="action.completed"
@change="toggleComplete(action.id)"
class="checkbox"
>
</div>
<div class="action-content">
<div class="action-header">
<h4 class="action-title" :class="{ completed: action.completed }">{{ action.title }}</h4>
<div class="action-meta">
<span class="priority-badge" :class="action.priority">{{ getPriorityText(action.priority) }}</span>
<span class="due-date" :class="{ overdue: isOverdue(action.dueDate) }">
{{ formatDueDate(action.dueDate) }}
</span>
</div>
</div>
<p class="action-description">{{ action.description }}</p>
<div class="action-details">
<div class="detail-item">
<span class="detail-label">关联组别:</span>
<span class="detail-value">{{ action.relatedGroup || '全部' }}</span>
</div>
<div class="detail-item" v-if="action.assignee">
<span class="detail-label">负责人:</span>
<span class="detail-value">{{ action.assignee }}</span>
</div>
<div class="detail-item" v-if="action.progress !== undefined">
<span class="detail-label">进度:</span>
<div class="progress-mini">
<div class="progress-bar-mini">
<div class="progress-fill-mini" :style="{ width: action.progress + '%' }"></div>
</div>
<span class="progress-text-mini">{{ action.progress }}%</span>
</div>
</div>
</div>
<div class="action-footer">
<div class="action-tags">
<span v-for="tag in action.tags" :key="tag" class="tag">{{ tag }}</span>
</div>
<div class="action-buttons">
<button class="btn-edit" @click="editAction(action)">编辑</button>
<button class="btn-delete" @click="deleteAction(action.id)">删除</button>
</div>
</div>
</div>
</div>
</div>
<!-- 空状态 -->
<div v-if="filteredActions.length === 0" class="empty-state">
<div class="empty-icon"></div>
<div class="empty-text">
<h3>暂无待处理事项</h3>
<p>{{ filterPriority === 'all' ? '所有事项都已处理完成' : '该优先级下暂无事项' }}</p>
</div>
</div>
<!-- 新增表单模态框 -->
<div v-if="showAddForm" class="modal-overlay" @click="showAddForm = false">
<div class="modal-content" @click.stop>
<div class="modal-header">
<h3>新增待处理事项</h3>
<button class="close-btn" @click="showAddForm = false">×</button>
</div>
<form @submit.prevent="addAction" class="add-form">
<div class="form-group">
<label>标题</label>
<input v-model="newAction.title" type="text" required class="form-input">
</div>
<div class="form-group">
<label>描述</label>
<textarea v-model="newAction.description" class="form-textarea"></textarea>
</div>
<div class="form-row">
<div class="form-group">
<label>优先级</label>
<select v-model="newAction.priority" class="form-select">
<option value="low"></option>
<option value="medium"></option>
<option value="high"></option>
<option value="urgent">紧急</option>
</select>
</div>
<div class="form-group">
<label>截止日期</label>
<input v-model="newAction.dueDate" type="date" class="form-input">
</div>
</div>
<div class="form-group">
<label>关联组别</label>
<select v-model="newAction.relatedGroup" class="form-select">
<option value="">全部</option>
<option value="精英组">精英组</option>
<option value="冲锋组">冲锋组</option>
<option value="突破组">突破组</option>
<option value="新星组">新星组</option>
<option value="潜力组">潜力组</option>
</select>
</div>
<div class="form-actions">
<button type="button" @click="showAddForm = false" class="btn-cancel">取消</button>
<button type="submit" class="btn-submit">添加</button>
</div>
</form>
</div>
</div>
<div class="action-items-container">
<Calendar />
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
const props = defineProps({
selectedGroup: {
type: Object,
default: null
}
})
// 筛选优先级
const filterPriority = ref('all')
// 显示新增表单
const showAddForm = ref(false)
// 新增事项表单数据
const newAction = ref({
title: '',
description: '',
priority: 'medium',
dueDate: '',
relatedGroup: ''
})
// 待处理事项数据
const actions = ref([
{
id: 1,
title: '突破组转化率改进计划',
description: '针对突破组转化率连续下降问题,制定具体改进措施并跟踪执行',
priority: 'urgent',
dueDate: '2024-01-15',
relatedGroup: '突破组',
assignee: '王主管',
progress: 30,
tags: ['业绩改进', '紧急'],
completed: false,
createdAt: '2024-01-10'
},
{
id: 2,
title: '新星组人员补充',
description: '新星组当前人员不足需要招聘2名新销售并安排培训',
priority: 'high',
dueDate: '2024-01-20',
relatedGroup: '新星组',
assignee: '赵主管',
progress: 60,
tags: ['人员管理', '招聘'],
completed: false,
createdAt: '2024-01-08'
},
{
id: 3,
title: '月度业绩分析报告',
description: '整理各组月度业绩数据,分析趋势并提出下月目标建议',
priority: 'medium',
dueDate: '2024-01-25',
relatedGroup: '',
assignee: '中心组长',
progress: 80,
tags: ['数据分析', '报告'],
completed: false,
createdAt: '2024-01-05'
},
{
id: 4,
title: '销售技能培训安排',
description: '组织各组销售人员参加客户沟通技巧培训',
priority: 'medium',
dueDate: '2024-01-30',
relatedGroup: '',
assignee: '培训部',
progress: 20,
tags: ['培训', '技能提升'],
completed: false,
createdAt: '2024-01-12'
},
{
id: 5,
title: '客户满意度调研',
description: '对已成交客户进行满意度调研,收集改进建议',
priority: 'low',
dueDate: '2024-02-05',
relatedGroup: '',
assignee: '客服部',
progress: 0,
tags: ['客户服务', '调研'],
completed: false,
createdAt: '2024-01-14'
},
{
id: 6,
title: '精英组激励方案制定',
description: '为表现优秀的精英组制定专项激励方案',
priority: 'medium',
dueDate: '2024-01-18',
relatedGroup: '精英组',
assignee: '人事部',
progress: 100,
tags: ['激励', '团队管理'],
completed: true,
createdAt: '2024-01-01'
}
])
// 筛选后的事项
const filteredActions = computed(() => {
let filtered = actions.value
if (filterPriority.value !== 'all') {
filtered = filtered.filter(action => action.priority === filterPriority.value)
}
// 如果选中了特定组别,优先显示相关事项
if (props.selectedGroup) {
filtered = filtered.sort((a, b) => {
const aRelated = a.relatedGroup === props.selectedGroup.name
const bRelated = b.relatedGroup === props.selectedGroup.name
if (aRelated && !bRelated) return -1
if (!aRelated && bRelated) return 1
return 0
})
}
return filtered.filter(action => !action.completed)
})
// 已完成数量
const completedCount = computed(() => {
return actions.value.filter(action => action.completed).length
})
// 按优先级获取数量
const getCountByPriority = (priority) => {
return actions.value.filter(action => action.priority === priority && !action.completed).length
}
// 切换完成状态
const toggleComplete = (id) => {
const action = actions.value.find(a => a.id === id)
if (action) {
action.completed = !action.completed
}
}
// 判断是否过期
const isOverdue = (dueDate) => {
return new Date(dueDate) < new Date()
}
// 获取优先级文本
const getPriorityText = (priority) => {
const priorityMap = {
urgent: '紧急',
high: '高',
medium: '中',
low: '低'
}
return priorityMap[priority] || priority
}
// 格式化截止日期
const formatDueDate = (dueDate) => {
const date = new Date(dueDate)
const today = new Date()
const diffTime = date - today
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24))
if (diffDays < 0) {
return `逾期${Math.abs(diffDays)}`
} else if (diffDays === 0) {
return '今天到期'
} else if (diffDays === 1) {
return '明天到期'
} else {
return `${diffDays}天后到期`
}
}
// 编辑事项
const editAction = (action) => {
// 这里可以实现编辑功能
console.log('编辑事项:', action)
}
// 删除事项
const deleteAction = (id) => {
if (confirm('确定要删除这个事项吗?')) {
const index = actions.value.findIndex(a => a.id === id)
if (index > -1) {
actions.value.splice(index, 1)
}
}
}
// 添加新事项
const addAction = () => {
const newId = Math.max(...actions.value.map(a => a.id)) + 1
actions.value.push({
id: newId,
...newAction.value,
progress: 0,
tags: [],
completed: false,
createdAt: new Date().toISOString().split('T')[0]
})
// 重置表单
newAction.value = {
title: '',
description: '',
priority: 'medium',
dueDate: '',
relatedGroup: ''
}
showAddForm.value = false
}
import Calendar from './Calendar.vue';
</script>
<style lang="scss" scoped>
.action-items {
background: white;
border-radius: 12px;
padding: 1.5rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
<style scoped>
.action-items-container {
height: 100%;
display: flex;
flex-direction: column;
.actions-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
h2 {
font-size: 1.2rem;
font-weight: 600;
color: #1e293b;
margin: 0;
}
.header-controls {
display: flex;
gap: 0.75rem;
align-items: center;
.priority-filter {
padding: 0.5rem;
border: 1px solid #e2e8f0;
border-radius: 6px;
font-size: 0.85rem;
background: white;
cursor: pointer;
&:focus {
outline: none;
border-color: #3b82f6;
}
}
.add-btn {
padding: 0.5rem 1rem;
background: #3b82f6;
color: white;
border: none;
border-radius: 6px;
font-size: 0.85rem;
font-weight: 500;
cursor: pointer;
transition: background 0.2s ease;
&:hover {
background: #2563eb;
}
}
}
}
// 统计概览
.actions-summary {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1rem;
margin-bottom: 1.5rem;
.summary-item {
text-align: center;
padding: 1rem;
border-radius: 8px;
&.urgent {
background: #fef2f2;
border: 1px solid #fecaca;
}
&.high {
background: #fef3c7;
border: 1px solid #fed7aa;
}
&.medium {
background: #eff6ff;
border: 1px solid #bfdbfe;
}
&.completed {
background: #f0fdf4;
border: 1px solid #bbf7d0;
}
.summary-count {
font-size: 1.5rem;
font-weight: bold;
color: #1f2937;
margin-bottom: 0.25rem;
}
.summary-label {
font-size: 0.8rem;
color: #6b7280;
}
}
}
// 事项列表
.actions-list {
flex: 1;
overflow-y: auto;
.action-item {
display: flex;
padding: 1rem;
border: 1px solid #e5e7eb;
border-radius: 8px;
margin-bottom: 1rem;
transition: all 0.2s ease;
&:last-child {
margin-bottom: 0;
}
&:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
&.urgent {
border-left: 4px solid #ef4444;
}
&.high {
border-left: 4px solid #f59e0b;
}
&.medium {
border-left: 4px solid #3b82f6;
}
&.low {
border-left: 4px solid #10b981;
}
&.completed {
opacity: 0.6;
background: #f9fafb;
}
&.overdue {
background: #fef2f2;
}
.action-checkbox {
margin-right: 1rem;
.checkbox {
width: 18px;
height: 18px;
cursor: pointer;
}
}
.action-content {
flex: 1;
.action-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 0.5rem;
.action-title {
font-size: 1rem;
font-weight: 600;
color: #1f2937;
margin: 0;
&.completed {
text-decoration: line-through;
color: #9ca3af;
}
}
.action-meta {
display: flex;
gap: 0.5rem;
align-items: center;
.priority-badge {
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 500;
&.urgent {
background: #fee2e2;
color: #991b1b;
}
&.high {
background: #fef3c7;
color: #92400e;
}
&.medium {
background: #dbeafe;
color: #1e40af;
}
&.low {
background: #dcfce7;
color: #166534;
}
}
.due-date {
font-size: 0.8rem;
color: #6b7280;
&.overdue {
color: #ef4444;
font-weight: 600;
}
}
}
}
.action-description {
font-size: 0.9rem;
color: #6b7280;
margin: 0 0 1rem 0;
line-height: 1.5;
}
.action-details {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 0.75rem;
margin-bottom: 1rem;
.detail-item {
display: flex;
align-items: center;
gap: 0.5rem;
.detail-label {
font-size: 0.8rem;
color: #9ca3af;
min-width: 60px;
}
.detail-value {
font-size: 0.8rem;
color: #374151;
font-weight: 500;
}
.progress-mini {
display: flex;
align-items: center;
gap: 0.5rem;
.progress-bar-mini {
width: 60px;
height: 4px;
background: #f3f4f6;
border-radius: 2px;
max-height: 600px;
overflow: hidden;
.progress-fill-mini {
height: 100%;
background: #3b82f6;
transition: width 0.3s ease;
}
}
.progress-text-mini {
font-size: 0.75rem;
color: #6b7280;
}
}
}
}
.action-footer {
display: flex;
justify-content: space-between;
align-items: center;
.action-tags {
display: flex;
gap: 0.5rem;
.tag {
padding: 0.25rem 0.5rem;
background: #f3f4f6;
color: #6b7280;
border-radius: 4px;
font-size: 0.75rem;
}
}
.action-buttons {
display: flex;
gap: 0.5rem;
.btn-edit, .btn-delete {
padding: 0.25rem 0.75rem;
border: none;
border-radius: 4px;
font-size: 0.75rem;
cursor: pointer;
transition: all 0.2s ease;
}
.btn-edit {
background: #f3f4f6;
color: #374151;
&:hover {
background: #e5e7eb;
}
}
.btn-delete {
background: #fee2e2;
color: #991b1b;
&:hover {
background: #fecaca;
}
}
}
}
}
}
}
// 空状态
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem;
text-align: center;
.empty-icon {
font-size: 3rem;
margin-bottom: 1rem;
opacity: 0.5;
}
.empty-text {
h3 {
font-size: 1.1rem;
color: #374151;
margin: 0 0 0.5rem 0;
}
p {
color: #6b7280;
margin: 0;
}
}
}
// 模态框
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
.modal-content {
background: white;
padding: 5px;
background: #ffffff;
border-radius: 12px;
padding: 1.5rem;
width: 90%;
max-width: 500px;
max-height: 80vh;
overflow-y: auto;
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
h3 {
margin: 0;
color: #1f2937;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
}
.close-btn {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: #6b7280;
&:hover {
color: #374151;
}
}
}
.add-form {
.form-group {
margin-bottom: 1rem;
label {
display: block;
font-size: 0.9rem;
font-weight: 500;
color: #374151;
margin-bottom: 0.5rem;
}
.form-input, .form-textarea, .form-select {
width: 100%;
padding: 0.75rem;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 0.9rem;
&:focus {
outline: none;
border-color: #3b82f6;
}
}
.form-textarea {
height: 80px;
resize: vertical;
}
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.form-actions {
display: flex;
gap: 1rem;
justify-content: flex-end;
margin-top: 1.5rem;
.btn-cancel, .btn-submit {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 6px;
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.btn-cancel {
background: #f3f4f6;
color: #374151;
&:hover {
background: #e5e7eb;
}
}
.btn-submit {
background: #3b82f6;
color: white;
&:hover {
background: #2563eb;
}
}
}
}
}
}
}
// 移动端适配
@media (max-width: 768px) {
.action-items {
padding: 1rem;
.actions-header {
flex-direction: column;
gap: 1rem;
align-items: flex-start;
}
.actions-summary {
grid-template-columns: repeat(4, 1fr);
}
.action-item {
.action-header {
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
}
.action-details {
grid-template-columns: 1fr;
}
.action-footer {
flex-direction: column;
align-items: flex-start;
gap: 0.75rem;
}
}
.modal-content {
.form-row {
grid-template-columns: 1fr;
}
}
}
.calendar-title {
margin: 0 0 20px 0;
color: #303133;
font-size: 20px;
font-weight: 600;
text-align: center;
padding-bottom: 15px;
border-bottom: 2px solid #f0f2f5;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -1,19 +1,43 @@
<template>
<div class="center-overview">
<div class="overview-header">
<h2>中心整体概览</h2>
<div class="stats-toggle">
<button
:class="['toggle-btn', { active: statsMode === 'period' }]"
@click="switchStatsMode('period')"
>
按期统计
</button>
<button
:class="['toggle-btn', { active: statsMode === 'month' }]"
@click="switchStatsMode('month')"
>
按月统计
</button>
</div>
</div>
<div class="overview-grid">
<div class="overview-card primary">
<div class="card-header">
<span class="card-title">中心总业绩</span>
<span class="card-title">
中心总业绩
<i @mouseenter="showTooltip($event, 'centerPerformance')" @mouseleave="hideTooltip"></i>
</span>
<span class="card-trend positive">{{ props.overallData.CenterPerformance?.center_monthly_vs_previous_deals }} vs 上期</span>
</div>
<div class="card-value">{{ props.overallData.CenterPerformance.center_monthly_deal_count || '552,000' }} </div>
<div class="card-value">{{ props.overallData.CenterPerformance.center_monthly_deal_count || '0' }} </div>
<div class="card-subtitle">月目标完成率: {{ props.overallData.CenterPerformance?.center_monthly_target_completion_rate || '56%' }}</div>
</div>
<div class="overview-card">
<div class="card-header">
<span class="card-title">活跃组数</span>
<span class="card-title">
活跃组数
<i @mouseenter="showTooltip($event, 'activeGroups')"
@mouseleave="hideTooltip"></i>
</span>
<span class="card-trend stable">{{ props.overallData.TotalGroupCount?.center_total_team_count}}/{{ props.overallData.TotalGroupCount?.center_total_team_count }} </span>
</div>
<div class="card-value">{{ props.overallData.TotalGroupCount?.center_total_team_count || '5' }} </div>
@@ -22,7 +46,12 @@
<div class="overview-card">
<div class="card-header">
<span class="card-title">中心转化率</span>
<span class="card-title">
中心转化率
<i
@mouseenter="showTooltip($event, 'conversionRate')"
@mouseleave="hideTooltip"></i>
</span>
<span class="card-trend positive">{{ props.overallData.CenterConversionRate?.center_monthly_vs_previous_deals }}vs 上期</span>
</div>
<div class="card-value">{{ props.overallData.CenterConversionRate?.center_conversion_rate || '5.2' }}</div>
@@ -31,16 +60,26 @@
<div class="overview-card">
<div class="card-header">
<span class="card-title">总通话次数</span>
<span class="card-title">
总通话次数
<i
@mouseenter="showTooltip($event, 'totalCalls')"
@mouseleave="hideTooltip"></i>
</span>
<span class="card-trend positive">{{ props.overallData.TotalCallCount?.total_call_count_vs_yesterday}} vs 上期</span>
</div>
<div class="card-value">{{ props.overallData.TotalCallCount?.total_call_count || '1,247' }} </div>
<div class="card-subtitle">有效通话: {{ props.overallData.TotalCallCount?.center_effective_call_count || '892' }}</div>
<div class="card-value">{{ props.overallData.TotalCallCount?.total_call_count || '0' }} </div>
<div class="card-subtitle">有效通话: {{ props.overallData.TotalCallCount?.center_effective_call_count || '0' }}</div>
</div>
<div class="overview-card">
<div class="card-header">
<span class="card-title">新增客户</span>
<span class="card-title">
新增客户
<i
@mouseenter="showTooltip($event, 'newCustomers')"
@mouseleave="hideTooltip"></i>
</span>
<span class="card-trend positive">{{ props.overallData.NewCustomer?.center_new_leads_vs_previous_period }} vs 上期</span>
</div>
<div class="card-value">{{ props.overallData.NewCustomer?.center_new_leads_count || '117' }} </div>
@@ -49,7 +88,12 @@
<div class="overview-card">
<div class="card-header">
<span class="card-title">定金转化</span>
<span class="card-title">
定金转化
<i
@mouseenter="showTooltip($event, 'depositConversion')"
@mouseleave="hideTooltip"></i>
</span>
<span class="card-trend positive">{{ props.overallData.DepositConversionRate?.center_deposit_conversion_vs_previous }} vs 上期</span>
</div>
<div class="card-value">{{ props.overallData.DepositConversionRate?.center_current_deposit_conversion_rate || '0' }} </div>
@@ -57,10 +101,37 @@
</div>
</div>
<!-- Tooltip组件 -->
<Tooltip
:visible="tooltip.visible"
:x="tooltip.x"
:y="tooltip.y"
:title="tooltip.title"
:description="tooltip.description"
/>
</div>
</template>
<script setup>
import { reactive, ref } from 'vue'
import Tooltip from '@/components/Tooltip.vue'
// 统计模式状态
const statsMode = ref('month') // 默认按月统计
// 定义emit事件
const emit = defineEmits(['update-check-type'])
// 切换统计模式
const switchStatsMode = (mode) => {
statsMode.value = mode
// 向父组件发送事件修改CheckType的值
const checkTypeValue = mode === 'period' ? 'period' : 'month'
emit('update-check-type', checkTypeValue)
console.log('切换统计模式:', mode, '发送CheckType值:', checkTypeValue)
}
// 中心整体概览组件
const props = defineProps({
overallData: {
@@ -75,6 +146,58 @@ const props = defineProps({
})
}
})
// Tooltip状态管理
const tooltip = reactive({
visible: false,
x: 0,
y: 0,
title: '',
description: ''
})
// 指标描述信息
const metricDescriptions = {
centerPerformance: {
title: '中心总业绩计算方式',
description: '月目标完成率 = 当月成交单数 / 月度目标单数 × 100%'
},
activeGroups: {
title: '活跃组数计算方式',
description: '本月有通话记录或成交记录的团队。总人数为所有活跃团队的人员总和'
},
conversionRate: {
title: '中心转化率计算方式',
description: '中心转化率 = 总成交客户数 / 总接触客户数 × 100%'
},
totalCalls: {
title: '总通话次数计算方式',
description: '所有销售人员的通话总次数包括接听和拨出。有效通话指通话时长超过30秒的通话记录'
},
newCustomers: {
title: '新增客户计算方式',
description: '本期新录入系统的客户数量。意向客户指经过初步沟通,有明确购买意向的客户数量'
},
depositConversion: {
title: '定金转化计算方式',
description: '定金转化率 = 缴纳定金客户数 / 意向客户总数 × 100%'
}
}
// 显示tooltip
const showTooltip = (event, metricType) => {
const rect = event.target.getBoundingClientRect()
tooltip.visible = true
tooltip.x = rect.left + rect.width / 2
tooltip.y = rect.top - 10
tooltip.title = metricDescriptions[metricType].title
tooltip.description = metricDescriptions[metricType].description
}
// 隐藏tooltip
const hideTooltip = () => {
tooltip.visible = false
}
</script>
<style lang="scss" scoped>
@@ -169,6 +292,39 @@ const props = defineProps({
color: #94a3b8;
font-size: 0.8rem;
}
.info-icon {
display: inline-block;
width: 16px;
height: 16px;
background: #409eff;
color: white;
border-radius: 50%;
text-align: center;
line-height: 16px;
font-size: 12px;
font-weight: bold;
margin-left: 6px;
cursor: pointer;
opacity: 0.8;
transition: all 0.3s ease;
&:hover {
opacity: 1;
transform: scale(1.1);
background: #66b3ff;
}
}
// 主要卡片中的图标样式
&.primary .info-icon {
background: rgba(255, 255, 255, 0.3);
color: white;
&:hover {
background: rgba(255, 255, 255, 0.5);
}
}
}
.trend-section {
@@ -225,8 +381,69 @@ const props = defineProps({
}
}
// 切换按钮样式
.overview-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
h2 {
margin: 0;
color: #1e293b;
font-size: 1.5rem;
font-weight: 600;
}
.stats-toggle {
display: flex;
background: #f1f5f9;
border-radius: 8px;
padding: 4px;
gap: 2px;
.toggle-btn {
padding: 8px 16px;
border: none;
background: transparent;
color: #64748b;
font-size: 0.875rem;
font-weight: 500;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: #e2e8f0;
color: #475569;
}
&.active {
background: #ffffff;
color: #0f172a;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
}
}
}
// 移动端适配
@media (max-width: 768px) {
.overview-header {
flex-direction: column;
align-items: flex-start;
gap: 1rem;
.stats-toggle {
align-self: stretch;
.toggle-btn {
flex: 1;
text-align: center;
}
}
}
.center-overview {
padding: 1rem;

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -22,6 +22,7 @@
class="ranking-card"
:class="getRankingClass(index)"
@click="$emit('select-group', group)"
@dblclick="navigateToManager(group)"
>
<div class="rank-badge">{{ index + 1 }}</div>
<div class="group-info">
@@ -31,7 +32,7 @@
<div class="key-metrics">
<div class="mini-metric">
<span class="mini-label">业绩</span>
<span class="mini-value">{{ formatCurrency(group.todayPerformance) }}</span>
<span class="mini-value">{{ group.todayPerformance }}</span>
</div>
<div class="mini-metric">
<span class="mini-label">转化</span>
@@ -47,6 +48,7 @@
<script setup>
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
const props = defineProps({
groups: {
@@ -65,6 +67,9 @@ const props = defineProps({
const emit = defineEmits(['select-group', 'manager-change'])
// 路由实例
const router = useRouter()
// 选中的高级经理
const selectedManager = ref('all')
@@ -170,22 +175,22 @@ const processedGroups = computed(() => {
})
}
// 处理 formal_plural 数据
// 处理 formal_plural 数据(业绩数据)
if (props.groupList.formal_plural) {
console.log('Processing formal_plural:', props.groupList.formal_plural)
Object.entries(props.groupList.formal_plural).forEach(([managerName, teamData]) => {
if (typeof teamData === 'object' && teamData !== null) {
Object.entries(teamData).forEach(([teamName, count]) => {
Object.entries(teamData).forEach(([teamName, performance]) => {
const existingGroup = groups.find(g => g.id === `${managerName}-${teamName}` || g.id === managerName)
if (existingGroup) {
existingGroup.newClients = count || 0
existingGroup.todayPerformance = performance || 0
}
})
} else if (typeof teamData === 'number') {
// 处理直接数值的情况
const existingGroup = groups.find(g => g.id === managerName)
if (existingGroup) {
existingGroup.newClients = teamData || 0
existingGroup.todayPerformance = teamData || 0
}
}
})
@@ -267,6 +272,17 @@ const getRankingClass = (index) => {
return 'rank-other'
}
// 处理部门双击事件,跳转到经理页面
const navigateToManager = (group) => {
router.push({
path: '/manager',
query: {
user_name: group.leader,
user_level: 2
}
})
}
// 获取趋势图标
const getTrendIcon = (trend) => {
const icons = {

View File

@@ -2,6 +2,7 @@
<div class="chart-container">
<div class="chart-header">
<h3>客户迫切解决的问题排行榜</h3>
<button @click="exportData" v-if="userStore.userInfo.user_level === 4">一键导出</button>
</div>
<div class="chart-content">
<div v-if="sortedData.length > 0" class="problem-ranking">
@@ -33,7 +34,13 @@
</template>
<script setup>
import { computed } from 'vue';
import { computed,onMounted } from 'vue';
import { exportCustomers, getExcellentRecordFile } from '@/api/secondTop';
import { useUserStore } from "@/stores/user";
import { ElMessage } from 'element-plus';
import * as XLSX from 'xlsx';
// 用户store实例
const userStore = useUserStore();
// 定义Props接收一个包含 { name: string, value: string | number } 的数组
const props = defineProps({
@@ -73,6 +80,190 @@ const getRankingClass = (index) => {
const getRankBadgeClass = (index) => {
return ['badge-gold', 'badge-silver', 'badge-bronze'][index] || 'badge-default';
};
async function exportData() {
const params = {
user_name: userStore.userInfo.username,
user_level: userStore.userInfo.user_level.toString(),
}
try {
ElMessage.info('正在导出数据,请稍候...')
console.log('导出参数:', params)
const res = await exportCustomers()
if (res.code === 200 && res.data && res.data.length > 0) {
ElMessage.success('数据导出成功')
// 处理数据,将复杂的嵌套对象展平
const exportData = res.data.map(customer => {
const flatData = {
'昵称': customer.nickname || '',
'客户姓名': customer.customer_name || '',
'性别': customer.gender || '',
'跟进人': customer.follow_up_name || '',
'手机号': customer.phone || '',
'是否入群': customer.is_in_group || '',
'用户ID': customer.mantis_user_id || '',
}
const parseFormData = (formData) => {
if (typeof formData === 'string') {
try {
return JSON.parse(formData)
} catch (e) {
return null
}
}
return formData || null
}
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() : ''
}
// 处理到课情况
if (customer.live) {
flatData['课一到课情况'] = customer.live['课一'] || ''
flatData['课二到课情况'] = customer.live['课二'] || ''
flatData['课三到课情况'] = customer.live['课三'] || ''
flatData['课四到课情况'] = customer.live['课四'] || ''
}
// 处理问卷调查信息
if (parsedFormData && parsedFormData.additional_info) {
parsedFormData.additional_info.forEach((item) => {
const key = ensureUniqueKey(item.topic || '')
if (key) {
flatData[key] = item.answer || ''
}
})
}
return flatData
})
// 创建工作簿
const wb = XLSX.utils.book_new()
const allKeys = Array.from(new Set(exportData.flatMap(item => Object.keys(item))))
const ws = XLSX.utils.json_to_sheet(exportData, { header: allKeys })
// 设置列宽
const colWidths = allKeys.map(key => {
const maxCellLength = exportData.reduce((max, row) => {
const value = row[key]
const length = value === null || value === undefined ? 0 : String(value).length
return Math.max(max, length)
}, 0)
return { wch: Math.min(50, Math.max(10, key.length, maxCellLength)) }
})
ws['!cols'] = colWidths
// 添加工作表到工作簿
XLSX.utils.book_append_sheet(wb, ws, '客户数据')
// 生成文件名(包含当前时间)
const now = new Date()
const fileName = `客户数据导出_${now.getFullYear()}${(now.getMonth() + 1).toString().padStart(2, '0')}${now.getDate().toString().padStart(2, '0')}_${now.getHours().toString().padStart(2, '0')}${now.getMinutes().toString().padStart(2, '0')}.xlsx`
// 导出文件
XLSX.writeFile(wb, fileName)
ElMessage.success(`导出成功!共导出 ${exportData.length} 条数据`)
} else {
alert('暂无数据可导出')
}
} catch (error) {
console.error('导出失败:', error)
ElMessage.error('导出失败,请稍后重试')
}
}
</script>
<style lang="scss" scoped>
@@ -87,14 +278,42 @@ const getRankBadgeClass = (index) => {
}
.chart-header {
padding: 20px 20px 16px;
padding: 10px 20px 10px;
border-bottom: 1px solid #ebeef5;
display: flex;
justify-content: space-between;
align-items: center;
h3 {
margin: 0;
color: #303133;
font-size: 18px;
font-weight: 600;
}
button {
padding: 8px 16px;
background: linear-gradient(135deg, #409eff, #3a8ee6);
color: white;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
box-shadow: 0 2px 4px rgba(64, 158, 255, 0.3);
&:hover {
background: linear-gradient(135deg, #3a8ee6, #337ecc);
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(64, 158, 255, 0.4);
}
&:active {
transform: translateY(0);
box-shadow: 0 2px 4px rgba(64, 158, 255, 0.3);
}
}
}
.chart-content {

File diff suppressed because it is too large Load Diff

View File

@@ -1,19 +1,41 @@
<template>
<div class="center-overview">
<div class="overview-header">
<h2>整体概览</h2>
<div class="stats-toggle">
<button
:class="['toggle-btn', { active: statsMode === 'monthly' }]"
@click="switchStatsMode('monthly')"
>
按月统计
</button>
<button
:class="['toggle-btn', { active: statsMode === 'period' }]"
@click="switchStatsMode('period')"
>
按期统计
</button>
</div>
</div>
<div class="overview-grid">
<div class="overview-card primary">
<div class="card-header">
<span class="card-title">团队总业绩</span>
<span class="card-trend positive">{{ totalPerformance.team_current_vs_previous_deals }} vs 上期</span>
<span class="card-title">
团队总业绩
<span class="info-icon" @mouseenter="showTooltip($event, 'teamPerformance')" @mouseleave="hideTooltip"></span>
</span>
<span class="card-trend positive">{{ totalPerformance.team_current_vs_previous_period_deals_comparison }} vs 上期</span>
</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 class="overview-card">
<div class="card-header">
<span class="card-title">活跃组数</span>
<span class="card-title">
活跃组数
<span class="info-icon" @mouseenter="showTooltip($event, 'activeGroups')" @mouseleave="hideTooltip"></span>
</span>
<span class="card-trend stable">{{ activeGroups.total_group_count }}/{{ activeGroups.total_group_count }} </span>
</div>
<div class="card-value">{{ activeGroups.total_group_count }} </div>
@@ -22,8 +44,11 @@
<div class="overview-card">
<div class="card-header">
<span class="card-title">团队转化率</span>
<span class="card-trend positive">{{ conversionRate.team_current_vs_previous_deals }} vs 上期</span>
<span class="card-title">
团队转化率
<span class="info-icon" @mouseenter="showTooltip($event, 'conversionRate')" @mouseleave="hideTooltip"></span>
</span>
<span class="card-trend positive">{{ conversionRate.team_current_vs_previous_conversion_rate }} vs 上期</span>
</div>
<div class="card-value">{{ conversionRate.center_conversion_rate }}</div>
<div class="card-subtitle">团队平均转化率: {{ conversionRate.average_conversion_rate }}</div>
@@ -31,7 +56,10 @@
<div class="overview-card">
<div class="card-header">
<span class="card-title">总通话次数</span>
<span class="card-title">
总通话次数
<span class="info-icon" @mouseenter="showTooltip($event, 'totalCalls')" @mouseleave="hideTooltip"></span>
</span>
<span class="card-trend positive">{{ totalCalls.total_call_count_vs_yesterday }} vs 上期</span>
</div>
<div class="card-value">{{ totalCalls.total_call_count }}</div>
@@ -40,7 +68,10 @@
<div class="overview-card">
<div class="card-header">
<span class="card-title">新增客户</span>
<span class="card-title">
新增客户
<span class="info-icon" @mouseenter="showTooltip($event, 'newCustomers')" @mouseleave="hideTooltip"></span>
</span>
<span class="card-trend positive">{{ newCustomers.new_customer_vs_yesterday }} vs 上期</span>
</div>
<div class="card-value">{{ newCustomers.new_customer }} </div>
@@ -49,7 +80,10 @@
<div class="overview-card">
<div class="card-header">
<span class="card-title">定金转化</span>
<span class="card-title">
定金转化
<span class="info-icon" @mouseenter="showTooltip($event, 'depositConversion')" @mouseleave="hideTooltip"></span>
</span>
<span class="card-trend positive">{{ depositConversions.deposit_conversion_vs_previous }} vs 上期</span>
</div>
<div class="card-value">{{ depositConversions.current_deposit_conversion_rate }}</div>
@@ -57,11 +91,35 @@
</div>
</div>
<!-- Tooltip 组件 -->
<Tooltip
:visible="tooltip.visible"
:x="tooltip.x"
:y="tooltip.y"
:title="tooltip.title"
:description="tooltip.description"
/>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { computed, reactive, ref } from 'vue'
import Tooltip from '@/components/Tooltip.vue'
// 统计模式状态
const statsMode = ref('monthly') // 默认按月统计
// 定义emit事件
const emit = defineEmits(['update-check-type'])
// 切换统计模式
const switchStatsMode = (mode) => {
statsMode.value = mode
// 向父组件发送事件修改CheckType的值
const checkTypeValue = mode === 'monthly' ? 'month' : 'period'
emit('update-check-type', checkTypeValue)
console.log('切换统计模式:', mode, '发送CheckType值:', checkTypeValue)
}
// 中心整体概览组件
const props = defineProps({
@@ -77,7 +135,6 @@ const props = defineProps({
})
}
})
console.log(99999,props.overallTeamPerformance)
// 计算属性
const totalPerformance = computed(() => {
return props.overallTeamPerformance.totalPerformance
@@ -100,9 +157,62 @@ const newCustomers = computed(() => {
})
const depositConversions = computed(() => {
console.log(999991111,props.overallTeamPerformance.depositConversions)
return props.overallTeamPerformance.depositConversions
})
// 工具提示状态
const tooltip = reactive({
visible: false,
x: 0,
y: 0,
title: '',
description: ''
})
// 指标描述
const metricDescriptions = {
teamPerformance: {
title: '团队总业绩计算方式',
description: '所有团队成员在选定时间范围内的成交金额总和,包括全款订单和定金订单的累计业绩。'
},
activeGroups: {
title: '活跃组数计算方式',
description: '当前有成员在线且有业务活动的团队组数,以及各组的总人数统计。'
},
conversionRate: {
title: '团队转化率计算方式',
description: '团队总成交单数 ÷ 团队总新增客户数 × 100%'
},
totalCalls: {
title: '总通话次数计算方式',
description: '所有团队成员在选定时间范围内的通话总次数,包括外呼、接听等所有通话记录。'
},
newCustomers: {
title: '新增客户计算方式',
description: '所有团队成员在选定时间范围内新建档的客户总数,包括意向客户和潜在客户。'
},
depositConversion: {
title: '定金转化计算方式',
description: '定金订单数 ÷ 总成交单数 × 100%'
}
}
// 显示工具提示
const showTooltip = (event, metricType) => {
const metric = metricDescriptions[metricType]
if (metric) {
tooltip.title = metric.title
tooltip.description = metric.description
tooltip.x = event.clientX
tooltip.y = event.clientY
tooltip.visible = true
}
}
// 隐藏工具提示
const hideTooltip = () => {
tooltip.visible = false
}
</script>
<style lang="scss" scoped>
@@ -112,6 +222,44 @@ const depositConversions = computed(() => {
padding: 0.5rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
.overview-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
.stats-toggle {
display: flex;
background: #f8fafc;
border-radius: 8px;
padding: 4px;
gap: 2px;
.toggle-btn {
padding: 8px 16px;
border: none;
background: transparent;
border-radius: 6px;
font-size: 0.875rem;
font-weight: 500;
color: #64748b;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: #e2e8f0;
color: #475569;
}
&.active {
background: #3b82f6;
color: white;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
}
}
}
h2 {
font-size: 1.3rem;
font-weight: 600;
@@ -146,6 +294,15 @@ const depositConversions = computed(() => {
color: rgba(255, 255, 255, 0.9);
}
.card-title .info-icon {
color: rgba(255, 255, 255, 0.7);
&:hover {
color: rgba(255, 255, 255, 1);
opacity: 1;
}
}
.card-trend {
color: rgba(255, 255, 255, 0.8);
}
@@ -166,6 +323,24 @@ const depositConversions = computed(() => {
color: #64748b;
font-size: 0.9rem;
font-weight: 500;
display: flex;
align-items: center;
gap: 0.25rem;
.info-icon {
color: #94a3b8;
font-size: 0.75rem;
cursor: pointer;
opacity: 0.7;
transition: all 0.2s ease;
margin-left: 0.25rem;
&:hover {
opacity: 1;
color: #3b82f6;
transform: scale(1.1);
}
}
}
.card-trend {
@@ -257,6 +432,19 @@ const depositConversions = computed(() => {
.center-overview {
padding: 1rem;
.overview-header {
flex-direction: column;
align-items: flex-start;
gap: 1rem;
.stats-toggle {
.toggle-btn {
padding: 6px 12px;
font-size: 0.8rem;
}
}
}
.overview-grid {
grid-template-columns: repeat(2, 1fr);
gap: 0.75rem;

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -11,6 +11,7 @@
class="ranking-card"
:class="getRankingClass(index)"
@click="$emit('select-group', group)"
@dblclick="$emit('team-double-click', group)"
>
<div class="rank-badge">{{ index + 1 }}</div>
<div class="group-info">
@@ -20,7 +21,7 @@
<div class="key-metrics">
<div class="mini-metric">
<span class="mini-label">业绩</span>
<span class="mini-value">{{ formatCurrency(group.todayPerformance) }}</span>
<span class="mini-value">{{ group.todayPerformance }}</span>
</div>
<div class="mini-metric">
<span class="mini-label">转化</span>
@@ -48,7 +49,7 @@ const props = defineProps({
}
})
const emit = defineEmits(['select-group'])
const emit = defineEmits(['select-group', 'team-double-click'])
@@ -83,7 +84,7 @@ const processedGroups = computed(() => {
id: index + 1,
name: name,
leader: leader,
todayPerformance: performance * 10000, // 假设单位转换
todayPerformance: performance, // 假设单位转换
conversionRate: conversionRate,
newClients: Math.floor(performance * 2.5), // 根据业绩估算
deals: performance,

View File

@@ -13,16 +13,23 @@
</template>
<script setup>
import { ref, reactive, onMounted, onBeforeUnmount } from 'vue'
import { ref, reactive, onMounted, onBeforeUnmount, watch, nextTick } from 'vue'
import Chart from 'chart.js/auto'
const props = defineProps({
selectedGroup: {
type: Object,
default: null
},
teamSalesFunnel: {
type: Object,
default: () => ({})
}
})
// 组件状态跟踪
const isComponentMounted = ref(true)
// Chart.js 实例
const chartInstances = {}
@@ -35,6 +42,21 @@ const funnelData = reactive({
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: 创建或更新图表
const createOrUpdateChart = (chartId, canvasRef, config) => {
if (chartInstances[chartId]) {
@@ -72,10 +94,19 @@ const renderPersonalFunnelChart = () => {
// 生命周期钩子
onMounted(() => {
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(() => {
isComponentMounted.value = false
Object.values(chartInstances).forEach(chart => chart.destroy())
})
</script>
@@ -120,7 +151,7 @@ $white: #ffffff;
display: flex;
justify-content: space-between;
align-items: center;
padding: 0px 20px 16px;
padding: 0px 20px 10px;
border-bottom: 1px solid #ebeef5;
h3 {
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);
display: flex;
flex-direction: column;
height: 26rem !important;
height: 21.5rem !important;
max-height: 26rem;
// flex: 1;
}
@@ -96,7 +96,7 @@ $white: #ffffff;
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 20px 16px;
padding: 10px 20px 10px;
border-bottom: 1px solid #ebeef5;
h3 {

View File

@@ -5,66 +5,124 @@
<div class="stat-icon customer-rate">
<i class="el-icon-chat-dot-round"></i>
</div>
<div class="kpi-value">{{ customerCommunicationRate.active_customer_communication_rate||0 }}</div>
<p>活跃客户沟通率</p>
<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>
</div>
<div class="kpi-item stat-item">
<div class="stat-icon response-time">
<i class="el-icon-timer"></i>
</div>
<div class="kpi-value">{{ averageResponseTime.average_answer_time||0 }}<span class="kpi-unit">分钟</span></div>
<p>平均应答时间</p>
<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>
</div>
<div class="kpi-item stat-item">
<div class="stat-icon timeout-rate">
<i class="el-icon-warning"></i>
</div>
<div class="kpi-value">{{ timeoutResponseRate.timeout_rate||0 }}</div>
<p>超时应答率</p>
<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>
</div>
<div class="kpi-item stat-item">
<div class="stat-icon severe-timeout-rate">
<i class="el-icon-warning-outline"></i>
</div>
<div class="kpi-value">{{ timeoutResponseRate.serious_timeout_rate||0 }}</div>
<p>严重超时应答率</p>
<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>
</div>
<div class="kpi-item stat-item">
<div class="stat-icon form-rate">
<i class="el-icon-document"></i>
</div>
<div class="kpi-value">{{ formCompletionRate.table_filling_rate||0 }}</div>
<p>表格填写率</p>
<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>
</div>
</div>
<Tooltip
:visible="tooltip.visible"
:x="tooltip.x"
:y="tooltip.y"
:title="tooltip.title"
:description="tooltip.description"
/>
</div>
</template>
<script setup>
import { defineProps } from 'vue';
import { defineProps, reactive } from 'vue';
import Tooltip from '@/components/Tooltip.vue';
defineProps({
customerCommunicationRate: {
type: Number,
default: 0
type: Object,
default: () => ({})
},
averageResponseTime: {
type: Number,
default: 0
type: Object,
default: () => ({})
},
timeoutResponseRate: {
type: Number,
default: 0
type: Object,
default: () => ({})
},
severeTimeoutRate: {
type: Number,
default: 0
type: Object,
default: () => ({})
},
formCompletionRate: {
type: Number,
default: 0
type: Object,
default: () => ({})
}
});
// 工具提示状态
const tooltip = reactive({
visible: false,
x: 0,
y: 0,
title: '',
description: ''
});
// 指标描述
const metricDescriptions = {
customerCommunicationRate: {
title: '活跃客户沟通率计算方式',
description: '有效沟通的活跃客户数 ÷ 总活跃客户数 × 100%'
},
averageResponseTime: {
title: '平均应答时间计算方式',
description: '所有通话的应答时间总和 ÷ 通话总次数'
},
timeoutResponseRate: {
title: '超时应答率计算方式',
description: '超时应答的通话次数 ÷ 总通话次数 × 100%'
},
severeTimeoutRate: {
title: '严重超时应答率计算方式',
description: '严重超时应答的通话次数 ÷ 总通话次数 × 100%'
},
formCompletionRate: {
title: '表格填写率计算方式',
description: '已完成填写的表格数量 ÷ 应填写的表格总数 × 100%'
}
};
// 显示工具提示
const showTooltip = (event, metricType) => {
const metric = metricDescriptions[metricType];
if (metric) {
tooltip.title = metric.title;
tooltip.description = metric.description;
tooltip.x = event.clientX;
tooltip.y = event.clientY;
tooltip.visible = true;
}
};
// 隐藏工具提示
const hideTooltip = () => {
tooltip.visible = false;
};
</script>
<style scoped>
@@ -117,4 +175,18 @@ p {
.timeout-rate { color: #E6A23C; }
.severe-timeout-rate { color: #F56C6C; }
.form-rate { color: #909399; }
.info-icon {
color: #909399;
font-size: 12px;
cursor: pointer;
opacity: 0.7;
transition: opacity 0.3s ease;
margin-left: 4px;
}
.info-icon:hover {
opacity: 1;
color: #409EFF;
}
</style>

View File

@@ -1122,7 +1122,7 @@ const selectMember = (member) => {
// PC端保持一致布局
@media (min-width: 1024px) {
grid-template-columns: 50px 1fr 100px 80px 90px 90px;
grid-template-columns: 50px 1fr 80px 90px 90px;
gap: 1rem;
font-size: 0.875rem;
@@ -1131,7 +1131,7 @@ const selectMember = (member) => {
// 平板端适配
@media (max-width: 1023px) and (min-width: 769px) {
grid-template-columns: 45px 1fr 90px 70px 90px 90px;
grid-template-columns: 45px 1fr 70px 90px 90px;
gap: 0.75rem;
font-size: 0.8125rem;
@@ -1140,7 +1140,7 @@ const selectMember = (member) => {
// 移动端适配
@media (max-width: 768px) {
grid-template-columns: 40px 1fr 80px 60px 90px 90px;
grid-template-columns: 40px 1fr 60px 90px 90px;
gap: 0.5rem;
font-size: 0.8rem;

File diff suppressed because it is too large Load Diff

View File

@@ -42,11 +42,11 @@
<thead>
<tr>
<th>人员</th>
<th @click="sortBy('conversion_rate')" class="sortable">成交率 <span class="sort-icon" :class="{ active: sortField === 'conversion_rate' }">{{ sortOrder === 'desc' ? '↓' : '↑' }}</span></th>
<th @click="sortBy('total_deals')" class="sortable">成交单数 <span class="sort-icon" :class="{ active: sortField === 'total_deals' }">{{ sortOrder === 'desc' ? '↓' : '↑' }}</span></th>
<th>加微率</th>
<th>入群率</th>
<th>表单填写率</th>
<th @click="sortBy('conversion_rate')" class="sortable">成交率 <i class="info-icon" @mouseenter="showTooltip($event, 'conversionRate')" @mouseleave="hideTooltip" @click.stop></i> <span class="sort-icon" :class="{ active: sortField === 'conversion_rate' }">{{ sortOrder === 'desc' ? '↓' : '↑' }}</span></th>
<th @click="sortBy('total_deals')" class="sortable">成交单数 <i class="info-icon" @mouseenter="showTooltip($event, 'totalDeals')" @mouseleave="hideTooltip" @click.stop></i> <span class="sort-icon" :class="{ active: sortField === 'total_deals' }">{{ sortOrder === 'desc' ? '↓' : '↑' }}</span></th>
<th>加微率 <i class="info-icon" @mouseenter="showTooltip($event, 'plusVRate')" @mouseleave="hideTooltip"></i></th>
<th>入群率 <i class="info-icon" @mouseenter="showTooltip($event, 'groupRate')" @mouseleave="hideTooltip"></i></th>
<th>表单填写率 <i class="info-icon" @mouseenter="showTooltip($event, 'formFillingRate')" @mouseleave="hideTooltip"></i></th>
</tr>
</thead>
<tbody>
@@ -74,12 +74,22 @@
</table>
</div>
</div>
<!-- Tooltip 组件 -->
<Tooltip
:visible="tooltip.visible"
:x="tooltip.x"
:y="tooltip.y"
:title="tooltip.title"
:description="tooltip.description"
/>
</div>
</template>
<script setup>
import { ref, computed } from 'vue';
import { ref, computed, reactive } from 'vue';
import { useRouter } from 'vue-router';
import Tooltip from '@/components/Tooltip.vue';
const props = defineProps({
tableData: { type: Array, required: true },
@@ -93,6 +103,56 @@ const filters = ref({ centerLeader: '', advancedManager: '', manager: '', dealSt
const sortField = ref('conversion_rate');
const sortOrder = ref('desc');
// Tooltip 状态管理
const tooltip = reactive({
visible: false,
x: 0,
y: 0,
title: '',
description: ''
});
// 指标计算方式描述
const metricDescriptions = {
conversionRate: {
title: '成交率计算方式',
description: '成交单数 ÷ 本期总客户数 × 100%'
},
totalDeals: {
title: '成交单数计算方式',
description: '在选定时间范围内成功签约的订单总数,包括所有已确认的成交订单。'
},
plusVRate: {
title: '加微率计算方式',
description: '成功添加微信的客户数 ÷ 本期全部客户数 × 100%'
},
groupRate: {
title: '入群率计算方式',
description: '成功邀请进入微信群的客户数 ÷ 本期全部客户数 × 100%'
},
formFillingRate: {
title: '表单填写率计算方式',
description: '填写表单的客户数 ÷ 本期总客户数 × 100%'
}
};
// 显示工具提示
const showTooltip = (event, metricType) => {
const metric = metricDescriptions[metricType];
if (metric) {
tooltip.title = metric.title;
tooltip.description = metric.description;
tooltip.x = event.clientX;
tooltip.y = event.clientY;
tooltip.visible = true;
}
};
// 隐藏工具提示
const hideTooltip = () => {
tooltip.visible = false;
};
const centerLeaders = computed(() => {
return props.levelTree?.level_tree?.center_leaders || [];
});
@@ -227,6 +287,8 @@ th { background: #f7fafc; padding: 12px 16px; text-align: left; font-weight: 600
th.sortable { cursor: pointer; }
.sort-icon { margin-left: 4px; opacity: 0.5; }
.sort-icon.active { opacity: 1; color: #4299e1; }
.info-icon { margin-left: 4px; color: #666; cursor: pointer; font-style: normal; font-size: 12px; transition: color 0.2s; }
.info-icon:hover { color: #409eff; }
td { padding: 16px; border-bottom: 1px solid #f1f5f9; vertical-align: middle; }
tr { cursor: pointer; transition: background-color 0.2s ease; }
tr:hover { background-color: #f8fafc; }

View File

@@ -4,8 +4,8 @@
<h3>转化对比图</h3>
<div class="time-selector">
<select v-model="selectedTimeRange" @change="handleTimeRangeChange" class="time-select">
<option value="periods">本期 vs 上期</option>
<option value="month">本月 vs 上月</option>
<!-- <option value="periods">本期 vs 上期</option> -->
</select>
</div>
</div>
@@ -67,7 +67,7 @@ const props = defineProps({
const emit = defineEmits(['time-range-change']);
const selectedTimeRange = ref('periods');
const selectedTimeRange = ref('month');
// 计算属性:当前和上一期的标签
const currentPeriodLabel = computed(() => {

View File

@@ -17,7 +17,9 @@
<!-- 1. 主卡片中心总业绩 -->
<div class="kpi-card primary">
<div class="card-header">
<span class="card-label">总成交单数</span>
<span class="card-label">
总成交单数
</span>
<span class="card-trend" :class="getTrendClass(kpiData.totalSales.trend)">
{{ formatTrend(kpiData.totalSales.trend) }} vs 上期
</span>
@@ -34,7 +36,12 @@
<!-- 2. 定金转化率 -->
<div class="kpi-card">
<div class="card-header">
<span class="card-label">定金转化率</span>
<span class="card-label">
定金转化率
<span class="info-icon"
@mouseenter="showTooltip($event, 'depositConversion')"
@mouseleave="hideTooltip">!</span>
</span>
<span class="card-trend" :class="getTrendClass(kpiData.activeTeams.trend)">
{{ formatTrend(kpiData.activeTeams.trend, true) }} vs 上期
</span>
@@ -51,7 +58,12 @@
<!-- 3. 总通话次数 -->
<div class="kpi-card">
<div class="card-header">
<span class="card-label">总通话</span>
<span class="card-label">
总通话
<span class="info-icon"
@mouseenter="showTooltip($event, 'totalCalls')"
@mouseleave="hideTooltip">!</span>
</span>
<span class="card-trend" :class="getTrendClass(kpiData.totalCalls.trend)">
{{ formatTrend(kpiData.totalCalls.trend) }} vs 上期
</span>
@@ -68,7 +80,9 @@
<!-- 4. 新增客户 -->
<div class="kpi-card">
<div class="card-header">
<span class="card-label">新增客户</span>
<span class="card-label">
今日新增客户
</span>
<span class="card-trend" :class="getTrendClass(kpiData.newCustomers.trend)">
{{ formatTrend(kpiData.newCustomers.trend) }} vs 上期
</span>
@@ -85,7 +99,12 @@
<!-- 5. 中心转化率 -->
<div class="kpi-card">
<div class="card-header">
<span class="card-label">转化率</span>
<span class="card-label">
转化率
<span class="info-icon"
@mouseenter="showTooltip($event, 'conversionRate')"
@mouseleave="hideTooltip">!</span>
</span>
<span class="card-trend" :class="getTrendClass(kpiData.conversionRate.trend)">
{{ formatTrend(kpiData.conversionRate.trend, true) }} vs 上期
</span>
@@ -96,11 +115,21 @@
</div>
</div>
</div>
<!-- Tooltip组件 -->
<Tooltip
:visible="tooltip.visible"
:x="tooltip.x"
:y="tooltip.y"
:title="tooltip.title"
:description="tooltip.description"
/>
</div>
</template>
<script setup>
import { ref, computed, watch } from 'vue';
import { ref, computed, watch, reactive } from 'vue';
import Tooltip from '@/components/Tooltip.vue';
// 定义props
const props = defineProps({
@@ -117,6 +146,50 @@ const props = defineProps({
const isLoading = ref(false);
const error = ref(null);
// Tooltip状态管理
const tooltip = reactive({
visible: false,
x: 0,
y: 0,
title: '',
description: ''
});
// 指标描述
const metricDescriptions = {
depositConversion: {
title: '定金转化率计算方式',
description: '定金转化率 = (支付定金客户数 / 意向客户总数) × 100%'
},
totalCalls: {
title: '总通话次数计算方式',
description: '有效通话为接通电话次数,总通话为接通电话次数'
},
newCustomers: {
title: '新增客户计算方式',
description: '统计新建档的客户数量,不包括重复录入的客户,按首次录入时间计算。'
},
conversionRate: {
title: '转化率计算方式',
description: '转化率 = (成交客户数 / 总客户数) × 100%'
}
};
// 显示tooltip
function showTooltip(event, metricType) {
const rect = event.target.getBoundingClientRect();
tooltip.visible = true;
tooltip.x = rect.left + rect.width / 2;
tooltip.y = rect.top - 10;
tooltip.title = metricDescriptions[metricType].title;
tooltip.description = metricDescriptions[metricType].description;
}
// 隐藏tooltip
function hideTooltip() {
tooltip.visible = false;
}
// 计算属性将API数据转换为组件需要的格式
const kpiData = computed(() => {
const data = props.kpiData;
@@ -355,4 +428,39 @@ function formatTrend(trend, isPercentagePoint = false) {
font-size: 40px;
}
}
/* 感叹号图标样式 */
.info-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
background: rgba(255, 255, 255, 0.2);
color: #fff;
border-radius: 50%;
font-size: 10px;
font-weight: bold;
margin-left: 6px;
cursor: pointer;
opacity: 0.7;
transition: all 0.2s ease;
}
.info-icon:hover {
opacity: 1;
background: rgba(255, 255, 255, 0.3);
transform: scale(1.1);
}
/* 非主卡片中的图标样式 */
.kpi-card:not(.primary) .info-icon {
background: rgba(0, 0, 0, 0.1);
color: #666;
}
.kpi-card:not(.primary) .info-icon:hover {
background: rgba(0, 0, 0, 0.15);
color: #333;
}
</style>

View File

@@ -0,0 +1,273 @@
<template>
<div class="period-stage-container">
<div class="period-stage-header">
<h3>各中心营期阶段</h3>
</div>
<div class="period-stage-content">
<div class="center-list">
<div
v-for="(center, index) in periodStageData"
:key="index"
class="center-item"
>
<div class="center-info">
<div class="center-name">{{ center.center_name }}</div>
<div class="center-leader">负责人{{ center.center_leader_name }}</div>
</div>
<div class="stage-badge" :class="getStageClass(center.period_stage)">
{{ center.period_stage }}
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { getPeriodStage } from '@/api/top'
// 营期阶段数据
const periodStageData = ref([])
// 获取营期阶段数据
const fetchPeriodStageData = async () => {
try {
console.log('开始获取营期阶段数据...')
const res = await getPeriodStage()
console.log('API响应:', res)
if (res && res.data) {
periodStageData.value = res.data.period_stage
console.log('数据设置成功:', periodStageData.value)
} else {
console.log('API响应无数据使用默认数据')
setDefaultData()
}
} catch (error) {
console.error('获取营期阶段数据失败:', error)
setDefaultData()
}
}
// 设置默认数据
const setDefaultData = () => {
periodStageData.value = [
{
center_name: '一中心',
center_leader_name: '张三丰',
period_stage: '接数据'
},
{
center_name: '二中心',
center_leader_name: '朱一航',
period_stage: '未知阶段'
},
{
center_name: '三中心',
center_leader_name: '程琦',
period_stage: '课1'
}
]
console.log('已设置默认数据:', periodStageData.value)
}
// 获取阶段状态样式类
const getStageClass = (stage) => {
const stageMap = {
'接数据': 'stage-data',
'课1': 'stage-course1',
'课2': 'stage-course2',
'课3': 'stage-course3',
'课4': 'stage-course4',
'休息': 'stage-rest',
'未知阶段': 'stage-unknown'
}
return stageMap[stage] || 'stage-default'
}
// 组件挂载时获取数据
onMounted(() => {
fetchPeriodStageData()
})
</script>
<style scoped>
.period-stage-container {
background: white;
border-radius: 12px;
padding: 24px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.period-stage-header {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 16px;
border-bottom: 1px solid #f0f0f0;
}
.period-stage-header h3 {
margin: 0;
font-size: 18px;
font-weight: 600;
color: #333;
}
.stage-legend {
display: flex;
gap: 8px;
}
.legend-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: #666;
}
.legend-dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.legend-dot.active {
background-color: #1890ff;
}
.legend-dot.completed {
background-color: #52c41a;
}
.legend-dot.pending {
background-color: #d9d9d9;
}
.period-stage-content {
margin-top: 16px;
max-height: 250px;
overflow-y: auto;
}
.center-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.center-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 6px;
background: #f8fafc;
border-radius: 8px;
border: 1px solid #e2e8f0;
transition: all 0.2s ease;
}
.center-item:hover {
background: #f1f5f9;
border-color: #cbd5e1;
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.center-info {
flex: 1;
}
.center-name {
font-size: 16px;
font-weight: 600;
color: #1e293b;
margin-bottom: 4px;
}
.center-leader {
font-size: 14px;
color: #64748b;
}
.stage-badge {
padding: 6px 12px;
border-radius: 6px;
font-size: 12px;
font-weight: 600;
text-align: center;
min-width: 80px;
}
.stage-data {
background: #dbeafe;
color: #1e40af;
}
.stage-course1 {
background: #dcfce7;
color: #166534;
}
.stage-course2 {
background: #fef3c7;
color: #92400e;
}
.stage-course3 {
background: #fed7d7;
color: #c53030;
}
.stage-course4 {
background: #e9d5ff;
color: #7c3aed;
}
.stage-rest {
background: #f3f4f6;
color: #6b7280;
}
.stage-unknown {
background: #fecaca;
color: #dc2626;
}
.stage-default {
background: #e5e7eb;
color: #374151;
}
/* 响应式设计 */
@media (max-width: 768px) {
.period-stage-container {
padding: 16px;
}
.period-stage-header {
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
.stage-legend {
gap: 12px;
}
.stage-item {
padding-left: 32px;
margin-bottom: 24px;
}
.stage-timeline::before {
left: 10px;
}
.stage-dot {
left: 6px;
}
}
</style>

View File

@@ -4,14 +4,6 @@
<div class="chart-container">
<div class="chart-header">
<h3>优秀录音</h3>
<button class="upload-icon-btn" @click="triggerFileUpload" title="上传录音">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14 2H6C4.9 2 4 2.9 4 4V20C4 21.1 4.89 22 5.99 22H18C19.1 22 20 21.1 20 20V8L14 2Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<polyline points="14,2 14,8 20,8" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<line x1="12" y1="18" x2="12" y2="12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<polyline points="9,15 12,12 15,15" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
</div>
<div class="chart-content">
<div class="recording-section">
@@ -26,10 +18,10 @@
:class="{ active: selectedRecording === index }"
@click="selectRecording(index)"
>
<span class="recording-index">{{ recording.score}}</span>
<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-meta">
<span class="file-size">{{ formatFileSize(recording.size) }}</span>
<span class="upload-time">{{ recording.uploadTime }}</span>
</div>
</div>
@@ -67,9 +59,8 @@
<div class="result-header">
<button class="back-btn" @click="backToRecordings">
<i class="el-icon-arrow-left"></i>
返回录音列表
返回
</button>
<h4>{{ isConverting ? '正在转换...' : (currentViewType === 'transcript' ? '转换文本' : '录音分析') }}</h4>
<div class="header-actions">
<!-- 视图切换按钮 -->
<div class="view-toggle" v-if="currentTranscript && !isConverting">
@@ -90,7 +81,7 @@
</div>
<button class="expand-btn" @click="showExpandDialog" v-if="(currentTranscript && currentViewType === 'transcript') || (analysisResult && currentViewType === 'analysis')">
<i class="el-icon-full-screen"></i>
展开查看
展开
</button>
<button class="copy-btn" @click="copyText" v-if="currentTranscript && currentViewType === 'transcript'">
<i class="el-icon-document-copy"></i>
@@ -174,15 +165,21 @@
</div>
</template>
<script>
<script setup>
import { ref, reactive, computed, onMounted, onBeforeUnmount } from 'vue'
import { SimpleChatService } from '@/utils/ChatService.js'
import MarkdownIt from 'markdown-it'
export default {
name: 'QualityCalls',
data() {
return {
recordings: [
// Props定义
const props = defineProps({
qualityCalls: {
type: Array,
default: () => []
}
})
// 响应式数据
const staticRecordings = ref([
{
id: 1,
name: '常家硕-张三丰-亮剑二部-20分钟通话-25-07-16_18-23-04-44196-215.mp3',
@@ -191,77 +188,75 @@ export default {
date: '2024-01-15',
url: '/recordings/sample_call_1.mp3',
transcription: null
},
{
id: 2,
name: '常家硕-张三丰-亮剑二部-20分钟通话-25-07-16_18-23-01-439240-599.mp3',
size: 3145728, // 3MB
duration: '00:05:20',
date: '2024-01-14',
url: '/recordings/sample_call_2.mp3',
transcription: null
},
{
id: 3,
name: '常家硕-张三丰-亮剑二部-20分钟通话-25-07-16_18-23-02-754615-508.mp3',
size: 2048576, // 2MB
duration: '00:03:45',
date: '2024-01-15',
url: '/recordings/sample_call_1.mp3',
transcription: null
},
{
id: 4,
name: '丁传辉-丁传辉-勇士二部-20分钟通话-25-07-10_10-32-54-813815-322.mp3',
size: 3145728, // 3MB
duration: '00:05:20',
date: '2024-01-14',
url: '/recordings/sample_call_2.mp3',
transcription: null
}
],
selectedRecording: null,
currentAudio: null,
showTranscriptView: false,
isConverting: false,
currentTranscript: null,
showDialog: false,
])
const selectedRecording = ref(null)
const currentAudio = ref(null)
const showTranscriptView = ref(false)
const isConverting = ref(false)
const currentTranscript = ref(null)
const showDialog = ref(false)
// 录音分析相关
showAnalysisView: false,
isAnalyzing: false,
analysisResult: '',
currentViewType: 'transcript', // 'transcript' 或 'analysis'
const showAnalysisView = ref(false)
const isAnalyzing = ref(false)
const analysisResult = ref('')
const currentViewType = ref('transcript') // 'transcript' 或 'analysis'
// Dify API配置
DIFY_API_KEY_02: 'app-h4uBo5kOGoiYhjuBF1AHZi8b', // 通话录音分析
chatService_02: null,
md: null
}
},
created() {
const DIFY_API_KEY_02 = 'app-h4uBo5kOGoiYhjuBF1AHZi8b' // 通话录音分析
const chatService_02 = ref(null)
const md = ref(null)
// 初始化服务
this.chatService_02 = new SimpleChatService(this.DIFY_API_KEY_02)
this.md = new MarkdownIt({
onMounted(() => {
chatService_02.value = new SimpleChatService(DIFY_API_KEY_02)
md.value = new MarkdownIt({
html: true,
linkify: true,
typographer: true
})
},
computed: {
})
// 计算属性
// 处理传入的录音数据
const recordings = computed(() => {
if (!props.qualityCalls ) {
return staticRecordings.value;
}
const recordingsList = [];
props.qualityCalls.forEach((record, index) => {
recordingsList.push({
id: recordingsList.length + 1,
name: record.record_name ? record.record_name : `${record.sale_name}-录音-${index + 1}`,
date: new Date().toISOString().split('T')[0],
url: record.record_file_addr,
transcription: record.record_context || null,
score: record.record_score,
sop: record.record_report,
sale_name: record.record_name,
size: 2048576, // 默认文件大小 2MB
uploadTime: record.created_at,
});
});
return recordingsList;
})
// 格式化分析结果
formattedAnalysisResult() {
if (!this.analysisResult) return ''
return this.md.render(this.analysisResult)
const formattedAnalysisResult = computed(() => {
if (!analysisResult.value) return ''
return md.value.render(analysisResult.value)
})
// 生命周期钩子
onBeforeUnmount(() => {
if (currentAudio.value) {
currentAudio.value.pause()
currentAudio.value = null
}
},
beforeUnmount() {
if (this.currentAudio) {
this.currentAudio.pause()
this.currentAudio = null
}
},
methods: {
})
// 方法定义
// 录音文件选择
handleFileSelect(event) {
const handleFileSelect = (event) => {
const file = event.target.files[0]
if (file) {
const recording = {
@@ -273,56 +268,60 @@ export default {
isConverting: false,
transcript: null
}
this.recordings.push(recording)
staticRecordings.value.push(recording)
// 清空input以便重复选择同一文件
event.target.value = ''
}
},
}
// 选择录音
selectRecording(index) {
this.selectedRecording = index
},
const selectRecording = (index) => {
selectedRecording.value = index
}
// 播放/暂停录音
togglePlay(index) {
const recording = this.recordings[index]
const togglePlay = (index) => {
const recording = recordings.value[index]
// 停止当前播放的音频
if (this.currentAudio) {
this.currentAudio.pause()
this.recordings.forEach(r => r.isPlaying = false)
if (currentAudio.value) {
currentAudio.value.pause()
recordings.value.forEach(r => r.isPlaying = false)
}
if (!recording.isPlaying) {
this.currentAudio = new Audio(recording.url)
this.currentAudio.play()
currentAudio.value = new Audio(recording.url)
currentAudio.value.play()
recording.isPlaying = true
this.currentAudio.onended = () => {
currentAudio.value.onended = () => {
recording.isPlaying = false
this.currentAudio = null
currentAudio.value = null
}
}
},
// 转换为文
async convertToText(index) {
const recording = this.recordings[index]
this.selectedRecording = index
this.showTranscriptView = true
this.isConverting = true
this.currentTranscript = null
this.currentViewType = 'transcript'
}
// 转换为文
const convertToText = async (index) => {
const recording = recordings.value[index]
selectedRecording.value = index
showTranscriptView.value = true
isConverting.value = true
currentTranscript.value = null
currentViewType.value = 'transcript'
try {
// 模拟转换过程
await new Promise(resolve => setTimeout(resolve, 2000))
await new Promise(resolve => setTimeout(resolve, 1000))
// 这里应该调用实际的语音转文本API
// 目前使用模拟数据
recording.transcript = `这是 ${recording.name} 的转换文本示例。在实际应用中,这里会显示真实的语音转文本结果。您可以集成百度、阿里云、腾讯云等语音识别服务来实现真正的语音转文本功能。`
this.currentTranscript = recording.transcript
// 转换完成后自动开始录音分析
this.startRecordingAnalysis(recording)
// 使用从API获取的transcription数据
if (recording.transcription) {
recording.transcript = recording.transcription
currentTranscript.value = recording.transcription
} else {
// 如果没有transcription数据显示提示信息
recording.transcript = '暂无转换文本数据'
currentTranscript.value = '暂无转换文本数据'
}
// 添加转换完成的动画效果
const resultElement = document.querySelector('.conversion-result')
@@ -335,105 +334,135 @@ export default {
} catch (error) {
console.error('转换失败:', error)
alert('转换失败,请重试')
this.showTranscriptView = false
showTranscriptView.value = false
} finally {
this.isConverting = false
isConverting.value = false
}
}
},
// 开始通话录音分析
async startRecordingAnalysis(recording) {
this.isAnalyzing = true
this.analysisResult = ''
// 构建通话录音分析查询
const recordingQuery = `请对录音文件 ${recording.name} 进行通话录音分析,包括:
1. 通话质量评估
2. 客户情绪分析
3. 沟通效果评价
4. 关键信息提取
5. 改进建议
录音信息:
文件名:${recording.name}
文件大小:${this.formatFileSize(recording.size)}
转换文本:${recording.transcript}`
const startRecordingAnalysis = async (recording) => {
isAnalyzing.value = true
try {
await this.chatService_02.sendMessage(
recordingQuery,
(update) => {
// 实时更新通话录音分析结果
this.analysisResult = update.content
},
() => {
// 流结束回调
console.log('通话录音分析完成')
this.isAnalyzing = false
// 使用从API获取的sop数据作为录音分析结果
if (recording.sop) {
analysisResult.value = recording.sop
} else {
analysisResult.value = '暂无录音分析数据'
}
)
// 模拟分析过程
await new Promise(resolve => setTimeout(resolve, 500))
console.log('录音分析完成')
} catch (error) {
console.error('通话录音分析失败:', error)
this.analysisResult = '通话录音分析失败,请重试。'
this.isAnalyzing = false
console.error('录音分析失败:', error)
analysisResult.value = '录音分析失败,请重试。'
} finally {
isAnalyzing.value = false
}
}
},
// 切换视图类型
switchViewType(type) {
this.currentViewType = type
},
const switchViewType = (type) => {
currentViewType.value = type
// 如果切换到录音分析视图,且还没有分析结果,则开始分析
if (type === 'analysis' && !analysisResult.value && selectedRecording.value !== null) {
const recording = recordings.value[selectedRecording.value]
startRecordingAnalysis(recording)
}
}
// 返回录音列表
backToRecordings() {
this.showTranscriptView = false
this.currentTranscript = null
this.analysisResult = ''
this.currentViewType = 'transcript'
this.isAnalyzing = false
},
const backToRecordings = () => {
showTranscriptView.value = false
currentTranscript.value = null
analysisResult.value = ''
currentViewType.value = 'transcript'
isAnalyzing.value = false
}
// 复制文本
copyText() {
if (this.currentTranscript) {
navigator.clipboard.writeText(this.currentTranscript)
const copyText = async () => {
if (currentTranscript.value) {
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(currentTranscript.value)
alert('文本已复制到剪贴板')
} else {
// 降级方案:使用传统的复制方法
const textArea = document.createElement('textarea')
textArea.value = currentTranscript.value
document.body.appendChild(textArea)
textArea.select()
document.execCommand('copy')
document.body.removeChild(textArea)
alert('文本已复制到剪贴板')
}
},
} catch (error) {
console.error('复制失败:', error)
alert('复制失败,请手动复制')
}
}
}
// 复制分析结果
copyAnalysisText() {
if (this.analysisResult) {
navigator.clipboard.writeText(this.analysisResult)
const copyAnalysisText = async () => {
if (analysisResult.value) {
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(analysisResult.value)
alert('分析结果已复制到剪贴板')
} else {
// 降级方案:使用传统的复制方法
const textArea = document.createElement('textarea')
textArea.value = analysisResult.value
document.body.appendChild(textArea)
textArea.select()
document.execCommand('copy')
document.body.removeChild(textArea)
alert('分析结果已复制到剪贴板')
}
},
} catch (error) {
console.error('复制失败:', error)
alert('复制失败,请手动复制')
}
}
}
// 显示展开弹框
showExpandDialog() {
this.showDialog = true
},
const showExpandDialog = () => {
showDialog.value = true
}
// 关闭弹框
closeDialog() {
this.showDialog = false
},
const closeDialog = () => {
showDialog.value = false
}
// 格式化文件大小
formatFileSize(bytes) {
const formatFileSize = (bytes) => {
if (bytes === 0) return '0 Bytes'
const k = 1024
const sizes = ['Bytes', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
},
triggerFileUpload() {
}
const triggerFileUpload = () => {
const fileInput = document.createElement('input')
fileInput.type = 'file'
fileInput.accept = 'audio/*'
fileInput.style.display = 'none'
fileInput.addEventListener('change', this.handleFileSelect)
fileInput.addEventListener('change', handleFileSelect)
document.body.appendChild(fileInput)
fileInput.click()
document.body.removeChild(fileInput)
},
downloadRecording(index) {
const recording = this.recordings[index]
}
const downloadRecording = (index) => {
const recording = recordings.value[index]
if (recording && recording.url) {
const link = document.createElement('a')
link.href = recording.url
@@ -443,8 +472,6 @@ export default {
document.body.removeChild(link)
}
}
}
}
</script>
<style scoped>
@@ -484,7 +511,7 @@ export default {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 20px 0;
padding: 10px 20px 0;
border-bottom: 1px solid #ebeef5;
}
@@ -521,20 +548,19 @@ export default {
}
.chart-content {
padding: 20px;
padding: 10px;
}
.recording-section {
width: 100%;
min-height: 300px;
max-height: 500px;
min-height: 200px;
max-height: 300px;
overflow-y: auto;
}
.recording-list {
margin-bottom: 20px;
max-height: 400px;
overflow-y: auto;
}
.recording-item {
@@ -574,6 +600,39 @@ export default {
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 {
display: flex;
gap: 12px;

View File

@@ -3,13 +3,13 @@
<div class="card-header">
<h3>团队业绩排行榜</h3>
<select v-model="rankingPeriod" class="periods-select" @change="onPeriodChange">
<option value="periods">本期</option>
<!-- <option value="periods">本期</option> -->
<option value="month">月度</option>
<option value="year">年度</option>
</select>
</div>
<div class="ranking-list">
<div v-for="(item, index) in rankingData.slice(0, 4)" :key="item.id" class="ranking-item">
<div v-for="(item, index) in rankingData.slice(0, 8)" :key="item.id" class="ranking-item">
<div class="rank-number" :class="getRankClass(index)">
{{ index + 1 }}
</div>
@@ -39,7 +39,7 @@ const props = defineProps({
const emit = defineEmits(['periods-change']);
const rankingPeriod = ref('periods');
const rankingPeriod = ref('month');
const centerSalesRank = ref({});
// 计算属性:转换 centerSalesRank 数据格式
@@ -62,12 +62,15 @@ const rankingData = computed(() => {
return [];
}
return rankList.map((item, index) => ({
// 转换数据格式并按total_deals从大到小排序
return rankList
.map((item, index) => ({
id: index + 1,
name: item.center_leader,
performance: item.total_deals,
average_deals_per_member: item.average_deals_per_member
}));
}))
.sort((a, b) => b.performance - a.performance); // 按total_deals从大到小排序
});
// 获取全中心业绩排行榜数据

View File

@@ -7,12 +7,11 @@
</button>
</div>
<div class="task-list compact">
<div v-for="task in tasks.slice(0, 3)" :key="task.id" class="task-item">
<div v-for="task in tasks.slice(0, 10)" :key="task.id" class="task-item">
<div class="task-content">
<div class="task-title">{{ task.title }}</div>
<div class="task-meta" style="display: flex; gap: 15px;">
<span class="assignee">分配给: {{ task.assignee }}</span>
<span class="deadline">截止: {{ formatDate(task.deadline) }}</span>
<span class="deadline">创建时间: {{ formatDate(task.created_at) }}</span>
</div>
</div>
<div class="task-status" :class="task.status">
@@ -25,6 +24,7 @@
<script setup>
import { defineProps, defineEmits } from 'vue';
import { assignTasks } from '@/api/top.js';
defineProps({
tasks: Array,

View File

@@ -4,33 +4,41 @@
<div class="dashboard-header">
<h1>管理者数据看板</h1>
<!-- 头像 -->
<UserDropdown />
<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
:card-visibility="cardVisibility"
@update-card-visibility="updateCardVisibility"
/>
</div>
</div>
<!-- 第一行核心业绩指标销售实时进度下发任务 -->
<!-- 第一行核心业绩指标销售实时进度 -->
<div class="dashboard-row row-1">
<!-- 核心业绩指标 -->
<kpi-metrics :kpi-data="totalDeals" :format-number="formatNumber" />
<kpi-metrics v-if="cardVisibility.kpiMetrics" :kpi-data="totalDeals" :format-number="formatNumber" />
<!-- 销售实时进度 -->
<sales-progress :sales-data="realTimeProgress" />
<!-- 下发任务 -->
<task-list
:tasks="tasks"
:format-date="formatDate"
:get-task-status-text="getTaskStatusText"
@show-task-modal="showTaskModal = true"
/>
<sales-progress v-if="cardVisibility.salesProgress" :sales-data="realTimeProgress" />
<!-- 各中心营期阶段 -->
<period-stage v-if="cardVisibility.periodStage" />
</div>
<!-- 第二行 -->
<div class="dashboard-row row-3">
<!-- 转化漏斗 -->
<funnel-chart
v-if="cardVisibility.funnelChart"
:funnel-data="formattedFunnelData"
:comparison-data="formattedComparisonData"
@time-range-change="handleTimeRangeChange"
/>
<!-- 销售个人业绩排行榜 -->
<personal-sales-ranking
v-if="cardVisibility.personalSalesRanking"
:ranking-data="formattedSalesRankingData"
:format-number="formatNumber"
:get-rank-class="getRankClass"
@@ -39,7 +47,8 @@
/>
<!-- 优质通话 -->
<quality-calls
:quality-calls="qualityCalls"
v-if="cardVisibility.qualityCalls"
:quality-calls="excellentRecord"
@play-call="playCall"
@download-call="downloadCall"
/>
@@ -48,80 +57,30 @@
<div class="dashboard-row row-3">
<!-- 业绩排行榜 -->
<ranking-list
v-if="cardVisibility.rankingList"
:format-number="formatNumber"
:get-rank-class="getRankClass"
/>
<!-- 客户类型占比 -->
<customer-type :customer-data="customerTypeRatio" @category-change="getCustomerTypeRatio" />
<customer-type v-if="cardVisibility.customerType" :customer-data="customerTypeRatio" @category-change="getCustomerTypeRatio" />
<!-- 客户迫切解决的问题排行榜 -->
<problem-ranking :ranking-data="problemRankingData" />
<problem-ranking v-if="cardVisibility.problemRanking" :ranking-data="problemRankingData" />
</div>
<!-- 第四行详细数据表格和数据详情 -->
<div class="dashboard-row">
<CampManagement />
<div class="dashboard-row" v-show="false">
<CampManagement v-if="cardVisibility.campManagement" />
</div>
<!-- 第五行 -->
<div class="dashboard-row" >
<DetailedDataTable
v-if="cardVisibility.detailedDataTable"
:table-data="detailData"
:level-tree="levelTree"
v-model:selected-person="selectedPerson"
@filter-change="handleFilterChange"
/>
</div>
<!-- 新建任务模态框 -->
<div
v-if="showTaskModal"
class="modal-overlay"
@click="showTaskModal = false"
>
<div class="modal-content" @click.stop>
<div class="modal-header">
<h3>新建任务</h3>
<button class="close-btn" @click="showTaskModal = false">×</button>
</div>
<div class="modal-body">
<div class="form-group">
<label>任务标题</label>
<input
v-model="newTask.title"
type="text"
placeholder="请输入任务标题"
/>
</div>
<div class="form-group">
<label>分配给</label>
<select v-model="newTask.assignee">
<option value="">请选择员工</option>
<option
v-for="employee in employees"
:key="employee.id"
:value="employee.name"
>
{{ employee.name }}
</option>
</select>
</div>
<div class="form-group">
<label>截止日期</label>
<input v-model="newTask.deadline" type="date" />
</div>
<div class="form-group">
<label>任务描述</label>
<textarea
v-model="newTask.description"
placeholder="请输入任务描述"
></textarea>
</div>
</div>
<div class="modal-footer">
<button class="cancel-btn" @click="showTaskModal = false">
取消
</button>
<button class="confirm-btn" @click="createTask">创建任务</button>
</div>
</div>
</div>
</div>
</template>
@@ -140,10 +99,10 @@
<script setup>
import { ref, reactive, computed, onMounted, nextTick } from "vue";
import axios from "axios";
import UserDropdown from "@/components/UserDropdown.vue";
import KpiMetrics from "./components/KpiMetrics.vue";
import SalesProgress from "./components/SalesProgress.vue";
import TaskList from "./components/TaskList.vue";
import FunnelChart from "./components/FunnelChart.vue";
import CustomerProfile from "./components/CustomerProfile.vue";
import CustomerType from "./components/CustomerType.vue";
@@ -156,9 +115,106 @@ import QualityCalls from "./components/QualityCalls.vue";
import DataDetail from "./components/DataDetail.vue";
import CampManagement from "./components/CampManagement.vue";
import DetailedDataTable from "./components/DetailedDataTable.vue";
import PeriodStage from "./components/PeriodStage.vue";
import { getOverallCompanyPerformance,getCompanyDepositConversionRate,getCompanyTotalCallCount,getCompanyNewCustomer,getCompanyConversionRate,getCompanyRealTimeProgress
,getCompanyConversionRateVsLast,getSalesMonthlyPerformance,getCustomerTypeDistribution,getUrgentNeedToAddress,getLevelTree,getDetailedDataTable
} from "@/api/top";
,getCompanyConversionRateVsLast,getSalesMonthlyPerformance,getCustomerTypeDistribution,getUrgentNeedToAddress,getLevelTree,getDetailedDataTable,getExcellentRecordFile } from "@/api/top";
import { useUserStore } from "@/stores/user.js";
import FeedbackForm from "@/components/FeedbackForm.vue";
// 缓存系统
const cache = new Map();
const CACHE_DURATION = 30 * 60 * 1000; // 30分钟
// 缓存工具函数
const getCacheKey = (functionName, params = {}) => {
return `${functionName}_${JSON.stringify(params)}`;
};
const isValidCache = (cacheItem) => {
return cacheItem && (Date.now() - cacheItem.timestamp) < CACHE_DURATION;
};
const setCache = (key, data) => {
cache.set(key, {
data,
timestamp: Date.now()
});
};
const getCache = (key) => {
const cacheItem = cache.get(key);
if (isValidCache(cacheItem)) {
return cacheItem.data;
}
return null;
};
// 带缓存的API调用包装器
const withCache = async (cacheKey, apiCall) => {
const cachedData = getCache(cacheKey);
if (cachedData) {
console.log(`使用缓存数据: ${cacheKey}`);
return cachedData;
}
try {
const result = await apiCall();
setCache(cacheKey, result);
console.log(`缓存新数据: ${cacheKey}`);
return result;
} catch (error) {
console.error(`API调用失败: ${cacheKey}`, error);
throw error;
}
};
// 清除缓存函数
const clearCache = () => {
cache.clear();
console.log('所有缓存已清除');
};
// 清除特定缓存
const clearSpecificCache = (functionName, params = {}) => {
const cacheKey = getCacheKey(functionName, params);
cache.delete(cacheKey);
console.log(`已清除缓存: ${cacheKey}`);
};
// 获取缓存状态信息
const getCacheInfo = () => {
const cacheEntries = Array.from(cache.entries());
const validEntries = cacheEntries.filter(([key, value]) => isValidCache(value));
const expiredEntries = cacheEntries.filter(([key, value]) => !isValidCache(value));
// 清除过期缓存
expiredEntries.forEach(([key]) => cache.delete(key));
return {
totalCached: validEntries.length,
expiredCleaned: expiredEntries.length,
cacheKeys: validEntries.map(([key]) => key)
};
};
// 强制刷新所有数据(清除缓存并重新获取)
const forceRefreshAllData = async () => {
clearCache();
console.log('开始强制刷新所有数据...');
await getRealTimeProgress();
await getTotalDeals();
await getConversionComparison('month');
await getCompanySalesRank('red');
await getCustomerTypeRatio('child_education');
await getCustomerUrgency();
await CusotomGetLevelTree();
await getDetailData();
await CenterExcellentRecord();
console.log('所有数据刷新完成');
};
const rankingPeriod = ref("month");
const rankingData = ref([
{ id: 1, name: "张三", department: "销售一部", performance: 125000 },
@@ -172,28 +228,40 @@ const sortField = ref("dealRate");
const sortOrder = ref("desc");
const selectedPerson = ref(null);
const tasks = ref([
{
id: 1,
title: "完成Q4销售目标制定",
assignee: "张三",
deadline: "2024-01-15",
status: "pending",
}
]);
const userStore = useUserStore();
const employees = ref([
{ id: 1, name: "张三" }
]);
const showTaskModal = ref(false);
const newTask = reactive({
title: "",
assignee: "",
deadline: "",
description: "",
// 卡片显示状态管理
const cardVisibility = ref({
kpiMetrics: true,
salesProgress: true,
periodStage: true,
funnelChart: true,
personalSalesRanking: true,
qualityCalls: true,
rankingList: true,
customerType: true,
problemRanking: true,
campManagement: true,
detailedDataTable: true
});
// FeedbackForm 控制变量
const showFeedbackForm = ref(false);
// FeedbackForm 控制方法
const showFeedbackFormModal = () => {
showFeedbackForm.value = true;
};
const closeFeedbackFormModal = () => {
showFeedbackForm.value = false;
};
// 更新卡片显示状态
const updateCardVisibility = (newVisibility) => {
Object.assign(cardVisibility.value, newVisibility);
};
// 计算属性
const filteredTableData = computed(() => {
let filtered = tableData.value;
@@ -224,9 +292,9 @@ const filteredTableData = computed(() => {
});
// 方法
const refreshData = () => {
// 刷新数据逻辑
console.log("刷新数据");
const refreshData = async () => {
// 强制刷新所有数据
await forceRefreshAllData();
};
// 处理时间范围变化
@@ -291,7 +359,17 @@ const formatTime = (timestamp) => {
};
const formatDate = (dateString) => {
return new Date(dateString).toLocaleDateString("zh-CN");
if (!dateString) return '';
// 处理 "2025-08-21 11:58:10" 格式的时间字符串
try {
const date = new Date(dateString.replace(' ', 'T'));
if (isNaN(date.getTime())) {
return dateString; // 如果解析失败,返回原字符串
}
return date.toLocaleDateString("zh-CN");
} catch (error) {
return dateString;
}
};
const formatDuration = (minutes) => {
@@ -304,14 +382,7 @@ const selectPerson = (person) => {
selectedPerson.value = person;
};
const getTaskStatusText = (status) => {
const statusMap = {
pending: "待处理",
"in-progress": "进行中",
completed: "已完成",
};
return statusMap[status] || status;
};
const playCall = (callId) => {
console.log("播放通话录音:", callId);
@@ -321,50 +392,39 @@ const downloadCall = (callId) => {
console.log("下载通话录音:", callId);
};
const createTask = () => {
if (!newTask.title || !newTask.assignee || !newTask.deadline) {
alert("请填写完整信息");
return;
}
const task = {
id: Date.now(),
title: newTask.title,
assignee: newTask.assignee,
deadline: newTask.deadline,
status: "pending",
};
tasks.value.unshift(task);
// 重置表单
Object.assign(newTask, {
title: "",
assignee: "",
deadline: "",
description: "",
});
showTaskModal.value = false;
};
// 核心数据
const totalDeals = ref({});
// 核心数据--总成交金额
async function getTotalDeals() {
try {
const cacheKey = getCacheKey('getTotalDeals');
const cachedResult = getCache(cacheKey);
if (cachedResult) {
console.log('使用缓存数据: getTotalDeals');
totalDeals.value = cachedResult;
return;
}
const res1 = await getOverallCompanyPerformance()
const res2=await getCompanyDepositConversionRate()
const res3=await getCompanyTotalCallCount()
const res4=await getCompanyNewCustomer()
const res5=await getCompanyConversionRate()
totalDeals.value={
const result = {
totalDeal:res1.data, //总成交单数
DingconversionRate:res2.data, //定金转化率
totalCallCount:res3.data, // 总通话
newCustomer:res4.data, //新客户
conversionRate:res5.data,//转化率
}
};
totalDeals.value = result;
setCache(cacheKey, result);
console.log('缓存新数据: getTotalDeals');
} catch (error) {
console.error("获取总成交金额失败:", error);
@@ -372,11 +432,17 @@ async function getTotalDeals() {
}
// 实时进度
const realTimeProgress = ref({});
async function getRealTimeProgress() {
try {
const res = await getCompanyRealTimeProgress()
// console.log(111111,res)
realTimeProgress.value = res.data
const cacheKey = getCacheKey('getRealTimeProgress');
const result = await withCache(cacheKey, async () => {
const res = await getCompanyRealTimeProgress();
return res.data;
});
realTimeProgress.value = result;
} catch (error) {
console.error("获取实时进度失败:", error);
}
@@ -454,9 +520,12 @@ async function getConversionComparison(data) {
check_type:data //month periods
}
try {
const res = await getCompanyConversionRateVsLast(params)
console.log(111111,res)
conversionComparison.value = res.data
const cacheKey = getCacheKey('getConversionComparison', params);
const result = await withCache(cacheKey, async () => {
const res = await getCompanyConversionRateVsLast(params);
return res.data;
});
conversionComparison.value = result;
} catch (error) {
console.error("获取转化对比失败:", error);
}
@@ -505,8 +574,12 @@ async function getCompanySalesRank(Rank) {
rank_type:Rank,
}
try {
const res = await getSalesMonthlyPerformance(params)
companySalesRank.value = res.data
const cacheKey = getCacheKey('getCompanySalesRank', params);
const result = await withCache(cacheKey, async () => {
const res = await getSalesMonthlyPerformance(params);
return res.data;
});
companySalesRank.value = result;
} catch (error) {
console.error("获取销售月度业绩红黑榜失败:", error);
}
@@ -519,20 +592,37 @@ async function getCustomerTypeRatio(data) {
distribution_type:data // child_education territory occupation
}
try {
const res = await getCustomerTypeDistribution(params)
console.log(1222222,res)
customerTypeRatio.value = res.data
const cacheKey = getCacheKey('getCustomerTypeRatio', params);
const result = await withCache(cacheKey, async () => {
const res = await getCustomerTypeDistribution(params);
return res.data;
});
customerTypeRatio.value = result;
} catch (error) {
console.error("获取客户类型占比失败:", error);
}
}
// 客户迫切解决的问题排行榜
const customerUrgency = ref({});
const problemRankingData = ref([]);
async function getCustomerUrgency() {
try {
const res = await getUrgentNeedToAddress()
console.log(1222222,res)
customerUrgency.value = res.data
const cacheKey = getCacheKey('getCustomerUrgency');
const result = await withCache(cacheKey, async () => {
const res = await getUrgentNeedToAddress();
return res.data;
});
customerUrgency.value = result;
// 将API返回的数据转换为ProblemRanking组件需要的格式
if (result && result.company_urgent_issue_ratio) {
problemRankingData.value = Object.entries(result.company_urgent_issue_ratio).map(([name, value]) => ({
name,
value
}));
}
} catch (error) {
console.error("获取客户迫切解决的问题排行榜失败:", error);
}
@@ -541,9 +631,12 @@ async function getCustomerUrgency() {
const levelTree = ref({});
async function CusotomGetLevelTree() {
try {
const res = await getLevelTree()
console.log(1222222,res)
levelTree.value = res.data
const cacheKey = getCacheKey('CusotomGetLevelTree');
const result = await withCache(cacheKey, async () => {
const res = await getLevelTree();
return res.data;
});
levelTree.value = result;
} catch (error) {
console.error("获取级别树失败:", error);
}
@@ -551,22 +644,18 @@ async function CusotomGetLevelTree() {
// 获取详细数据表格
const detailData = ref({});
async function getDetailData(params) {
if(params?.center_leader){
try {
const res = await getDetailedDataTable(params)
detailData.value = res.data
const cacheKey = getCacheKey('getDetailData', params || {});
const result = await withCache(cacheKey, async () => {
const res = params?.center_leader
? await getDetailedDataTable(params)
: await getDetailedDataTable();
return res.data;
});
detailData.value = result;
} catch (error) {
console.error("获取详细数据表格失败:", error);
}
}else{
try {
const res = await getDetailedDataTable()
detailData.value = res.data
} catch (error) {
console.error("获取详细数据表格失败:", error);
}
}
}
// 处理筛选器变化
@@ -574,17 +663,53 @@ const handleFilterChange = (filterParams) => {
console.log('筛选器变化:', filterParams)
getDetailData(filterParams)
}
// 优秀录音
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);
}
}
onMounted(async() => {
// 页面初始化逻辑
await getRealTimeProgress()
await getTotalDeals()
await getConversionComparison('month')
await getCompanySalesRank('red')
await getCustomerTypeRatio('child_education')
await getCustomerUrgency()
await CusotomGetLevelTree()
await getDetailData()
console.log('页面初始化,开始加载数据...');
getRealTimeProgress()
getTotalDeals()
getConversionComparison('month')
getCompanySalesRank('red')
getCustomerTypeRatio('child_education')
getCustomerUrgency()
CusotomGetLevelTree()
getDetailData()
CenterExcellentRecord()
// 输出缓存状态信息
const cacheInfo = getCacheInfo();
console.log('数据加载完成,缓存状态:', cacheInfo);
// 在开发环境下暴露缓存管理函数到全局,方便调试
if (import.meta.env.DEV) {
window.dashboardCache = {
clearCache,
clearSpecificCache,
getCacheInfo,
forceRefreshAllData,
cache: cache
};
console.log('开发模式:缓存管理函数已暴露到 window.dashboardCache');
}
});
</script>
@@ -2058,4 +2183,20 @@ button {
-ms-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>