Compare commits

..

114 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
53 changed files with 20637 additions and 5119 deletions

View File

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

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": { "dependencies": {
"@element-plus/icons-vue": "^2.3.1", "@element-plus/icons-vue": "^2.3.1",
"@fullcalendar/core": "^6.1.19",
"axios": "^1.10.0", "axios": "^1.10.0",
"chart.js": "^4.5.0", "chart.js": "^4.5.0",
"dompurify": "^3.2.6", "dompurify": "^3.2.6",
@@ -18,11 +19,12 @@
"markdown-it": "^14.1.0", "markdown-it": "^14.1.0",
"marked": "^16.1.1", "marked": "^16.1.1",
"pinia": "^3.0.2", "pinia": "^3.0.2",
"pinia-plugin-persistedstate": "^3.2.3", "pinia-plugin-persistedstate": "^4.5.0",
"vue": "^3.5.17", "vue": "^3.5.17",
"vue-chartjs": "^5.3.2", "vue-chartjs": "^5.3.2",
"vue-echarts": "^7.0.3", "vue-echarts": "^7.0.3",
"vue-router": "^4.5.0" "vue-router": "^4.5.0",
"xlsx": "^0.18.5"
}, },
"devDependencies": { "devDependencies": {
"@tsconfig/node22": "^22.0.1", "@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 // 今日通话 /api/v1/more_level_screening/today_call
export const getTodayCall = (params) => { export const getTodayCall = (params) => {
return https.post('/api/v1/sales/today_call', params) return https.post('/api/v1/sales/current_camp_call', params)
} }
// 表格填写率 /api/v1/more_level_screening/table_filling_rate // 表格填写率 /api/v1/more_level_screening/table_filling_rate
@@ -73,6 +73,16 @@ export const getSalesFunnel = (params) => {
export const getGoldContactTime = (params) => { export const getGoldContactTime = (params) => {
return https.post('/api/v1/sales/get_gold_contact_time', 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) return https.post('/api/v1/manager/week_total_call', params)
} }
// 有效通话时长
// 新增意向客户 /api/v1/manager/week_add_customer_total // 新增意向客户 /api/v1/manager/week_add_customer_total
export const getWeekAddCustomerTotal = (params) => { export const getWeekAddCustomerTotal = (params) => {
return https.post('/api/v1/manager/week_add_customer_total', params) return https.post('/api/v1/manager/week_add_customer_total', params)
@@ -38,11 +36,30 @@ export const getGroupFunnel = (params) => {
export const getGroupRanking = (params) => { export const getGroupRanking = (params) => {
return https.post('/api/v1/manager/group_ranking', params) return https.post('/api/v1/manager/group_ranking', params)
} }
// 团队成员业绩详情 /api/v1/manager/group_detail // 团队成员业绩详情 /api/v1/manager/group_detail
export const getGroupDetail = (params) => { export const getGroupDetail = (params) => {
return https.post('/api/v1/manager/group_detail', 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

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

View File

@@ -65,12 +65,12 @@ export const getDetailedDataTable = (params) => {
return https.post('/api/v1/level_five/overview/detailed_data_table', params) return https.post('/api/v1/level_five/overview/detailed_data_table', params)
} }
// 下发任务 /api/v1/level_five/overview/assign_tasks // 获取各中心营期阶段 /api/v1/level_five/overview/get_period_stage
export const assignTasks = (params) => { export const getPeriodStage = (params) => {
return https.post('http://192.168.15.60:8890/api/v1/level_five/overview/assign_tasks', params) return https.get('/api/v1/level_five/overview/get_period_stage', params)
} }
// 获取优秀录音文件 /api/v1/level_four/overview/get_excellent_record_file // 获取优秀录音文件 /api/v1/level_five/overview/get_excellent_record_file
export const getExcellentRecordFile = (params) => { export const getExcellentRecordFile = (params) => {
return https.post('/api/v1/level_four/overview/get_excellent_record_file', params) return https.post('/api/v1/level_five/overview/get_excellent_record_file', params)
} }

View File

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

View File

@@ -25,6 +25,13 @@
</svg> </svg>
修改密码 修改密码
</div> </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"> <div class="dropdown-item logout-item" @click="handleLogout">
<svg width="16" height="16" viewBox="0 0 16 16" style="margin-right: 8px;"> <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"/> <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> </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 v-if="showLogoutModal" class="logout-modal-overlay" @click="cancelLogout">
<div class="logout-modal" @click.stop> <div class="logout-modal" @click.stop>
@@ -166,11 +216,28 @@
</template> </template>
<script setup> <script setup>
import { ref, onMounted, onUnmounted } from 'vue' import { ref, reactive, onMounted, onUnmounted, watch } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
import http from '@/utils/https' 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() const router = useRouter()
@@ -192,6 +259,50 @@ const passwordForm = ref({
newPassword: '' newPassword: ''
}) // 修改密码表单数据 }) // 修改密码表单数据
const passwordLoading = ref(false) // 修改密码加载状态 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 = () => { const toggleDropdown = () => {
@@ -309,6 +420,39 @@ const cancelPasswordChange = () => {
passwordForm.value.newPassword = '' 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 = () => { const handleLogout = () => {
showDropdown.value = false showDropdown.value = false
@@ -319,15 +463,21 @@ const handleLogout = () => {
const confirmLogout = () => { const confirmLogout = () => {
console.log('用户确认退出登录') console.log('用户确认退出登录')
// 清除用户信息(如果有的话) try {
// localStorage.removeItem('token') // 清除用户状态
// sessionStorage.clear() userStore.logout()
// 关闭弹窗 // 关闭弹窗
showLogoutModal.value = false showLogoutModal.value = false
// 跳转到登录页面 // 跳转到登录页面
router.push('/login') 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 { .password-modal-overlay {
position: fixed; position: fixed;

View File

@@ -11,43 +11,43 @@ const routes = [
{ {
path: '/', path: '/',
name: 'Home', name: 'Home',
redirect: '/sale' redirect: '/login'
}, },
{ {
path: '/login', path: '/login',
name: 'Login', name: 'Login',
component: Login, component: Login,
// meta: { requiresAuth: false } meta: { requiresAuth: false }
}, },
{ {
path: '/sale', path: '/sale',
name: 'Sale', name: 'Sale',
component: Sale, component: Sale,
// meta: { requiresAuth: true, minLevel: 1 } meta: { requiresAuth: true, minLevel: 1 }
}, },
{ {
path: '/manager', path: '/manager',
name: 'Manager', name: 'Manager',
component: Manager, component: Manager,
// meta: { requiresAuth: true, minLevel: 2 } meta: { requiresAuth: true, minLevel: 2 }
}, },
{ {
path: '/senior-manager', path: '/senior-manager',
name: 'SeniorManager', name: 'SeniorManager',
component: SeniorManager, component: SeniorManager,
// meta: { requiresAuth: true, minLevel: 3 } meta: { requiresAuth: true, minLevel: 3 }
}, },
{ {
path: '/second-top', path: '/second-top',
name: 'SecondTop', name: 'SecondTop',
component: SecondTop, component: SecondTop,
// meta: { requiresAuth: true, minLevel: 4 } meta: { requiresAuth: true, minLevel: 4 }
}, },
{ {
path: '/top', path: '/top',
name: 'Top', name: 'Top',
component: TopOne, 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() 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) { if (!to.meta.requiresAuth) {
next() next()
return return
} }
// 如果访问登录页面,始终允许访问(允许重新登录)
if (to.path === '/login') {
next()
return
}
// 检查是否已登录 // 检查是否已登录
if (!userStore.isLoggedIn || !userStore.userInfo) { if (!userStore.isLoggedIn || !userStore.userInfo) {
next('/login') 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实例 // 创建axios实例
const service = axios.create({ 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, // 请求超时时间 timeout: 100000, // 请求超时时间
headers: { headers: {
'Content-Type': 'application/json;charset=UTF-8' 'Content-Type': 'application/json;charset=UTF-8'
@@ -15,9 +16,6 @@ const service = axios.create({
// 请求拦截器 // 请求拦截器
service.interceptors.request.use( service.interceptors.request.use(
config => { config => {
// 在发送请求之前做些什么
// console.log('发送请求:', config)
// 添加token到请求头 // 添加token到请求头
const userStore = useUserStore() const userStore = useUserStore()
const token = userStore.token const token = userStore.token
@@ -31,13 +29,9 @@ service.interceptors.request.use(
_t: Date.now() _t: Date.now()
} }
} }
// 显示加载状态 // 显示加载状态
if (config.showLoading !== false) { if (config.showLoading !== false) {
// 可以在这里添加全局loading }
console.log('显示加载中...')
}
return config return config
}, },
error => { error => {
@@ -47,16 +41,10 @@ service.interceptors.request.use(
return Promise.reject(error) return Promise.reject(error)
} }
) )
// 响应拦截器 // 响应拦截器
service.interceptors.response.use( service.interceptors.response.use(
response => { response => {
// 隐藏加载状态
// console.log('隐藏加载中...')
// 对响应数据做点什么
// console.log('收到响应:', response)
const { data, status } = response const { data, status } = response
// HTTP状态码检查 // HTTP状态码检查

View File

@@ -260,12 +260,15 @@
</template> </template>
<script setup> <script setup>
import { ref } from 'vue' import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router' import { useRouter, useRoute } from 'vue-router'
import http from '@/utils/https' import http from '@/utils/https'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
const router = useRouter() const router = useRouter()
const route = useRoute()
const userStore = useUserStore() const userStore = useUserStore()
// 响应式数据 // 响应式数据
@@ -392,8 +395,12 @@ const handleLogin = async () => {
loading.value = true loading.value = true
errorMessage.value = '' errorMessage.value = ''
// 清除本地存储的用户数据,确保使用最新的登录信息
userStore.logout()
try { try {
// 调用登录API // 调用登录API
// token检测
const response = await http.post('/api/v1/login', { const response = await http.post('/api/v1/login', {
username: loginForm.value.username, username: loginForm.value.username,
password: loginForm.value.password password: loginForm.value.password
@@ -546,6 +553,64 @@ const handleSetSecurity = async () => {
const cancelSecuritySetup = () => { const cancelSecuritySetup = () => {
alert('首次登录必须设置密保问题') 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> </script>
<style scoped> <style scoped>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -1,6 +1,9 @@
<template> <template>
<div class="team-report"> <div class="team-report">
<h2>今日团队实时战报</h2> <div class="header-container">
<h2>今日团队实时战报</h2>
<button class="analysis-button" @click="showTeamAnalysis">团队分析</button>
</div>
<div class="report-grid"> <div class="report-grid">
<div class="report-card"> <div class="report-card">
<div class="card-header"> <div class="card-header">
@@ -12,9 +15,9 @@
<div class="report-card"> <div class="report-card">
<div class="card-header"> <div class="card-header">
<span class="card-title">有效通话时长 <i class="info-icon" @mouseenter="showTooltip('callDuration', $event)" @mouseleave="hideTooltip"></i></span> <span class="card-title">有效通话时长 <i class="info-icon" @mouseenter="showTooltip('callDuration', $event)" @mouseleave="hideTooltip"></i></span>
<span class="card-trend negative">{{ weekTotalData.week_total_call?.team_data?.current_rate_last_current || '0%' }} vs 上期</span> <span class="card-trend negative">{{ weekTotalData.group_call_duration?.group_data?.current_rate_last_current || '0%' }} vs 上期</span>
</div> </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>
<div class="report-card"> <div class="report-card">
<div class="card-header"> <div class="card-header">
@@ -32,7 +35,7 @@
</div> </div>
<div class="report-card"> <div class="report-card">
<div class="card-header"> <div class="card-header">
<span class="card-title">月度总业绩 <i class="info-icon" @mouseenter="showTooltip('monthlyRevenue', $event)" @mouseleave="hideTooltip"></i></span> <span class="card-title">本月成交单数 <i class="info-icon" @mouseenter="showTooltip('monthlyRevenue', $event)" @mouseleave="hideTooltip"></i></span>
<span class="card-trend positive">+8% vs 上月</span> <span class="card-trend positive">+8% vs 上月</span>
</div> </div>
<div class="card-value">{{ formatCurrency(weekTotalData.week_add_fee_total?.total_add_fee || 0) }} </div> <div class="card-value">{{ formatCurrency(weekTotalData.week_add_fee_total?.total_add_fee || 0) }} </div>
@@ -70,11 +73,15 @@ const props = defineProps({
week_add_customer_total: {}, week_add_customer_total: {},
week_add_deal_total: {}, week_add_deal_total: {},
week_add_fee_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) => { watch(() => props.weekTotalData, (newData) => {
console.log('TeamReport 收到的数据:', newData) console.log('TeamReport 收到的数据:', newData)
@@ -121,12 +128,12 @@ const metricDescriptions = {
description: '本期新增的成交订单数量,已确认付款或签约的客户订单。' description: '本期新增的成交订单数量,已确认付款或签约的客户订单。'
}, },
monthlyRevenue: { monthlyRevenue: {
title: '月度总业绩', title: '本月成交单数',
description: '本月团队累计完成的销售业绩总额,包括所有已确认的订单金额。' description: '本月团队累计完成的销售订单数量,包括所有已确认的订单。'
}, },
conversionRate: { conversionRate: {
title: '定金转化率', title: '定金转化率',
description: '支付定金的客户数 ÷ 意向客户总数,反映客户从意向到付费的转化效果。' description: '支付定金的客户数 ÷ 意向客户总数'
} }
} }
@@ -145,6 +152,11 @@ const showTooltip = (metricType, event) => {
const hideTooltip = () => { const hideTooltip = () => {
tooltip.visible = false tooltip.visible = false
} }
// 显示团队分析
const showTeamAnalysis = () => {
emit('show-team-analysis')
}
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@@ -155,11 +167,33 @@ const hideTooltip = () => {
padding: 1.5rem; padding: 1.5rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
.header-container {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
h2 { h2 {
font-size: 1.2rem; font-size: 1.2rem;
font-weight: 600; font-weight: 600;
color: #1e293b; color: #1e293b;
margin: 0 0 1.5rem 0; margin: 0;
}
.analysis-button {
background: #409eff;
color: white;
border: none;
border-radius: 4px;
padding: 8px 16px;
font-size: 0.9rem;
cursor: pointer;
transition: background-color 0.3s;
&:hover {
background: #337ecc;
}
} }
.report-grid { .report-grid {

View File

@@ -37,9 +37,11 @@
<!-- Top Section - Team Alerts and Today's Report --> <!-- Top Section - Team Alerts and Today's Report -->
<div class="top-section"> <div class="top-section">
<!-- Team Alerts --> <!-- Team Alerts -->
<TeamAlerts :abnormalData="groupAbnormalResponse" /> <!-- <TeamAlerts :abnormalData="groupAbnormalResponse" /> -->
<!-- Today's Team Report --> <GoodMusic :quality-calls="excellentRecord"
<TeamReport :weekTotalData="weekTotalData" /> />
<!-- Today's Team Report -->
<TeamReport :weekTotalData="weekTotalData" @show-team-analysis="fetchTeamAnalysis" />
</div> </div>
<!-- Sales Funnel Section --> <!-- Sales Funnel Section -->
@@ -60,27 +62,47 @@
<!-- Right Section --> <!-- Right Section -->
<div class="right-section"> <div class="right-section">
<!-- Member Details --> <!-- Member Details -->
<MemberDetails :selected-member="selectedMember" /> <MemberDetails :selected-member="selectedMember" :memberDetails="memberDetails" />
</div> </div>
</div> </div>
</main> </main>
</div> </div>
<!-- 团队分析弹窗 -->
<div v-if="showTeamAnalysisModal" class="modal-overlay" @click="showTeamAnalysisModal = false">
<div class="modal-content" @click.stop>
<div class="modal-header">
<h3>团队整体三阶分析报告</h3>
<button class="close-button" @click="showTeamAnalysisModal = false">×</button>
</div>
<div class="modal-body">
<div v-for="(report, index) in teamAnalysisData" :key="index" class="report-item">
<div class="report-meta">
<span class="time-range">{{ report.start_time }} {{ report.end_time }}</span>
<span class="created-at">生成时间: {{ report.created_at }}</span>
</div>
<div class="report-content" v-html="formatReportContent(report.report)"></div>
</div>
</div>
</div>
</div>
</template> </template>
<script setup> <script setup>
import { ref, onMounted, computed } from "vue"; import { ref, onMounted, computed } from "vue";
import TeamAlerts from "./components/TeamAlerts.vue"; import TeamAlerts from "./components/TeamAlerts.vue";
import GoodMusic from "./components/GoodMusic.vue";
import TeamReport from "./components/TeamReport.vue"; import TeamReport from "./components/TeamReport.vue";
import SalesFunnel from "./components/SalesFunnel.vue"; import SalesFunnel from "./components/SalesFunnel.vue";
import PerformanceRanking from "./components/PerformanceRanking.vue"; import PerformanceRanking from "./components/PerformanceRanking.vue";
import MemberDetails from "./components/MemberDetails.vue"; import MemberDetails from "./components/MemberDetails.vue";
import Sale from "../person/Sale.vue"; import Sale from "../person/sale.vue";
import SalesTimelineWithTaskList from "../person/components/SalesTimelineWithTaskList.vue"; import SalesTimelineWithTaskList from "../person/components/SalesTimelineWithTaskList.vue";
import RawDataCards from "../person/components/RawDataCards.vue"; import RawDataCards from "../person/components/RawDataCards.vue";
import CustomerDetail from "../person/components/CustomerDetail.vue"; import CustomerDetail from "../person/components/CustomerDetail.vue";
import { useUserStore } from "@/stores/user"; import { useUserStore } from "@/stores/user";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import {getGroupAbnormalResponse, getWeekTotalCall, getWeekAddCustomerTotal, getWeekAddDealTotal, 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 = [ const teamMembers = [
@@ -95,54 +117,6 @@ const teamMembers = [
newClients: 12, newClients: 12,
deals: 5, deals: 5,
avgDealValue: 24000, 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,
} }
]; ];
@@ -155,9 +129,10 @@ const userStore = useUserStore();
// 获取通用请求参数的函数 // 获取通用请求参数的函数
const getRequestParams = () => { const getRequestParams = () => {
const params = {} const params = {}
// 从路由参数获取 // 从路由参数获取
const routeUserLevel = router.currentRoute.value.query.user_level || router.currentRoute.value.params.user_level const routeUserLevel = router.currentRoute.value.query.user_level || router.currentRoute.value.params.user_level
const routeUserName = router.currentRoute.value.query.user_name || router.currentRoute.value.params.user_name const routeUserName = router.currentRoute.value.query.user_name || router.currentRoute.value.params.user_name
// 如果路由有参数,使用路由参数 // 如果路由有参数,使用路由参数
if (routeUserLevel) { if (routeUserLevel) {
params.user_level = routeUserLevel.toString() params.user_level = routeUserLevel.toString()
@@ -165,6 +140,14 @@ const getRequestParams = () => {
if (routeUserName) { if (routeUserName) {
params.user_name = routeUserName params.user_name = routeUserName
} }
// 如果没有路由参数,使用当前登录用户的信息
if (!params.user_level && userStore.userInfo?.user_level) {
params.user_level = userStore.userInfo.user_level.toString()
}
if (!params.user_name && userStore.userInfo?.username) {
params.user_name = userStore.userInfo.username
}
return params return params
} }
@@ -192,17 +175,53 @@ const weekTotalData = ref({
week_add_fee_total: {}, week_add_fee_total: {},
pay_deposit_to_money_rate: {}, pay_deposit_to_money_rate: {},
group_funnel: {}, group_funnel: {},
week_add_fee_total: {}, group_call_duration: {},
}); })
// 团队异常预警 // 团队异常预警
const groupAbnormalResponse = ref({}) const groupAbnormalResponse = ref({})
async function TeamGetGroupAbnormalResponse() { async function TeamGetGroupAbnormalResponse() {
const params = getRequestParams() const params = getRequestParams()
const hasParams = params.user_name const hasParams = params.user_name
const res = await getGroupAbnormalResponse(hasParams ? params : undefined) try {
console.log(res) const response = await getGroupAbnormalResponse(hasParams ? params : undefined)
if (res.code === 200) { const rawData = response.data
groupAbnormalResponse.value = res.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)
} }
} }
// 团队总通话 // 团队总通话
@@ -215,6 +234,16 @@ async function TeamGetWeekTotalCall() {
weekTotalData.value.week_total_call = res.data 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() { async function TeamGetWeekAddCustomerTotal() {
const params = getRequestParams() const params = getRequestParams()
@@ -235,7 +264,48 @@ async function TeamGetWeekAddDealTotal() {
weekTotalData.value.week_add_deal_total = res.data weekTotalData.value.week_add_deal_total = res.data
} }
} }
// 月度总业绩 // 优秀录音
// 获取优秀录音
const excellentRecord = ref([]);
// 获取优秀录音文件
async function CentergetGoodRecord() {
console.log('CentergetGoodRecord 开始执行')
try {
const params = getRequestParams()
const params1 = {
user_level: userStore.userInfo?.user_level?.toString() || '',
user_name: userStore.userInfo?.username || ''
}
// 检查参数是否有效
const hasParams = params.user_name && params.user_level
const requestParams = hasParams ? {
...params,
} : params1
console.log('CentergetGoodRecord request params:', requestParams)
// 验证必要参数是否存在
if (!requestParams.user_name || !requestParams.user_level) {
console.error("缺少必要的请求参数:", requestParams);
return;
}
// 直接发送请求,不使用缓存
const res = await getExcellentRecordFile(requestParams)
console.log(972872132,res)
if (res && res.code === 200 && res.data) {
excellentRecord.value = res.data || []
console.log('获取优秀录音成功:', res.data)
} else {
console.error("获取优秀录音失败,响应数据不完整:", res);
excellentRecord.value = []
}
} catch (error) {
console.error("获取优秀录音失败:", error);
excellentRecord.value = []
}
}
// 定金转化 // 定金转化
@@ -281,66 +351,127 @@ async function TeamGetGroupRanking() {
console.log(res) console.log(res)
if (res.code === 200) { if (res.code === 200) {
groupRanking.value = res.data 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 memberDetails = ref({})
// 当前选中的成员,默认为第一名 // 团队分析数据
const selectedMember = ref(teamMembers[0]); const teamAnalysisData = ref([])
const showTeamAnalysisModal = ref(false)
// 当前选中的成员,默认为空
const selectedMember = ref(null);
// 选择成员函数 // 选择成员函数
const selectMember = (member) => { const selectMember = (member) => {
selectedMember.value = member; selectedMember.value = member;
console.log(122331,member)
TeamGetGroupDetail(member.user_name)
}; };
onMounted(async () => { // 成员详细数据
await TeamGetGroupAbnormalResponse() async function TeamGetGroupDetail(member) {
await TeamGetWeekTotalCall() const res = await getGroupDetail({user_name:member})
await TeamGetWeekAddCustomerTotal() console.log(res)
await TeamGetWeekAddDealTotal() if (res.code === 200) {
await TeamGetWeekAddFeeTotal() memberDetails.value = res.data
await TeamGetGroupFunnel() /**
await TeamGetGroupRanking() * 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> </script>
@@ -657,12 +788,12 @@ onMounted(async () => {
.top-section { .top-section {
display: grid; display: grid;
grid-template-columns: 1fr 3fr; grid-template-columns: 1fr 3fr;
gap: 1rem; gap: 0.5rem;
// PC端保持一致布局 // PC端保持一致布局
@media (min-width: 1024px) { @media (min-width: 1024px) {
grid-template-columns: 1fr 3fr; grid-template-columns: 1fr 3fr;
gap: 1.5rem; gap: 1rem;
} }
// 平板端适配 // 平板端适配
@@ -687,7 +818,7 @@ onMounted(async () => {
.analytics-section { .analytics-section {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
gap: 1rem; gap: 0.5rem;
margin-bottom: 1rem; margin-bottom: 1rem;
// PC端保持一致布局 // PC端保持一致布局
@@ -1861,5 +1992,267 @@ onMounted(async () => {
} }
} }
} }
/* 团队分析弹窗样式 */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.modal-content {
background: white;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
max-width: 90vw;
max-height: 90vh;
overflow: hidden;
display: flex;
flex-direction: column;
}
.modal-header {
padding: 1rem 1.5rem;
border-bottom: 1px solid #eee;
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-header h3 {
margin: 0;
font-size: 1.25rem;
color: #333;
}
.close-button {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: #999;
padding: 0;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
}
.close-button:hover {
color: #333;
}
.modal-body {
padding: 1.5rem;
overflow-y: auto;
max-height: calc(90vh - 80px);
}
.report-item {
margin-bottom: 2rem;
}
.report-item:last-child {
margin-bottom: 0;
}
.report-meta {
display: flex;
justify-content: space-between;
margin-bottom: 1rem;
font-size: 0.9rem;
color: #666;
}
.report-content {
line-height: 1.6;
}
.report-content :deep(h1),
.report-content :deep(h2),
.report-content :deep(h3),
.report-content :deep(h4),
.report-content :deep(h5),
.report-content :deep(h6) {
margin: 1.5rem 0 1rem 0;
font-weight: 600;
}
.report-content :deep(h1) {
font-size: 1.75rem;
border-bottom: 2px solid #eee;
padding-bottom: 0.5rem;
}
.report-content :deep(h2) {
font-size: 1.5rem;
border-bottom: 1px solid #eee;
padding-bottom: 0.5rem;
}
.report-content :deep(h3) {
font-size: 1.25rem;
}
.report-content :deep(p) {
margin: 0.75rem 0;
}
.report-content :deep(ul),
.report-content :deep(ol) {
margin: 0.75rem 0;
padding-left: 1.5rem;
}
.report-content :deep(li) {
margin: 0.25rem 0;
}
.report-content :deep(strong) {
font-weight: 600;
}
.report-content :deep(em) {
font-style: italic;
}
}
/* 团队分析弹窗 */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.modal-content {
background: white;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
max-width: 90vw;
max-height: 90vh;
overflow: hidden;
display: flex;
flex-direction: column;
}
.modal-header {
padding: 1rem 1.5rem;
border-bottom: 1px solid #eee;
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-header h3 {
margin: 0;
font-size: 1.25rem;
color: #333;
}
.close-button {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: #999;
padding: 0;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
}
.close-button:hover {
color: #333;
}
.modal-body {
padding: 1.5rem;
overflow-y: auto;
max-height: calc(90vh - 80px);
}
.report-item {
margin-bottom: 2rem;
}
.report-item:last-child {
margin-bottom: 0;
}
.report-meta {
display: flex;
justify-content: space-between;
margin-bottom: 1rem;
font-size: 0.9rem;
color: #666;
}
.report-content {
line-height: 1.6;
}
.report-content :deep(h1),
.report-content :deep(h2),
.report-content :deep(h3),
.report-content :deep(h4),
.report-content :deep(h5),
.report-content :deep(h6) {
margin: 1.5rem 0 1rem 0;
font-weight: 600;
}
.report-content :deep(h1) {
font-size: 1.75rem;
border-bottom: 2px solid #eee;
padding-bottom: 0.5rem;
}
.report-content :deep(h2) {
font-size: 1.5rem;
border-bottom: 1px solid #eee;
padding-bottom: 0.5rem;
}
.report-content :deep(h3) {
font-size: 1.25rem;
}
.report-content :deep(p) {
margin: 0.75rem 0;
}
.report-content :deep(ul),
.report-content :deep(ol) {
margin: 0.75rem 0;
padding-left: 1.5rem;
}
.report-content :deep(li) {
margin: 0.25rem 0;
}
.report-content :deep(strong) {
font-weight: 600;
}
.report-content :deep(em) {
font-style: italic;
} }
</style> </style>

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -6,12 +6,12 @@
<div class="kpi-item stat-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> <div class="kpi-value">{{ customerCommunicationRate }}</div>
<p>活跃客户沟通率 <i class="info-icon" @mouseenter="showTooltip('customerCommunicationRate', $event)" @mouseleave="hideTooltip"></i></p> <p>客户沟通率 <i class="info-icon" @mouseenter="showTooltip('customerCommunicationRate', $event)" @mouseleave="hideTooltip"></i></p>
</div> </div>
<div class="kpi-item stat-item" > <div class="kpi-item stat-item" >
<div class="stat-icon response-time"><i class="el-icon-timer"></i></div> <div class="stat-icon response-time"><i class="el-icon-timer"></i></div>
<div class="kpi-value">{{ averageResponseTime }}<span class="kpi-unit"></span></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> <p>均响应时间 <i class="info-icon" @mouseenter="showTooltip('averageResponseTime', $event)" @mouseleave="hideTooltip"></i></p>
</div> </div>
<div class="kpi-item stat-item" > <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>
@@ -21,7 +21,7 @@
<div class="kpi-item stat-item"> <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> <div class="kpi-value">{{ severeTimeoutRate }}</div>
<p>严重超时应答 <i class="info-icon" @mouseenter="showTooltip('severeTimeoutRate', $event)" @mouseleave="hideTooltip"></i></p> <p>严重超时率 <i class="info-icon" @mouseenter="showTooltip('severeTimeoutRate', $event)" @mouseleave="hideTooltip"></i></p>
</div> </div>
<div class="kpi-item stat-item"> <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>
@@ -89,7 +89,6 @@ const hideTooltip = () => {
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
/* ... 您的样式代码不变 ... */
</style> </style>
<style lang="scss" scoped> <style lang="scss" scoped>

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

@@ -4,7 +4,7 @@
<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 class="section-header">
<!-- 动态顶栏根据是否有路由参数显示不同内容 --> <!-- 动态顶栏根据是否有路由参数显示不同内容 -->
<!-- 路由跳转时的顶栏面包屑 + 姓名 --> <!-- 路由跳转时的顶栏面包屑 + 姓名 -->
@@ -13,21 +13,36 @@
<span class="breadcrumb-item" @click="goBack">团队管理 >{{ routeUserName }}</span> <span class="breadcrumb-item" @click="goBack">团队管理 >{{ routeUserName }}</span>
<span class="breadcrumb-item current"> 数据驾驶舱</span> <span class="breadcrumb-item current"> 数据驾驶舱</span>
</div> </div>
<div class="user-name"> <div style="display: flex; align-items: center; gap: 20px;">
{{ routeUserName }} <div class="user-name">
{{ routeUserName }}
</div>
</div> </div>
</div> </div>
<!-- 自己登录时的顶栏原有样式 --> <!-- 自己登录时的顶栏原有样式 -->
<template v-else> <template v-else>
<div style="display: flex; justify-content: space-between; align-items: center; width: 100%;"> <div style="display: flex; justify-content: space-between; align-items: center; width: 100%;">
<h1 class="app-title">销售驾驶舱</h1> <h1 class="app-title">分析师驾驶舱</h1>
<div <div
class="quick-stats" class="quick-stats"
style="display: flex; align-items: center; gap: 30px" style="display: flex; align-items: center; gap: 30px"
> >
</div> </div>
<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>
</template> </template>
</div> </div>
@@ -42,6 +57,7 @@
v-else v-else
:data="timelineData" :data="timelineData"
@stage-select="handleStageSelect" @stage-select="handleStageSelect"
@sub-stage-select="handleSubStageSelect"
:selected-stage="selectedStage" :selected-stage="selectedStage"
:contacts="filteredContacts" :contacts="filteredContacts"
:selected-contact-id="selectedContactId" :selected-contact-id="selectedContactId"
@@ -56,7 +72,7 @@
</section> </section>
<!-- 原始数据卡片区域 --> <!-- 原始数据卡片区域 -->
<section class="raw-data-section"> <section v-if="cardVisibility.rawData && selectedContact" class="raw-data-section">
<div class="section-header"> <div class="section-header">
<h2>原始数据</h2> <h2>原始数据</h2>
<p class="section-subtitle">客户互动的原始记录和数据</p> <p class="section-subtitle">客户互动的原始记录和数据</p>
@@ -70,7 +86,9 @@
@view-form-data="handleViewFormData" @view-form-data="handleViewFormData"
@view-chat-data="handleViewChatData" @view-chat-data="handleViewChatData"
@view-call-data="handleViewCallData" @view-call-data="handleViewCallData"
@analyze-sop="handleAnalyzeSop" /> @analyze-sop="handleAnalyzeSop"
@show-modal="handleShowModal"
@show-download-modal="handleShowDownloadModal" />
</div> </div>
</section> </section>
@@ -79,10 +97,7 @@
<!-- 主要工作区域 --> <!-- 主要工作区域 -->
<main class="main-content"> <main class="main-content">
<!-- 客户详情区域 --> <!-- 客户详情区域 -->
<section class="detail-section"> <section v-if="cardVisibility.customerDetail && selectedContact" class="detail-section">
<div class="section-header">
<h2>客户详情</h2>
</div>
<div class="section-content"> <div class="section-content">
<CustomerDetail <CustomerDetail
ref="customerDetailRef" ref="customerDetailRef"
@@ -94,7 +109,7 @@
</section> </section>
</main> </main>
</div> </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"> <div class="section-content">
<!-- 数据分析区域加载状态 --> <!-- 数据分析区域加载状态 -->
@@ -113,6 +128,46 @@
/> />
</div> </div>
</section> </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> </div>
</template> </template>
@@ -124,11 +179,14 @@ import CustomerDetail from "./components/CustomerDetail.vue";
import PersonalDashboard from "./components/PersonalDashboard.vue"; import PersonalDashboard from "./components/PersonalDashboard.vue";
import SalesTimelineWithTaskList from "./components/SalesTimelineWithTaskList.vue"; import SalesTimelineWithTaskList from "./components/SalesTimelineWithTaskList.vue";
import RawDataCards from "./components/RawDataCards.vue"; import RawDataCards from "./components/RawDataCards.vue";
import WeekAnalize from "./components/WeekAnalize.vue";
import UserDropdown from "@/components/UserDropdown.vue"; import UserDropdown from "@/components/UserDropdown.vue";
import Loading from "@/components/Loading.vue"; import Loading from "@/components/Loading.vue";
import FeedbackForm from "@/components/FeedbackForm.vue";
import {getCustomerAttendance,getTodayCall,getProblemDistribution,getTableFillingRate,getAverageResponseTime, import {getCustomerAttendance,getTodayCall,getProblemDistribution,getTableFillingRate,getAverageResponseTime,
getWeeklyActiveCommunicationRate,getTimeoutResponseRate,getCustomerCallInfo,getCustomerChatInfo,getCustomerFormInfo, getWeeklyActiveCommunicationRate,getTimeoutResponseRate,getCustomerCallInfo,getCustomerChatInfo,getCustomerFormInfo,
getConversionRateAndAllocatedData,getCustomerAttendanceAfterClass4,getPayMoneyCustomers,getSalesFunnel,getGoldContactTime} from "@/api/api.js" getConversionRateAndAllocatedData,getCustomerAttendanceAfterClass4,getPayMoneyCustomers,getSalesFunnel,getGoldContactTime,
getAvgCallTime,getCallSuccessRate,getSecondOrderAnalysisReport} from "@/api/api.js"
// 路由实例 // 路由实例
const router = useRouter(); const router = useRouter();
@@ -149,7 +207,6 @@ const getRequestParams = () => {
if (routeUserName) { if (routeUserName) {
params.user_name = routeUserName params.user_name = routeUserName
} }
return params return params
} }
@@ -180,6 +237,37 @@ const isStatisticsLoading = ref(false); // 统计数据加载状态
const isUrgentProblemLoading = ref(false); // 紧急问题数据加载状态 const isUrgentProblemLoading = ref(false); // 紧急问题数据加载状态
const isTimelineLoading = 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数据 // KPI数据
const kpiDataState = reactive({ const kpiDataState = reactive({
totalCalls: 85, totalCalls: 85,
@@ -202,9 +290,25 @@ const statisticsData = reactive({
// 客户迫切解决的问题数据 // 客户迫切解决的问题数据
const urgentProblemData = ref([]); const urgentProblemData = ref([]);
// 弹框状态
const showModal = ref(false)
const modalContent = ref('')
const modalTitle = ref('')
// FeedbackForm 弹框状态
const showFeedbackForm = ref(false)
// 下载弹框状态
const showDownloadModal = ref(false)
const downloadModalContent = ref('')
const downloadModalTitle = ref('')
// 时间线数据 // 时间线数据
const timelineData = ref({}); const timelineData = ref({});
// 周期分析数据
const weekAnalysisData = ref({});
// 客户列表数据 // 客户列表数据
const customersList = ref([]); const customersList = ref([]);
@@ -219,11 +323,13 @@ const payMoneyCustomersList = ref([]);
const payMoneyCustomersCount = ref(0); const payMoneyCustomersCount = ref(0);
// 表单信息 // 表单信息
const formInfo = ref({}); const formInfo = ref([]);
// 通话记录 // 通话记录
const callRecords = ref([]); const callRecords = ref([]);
// 聊天记录 // 聊天记录
const chatRecords = ref([]); const chatRecords = ref([]);
// 电话接通率
const callSuccessRate = ref(0)
// MOCK DATA (Should ideally come from a store or API) // MOCK DATA (Should ideally come from a store or API)
const MOCK_DATA = reactive({ const MOCK_DATA = reactive({
@@ -259,21 +365,41 @@ async function getCoreKpi() {
try { try {
const params = getRequestParams() const params = getRequestParams()
const hasParams = params.user_name const hasParams = params.user_name
// 并发请求所有KPI接口
const [
todayCallRes,
conversionRes,
avgCallTimeRes,
callSuccessRateRes
] = await Promise.all([
getTodayCall(hasParams ? params : undefined),
getConversionRateAndAllocatedData(hasParams ? params : undefined),
getAvgCallTime(hasParams ? params : undefined),
getCallSuccessRate(hasParams ? params : undefined)
])
// 今日通话数据 // 今日通话数据
const res = await getTodayCall(hasParams ? params : undefined) if (todayCallRes.code === 200) {
if (res.code === 200) { kpiDataState.totalCalls = todayCallRes.data.call_count
kpiDataState.totalCalls = res.data.today_call
} }
// 转化率、分配数据量、加微率 // 转化率、分配数据量、加微率
const conversionRes = await getConversionRateAndAllocatedData(hasParams ? params : undefined)
if (conversionRes.code === 200) { if (conversionRes.code === 200) {
kpiDataState.conversionRate = conversionRes.data.conversion_rate || 0 kpiDataState.conversionRate = conversionRes.data.conversion_rate || 0
kpiDataState.assignedData = conversionRes.data.all_count || 0 kpiDataState.assignedData = conversionRes.data.all_count || 0
kpiDataState.wechatAddRate = conversionRes.data.plus_v_conversion_rate || 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) { } catch (error) {
console.error('获取核心KPI数据失败:', error) console.error('获取核心KPI数据失败:', error)
} finally { } finally {
@@ -287,26 +413,35 @@ async function getStatisticsData() {
const params = getRequestParams() const params = getRequestParams()
const hasParams = params.user_name const hasParams = params.user_name
// 获取表单填写率 // 并发请求所有统计数据
const fillingRateRes = await 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) { if (fillingRateRes.code === 200) {
statisticsData.formCompletionRate = fillingRateRes.data.filling_rate statisticsData.formCompletionRate = fillingRateRes.data.filling_rate
} }
// 获取平均响应时间 // 处理平均响应时间
const avgResponseRes = await getAverageResponseTime(hasParams ? params : undefined)
if (avgResponseRes.code === 200) { if (avgResponseRes.code === 200) {
statisticsData.averageResponseTime = avgResponseRes.data.average_minutes statisticsData.averageResponseTime = avgResponseRes.data.average_minutes
} }
// 获取客户沟通率 // 处理客户沟通率
const communicationRes = await getWeeklyActiveCommunicationRate(hasParams ? params : undefined)
if (communicationRes.code === 200) { if (communicationRes.code === 200) {
statisticsData.customerCommunicationRate = communicationRes.data.communication_rate statisticsData.customerCommunicationRate = communicationRes.data.communication_rate
} }
// 获取超时响应率 // 处理超时响应率
const timeoutRes = await getTimeoutResponseRate(hasParams ? params : undefined)
if (timeoutRes.code === 200) { if (timeoutRes.code === 200) {
statisticsData.timeoutResponseRate = timeoutRes.data.overtime_rate_600 statisticsData.timeoutResponseRate = timeoutRes.data.overtime_rate_600
statisticsData.severeTimeoutRate = timeoutRes.data.overtime_rate_800 statisticsData.severeTimeoutRate = timeoutRes.data.overtime_rate_800
@@ -327,11 +462,8 @@ async function getUrgentProblem() {
const res = await getProblemDistribution(hasParams ? params : undefined) const res = await getProblemDistribution(hasParams ? params : undefined)
if(res.code === 200) { if(res.code === 200) {
// 将API返回的对象格式转换为数组格式 // 将API返回的对象格式转换为数组格式
const problemDistribution = res.data.problem_distribution const problemDistributionCount = res.data.problem_distribution_count
urgentProblemData.value = Object.entries(problemDistribution).map(([name, percentage]) => ({ urgentProblemData.value = Object.entries(problemDistributionCount).map(([name, value]) => ({ name, value }))
name: name,
value: parseInt(percentage.replace('%', '')) || 0
}))
} }
} catch (error) { } catch (error) {
console.error('获取紧急问题数据失败:', error) console.error('获取紧急问题数据失败:', error)
@@ -355,7 +487,6 @@ async function getTimeline() {
value: parseInt(count) || 0 value: parseInt(count) || 0
})) }))
} }
// 处理客户列表数据
if (res.data.all_customers_list) { if (res.data.all_customers_list) {
customersList.value = res.data.all_customers_list customersList.value = res.data.all_customers_list
} }
@@ -371,10 +502,10 @@ async function getTimeline() {
if (classRes.data.class_customers_list) { if (classRes.data.class_customers_list) {
// 存储课1-4阶段的原始数据根据pay_status设置正确的type // 存储课1-4阶段的原始数据根据pay_status设置正确的type
courseCustomers.value['课1-4'] = classRes.data.class_customers_list.map(customer => { courseCustomers.value['课1-4'] = classRes.data.class_customers_list.map(customer => {
let customerType = '课1-4'; // 默认类型 let customerType = ''; // 默认类型
// 根据pay_status设置具体的type // 根据pay_status设置具体的type
if (customer.pay_status === '未支付' || customer.pay_status === '点击未支付') { if (customer.pay_status === '点击未支付') {
customerType = '点击未支付'; customerType = '点击未支付';
} else if (customer.pay_status === '付定金') { } else if (customer.pay_status === '付定金') {
customerType = '付定金'; customerType = '付定金';
@@ -400,7 +531,8 @@ async function getTimeline() {
class_situation: customer.class_situation, class_situation: customer.class_situation,
class_num: Object.keys(customer.class_situation || {}), // 添加class_num字段 class_num: Object.keys(customer.class_situation || {}), // 添加class_num字段
pay_status: customer.pay_status, pay_status: customer.pay_status,
records: [] records: [],
time_and_camp_stage: customer.time_and_camp_stage || []
}; };
}) })
@@ -433,7 +565,8 @@ async function getTimeline() {
class_situation: customer.class_situation, class_situation: customer.class_situation,
scrm_user_main_code: customer.scrm_user_main_code, scrm_user_main_code: customer.scrm_user_main_code,
weChat_avatar: customer.weChat_avatar, 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 // 后三个阶段的客户数据已存储在courseCustomers['课1-4']中不需要合并到customersList
} }
@@ -466,12 +599,13 @@ async function getCustomerForm() {
const routeParams = getRequestParams() const routeParams = getRequestParams()
const params = { const params = {
user_name: routeParams.user_name || userStore.userInfo.username, user_name: routeParams.user_name || userStore.userInfo.username,
customer_name: selectedContact.value.name, phone: selectedContact.value.phone,
} }
try { try {
const res = await getCustomerFormInfo(params) const res = await getCustomerFormInfo(params)
console.log('获取客户表单数据:', res)
if(res.code === 200) { if(res.code === 200) {
formInfo.value = res.data formInfo.value = res.data || []
} }
} catch (error) { } catch (error) {
// 静默处理错误 // 静默处理错误
@@ -480,20 +614,17 @@ async function getCustomerForm() {
// 聊天记录 // 聊天记录
async function getCustomerChat() { async function getCustomerChat() {
if (!selectedContact.value || !selectedContact.value.name) { if (!selectedContact.value || !selectedContact.value.name) {
console.warn('无法获取客户聊天记录:客户信息不完整');
return; return;
} }
const routeParams = getRequestParams() const routeParams = getRequestParams()
const params = { const params = {
user_name: routeParams.user_name || userStore.userInfo.username, user_name: routeParams.user_name || userStore.userInfo.username,
customer_name: selectedContact.value.name, phone: selectedContact.value.phone,
} }
try { try {
const res = await getCustomerChatInfo(params) const res = await getCustomerChatInfo(params)
if(res.code === 200) { if(res.code === 200) {
chatRecords.value = res.data chatRecords.value = res.data
console.log('聊天数据获取成功:', res.data)
console.log('chatRecords.value:', chatRecords.value)
} else { } else {
console.log('聊天数据获取失败:', res) console.log('聊天数据获取失败:', res)
} }
@@ -504,29 +635,17 @@ async function getCustomerChat() {
// 通话记录 // 通话记录
async function getCustomerCall() { async function getCustomerCall() {
if (!selectedContact.value || !selectedContact.value.name) { if (!selectedContact.value || !selectedContact.value.name) {
console.warn('无法获取客户通话记录:客户信息不完整');
return; return;
} }
const routeParams = getRequestParams() const routeParams = getRequestParams()
const params = { const params = {
user_name: routeParams.user_name || userStore.userInfo.username, user_name: routeParams.user_name || userStore.userInfo.username,
customer_name: selectedContact.value.name, phone: selectedContact.value.phone,
} }
try { try {
const res = await getCustomerCallInfo(params) const res = await getCustomerCallInfo(params)
if(res.code === 200) { if(res.code === 200) {
callRecords.value = res.data callRecords.value = res.data
console.log('Call Records Data from API:', res.data)
console.log('callRecords.value after assignment:', callRecords.value)
/**
* "data": {
"user_name": "常琳",
"customer_name": "191桐桐爸爸高一男",
"record_file_addr_list": [
"http://192.168.3.112:5000/api/record/download/杨振彦-20分钟通话-25-08-19_07-23-37-744009-835.mp3"
]
}
*/
} }
} catch (error) { } catch (error) {
// 静默处理错误 // 静默处理错误
@@ -576,7 +695,8 @@ const formattedCustomersList = computed(() => {
return []; return [];
} }
return customersList.value.map(customer => ({ return customersList.value?.map(customer => ({
wechat_id: customer.customer_wechat_id,
id: customer.customer_name, // 使用客户姓名作为唯一标识 id: customer.customer_name, // 使用客户姓名作为唯一标识
name: customer.customer_name, name: customer.customer_name,
phone: customer.phone, phone: customer.phone,
@@ -626,6 +746,7 @@ const selectContact = (id) => {
// 当选中客户后,获取客户表单数据 // 当选中客户后,获取客户表单数据
nextTick(async () => { nextTick(async () => {
if (selectedContact.value && selectedContact.value.name) { if (selectedContact.value && selectedContact.value.name) {
await getCustomerForm(); await getCustomerForm();
await getCustomerChat(); await getCustomerChat();
await getCustomerCall(); await getCustomerCall();
@@ -672,7 +793,8 @@ const handleStageSelect = (stage, extraData = null) => {
scrm_user_main_code: customer.scrm_user_main_code, scrm_user_main_code: customer.scrm_user_main_code,
weChat_avatar: customer.weChat_avatar, weChat_avatar: customer.weChat_avatar,
class_situation: customer.class_situation, class_situation: customer.class_situation,
records: customer.records records: customer.records,
time_and_camp_stage: customer.time_and_camp_stage || []
})); }));
// 更新当前筛选的客户数据 // 更新当前筛选的客户数据
@@ -680,7 +802,7 @@ const handleStageSelect = (stage, extraData = null) => {
} else if (extraData && extraData.isCourseStage) { } else if (extraData && extraData.isCourseStage) {
// 处理课1-4阶段的课程数据(保持原有逻辑 // 处理课阶段的数据(课1-4、课1、课2、课3、课4
const courseContacts = extraData.courseData.map(customer => ({ const courseContacts = extraData.courseData.map(customer => ({
@@ -690,8 +812,8 @@ const handleStageSelect = (stage, extraData = null) => {
profession: customer.profession, profession: customer.profession,
education: customer.education, education: customer.education,
avatar: customer.avatar, avatar: customer.avatar,
type: customer.type || '课1-4', // 保持原有type字段如果没有则默认为课1-4 type: customer.type || stage, // 使用当前选中的阶段作为type
salesStage: customer.type || '课1-4', // 使用customer.type作为salesStage salesStage: customer.type || stage, // 使用customer.type或当前阶段作为salesStage
health: customer.health, health: customer.health,
customer_name: customer.customer_name, customer_name: customer.customer_name,
customer_occupation: customer.customer_occupation, customer_occupation: customer.customer_occupation,
@@ -701,7 +823,8 @@ const handleStageSelect = (stage, extraData = null) => {
class_situation: customer.class_situation, class_situation: customer.class_situation,
class_num: customer.class_num, // 添加class_num字段 class_num: customer.class_num, // 添加class_num字段
pay_status: customer.pay_status, // 添加pay_status字段 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; currentFilteredCustomers.value = courseContacts;
@@ -711,52 +834,112 @@ const handleStageSelect = (stage, extraData = null) => {
currentFilteredCustomers.value = []; currentFilteredCustomers.value = [];
} }
}; };
// 处理子时间轴阶段选择
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) => { const handleViewFormData = async (contact) => {
// 获取客户表单数据 // 获取客户表单数据
await getCustomerForm(); await getCustomerForm();
console.log('表单数据已加载:', formInfo.value);
}; };
const handleViewChatData = async (contact) => { const handleViewChatData = async (contact) => {
console.log('查看聊天数据:', contact)
await getCustomerChatInfo({ await getCustomerChatInfo({
customerId: selectedContact.value?.customerId || 1 customerId: selectedContact.value?.customerId || 1
}) })
console.log('聊天数据已更新:', chatRecords.value)
}; };
const handleViewCallData = (contact) => { const handleViewCallData = (contact) => {
// TODO: 实现通话录音查看逻辑 // TODO: 实现通话录音查看逻辑
}; };
// 处理SOP分析事件 // 处理弹框显示事件
const handleAnalyzeSop = (analyzeData) => { const handleShowModal = (title, content) => {
console.log('收到SOP分析请求:', analyzeData); console.log('handleShowModal0000', title)
if (customerDetailRef.value && analyzeData.content) { modalTitle.value = title.title
customerDetailRef.value.startSopAnalysis(analyzeData.content); modalContent.value = title.content
} showModal.value = true
}; }
// 关闭弹框
const closeModal = () => {
showModal.value = false
modalContent.value = ''
modalTitle.value = ''
}
// 处理下载弹框显示
const handleShowDownloadModal = (title, content) => {
downloadModalTitle.value = title
downloadModalContent.value = content
showDownloadModal.value = true
}
// 关闭下载弹框
const closeDownloadModal = () => {
showDownloadModal.value = false
downloadModalContent.value = ''
downloadModalTitle.value = ''
}
// 显示 FeedbackForm
const showFeedbackFormModal = () => {
showFeedbackForm.value = true
}
// 关闭 FeedbackForm
const closeFeedbackFormModal = () => {
showFeedbackForm.value = false
}
// // 处理SOP分析事件
// const handleAnalyzeSop = (analyzeData) => {
// console.log('handleAnalyzeSop', analyzeData)
// console.log('analyzeData.content', customerDetailRef.value)
// if (customerDetailRef.value && analyzeData.content) {
// customerDetailRef.value.startSopAnalysis(analyzeData.content);
// }
// };
// 销售漏斗 // 销售漏斗
const SalesFunnel = ref([]) const SalesFunnel = ref([])
async function CenterGetSalesFunnel() { async function CenterGetSalesFunnel() {
const params = getRequestParams() const params = getRequestParams()
const hasParams = params.user_name const hasParams = params.user_name
const res = await getSalesFunnel(hasParams?params:undefined) const res = await getSalesFunnel(hasParams ? params : undefined)
if(res.code === 200){ if(res.code === 200){
SalesFunnel.value = res.data SalesFunnel.value = res.data
/**
* "data": {
"user_name": "常琳",
"user_level": 1,
"sale_funnel": {
"线索总数": 11,
"有效沟通": 9,
"到课数据": 8,
"预付定金": 0,
"成功签单": 0
}
}
*/
} }
} }
// 黄金联络时间段 // 黄金联络时间段
@@ -764,25 +947,52 @@ const goldContactTime = ref([])
async function CenterGetGoldContactTime() { async function CenterGetGoldContactTime() {
const params = getRequestParams() const params = getRequestParams()
const hasParams = params.user_name const hasParams = params.user_name
const res = await getGoldContactTime(hasParams?params:undefined) const res = await getGoldContactTime(hasParams ? params : undefined)
if(res.code === 200){ if(res.code === 200){
goldContactTime.value = res.data 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 // LIFECYCLE HOOKS
onMounted(async () => { onMounted(async () => {
try { try {
isPageLoading.value = true isPageLoading.value = true
await getCoreKpi() getStatisticsData()
await CenterGetGoldContactTime() getCoreKpi()
await CenterGetSalesFunnel() CenterGetGoldContactTime()
await getCustomerForm() CenterGetSalesFunnel()
await getCustomerChat() getCustomerForm()
await getUrgentProblem() getCustomerChat()
await getCustomerCall() getUrgentProblem()
await getTimeline() getCustomerCall()
await getCustomerPayMoney() getTimeline()
// 开发环境下暴露数据刷新函数到全局对象,方便调试
if (process.env.NODE_ENV === 'development') {
window.saleData = {
forceRefreshAllData
}
}
// 等待数据加载完成后选择默认客户 // 等待数据加载完成后选择默认客户
await nextTick(); await nextTick();
@@ -870,9 +1080,9 @@ $primary: #3b82f6;
} }
// 主要布局 // 主要布局
.main-layout { .main-layout {
width: 100vw; width: 99vw;
margin: 0 auto; margin-bottom: 1rem;
padding: 1rem; // padding: 1rem;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1rem; gap: 1rem;
@@ -1610,4 +1820,191 @@ $primary: #3b82f6;
} }
} }
</style> // 弹框样式
.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,686 +1,31 @@
<template> <template>
<div class="action-items"> <div class="action-items-container">
<div class="actions-header"> <Calendar />
<h2>待处理事项</h2>
<div class="header-controls">
<select v-model="filterPriority" class="priority-filter">
<option value="all">全部状态</option>
<option value="待处理">待处理</option>
<option value="正在处理">正在处理</option>
<option value="已完成">已完成</option>
</select>
</div>
</div>
<div class="actions-list">
<div v-if="filteredActions.length === 0" class="no-tasks">
<p>暂无任务</p>
</div>
<div v-else class="task-list">
<div
v-for="task in filteredActions"
:key="task.task_id"
class="task-row"
>
<div class="task-info">
<div class="task-row-1">
<span class="task-title">{{ task.task_title }}</span>
<span class="task-content">{{ task.task_content }}</span>
</div>
<div class="task-row-2">
<span class="task-date">到期时间: {{ formatDueDate(task.expiration_date) }}</span>
<span class="task-created">创建时间: {{ task.created_at }}</span>
</div>
</div>
<div class="task-actions">
<span class="status-tag" :class="getTaskStatusClass(task.state)">
{{ task.state }}
</span>
<button
class="status-btn"
@click="changeTaskStatus(task)"
>
处理任务
</button>
</div>
</div>
</div>
</div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, computed, onMounted } from 'vue' import Calendar from './Calendar.vue';
import axios from 'axios'
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([])
// 筛选后的事项
const filteredActions = computed(() => {
console.log('计算filteredActions - actions.value:', actions.value)
console.log('计算filteredActions - filterPriority.value:', filterPriority.value)
let filtered = actions.value
if (filterPriority.value !== 'all') {
filtered = filtered.filter(task => task.state === filterPriority.value)
}
console.log('计算filteredActions - filtered结果:', filtered)
return filtered
})
// 获取任务状态样式类
const getTaskStatusClass = (state) => {
switch (state) {
case '待处理':
return 'status-pending'
case '正在处理':
return 'status-processing'
case '已完成':
return 'status-completed'
default:
return 'status-default'
}
}
// 格式化到期日期
const formatDueDate = (dateStr) => {
if (!dateStr) return '无'
// 如果是YYYYMMDD格式转换为YYYY-MM-DD
if (dateStr.length === 8) {
const year = dateStr.substring(0, 4)
const month = dateStr.substring(4, 6)
const day = dateStr.substring(6, 8)
return `${year}-${month}-${day}`
}
return dateStr
}
// 获取任务列表
const getTaskList = async () => {
try {
const res = await axios.post('http://192.168.15.60:8890/api/v1/level_five/overview/view_tasks', {}, {
headers: {
'Authorization': 'Bearer ' + localStorage.getItem('token')
}
})
console.log(888888,res)
if (res.data.code === 200) {
actions.value = res.data.data.tasks || res.data.data
console.log(777777,actions.value)
}
} catch (error) {
console.error('获取任务列表失败:', error)
}
}
// 修改任务状态按钮点击事件
const changeTaskStatus = (task) => {
const statusOptions = ['待处理', '正在处理', '已完成']
const currentIndex = statusOptions.indexOf(task.state)
const nextIndex = (currentIndex + 1) % statusOptions.length
const newState = statusOptions[nextIndex]
updateTaskState(task.task_id, newState)
}
// 修改任务状态 http://192.168.15.56:8890/api/v1/level_four/overview/update_task_state
const updateTaskState = async (taskId, state) => {
try {
const res = await axios.put('http://192.168.15.60:8890/api/v1/level_four/overview/update_task_state', {
task_ids: [taskId],
new_state: state
}, {
headers: {
'Authorization': 'Bearer ' + localStorage.getItem('token')
}
})
if (res.data.code === 200) {
console.log('任务状态更新成功')
// 刷新任务列表
await getTaskList()
}
} catch (error) {
console.error('更新任务状态失败:', error)
}
}
// 编辑事项
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
}
onMounted(async () => {
await getTaskList()
})
</script> </script>
<style lang="scss" scoped> <style scoped>
.action-items { .action-items-container {
background: white;
border-radius: 12px;
padding: 1.5rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
height: 100%; height: 100%;
display: flex; max-height: 600px;
flex-direction: column; overflow: hidden;
padding: 5px;
.actions-header { background: #ffffff;
display: flex; border-radius: 12px;
justify-content: space-between; box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
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-list {
flex: 1;
overflow-y: auto;
.no-tasks {
text-align: center;
padding: 40px 20px;
color: #999;
p {
margin: 0;
font-size: 0.9rem;
}
}
.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);
border-color: #d0d0d0;
}
&.status-pending {
border-left: 4px solid #f59e0b;
}
&.status-processing {
border-left: 4px solid #3b82f6;
}
&.status-completed {
border-left: 4px solid #10b981;
opacity: 0.8;
background: #f9fafb;
}
.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;
flex: 1;
margin-right: 12px;
}
.action-meta {
display: flex;
gap: 0.5rem;
align-items: center;
flex-shrink: 0;
.status-badge {
padding: 0.25rem 0.5rem;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 500;
white-space: nowrap;
&.status-pending {
background: #fff3cd;
color: #856404;
border: 1px solid #ffeaa7;
}
&.status-processing {
background: #d1ecf1;
color: #0c5460;
border: 1px solid #bee5eb;
}
&.status-completed {
background: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
&.status-default {
background: #f8f9fa;
color: #6c757d;
border: 1px solid #dee2e6;
}
}
.due-date {
font-size: 0.75rem;
color: #666;
background: #f8f9fa;
padding: 2px 6px;
border-radius: 4px;
}
}
}
.action-description {
font-size: 0.9rem;
color: #6b7280;
margin: 0 0 1rem 0;
line-height: 1.5;
}
.action-details {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid #f0f0f0;
.detail-item {
display: flex;
gap: 8px;
font-size: 0.8rem;
color: #888;
.detail-label {
font-weight: 500;
min-width: 60px;
}
.detail-value {
color: #666;
}
}
}
}
}
}
// 空状态
.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;
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;
}
.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;
}
}
}
}
}
}
} }
// 任务列表样式 .calendar-title {
.task-list { margin: 0 0 20px 0;
.task-row { color: #303133;
display: flex; font-size: 20px;
justify-content: space-between; font-weight: 600;
align-items: center; text-align: center;
padding: 12px 0; padding-bottom: 15px;
border-bottom: 1px solid #e5e7eb; border-bottom: 2px solid #f0f2f5;
&:hover {
background-color: #f9fafb;
}
.task-info {
display: flex;
flex-direction: column;
gap: 8px;
flex: 1;
margin-right: 16px;
.task-row-1, .task-row-2 {
display: flex;
gap: 12px;
align-items: center;
}
.task-row-1 {
.task-title {
font-weight: 600;
color: #111827;
font-size: 14px;
margin-right: 8px;
}
.task-content {
color: #6b7280;
font-size: 13px;
flex: 1;
}
}
.task-row-2 {
.task-date, .task-created {
color: #9ca3af;
font-size: 12px;
}
}
}
.task-actions {
display: flex;
align-items: center;
gap: 12px;
.status-tag {
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
&.pending {
background-color: #fef3c7;
color: #d97706;
}
&.in-progress {
background-color: #dbeafe;
color: #2563eb;
}
&.completed {
background-color: #d1fae5;
color: #059669;
}
}
.status-btn {
padding: 6px 12px;
background-color: #3b82f6;
color: white;
border: none;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
transition: background-color 0.2s;
&:hover {
background-color: #2563eb;
}
}
}
}
.no-tasks {
text-align: center;
padding: 40px 20px;
color: #6b7280;
}
// 移动端适配
@media (max-width: 768px) {
.task-list {
.task-row {
flex-direction: column;
align-items: flex-start;
gap: 12px;
.task-actions {
align-self: flex-end;
}
}
}
}
} }
</style> </style>

File diff suppressed because it is too large Load Diff

View File

@@ -1,19 +1,43 @@
<template> <template>
<div class="center-overview"> <div class="center-overview">
<h2>中心整体概览</h2> <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-grid">
<div class="overview-card primary"> <div class="overview-card primary">
<div class="card-header"> <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> <span class="card-trend positive">{{ props.overallData.CenterPerformance?.center_monthly_vs_previous_deals }} vs 上期</span>
</div> </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 class="card-subtitle">月目标完成率: {{ props.overallData.CenterPerformance?.center_monthly_target_completion_rate || '56%' }}</div>
</div> </div>
<div class="overview-card"> <div class="overview-card">
<div class="card-header"> <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> <span class="card-trend stable">{{ props.overallData.TotalGroupCount?.center_total_team_count}}/{{ props.overallData.TotalGroupCount?.center_total_team_count }} </span>
</div> </div>
<div class="card-value">{{ props.overallData.TotalGroupCount?.center_total_team_count || '5' }} </div> <div class="card-value">{{ props.overallData.TotalGroupCount?.center_total_team_count || '5' }} </div>
@@ -22,7 +46,12 @@
<div class="overview-card"> <div class="overview-card">
<div class="card-header"> <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> <span class="card-trend positive">{{ props.overallData.CenterConversionRate?.center_monthly_vs_previous_deals }}vs 上期</span>
</div> </div>
<div class="card-value">{{ props.overallData.CenterConversionRate?.center_conversion_rate || '5.2' }}</div> <div class="card-value">{{ props.overallData.CenterConversionRate?.center_conversion_rate || '5.2' }}</div>
@@ -31,16 +60,26 @@
<div class="overview-card"> <div class="overview-card">
<div class="card-header"> <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> <span class="card-trend positive">{{ props.overallData.TotalCallCount?.total_call_count_vs_yesterday}} vs 上期</span>
</div> </div>
<div class="card-value">{{ props.overallData.TotalCallCount?.total_call_count || '1,247' }} </div> <div class="card-value">{{ props.overallData.TotalCallCount?.total_call_count || '0' }} </div>
<div class="card-subtitle">有效通话: {{ props.overallData.TotalCallCount?.center_effective_call_count || '892' }}</div> <div class="card-subtitle">有效通话: {{ props.overallData.TotalCallCount?.center_effective_call_count || '0' }}</div>
</div> </div>
<div class="overview-card"> <div class="overview-card">
<div class="card-header"> <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> <span class="card-trend positive">{{ props.overallData.NewCustomer?.center_new_leads_vs_previous_period }} vs 上期</span>
</div> </div>
<div class="card-value">{{ props.overallData.NewCustomer?.center_new_leads_count || '117' }} </div> <div class="card-value">{{ props.overallData.NewCustomer?.center_new_leads_count || '117' }} </div>
@@ -49,18 +88,50 @@
<div class="overview-card"> <div class="overview-card">
<div class="card-header"> <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> <span class="card-trend positive">{{ props.overallData.DepositConversionRate?.center_deposit_conversion_vs_previous }} vs 上期</span>
</div> </div>
<div class="card-value">{{ props.overallData.DepositConversionRate?.center_current_deposit_conversion_rate || '0' }} </div> <div class="card-value">{{ props.overallData.DepositConversionRate?.center_current_deposit_conversion_rate || '0' }} </div>
<div class="card-subtitle">平均定金转化率: {{ props.overallData.DepositConversionRate?.center_monthly_deposit_conversion_rate || '0' }}</div> <div class="card-subtitle">平均定金转化率: {{ props.overallData.DepositConversionRate?.center_monthly_deposit_conversion_rate || '0' }}</div>
</div> </div>
</div> </div>
<!-- Tooltip组件 -->
<Tooltip
:visible="tooltip.visible"
:x="tooltip.x"
:y="tooltip.y"
:title="tooltip.title"
:description="tooltip.description"
/>
</div> </div>
</template> </template>
<script setup> <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({ const props = defineProps({
overallData: { 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> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@@ -169,6 +292,39 @@ const props = defineProps({
color: #94a3b8; color: #94a3b8;
font-size: 0.8rem; 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 { .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) { @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 { .center-overview {
padding: 1rem; padding: 1rem;

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -32,7 +32,7 @@
<div class="key-metrics"> <div class="key-metrics">
<div class="mini-metric"> <div class="mini-metric">
<span class="mini-label">业绩</span> <span class="mini-label">业绩</span>
<span class="mini-value">{{ formatCurrency(group.todayPerformance) }}</span> <span class="mini-value">{{ group.todayPerformance }}</span>
</div> </div>
<div class="mini-metric"> <div class="mini-metric">
<span class="mini-label">转化</span> <span class="mini-label">转化</span>
@@ -175,22 +175,22 @@ const processedGroups = computed(() => {
}) })
} }
// 处理 formal_plural 数据 // 处理 formal_plural 数据(业绩数据)
if (props.groupList.formal_plural) { if (props.groupList.formal_plural) {
console.log('Processing formal_plural:', props.groupList.formal_plural) console.log('Processing formal_plural:', props.groupList.formal_plural)
Object.entries(props.groupList.formal_plural).forEach(([managerName, teamData]) => { Object.entries(props.groupList.formal_plural).forEach(([managerName, teamData]) => {
if (typeof teamData === 'object' && teamData !== null) { 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) const existingGroup = groups.find(g => g.id === `${managerName}-${teamName}` || g.id === managerName)
if (existingGroup) { if (existingGroup) {
existingGroup.newClients = count || 0 existingGroup.todayPerformance = performance || 0
} }
}) })
} else if (typeof teamData === 'number') { } else if (typeof teamData === 'number') {
// 处理直接数值的情况 // 处理直接数值的情况
const existingGroup = groups.find(g => g.id === managerName) const existingGroup = groups.find(g => g.id === managerName)
if (existingGroup) { if (existingGroup) {
existingGroup.newClients = teamData || 0 existingGroup.todayPerformance = teamData || 0
} }
} }
}) })
@@ -275,10 +275,10 @@ const getRankingClass = (index) => {
// 处理部门双击事件,跳转到经理页面 // 处理部门双击事件,跳转到经理页面
const navigateToManager = (group) => { const navigateToManager = (group) => {
router.push({ router.push({
path: '/senior-manager', path: '/manager',
query: { query: {
user_name: group.id, user_name: group.leader,
user_level: 3 user_level: 2
} }
}) })
} }

View File

@@ -2,6 +2,7 @@
<div class="chart-container"> <div class="chart-container">
<div class="chart-header"> <div class="chart-header">
<h3>客户迫切解决的问题排行榜</h3> <h3>客户迫切解决的问题排行榜</h3>
<button @click="exportData" v-if="userStore.userInfo.user_level === 4">一键导出</button>
</div> </div>
<div class="chart-content"> <div class="chart-content">
<div v-if="sortedData.length > 0" class="problem-ranking"> <div v-if="sortedData.length > 0" class="problem-ranking">
@@ -33,7 +34,13 @@
</template> </template>
<script setup> <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 } 的数组 // 定义Props接收一个包含 { name: string, value: string | number } 的数组
const props = defineProps({ const props = defineProps({
@@ -73,6 +80,190 @@ const getRankingClass = (index) => {
const getRankBadgeClass = (index) => { const getRankBadgeClass = (index) => {
return ['badge-gold', 'badge-silver', 'badge-bronze'][index] || 'badge-default'; 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> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@@ -87,14 +278,42 @@ const getRankBadgeClass = (index) => {
} }
.chart-header { .chart-header {
padding: 20px 20px 16px; padding: 10px 20px 10px;
border-bottom: 1px solid #ebeef5; border-bottom: 1px solid #ebeef5;
display: flex;
justify-content: space-between;
align-items: center;
h3 { h3 {
margin: 0; margin: 0;
color: #303133; color: #303133;
font-size: 18px; font-size: 18px;
font-weight: 600; 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 { .chart-content {
@@ -219,4 +438,4 @@ const getRankBadgeClass = (index) => {
color: #909399; color: #909399;
font-size: 16px; font-size: 16px;
} }
</style> </style>

View File

@@ -24,28 +24,22 @@
<p>统筹多组运营优化资源配置驱动业绩增长实现团队协同发展</p> <p>统筹多组运营优化资源配置驱动业绩增长实现团队协同发展</p>
</div> </div>
<!-- 营期阶段信息与调控 -->
<div class="stage-info" style="margin-left: 100px;">
<span class="stage-label">营期所属阶段</span>
<span class="stage-value">{{ currentStage }}</span>
<!-- 仅在"接数据"阶段显示调控UI -->
<div v-if="isDataReceivingStage" class="stage-control">
<span class="control-label">调整"接数据"天数:</span>
<input type="number" v-model.number="dataReceivingStage.days" min="1" class="days-input" />
<button @click="saveCampSettings" class="save-button">保存</button>
</div>
<!-- 非接数据阶段显示结束营期按钮 -->
<div v-if="!isDataReceivingStage" class="stage-control">
<button @click="finishCamp" class="finish-camp-button">结束营期</button>
</div>
</div>
</template> </template>
<div v-if="!isRouteNavigation"> <div v-if="!isRouteNavigation">
<!-- 用户下拉菜单 --> <!-- 用户下拉菜单 -->
<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>
</div> </div>
@@ -56,37 +50,52 @@
<!-- Top Section - Center Overview and Action Items --> <!-- Top Section - Center Overview and Action Items -->
<div class="top-section"> <div class="top-section">
<!-- Center Performance Overview --> <!-- Center Performance Overview -->
<CenterOverview :overall-data="overallCenterPerformance" /> <CenterOverview
v-if="cardVisibility.centerOverview"
:key="CheckType"
:overall-data="overallCenterPerformance"
@update-check-type="updateCheckType"
/>
<!-- Action Items (Compact) --> <!-- Action Items (Compact) -->
<div class="action-items-compact"> <div v-if="cardVisibility.actionItems" class="action-items-compact">
<ActionItems :selected-group="selectedGroup" /> <ActionItems :selected-group="selectedGroup" />
</div> </div>
</div> </div>
<div class="BB-section"> <div class="BB-section">
<!--客户类型占比--> <!--客户类型占比-->
<CustomerType :customer-data="customerTypeDistribution" @category-change="handleCustomerTypeChange" /> <CustomerType
v-if="cardVisibility.customerType"
:customer-data="customerTypeDistribution"
@category-change="handleCustomerTypeChange"
/>
<!-- 优秀录音 --> <!-- 优秀录音 -->
<GoodMusic /> <GoodMusic
v-if="cardVisibility.goodMusic"
:quality-calls="excellentRecord"
/>
<!-- 客户问题排行 --> <!-- 客户问题排行 -->
<ProblemRanking :ranking-data="formattedUrgentNeedData" /> <ProblemRanking
v-if="cardVisibility.problemRanking"
:ranking-data="formattedUrgentNeedData"
/>
</div> </div>
<!-- Bottom Section --> <!-- Bottom Section -->
<div class="bottom-section"> <div class="bottom-section">
<!-- Left Section - Group Performance Ranking --> <!-- Left Section - Group Performance Ranking -->
<div class="left-section"> <div v-if="cardVisibility.groupRanking" class="left-section">
<GroupRanking :groups="groups" :selected-group="selectedGroup" :conversion-data="conversionRateVsAverage" @select-group="selectGroup" /> <GroupRanking :groups="groups" :selected-group="selectedGroup" :conversion-data="conversionRateVsAverage" @select-group="selectGroup" />
</div> </div>
<!-- Right Section - Group Comparison --> <!-- Right Section - Group Comparison -->
<div class="right-section"> <div v-if="cardVisibility.groupComparison" class="right-section">
<GroupComparison :groups="groups" :senior-manager-data="seniorManagerList" :group-list="groupList" @select-group="selectGroup" @manager-change="handleManagerChange" /> <GroupComparison :groups="groups" :senior-manager-data="seniorManagerList" :group-list="groupList" @select-group="selectGroup" @manager-change="handleManagerChange" />
</div> </div>
</div> </div>
<!-- Team Members Detail Section --> <!-- Team Members Detail Section -->
<div class="team-detail-section" v-if="selectedGroup"> <div class="team-detail-section" v-if="selectedGroup && cardVisibility.teamDetail">
<div class="team-detail-header"> <div class="team-detail-header">
<h2>{{ selectedGroup.name }} - 团队成员详情</h2> <h2>{{ selectedGroup.name }} - 团队成员详情</h2>
<div class="team-summary"> <div class="team-summary">
@@ -103,6 +112,22 @@
<span class="value">{{ selectedGroup.conversionRate }}%</span> <span class="value">{{ selectedGroup.conversionRate }}%</span>
</div> </div>
</div> </div>
<div class="group-performance">
<button @click="showTeamAnalysisModal">团队整体分析</button>
</div>
</div>
<!-- 团队整体分析弹窗 -->
<div v-if="showTeamAnalysis" class="team-analysis-modal" @click.self="closeTeamAnalysisModal">
<div class="modal-content">
<div class="modal-header">
<h3>团队整体分析</h3>
<button class="close-btn" @click="closeTeamAnalysisModal">×</button>
</div>
<div class="modal-body">
<p>这里是团队整体分析的内容</p>
</div>
</div>
</div> </div>
<div class="members-grid"> <div class="members-grid">
@@ -134,13 +159,21 @@
<div class="metric-row"> <div class="metric-row">
<div class="metric-item"> <div class="metric-item">
<span class="metric-label">转化率</span> <span class="metric-label">转化率<i class="info-icon" @mouseenter="showTooltip($event, 'teamPerformance')" @mouseleave="hideTooltip"></i></span>
<span class="metric-value">{{ member.conversionRate }}%</span> <span class="metric-value">{{ member.conversionRate }}%</span>
</div> </div>
<div class="metric-item"> <div class="metric-item">
<span class="metric-label">通话次数</span> <span class="metric-label">通话次数</span>
<span class="metric-value">{{ member.callCount }}</span> <span class="metric-value">{{ member.callCount }}</span>
</div> </div>
<!-- Tooltip 组件 -->
<Tooltip
:visible="tooltip.visible"
:x="tooltip.x"
:y="tooltip.y"
:title="tooltip.title"
:description="tooltip.description"
/>
</div> </div>
<div class="metric-row"> <div class="metric-row">
@@ -161,12 +194,71 @@
</main> </main>
<!-- Loading 组件 --> <!-- Loading 组件 -->
<Loading :visible="isLoading" text="数据加载中..." /> <!-- <Loading :visible="isLoading" text="数据加载中..." /> -->
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, onMounted, computed } from 'vue' import { ref, onMounted, computed,reactive } from 'vue'
import FeedbackForm from "@/components/FeedbackForm.vue";
// 30分钟数据缓存系统
const cache = new Map()
const CACHE_DURATION = 30 * 60 * 1000 // 30分钟
// 生成缓存键
const getCacheKey = (functionName, params = {}) => {
const sortedParams = Object.keys(params).sort().reduce((result, key) => {
result[key] = params[key]
return result
}, {})
return `${functionName}_${JSON.stringify(sortedParams)}`
}
// 检查缓存是否有效
const isValidCache = (cacheData) => {
return cacheData && (Date.now() - cacheData.timestamp) < CACHE_DURATION
}
// 设置缓存
const setCache = (key, data) => {
cache.set(key, {
data,
timestamp: Date.now()
})
}
// 获取缓存
const getCache = (key) => {
const cacheData = cache.get(key)
if (isValidCache(cacheData)) {
return cacheData.data
}
cache.delete(key) // 删除过期缓存
return null
}
// 带缓存的API调用包装器
const withCache = async (functionName, apiCall, params = {}) => {
const cacheKey = getCacheKey(functionName, params)
const cachedData = getCache(cacheKey)
if (cachedData) {
console.log(`[缓存命中] ${functionName}:`, cachedData)
return cachedData
}
try {
const result = await apiCall()
if (result && result.code === 200) {
setCache(cacheKey, result)
console.log(`[缓存设置] ${functionName}:`, result)
}
return result
} catch (error) {
console.error(`[API调用失败] ${functionName}:`, error)
throw error
}
}
import CenterOverview from './components/CenterOverview.vue' import CenterOverview from './components/CenterOverview.vue'
import GroupComparison from './components/GroupComparison.vue' import GroupComparison from './components/GroupComparison.vue'
@@ -179,6 +271,7 @@
import seniorManager from './components/seniorManager.vue' import seniorManager from './components/seniorManager.vue'
import UserDropdown from '@/components/UserDropdown.vue' import UserDropdown from '@/components/UserDropdown.vue'
import Loading from '@/components/Loading.vue' import Loading from '@/components/Loading.vue'
import Tooltip from '@/components/Tooltip.vue'
import { import {
getOverallCenterPerformance, getTotalGroupCount, getCenterConversionRate, getTotalCallCount, getNewCustomer getOverallCenterPerformance, getTotalGroupCount, getCenterConversionRate, getTotalCallCount, getNewCustomer
, getDepositConversionRate, getCustomerTypeDistribution, getUrgentNeedToAddress, getCenterAdvancedManagerList, getTeamRanking, , getDepositConversionRate, getCustomerTypeDistribution, getUrgentNeedToAddress, getCenterAdvancedManagerList, getTeamRanking,
@@ -189,6 +282,49 @@
const router = useRouter(); const router = useRouter();
// 用户store实例 // 用户store实例
const userStore = useUserStore(); const userStore = useUserStore();
const CheckType = ref('month')
// 卡片显示状态
const cardVisibility = ref({
centerOverview: true,
actionItems: true,
customerType: true,
goodMusic: true,
problemRanking: true,
groupRanking: true,
groupComparison: true,
teamDetail: true
})
// FeedbackForm 控制变量
const showFeedbackForm = ref(false)
// 团队整体分析弹窗控制变量
const showTeamAnalysis = ref(false)
// 更新卡片显示状态
const updateCardVisibility = (newVisibility) => {
Object.assign(cardVisibility.value, newVisibility)
console.log('卡片显示状态已更新:', cardVisibility.value)
}
// FeedbackForm 控制方法
const showFeedbackFormModal = () => {
showFeedbackForm.value = true
}
const closeFeedbackFormModal = () => {
showFeedbackForm.value = false
}
// 团队整体分析弹窗控制方法
const showTeamAnalysisModal = () => {
showTeamAnalysis.value = true
}
const closeTeamAnalysisModal = () => {
showTeamAnalysis.value = false
}
// 营期调控逻辑 // 营期调控逻辑
// This would ideally come from a prop or API call based on the logged-in user // This would ideally come from a prop or API call based on the logged-in user
const centerData = ref({ const centerData = ref({
@@ -253,11 +389,9 @@ const centerData = ref({
// 保存营期 // 保存营期
const saveCampSettings = async () => { const saveCampSettings = async () => {
recalculateStageDates(); recalculateStageDates();
// 准备API请求参数 // 准备API请求参数
const params = { const params = {
user_name: userStore.userInfo.username, ...getRequestParams(),
user_level: userStore.userInfo.user_level.toString(),
receipt_data_time: dataReceivingStage.value.days.toString() receipt_data_time: dataReceivingStage.value.days.toString()
}; };
@@ -274,30 +408,6 @@ const centerData = ref({
alert('保存失败,请重试'); alert('保存失败,请重试');
} }
}; };
// 结束营期
const finishCamp = async () => {
try {
const params = {
user_name: userStore.userInfo.username,
user_level: userStore.userInfo.user_level.toString(),
is_camp_finish: "true"
};
const res = await getCampPeriodAdmin(params);
if (res.code === 200) {
console.log('营期结束成功:', res.data);
alert('营期已成功结束!');
// 可以在这里添加页面跳转或其他后续操作
} else {
alert('结束营期失败,请重试');
}
} catch (error) {
console.error('结束营期失败:', error);
alert('结束营期失败,请重试!');
}
};
// console.log('currentStage', userStore.userInfo) // console.log('currentStage', userStore.userInfo)
// 获取,修改当前营期 // 获取,修改当前营期
@@ -424,8 +534,16 @@ const centerData = ref({
async function CenterOverallCenterPerformance() { async function CenterOverallCenterPerformance() {
const params = getRequestParams() const params = getRequestParams()
const hasParams = params.user_name const hasParams = params.user_name
const requestParams = hasParams ? {
...params,
check_type: CheckType.value
} : {check_type: CheckType.value}
try { try {
const res = await getOverallCenterPerformance(hasParams ? params : undefined) const res = await withCache('CenterOverallCenterPerformance',
() => getOverallCenterPerformance(requestParams),
requestParams
)
if (res.code === 200) { if (res.code === 200) {
overallCenterPerformance.value.CenterPerformance = res.data overallCenterPerformance.value.CenterPerformance = res.data
} }
@@ -437,8 +555,16 @@ const centerData = ref({
async function CenterTotalGroupCount() { async function CenterTotalGroupCount() {
const params = getRequestParams() const params = getRequestParams()
const hasParams = params.user_name const hasParams = params.user_name
const requestParams = hasParams ? {
...params,
check_type: CheckType.value
} : {check_type: CheckType.value}
try { try {
const res = await getTotalGroupCount(hasParams ? params : undefined) const res = await withCache('CenterTotalGroupCount',
() => getTotalGroupCount(requestParams),
requestParams
)
if (res.code === 200) { if (res.code === 200) {
overallCenterPerformance.value.TotalGroupCount = res.data overallCenterPerformance.value.TotalGroupCount = res.data
} }
@@ -450,8 +576,16 @@ const centerData = ref({
async function CenterConversionRate() { async function CenterConversionRate() {
const params = getRequestParams() const params = getRequestParams()
const hasParams = params.user_name const hasParams = params.user_name
const requestParams = hasParams ? {
...params,
check_type: CheckType.value
} : {check_type: CheckType.value}
try { try {
const res = await getCenterConversionRate(hasParams ? params : undefined) const res = await withCache('CenterConversionRate',
() => getCenterConversionRate(requestParams),
requestParams
)
if (res.code === 200) { if (res.code === 200) {
overallCenterPerformance.value.CenterConversionRate = res.data overallCenterPerformance.value.CenterConversionRate = res.data
} }
@@ -463,8 +597,16 @@ const centerData = ref({
async function CenterTotalCallCount() { async function CenterTotalCallCount() {
const params = getRequestParams() const params = getRequestParams()
const hasParams = params.user_name const hasParams = params.user_name
const requestParams = hasParams ? {
...params,
check_type: CheckType.value
} : {check_type: CheckType.value}
try { try {
const res = await getTotalCallCount(hasParams ? params : undefined) const res = await withCache('CenterTotalCallCount',
() => getTotalCallCount(requestParams),
requestParams
)
if (res.code === 200) { if (res.code === 200) {
overallCenterPerformance.value.TotalCallCount = res.data overallCenterPerformance.value.TotalCallCount = res.data
} }
@@ -476,8 +618,16 @@ const centerData = ref({
async function CenterNewCustomer() { async function CenterNewCustomer() {
const params = getRequestParams() const params = getRequestParams()
const hasParams = params.user_name const hasParams = params.user_name
const requestParams = hasParams ? {
...params,
check_type: CheckType.value
} : {check_type: CheckType.value}
try { try {
const res = await getNewCustomer(hasParams ? params : undefined) const res = await withCache('CenterNewCustomer',
() => getNewCustomer(requestParams),
requestParams
)
if (res.code === 200) { if (res.code === 200) {
overallCenterPerformance.value.NewCustomer = res.data overallCenterPerformance.value.NewCustomer = res.data
} }
@@ -489,8 +639,16 @@ const centerData = ref({
async function CenterDepositConversionRate() { async function CenterDepositConversionRate() {
const params = getRequestParams() const params = getRequestParams()
const hasParams = params.user_name const hasParams = params.user_name
const requestParams = hasParams ? {
...params,
check_type: CheckType.value
} : {check_type: CheckType.value}
try { try {
const res = await getDepositConversionRate(hasParams ? params : undefined) const res = await withCache('CenterDepositConversionRate',
() => getDepositConversionRate(requestParams),
requestParams
)
if (res.code === 200) { if (res.code === 200) {
overallCenterPerformance.value.DepositConversionRate = res.data overallCenterPerformance.value.DepositConversionRate = res.data
} }
@@ -504,8 +662,12 @@ const centerData = ref({
const hasParams = params.user_name const hasParams = params.user_name
// 添加distribution_type参数 // 添加distribution_type参数
const requestParams = hasParams ? { ...params, distribution_type: distributionType } : { distribution_type: distributionType } const requestParams = hasParams ? { ...params, distribution_type: distributionType } : { distribution_type: distributionType }
try { try {
const res = await getCustomerTypeDistribution(requestParams) const res = await withCache('CenterCustomerType',
() => getCustomerTypeDistribution(requestParams),
requestParams
)
if (res.code === 200) { if (res.code === 200) {
customerTypeDistribution.value = res.data customerTypeDistribution.value = res.data
} }
@@ -522,8 +684,13 @@ const centerData = ref({
async function CenterUrgentNeedToAddress() { async function CenterUrgentNeedToAddress() {
const params = getRequestParams() const params = getRequestParams()
const hasParams = params.user_name const hasParams = params.user_name
const requestParams = hasParams ? params : {}
try { try {
const res = await getUrgentNeedToAddress(hasParams ? params : undefined) const res = await withCache('CenterUrgentNeedToAddress',
() => getUrgentNeedToAddress(hasParams ? params : undefined),
requestParams
)
if (res.code === 200) { if (res.code === 200) {
urgentNeedToAddress.value = res.data urgentNeedToAddress.value = res.data
} }
@@ -536,8 +703,13 @@ const conversionRateVsAverage = ref({})
async function CenterConversionRateVsAverage() { async function CenterConversionRateVsAverage() {
const params = getRequestParams() const params = getRequestParams()
const hasParams = params.user_name const hasParams = params.user_name
const requestParams = hasParams ? params : {}
try { try {
const res = await getConversionRateVsAverage(hasParams ? params : undefined) const res = await withCache('CenterConversionRateVsAverage',
() => getConversionRateVsAverage(hasParams ? params : undefined),
requestParams
)
if (res.code === 200) { if (res.code === 200) {
conversionRateVsAverage.value = res.data conversionRateVsAverage.value = res.data
} }
@@ -551,8 +723,13 @@ const conversionRateVsAverage = ref({})
async function CenterSeniorManagerList() { async function CenterSeniorManagerList() {
const params = getRequestParams() const params = getRequestParams()
const hasParams = params.user_name const hasParams = params.user_name
const requestParams = hasParams ? params : {}
try { try {
const res = await getCenterAdvancedManagerList(hasParams ? params : undefined) const res = await withCache('CenterSeniorManagerList',
() => getCenterAdvancedManagerList(hasParams ? params : undefined),
requestParams
)
if (res.code === 200) { if (res.code === 200) {
seniorManagerList.value = res.data seniorManagerList.value = res.data
} }
@@ -579,8 +756,12 @@ const conversionRateVsAverage = ref({})
requestParams.team_leader_name = selectedManager requestParams.team_leader_name = selectedManager
} }
} }
try { try {
const res = await getTeamRanking(requestParams) const res = await withCache('CenterGroupList',
() => getTeamRanking(requestParams),
requestParams
)
console.log('API Response:', res) console.log('API Response:', res)
if (res.code === 200) { if (res.code === 200) {
groupList.value = res.data groupList.value = res.data
@@ -596,7 +777,10 @@ const conversionRateVsAverage = ref({})
// 根据传来的组名字来获取组业绩详情 // 根据传来的组名字来获取组业绩详情
async function CenterGroupPerformance(groupName) { async function CenterGroupPerformance(groupName) {
const params = getRequestParams() const routeParams = getRequestParams()
const params = routeParams.user_name
? routeParams
: {user_name: userStore.userInfo.username, user_level: userStore.userInfo.user_level.toString()}
const hasParams = params.user_name const hasParams = params.user_name
const requestParams = hasParams ? { const requestParams = hasParams ? {
...params, ...params,
@@ -606,7 +790,10 @@ const conversionRateVsAverage = ref({})
} }
try { try {
const res = await getTeamRankingInfo(requestParams) const res = await withCache('CenterGroupPerformance',
() => getTeamRankingInfo(requestParams),
requestParams
)
if (res.code === 200) { if (res.code === 200) {
groupPerformance.value = res.data groupPerformance.value = res.data
@@ -647,7 +834,7 @@ const conversionRateVsAverage = ref({})
deals: member.deals_this_period || 0, deals: member.deals_this_period || 0,
rank: member.rank || 0 rank: member.rank || 0
})) }))
.sort((a, b) => b.deals - a.deals) // 根据成交单数从高到低排序 .sort((a, b) => a.rank - b.rank) // 根据排名降序排列(排名数字越大越靠后)
// 更新selectedGroup的members数据 // 更新selectedGroup的members数据
selectedGroup.value = { selectedGroup.value = {
@@ -701,22 +888,121 @@ const conversionRateVsAverage = ref({})
}) })
} }
// 获取优秀录音 // 获取优秀录音
const goodRecord = ref([]) const excellentRecord = ref([]);
// 获取优秀录音文件 // 获取优秀录音文件
async function getGoodRecord() { async function CentergetGoodRecord() {
const params = getRequestParams() console.log('CentergetGoodRecord 开始执行')
const hasParams = params.user_name
const requestParams = hasParams ? {
...params,
} : {
}
try { 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) const res = await getExcellentRecordFile(requestParams)
if (res.code === 200) {
goodRecord.value = res.data if (res && res.code === 200 && res.data) {
excellentRecord.value = res.data || []
console.log('获取优秀录音成功:', res.data)
} else {
console.error("获取优秀录音失败,响应数据不完整:", res);
excellentRecord.value = []
} }
} catch (error) { } catch (error) {
console.error('获取优秀录音失败:', error) console.error("获取优秀录音失败:", error);
excellentRecord.value = []
}
}
// 缓存管理功能
// 清除所有缓存
const clearCache = () => {
cache.clear()
console.log('[缓存清除] 所有缓存已清除')
}
// 清除特定缓存
const clearSpecificCache = (functionName, params = {}) => {
const cacheKey = getCacheKey(functionName, params)
cache.delete(cacheKey)
console.log(`[缓存清除] ${functionName} 缓存已清除`)
}
// 获取缓存信息
const getCacheInfo = () => {
const cacheInfo = {
totalCount: cache.size,
validCount: 0,
expiredCount: 0,
cacheKeys: []
}
cache.forEach((value, key) => {
if (isValidCache(value)) {
cacheInfo.validCount++
cacheInfo.cacheKeys.push({
key,
timestamp: value.timestamp,
remainingTime: CACHE_DURATION - (Date.now() - value.timestamp)
})
} else {
cacheInfo.expiredCount++
cache.delete(key) // 清除过期缓存
}
})
console.log('[缓存信息]', cacheInfo)
return cacheInfo
}
// 强制刷新所有数据清除缓存并重新调用API
const forceRefreshAllData = async () => {
clearCache()
isLoading.value = true
try {
const currentQuery = router.currentRoute.value.query
const isFromRoute = currentQuery.fromRoute ||
sessionStorage.getItem('fromRoute') ||
(currentQuery.user_name && currentQuery.user_level)
if (!isFromRoute) {
CenterCampPeriodAdmin()
}
CentergetGoodRecord()
CenterOverallCenterPerformance()
CenterTotalGroupCount()
CenterConversionRate()
CenterTotalCallCount()
CenterNewCustomer()
CenterDepositConversionRate()
CenterCustomerType()
CenterUrgentNeedToAddress()
CenterConversionRateVsAverage()
CenterSeniorManagerList()
CenterGroupList('all')
console.log('[强制刷新] 所有数据已重新加载')
} catch (error) {
console.error('[强制刷新] 数据加载失败:', error)
} finally {
isLoading.value = false
} }
} }
@@ -724,9 +1010,6 @@ const conversionRateVsAverage = ref({})
onMounted(async () => { onMounted(async () => {
try { try {
isLoading.value = true isLoading.value = true
// 判断页面进入方式如果是通过路由跳转进入URL中有user_name和user_level参数则不发送CenterCampPeriodAdmin请求
// 如果是直接登录进入页面,则发送请求
const currentQuery = router.currentRoute.value.query const currentQuery = router.currentRoute.value.query
const isFromRoute = currentQuery.fromRoute || const isFromRoute = currentQuery.fromRoute ||
sessionStorage.getItem('fromRoute') || sessionStorage.getItem('fromRoute') ||
@@ -741,15 +1024,29 @@ const conversionRateVsAverage = ref({})
await CenterTotalGroupCount() await CenterTotalGroupCount()
await CenterConversionRate() await CenterConversionRate()
await CenterTotalCallCount() await CenterTotalCallCount()
await CentergetGoodRecord()
await CenterNewCustomer() await CenterNewCustomer()
await CenterDepositConversionRate() await CenterDepositConversionRate()
await CenterCustomerType() await CenterCustomerType()
await CenterUrgentNeedToAddress() await CenterUrgentNeedToAddress()
await CenterConversionRateVsAverage() await CenterConversionRateVsAverage()
await CenterSeniorManagerList() await CenterSeniorManagerList()
// 获取优秀录音
await getGoodRecord()
await CenterGroupList('all') // 初始化加载全部高级经理数据 await CenterGroupList('all') // 初始化加载全部高级经理数据
// 输出缓存信息
getCacheInfo()
// 开发环境下暴露缓存管理函数到全局
if (process.env.NODE_ENV === 'development') {
window.secondTopCache = {
clearCache,
clearSpecificCache,
getCacheInfo,
forceRefreshAllData,
cache
}
console.log('[开发模式] 缓存管理函数已暴露到 window.secondTopCache')
}
} catch (error) { } catch (error) {
console.error('数据加载失败:', error) console.error('数据加载失败:', error)
} finally { } finally {
@@ -757,6 +1054,48 @@ const conversionRateVsAverage = ref({})
} }
}) })
// 更新CheckType并重新获取数据
const updateCheckType = async (newValue) => {
CheckType.value = newValue
console.log('CheckType已更新为:', newValue)
// 使用强制刷新功能重新获取数据
await forceRefreshAllData()
}
// 工具提示状态
const tooltip = reactive({
visible: false,
x: 0,
y: 0,
title: '',
description: ''
})
// 指标描述
const metricDescriptions = {
teamPerformance: {
title: '转化率',
description: '本期最终成交/本期客户总数'
}
}
// 显示工具提示
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> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@@ -1174,7 +1513,20 @@ const conversionRateVsAverage = ref({})
} }
} }
} }
.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);
}
}
// 客户详情区域 // 客户详情区域
.customer-detail-section { .customer-detail-section {
background: white; background: white;
@@ -1311,7 +1663,77 @@ const conversionRateVsAverage = ref({})
} }
} }
} }
/* 团队分析弹窗样式 */
.team-analysis-modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.team-analysis-modal .modal-content {
background-color: white;
border-radius: 8px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
width: 80%;
max-width: 600px;
max-height: 80vh;
overflow: hidden;
display: flex;
flex-direction: column;
}
.team-analysis-modal .modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
border-bottom: 1px solid #e2e8f0;
}
.team-analysis-modal .modal-header h3 {
margin: 0;
color: #1a202c;
font-size: 1.25rem;
}
.team-analysis-modal .close-btn {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: #718096;
padding: 0;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
}
.team-analysis-modal .close-btn:hover {
color: #1a202c;
}
.team-analysis-modal .modal-body {
padding: 1rem;
overflow-y: auto;
flex: 1;
}
.team-analysis-modal .modal-body p {
margin: 0;
color: #4a5568;
line-height: 1.5;
}
// 路由导航顶栏样式 // 路由导航顶栏样式
.route-header { .route-header {
display: flex; display: flex;
@@ -1359,6 +1781,22 @@ const conversionRateVsAverage = ref({})
border-radius: 0.5rem; border-radius: 0.5rem;
border: 1px solid rgba(0, 0, 0, 0.1); border: 1px solid rgba(0, 0, 0, 0.1);
} }
/* 意见反馈按钮样式 */
.feedback-btn {
background-color: #4299e1;
color: white;
border: none;
border-radius: 6px;
padding: 0.5rem 1rem;
font-size: 0.9rem;
cursor: pointer;
transition: background-color 0.2s;
&:hover {
background-color: #3182ce;
}
}
} }
.stage-control { .stage-control {
margin-left: 20px; margin-left: 20px;

View File

@@ -1,6 +1,22 @@
<template> <template>
<div class="center-overview"> <div class="center-overview">
<h2>整体概览</h2> <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-grid">
<div class="overview-card primary"> <div class="overview-card primary">
<div class="card-header"> <div class="card-header">
@@ -8,10 +24,10 @@
团队总业绩 团队总业绩
<span class="info-icon" @mouseenter="showTooltip($event, 'teamPerformance')" @mouseleave="hideTooltip"></span> <span class="info-icon" @mouseenter="showTooltip($event, 'teamPerformance')" @mouseleave="hideTooltip"></span>
</span> </span>
<span class="card-trend positive">{{ totalPerformance.team_current_vs_previous_deals }} vs 上期</span> <span class="card-trend positive">{{ totalPerformance.team_current_vs_previous_period_deals_comparison }} vs 上期</span>
</div> </div>
<div class="card-value">{{ totalPerformance.current_team_odd_numbers||0 }}</div> <div class="card-value">{{ totalPerformance.current_team_odd_numbers||0 }}</div>
<div class="card-subtitle">月目标完成率: {{ totalPerformance.team_monthly_performance }}</div> <div class="card-subtitle">月目标完成率: {{ totalPerformance.team_monthly_target_completion_rate }}</div>
</div> </div>
<div class="overview-card"> <div class="overview-card">
@@ -32,7 +48,7 @@
团队转化率 团队转化率
<span class="info-icon" @mouseenter="showTooltip($event, 'conversionRate')" @mouseleave="hideTooltip"></span> <span class="info-icon" @mouseenter="showTooltip($event, 'conversionRate')" @mouseleave="hideTooltip"></span>
</span> </span>
<span class="card-trend positive">{{ conversionRate.team_current_vs_previous_deals }} vs 上期</span> <span class="card-trend positive">{{ conversionRate.team_current_vs_previous_conversion_rate }} vs 上期</span>
</div> </div>
<div class="card-value">{{ conversionRate.center_conversion_rate }}</div> <div class="card-value">{{ conversionRate.center_conversion_rate }}</div>
<div class="card-subtitle">团队平均转化率: {{ conversionRate.average_conversion_rate }}</div> <div class="card-subtitle">团队平均转化率: {{ conversionRate.average_conversion_rate }}</div>
@@ -87,9 +103,24 @@
</template> </template>
<script setup> <script setup>
import { computed, reactive } from 'vue' import { computed, reactive, ref } from 'vue'
import Tooltip from '@/components/Tooltip.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({ const props = defineProps({
overallTeamPerformance: { overallTeamPerformance: {
@@ -104,7 +135,6 @@ const props = defineProps({
}) })
} }
}) })
console.log(99999,props.overallTeamPerformance)
// 计算属性 // 计算属性
const totalPerformance = computed(() => { const totalPerformance = computed(() => {
return props.overallTeamPerformance.totalPerformance return props.overallTeamPerformance.totalPerformance
@@ -127,7 +157,6 @@ const newCustomers = computed(() => {
}) })
const depositConversions = computed(() => { const depositConversions = computed(() => {
console.log(999991111,props.overallTeamPerformance.depositConversions)
return props.overallTeamPerformance.depositConversions return props.overallTeamPerformance.depositConversions
}) })
@@ -144,27 +173,27 @@ const tooltip = reactive({
const metricDescriptions = { const metricDescriptions = {
teamPerformance: { teamPerformance: {
title: '团队总业绩计算方式', title: '团队总业绩计算方式',
description: '统计所有团队成员在选定时间范围内的成交金额总和,包括全款订单和定金订单的累计业绩。' description: '所有团队成员在选定时间范围内的成交金额总和,包括全款订单和定金订单的累计业绩。'
}, },
activeGroups: { activeGroups: {
title: '活跃组数计算方式', title: '活跃组数计算方式',
description: '统计当前有成员在线且有业务活动的团队组数,以及各组的总人数统计。' description: '当前有成员在线且有业务活动的团队组数,以及各组的总人数统计。'
}, },
conversionRate: { conversionRate: {
title: '团队转化率计算方式', title: '团队转化率计算方式',
description: '团队总成交单数 ÷ 团队总新增客户数 × 100%,反映整个团队将潜在客户转化为成交客户的综合能力。' description: '团队总成交单数 ÷ 团队总新增客户数 × 100%'
}, },
totalCalls: { totalCalls: {
title: '总通话次数计算方式', title: '总通话次数计算方式',
description: '统计所有团队成员在选定时间范围内的通话总次数,包括外呼、接听等所有通话记录。' description: '所有团队成员在选定时间范围内的通话总次数,包括外呼、接听等所有通话记录。'
}, },
newCustomers: { newCustomers: {
title: '新增客户计算方式', title: '新增客户计算方式',
description: '统计所有团队成员在选定时间范围内新建档的客户总数,包括意向客户和潜在客户。' description: '所有团队成员在选定时间范围内新建档的客户总数,包括意向客户和潜在客户。'
}, },
depositConversion: { depositConversion: {
title: '定金转化计算方式', title: '定金转化计算方式',
description: '定金订单数 ÷ 总成交单数 × 100%,反映客户从意向到实际付定金的转化效果。' description: '定金订单数 ÷ 总成交单数 × 100%'
} }
} }
@@ -193,6 +222,44 @@ const hideTooltip = () => {
padding: 0.5rem; padding: 0.5rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); 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 { h2 {
font-size: 1.3rem; font-size: 1.3rem;
font-weight: 600; font-weight: 600;
@@ -365,6 +432,19 @@ const hideTooltip = () => {
.center-overview { .center-overview {
padding: 1rem; 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 { .overview-grid {
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);
gap: 0.75rem; gap: 0.75rem;

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,35 +5,35 @@
<div class="stat-icon customer-rate"> <div class="stat-icon customer-rate">
<i class="el-icon-chat-dot-round"></i> <i class="el-icon-chat-dot-round"></i>
</div> </div>
<div class="kpi-value">{{ customerCommunicationRate.active_customer_communication_rate||0 }}</div> <div class="kpi-value">{{ (customerCommunicationRate && customerCommunicationRate.active_customer_communication_rate) || 0 }}</div>
<p>活跃客户沟通率 <i class="el-icon-warning info-icon" @mouseenter="showTooltip($event, 'customerCommunicationRate')" @mouseleave="hideTooltip"></i></p> <p>活跃客户沟通率 <i class="el-icon-warning info-icon" @mouseenter="showTooltip($event, 'customerCommunicationRate')" @mouseleave="hideTooltip"></i></p>
</div> </div>
<div class="kpi-item stat-item"> <div class="kpi-item stat-item">
<div class="stat-icon response-time"> <div class="stat-icon response-time">
<i class="el-icon-timer"></i> <i class="el-icon-timer"></i>
</div> </div>
<div class="kpi-value">{{ averageResponseTime.average_answer_time||0 }}<span class="kpi-unit">分钟</span></div> <div class="kpi-value">{{ (averageResponseTime && averageResponseTime.average_answer_time)||0 }}<span class="kpi-unit">分钟</span></div>
<p>平均应答时间 <i class="el-icon-warning info-icon" @mouseenter="showTooltip($event, 'averageResponseTime')" @mouseleave="hideTooltip"></i></p> <p>平均应答时间 <i class="el-icon-warning info-icon" @mouseenter="showTooltip($event, 'averageResponseTime')" @mouseleave="hideTooltip"></i></p>
</div> </div>
<div class="kpi-item stat-item"> <div class="kpi-item stat-item">
<div class="stat-icon timeout-rate"> <div class="stat-icon timeout-rate">
<i class="el-icon-warning"></i> <i class="el-icon-warning"></i>
</div> </div>
<div class="kpi-value">{{ timeoutResponseRate.timeout_rate||0 }}</div> <div class="kpi-value">{{ (timeoutResponseRate && timeoutResponseRate.timeout_rate)||0 }}</div>
<p>超时应答率 <i class="el-icon-warning info-icon" @mouseenter="showTooltip($event, 'timeoutResponseRate')" @mouseleave="hideTooltip"></i></p> <p>超时应答率 <i class="el-icon-warning info-icon" @mouseenter="showTooltip($event, 'timeoutResponseRate')" @mouseleave="hideTooltip"></i></p>
</div> </div>
<div class="kpi-item stat-item"> <div class="kpi-item stat-item">
<div class="stat-icon severe-timeout-rate"> <div class="stat-icon severe-timeout-rate">
<i class="el-icon-warning-outline"></i> <i class="el-icon-warning-outline"></i>
</div> </div>
<div class="kpi-value">{{ timeoutResponseRate.serious_timeout_rate||0 }}</div> <div class="kpi-value">{{ (timeoutResponseRate && timeoutResponseRate.serious_timeout_rate)||0 }}</div>
<p>严重超时应答率 <i class="el-icon-warning info-icon" @mouseenter="showTooltip($event, 'severeTimeoutRate')" @mouseleave="hideTooltip"></i></p> <p>严重超时应答率 <i class="el-icon-warning info-icon" @mouseenter="showTooltip($event, 'severeTimeoutRate')" @mouseleave="hideTooltip"></i></p>
</div> </div>
<div class="kpi-item stat-item"> <div class="kpi-item stat-item">
<div class="stat-icon form-rate"> <div class="stat-icon form-rate">
<i class="el-icon-document"></i> <i class="el-icon-document"></i>
</div> </div>
<div class="kpi-value">{{ formCompletionRate.table_filling_rate||0 }}</div> <div class="kpi-value">{{ (formCompletionRate && formCompletionRate.table_filling_rate)||0 }}</div>
<p>表格填写率 <i class="el-icon-warning info-icon" @mouseenter="showTooltip($event, 'formCompletionRate')" @mouseleave="hideTooltip"></i></p> <p>表格填写率 <i class="el-icon-warning info-icon" @mouseenter="showTooltip($event, 'formCompletionRate')" @mouseleave="hideTooltip"></i></p>
</div> </div>
</div> </div>
@@ -53,24 +53,24 @@ import Tooltip from '@/components/Tooltip.vue';
defineProps({ defineProps({
customerCommunicationRate: { customerCommunicationRate: {
type: Number, type: Object,
default: 0 default: () => ({})
}, },
averageResponseTime: { averageResponseTime: {
type: Number, type: Object,
default: 0 default: () => ({})
}, },
timeoutResponseRate: { timeoutResponseRate: {
type: Number, type: Object,
default: 0 default: () => ({})
}, },
severeTimeoutRate: { severeTimeoutRate: {
type: Number, type: Object,
default: 0 default: () => ({})
}, },
formCompletionRate: { formCompletionRate: {
type: Number, type: Object,
default: 0 default: () => ({})
} }
}); });
@@ -87,23 +87,23 @@ const tooltip = reactive({
const metricDescriptions = { const metricDescriptions = {
customerCommunicationRate: { customerCommunicationRate: {
title: '活跃客户沟通率计算方式', title: '活跃客户沟通率计算方式',
description: '有效沟通的活跃客户数 ÷ 总活跃客户数 × 100%,反映团队与活跃客户的沟通覆盖程度。' description: '有效沟通的活跃客户数 ÷ 总活跃客户数 × 100%'
}, },
averageResponseTime: { averageResponseTime: {
title: '平均应答时间计算方式', title: '平均应答时间计算方式',
description: '所有通话的应答时间总和 ÷ 通话总次数,以分钟为单位,反映团队的响应效率。' description: '所有通话的应答时间总和 ÷ 通话总次数'
}, },
timeoutResponseRate: { timeoutResponseRate: {
title: '超时应答率计算方式', title: '超时应答率计算方式',
description: '超时应答的通话次数 ÷ 总通话次数 × 100%超时标准通常为30秒以上。' description: '超时应答的通话次数 ÷ 总通话次数 × 100%'
}, },
severeTimeoutRate: { severeTimeoutRate: {
title: '严重超时应答率计算方式', title: '严重超时应答率计算方式',
description: '严重超时应答的通话次数 ÷ 总通话次数 × 100%严重超时标准通常为60秒以上。' description: '严重超时应答的通话次数 ÷ 总通话次数 × 100%'
}, },
formCompletionRate: { formCompletionRate: {
title: '表格填写率计算方式', title: '表格填写率计算方式',
description: '已完成填写的表格数量 ÷ 应填写的表格总数 × 100%,反映团队的工作完成度。' description: '已完成填写的表格数量 ÷ 应填写的表格总数 × 100%'
} }
}; };

File diff suppressed because it is too large Load Diff

View File

@@ -42,11 +42,11 @@
<thead> <thead>
<tr> <tr>
<th>人员</th> <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('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">成交单数 <span class="sort-icon" :class="{ active: sortField === 'total_deals' }">{{ 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>加微率</th> <th>加微率 <i class="info-icon" @mouseenter="showTooltip($event, 'plusVRate')" @mouseleave="hideTooltip"></i></th>
<th>入群率</th> <th>入群率 <i class="info-icon" @mouseenter="showTooltip($event, 'groupRate')" @mouseleave="hideTooltip"></i></th>
<th>表单填写率</th> <th>表单填写率 <i class="info-icon" @mouseenter="showTooltip($event, 'formFillingRate')" @mouseleave="hideTooltip"></i></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -74,12 +74,22 @@
</table> </table>
</div> </div>
</div> </div>
<!-- Tooltip 组件 -->
<Tooltip
:visible="tooltip.visible"
:x="tooltip.x"
:y="tooltip.y"
:title="tooltip.title"
:description="tooltip.description"
/>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, computed } from 'vue'; import { ref, computed, reactive } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import Tooltip from '@/components/Tooltip.vue';
const props = defineProps({ const props = defineProps({
tableData: { type: Array, required: true }, tableData: { type: Array, required: true },
@@ -93,6 +103,56 @@ const filters = ref({ centerLeader: '', advancedManager: '', manager: '', dealSt
const sortField = ref('conversion_rate'); const sortField = ref('conversion_rate');
const sortOrder = ref('desc'); 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(() => { const centerLeaders = computed(() => {
return props.levelTree?.level_tree?.center_leaders || []; 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; } th.sortable { cursor: pointer; }
.sort-icon { margin-left: 4px; opacity: 0.5; } .sort-icon { margin-left: 4px; opacity: 0.5; }
.sort-icon.active { opacity: 1; color: #4299e1; } .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; } td { padding: 16px; border-bottom: 1px solid #f1f5f9; vertical-align: middle; }
tr { cursor: pointer; transition: background-color 0.2s ease; } tr { cursor: pointer; transition: background-color 0.2s ease; }
tr:hover { background-color: #f8fafc; } tr:hover { background-color: #f8fafc; }

View File

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

View File

@@ -17,7 +17,9 @@
<!-- 1. 主卡片中心总业绩 --> <!-- 1. 主卡片中心总业绩 -->
<div class="kpi-card primary"> <div class="kpi-card primary">
<div class="card-header"> <div class="card-header">
<span class="card-label">总成交单数</span> <span class="card-label">
总成交单数
</span>
<span class="card-trend" :class="getTrendClass(kpiData.totalSales.trend)"> <span class="card-trend" :class="getTrendClass(kpiData.totalSales.trend)">
{{ formatTrend(kpiData.totalSales.trend) }} vs 上期 {{ formatTrend(kpiData.totalSales.trend) }} vs 上期
</span> </span>
@@ -34,7 +36,12 @@
<!-- 2. 定金转化率 --> <!-- 2. 定金转化率 -->
<div class="kpi-card"> <div class="kpi-card">
<div class="card-header"> <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)"> <span class="card-trend" :class="getTrendClass(kpiData.activeTeams.trend)">
{{ formatTrend(kpiData.activeTeams.trend, true) }} vs 上期 {{ formatTrend(kpiData.activeTeams.trend, true) }} vs 上期
</span> </span>
@@ -51,7 +58,12 @@
<!-- 3. 总通话次数 --> <!-- 3. 总通话次数 -->
<div class="kpi-card"> <div class="kpi-card">
<div class="card-header"> <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)"> <span class="card-trend" :class="getTrendClass(kpiData.totalCalls.trend)">
{{ formatTrend(kpiData.totalCalls.trend) }} vs 上期 {{ formatTrend(kpiData.totalCalls.trend) }} vs 上期
</span> </span>
@@ -68,7 +80,9 @@
<!-- 4. 新增客户 --> <!-- 4. 新增客户 -->
<div class="kpi-card"> <div class="kpi-card">
<div class="card-header"> <div class="card-header">
<span class="card-label">新增客户</span> <span class="card-label">
今日新增客户
</span>
<span class="card-trend" :class="getTrendClass(kpiData.newCustomers.trend)"> <span class="card-trend" :class="getTrendClass(kpiData.newCustomers.trend)">
{{ formatTrend(kpiData.newCustomers.trend) }} vs 上期 {{ formatTrend(kpiData.newCustomers.trend) }} vs 上期
</span> </span>
@@ -85,7 +99,12 @@
<!-- 5. 中心转化率 --> <!-- 5. 中心转化率 -->
<div class="kpi-card"> <div class="kpi-card">
<div class="card-header"> <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)"> <span class="card-trend" :class="getTrendClass(kpiData.conversionRate.trend)">
{{ formatTrend(kpiData.conversionRate.trend, true) }} vs 上期 {{ formatTrend(kpiData.conversionRate.trend, true) }} vs 上期
</span> </span>
@@ -96,11 +115,21 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Tooltip组件 -->
<Tooltip
:visible="tooltip.visible"
:x="tooltip.x"
:y="tooltip.y"
:title="tooltip.title"
:description="tooltip.description"
/>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, computed, watch } from 'vue'; import { ref, computed, watch, reactive } from 'vue';
import Tooltip from '@/components/Tooltip.vue';
// 定义props // 定义props
const props = defineProps({ const props = defineProps({
@@ -117,6 +146,50 @@ const props = defineProps({
const isLoading = ref(false); const isLoading = ref(false);
const error = ref(null); 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数据转换为组件需要的格式 // 计算属性将API数据转换为组件需要的格式
const kpiData = computed(() => { const kpiData = computed(() => {
const data = props.kpiData; const data = props.kpiData;
@@ -355,4 +428,39 @@ function formatTrend(trend, isPercentagePoint = false) {
font-size: 40px; 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> </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-container">
<div class="chart-header"> <div class="chart-header">
<h3>优秀录音</h3> <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>
<div class="chart-content"> <div class="chart-content">
<div class="recording-section"> <div class="recording-section">
@@ -26,10 +18,10 @@
:class="{ active: selectedRecording === index }" :class="{ active: selectedRecording === index }"
@click="selectRecording(index)" @click="selectRecording(index)"
> >
<span class="recording-index">{{ recording.score}}</span>
<div class="recording-info"> <div class="recording-info">
<div class="recording-name" :title="recording.name">{{ recording.name.length > 10 ? recording.name.substring(0, 10) + '...' : recording.name }}</div> <div class="recording-name" :title="recording.name">{{ recording.name.length > 10 ? recording.name.substring(0, 10) + '...' : recording.name }}</div>
<div class="recording-meta"> <div class="recording-meta">
<span class="file-size">{{ formatFileSize(recording.size) }}</span>
<span class="upload-time">{{ recording.uploadTime }}</span> <span class="upload-time">{{ recording.uploadTime }}</span>
</div> </div>
</div> </div>
@@ -67,9 +59,8 @@
<div class="result-header"> <div class="result-header">
<button class="back-btn" @click="backToRecordings"> <button class="back-btn" @click="backToRecordings">
<i class="el-icon-arrow-left"></i> <i class="el-icon-arrow-left"></i>
返回录音列表 返回
</button> </button>
<h4>{{ isConverting ? '正在转换...' : (currentViewType === 'transcript' ? '转换文本' : '录音分析') }}</h4>
<div class="header-actions"> <div class="header-actions">
<!-- 视图切换按钮 --> <!-- 视图切换按钮 -->
<div class="view-toggle" v-if="currentTranscript && !isConverting"> <div class="view-toggle" v-if="currentTranscript && !isConverting">
@@ -90,7 +81,7 @@
</div> </div>
<button class="expand-btn" @click="showExpandDialog" v-if="(currentTranscript && currentViewType === 'transcript') || (analysisResult && currentViewType === 'analysis')"> <button class="expand-btn" @click="showExpandDialog" v-if="(currentTranscript && currentViewType === 'transcript') || (analysisResult && currentViewType === 'analysis')">
<i class="el-icon-full-screen"></i> <i class="el-icon-full-screen"></i>
展开查看 展开
</button> </button>
<button class="copy-btn" @click="copyText" v-if="currentTranscript && currentViewType === 'transcript'"> <button class="copy-btn" @click="copyText" v-if="currentTranscript && currentViewType === 'transcript'">
<i class="el-icon-document-copy"></i> <i class="el-icon-document-copy"></i>
@@ -174,277 +165,313 @@
</div> </div>
</template> </template>
<script> <script setup>
import { ref, reactive, computed, onMounted, onBeforeUnmount } from 'vue'
import { SimpleChatService } from '@/utils/ChatService.js' import { SimpleChatService } from '@/utils/ChatService.js'
import MarkdownIt from 'markdown-it' import MarkdownIt from 'markdown-it'
export default { // Props定义
name: 'QualityCalls', const props = defineProps({
data() { qualityCalls: {
return { type: Array,
recordings: [ default: () => []
{ }
id: 1, })
name: '常家硕-张三丰-亮剑二部-20分钟通话-25-07-16_18-23-04-44196-215.mp3',
size: 2048576, // 2MB
duration: '00:03:45',
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,
// 录音分析相关
showAnalysisView: false,
isAnalyzing: false,
analysisResult: '',
currentViewType: 'transcript', // 'transcript' 或 'analysis'
// Dify API配置
DIFY_API_KEY_02: 'app-h4uBo5kOGoiYhjuBF1AHZi8b', // 通话录音分析
chatService_02: null,
md: null
}
},
created() {
// 初始化服务
this.chatService_02 = new SimpleChatService(this.DIFY_API_KEY_02)
this.md = new MarkdownIt({
html: true,
linkify: true,
typographer: true
})
},
computed: {
// 格式化分析结果
formattedAnalysisResult() {
if (!this.analysisResult) return ''
return this.md.render(this.analysisResult)
}
},
beforeUnmount() {
if (this.currentAudio) {
this.currentAudio.pause()
this.currentAudio = null
}
},
methods: {
// 录音文件选择
handleFileSelect(event) {
const file = event.target.files[0]
if (file) {
const recording = {
name: file.name,
size: file.size,
uploadTime: new Date().toLocaleString(),
url: URL.createObjectURL(file),
isPlaying: false,
isConverting: false,
transcript: null
}
this.recordings.push(recording)
// 清空input以便重复选择同一文件
event.target.value = ''
}
},
// 选择录音
selectRecording(index) {
this.selectedRecording = index
},
// 播放/暂停录音
togglePlay(index) {
const recording = this.recordings[index]
// 停止当前播放的音频
if (this.currentAudio) {
this.currentAudio.pause()
this.recordings.forEach(r => r.isPlaying = false)
}
if (!recording.isPlaying) {
this.currentAudio = new Audio(recording.url)
this.currentAudio.play()
recording.isPlaying = true
this.currentAudio.onended = () => {
recording.isPlaying = false
this.currentAudio = null
}
}
},
// 转换为文本
async convertToText(index) {
const recording = this.recordings[index]
this.selectedRecording = index
this.showTranscriptView = true
this.isConverting = true
this.currentTranscript = null
this.currentViewType = 'transcript'
try {
// 模拟转换过程
await new Promise(resolve => setTimeout(resolve, 2000))
// 这里应该调用实际的语音转文本API
// 目前使用模拟数据
recording.transcript = `这是 ${recording.name} 的转换文本示例。在实际应用中,这里会显示真实的语音转文本结果。您可以集成百度、阿里云、腾讯云等语音识别服务来实现真正的语音转文本功能。`
this.currentTranscript = recording.transcript
// 转换完成后自动开始录音分析
this.startRecordingAnalysis(recording)
// 添加转换完成的动画效果
const resultElement = document.querySelector('.conversion-result')
if (resultElement) {
resultElement.classList.add('show-result')
setTimeout(() => {
resultElement.classList.remove('show-result')
}, 1000)
}
} catch (error) {
console.error('转换失败:', error)
alert('转换失败,请重试')
this.showTranscriptView = false
} finally {
this.isConverting = false
}
},
// 开始通话录音分析
async startRecordingAnalysis(recording) {
this.isAnalyzing = true
this.analysisResult = ''
// 构建通话录音分析查询
const recordingQuery = `请对录音文件 ${recording.name} 进行通话录音分析,包括:
1. 通话质量评估
2. 客户情绪分析
3. 沟通效果评价
4. 关键信息提取
5. 改进建议
录音信息: // 响应式数据
文件名:${recording.name} const staticRecordings = ref([
文件大小:${this.formatFileSize(recording.size)} {
转换文本:${recording.transcript}` id: 1,
name: '常家硕-张三丰-亮剑二部-20分钟通话-25-07-16_18-23-04-44196-215.mp3',
try { size: 2048576, // 2MB
await this.chatService_02.sendMessage( duration: '00:03:45',
recordingQuery, date: '2024-01-15',
(update) => { url: '/recordings/sample_call_1.mp3',
// 实时更新通话录音分析结果 transcription: null
this.analysisResult = update.content }
}, ])
() => {
// 流结束回调 const selectedRecording = ref(null)
console.log('通话录音分析完成') const currentAudio = ref(null)
this.isAnalyzing = false const showTranscriptView = ref(false)
} const isConverting = ref(false)
) const currentTranscript = ref(null)
} catch (error) { const showDialog = ref(false)
console.error('通话录音分析失败:', error) // 录音分析相关
this.analysisResult = '通话录音分析失败,请重试。' const showAnalysisView = ref(false)
this.isAnalyzing = false const isAnalyzing = ref(false)
} const analysisResult = ref('')
}, const currentViewType = ref('transcript') // 'transcript' 或 'analysis'
// Dify API配置
const DIFY_API_KEY_02 = 'app-h4uBo5kOGoiYhjuBF1AHZi8b' // 通话录音分析
const chatService_02 = ref(null)
const md = ref(null)
// 初始化服务
onMounted(() => {
chatService_02.value = new SimpleChatService(DIFY_API_KEY_02)
md.value = new MarkdownIt({
html: true,
linkify: true,
typographer: true
})
})
// 计算属性
// 处理传入的录音数据
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;
})
// 格式化分析结果
const formattedAnalysisResult = computed(() => {
if (!analysisResult.value) return ''
return md.value.render(analysisResult.value)
})
// 生命周期钩子
onBeforeUnmount(() => {
if (currentAudio.value) {
currentAudio.value.pause()
currentAudio.value = null
}
})
// 方法定义
// 录音文件选择
const handleFileSelect = (event) => {
const file = event.target.files[0]
if (file) {
const recording = {
name: file.name,
size: file.size,
uploadTime: new Date().toLocaleString(),
url: URL.createObjectURL(file),
isPlaying: false,
isConverting: false,
transcript: null
}
staticRecordings.value.push(recording)
// 清空input以便重复选择同一文件
event.target.value = ''
}
}
// 选择录音
const selectRecording = (index) => {
selectedRecording.value = index
}
// 播放/暂停录音
const togglePlay = (index) => {
const recording = recordings.value[index]
// 停止当前播放的音频
if (currentAudio.value) {
currentAudio.value.pause()
recordings.value.forEach(r => r.isPlaying = false)
}
if (!recording.isPlaying) {
currentAudio.value = new Audio(recording.url)
currentAudio.value.play()
recording.isPlaying = true
// 切换视图类型 currentAudio.value.onended = () => {
switchViewType(type) { recording.isPlaying = false
this.currentViewType = type currentAudio.value = null
}, }
}
}
// 转换为文字
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, 1000))
// 返回录音列表 // 使用从API获取的transcription数据
backToRecordings() { if (recording.transcription) {
this.showTranscriptView = false recording.transcript = recording.transcription
this.currentTranscript = null currentTranscript.value = recording.transcription
this.analysisResult = '' } else {
this.currentViewType = 'transcript' // 如果没有transcription数据显示提示信息
this.isAnalyzing = false recording.transcript = '暂无转换文本数据'
}, currentTranscript.value = '暂无转换文本数据'
// 复制文本 }
copyText() {
if (this.currentTranscript) { // 添加转换完成的动画效果
navigator.clipboard.writeText(this.currentTranscript) const resultElement = document.querySelector('.conversion-result')
if (resultElement) {
resultElement.classList.add('show-result')
setTimeout(() => {
resultElement.classList.remove('show-result')
}, 1000)
}
} catch (error) {
console.error('转换失败:', error)
alert('转换失败,请重试')
showTranscriptView.value = false
} finally {
isConverting.value = false
}
}
// 开始通话录音分析
const startRecordingAnalysis = async (recording) => {
isAnalyzing.value = true
try {
// 使用从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)
analysisResult.value = '录音分析失败,请重试。'
} finally {
isAnalyzing.value = false
}
}
// 切换视图类型
const switchViewType = (type) => {
currentViewType.value = type
// 如果切换到录音分析视图,且还没有分析结果,则开始分析
if (type === 'analysis' && !analysisResult.value && selectedRecording.value !== null) {
const recording = recordings.value[selectedRecording.value]
startRecordingAnalysis(recording)
}
}
// 返回录音列表
const backToRecordings = () => {
showTranscriptView.value = false
currentTranscript.value = null
analysisResult.value = ''
currentViewType.value = 'transcript'
isAnalyzing.value = false
}
// 复制文本
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('文本已复制到剪贴板') alert('文本已复制到剪贴板')
} }
}, } catch (error) {
// 复制分析结果 console.error('复制失败:', error)
copyAnalysisText() { alert('复制失败,请手动复制')
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('分析结果已复制到剪贴板') alert('分析结果已复制到剪贴板')
} }
}, } catch (error) {
// 显示展开弹框 console.error('复制失败:', error)
showExpandDialog() { alert('复制失败,请手动复制')
this.showDialog = true
},
// 关闭弹框
closeDialog() {
this.showDialog = false
},
// 格式化文件大小
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 fileInput = document.createElement('input')
fileInput.type = 'file'
fileInput.accept = 'audio/*'
fileInput.style.display = 'none'
fileInput.addEventListener('change', this.handleFileSelect)
document.body.appendChild(fileInput)
fileInput.click()
document.body.removeChild(fileInput)
},
downloadRecording(index) {
const recording = this.recordings[index]
if (recording && recording.url) {
const link = document.createElement('a')
link.href = recording.url
link.download = recording.name
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
}
} }
} }
} }
// 显示展开弹框
const showExpandDialog = () => {
showDialog.value = true
}
// 关闭弹框
const closeDialog = () => {
showDialog.value = false
}
// 格式化文件大小
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]
}
const triggerFileUpload = () => {
const fileInput = document.createElement('input')
fileInput.type = 'file'
fileInput.accept = 'audio/*'
fileInput.style.display = 'none'
fileInput.addEventListener('change', handleFileSelect)
document.body.appendChild(fileInput)
fileInput.click()
document.body.removeChild(fileInput)
}
const downloadRecording = (index) => {
const recording = recordings.value[index]
if (recording && recording.url) {
const link = document.createElement('a')
link.href = recording.url
link.download = recording.name
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
}
}
</script> </script>
<style scoped> <style scoped>
@@ -484,7 +511,7 @@ export default {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: 20px 20px 0; padding: 10px 20px 0;
border-bottom: 1px solid #ebeef5; border-bottom: 1px solid #ebeef5;
} }
@@ -521,20 +548,19 @@ export default {
} }
.chart-content { .chart-content {
padding: 20px; padding: 10px;
} }
.recording-section { .recording-section {
width: 100%; width: 100%;
min-height: 300px; min-height: 200px;
max-height: 500px; max-height: 300px;
overflow-y: auto; overflow-y: auto;
} }
.recording-list { .recording-list {
margin-bottom: 20px; margin-bottom: 20px;
max-height: 400px; max-height: 400px;
overflow-y: auto;
} }
.recording-item { .recording-item {
@@ -574,6 +600,39 @@ export default {
display: inline-block; display: inline-block;
} }
.recording-index {
/* 基础分数样式 */
padding: 2px 8px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
background-color: #e9ecef;
color: #495057;
margin-right: 10px;
}
/* 第一名样式 */
.recording-item:first-child .recording-index {
background: linear-gradient(135deg, #FFD700, #FFA500);
color: #fff;
box-shadow: 0 2px 4px rgba(255, 215, 0, 0.3);
}
/* 第二名样式 */
.recording-item:nth-child(2) .recording-index {
background: linear-gradient(135deg, #C0C0C0, #A9A9A9);
color: #fff;
box-shadow: 0 2px 4px rgba(192, 192, 192, 0.3);
}
/* 第三名样式 */
.recording-item:nth-child(3) .recording-index {
background: linear-gradient(135deg, #CD7F32, #A0522D);
color: #fff;
box-shadow: 0 2px 4px rgba(205, 127, 50, 0.3);
}
.recording-meta { .recording-meta {
display: flex; display: flex;
gap: 12px; gap: 12px;

View File

@@ -3,7 +3,7 @@
<div class="card-header"> <div class="card-header">
<h3>团队业绩排行榜</h3> <h3>团队业绩排行榜</h3>
<select v-model="rankingPeriod" class="periods-select" @change="onPeriodChange"> <select v-model="rankingPeriod" class="periods-select" @change="onPeriodChange">
<option value="periods">本期</option> <!-- <option value="periods">本期</option> -->
<option value="month">月度</option> <option value="month">月度</option>
<option value="year">年度</option> <option value="year">年度</option>
</select> </select>
@@ -39,7 +39,7 @@ const props = defineProps({
const emit = defineEmits(['periods-change']); const emit = defineEmits(['periods-change']);
const rankingPeriod = ref('periods'); const rankingPeriod = ref('month');
const centerSalesRank = ref({}); const centerSalesRank = ref({});
// 计算属性:转换 centerSalesRank 数据格式 // 计算属性:转换 centerSalesRank 数据格式
@@ -82,77 +82,6 @@ async function getCenterSalesRank(data) {
const res = await getCenterPerformanceRank(params); const res = await getCenterPerformanceRank(params);
console.log('获取中心业绩排行榜:', res); console.log('获取中心业绩排行榜:', res);
centerSalesRank.value = res.data; centerSalesRank.value = res.data;
/**
* 0
:
{center_leader: "潘加俊", total_deals: 0, average_deals_per_member: 0}
average_deals_per_member
:
0
center_leader
:
"潘加俊"
total_deals
:
0
1
:
{center_leader: "张三丰", total_deals: 44, average_deals_per_member: 1}
average_deals_per_member
:
1
center_leader
:
"张三丰"
total_deals
:
44
2
:
{center_leader: "朱一航", total_deals: 0, average_deals_per_member: 0}
average_deals_per_member
:
0
center_leader
:
"朱一航"
total_deals
:
0
3
:
{center_leader: "程琦", total_deals: 0, average_deals_per_member: 0}
average_deals_per_member
:
0
center_leader
:
"程琦"
total_deals
:
0
4
:
{center_leader: "王卓琳", total_deals: 6, average_deals_per_member: 0}
average_deals_per_member
:
0
center_leader
:
"王卓琳"
total_deals
:
6
5
:
{center_leader: "伍晶晶", total_deals: 5, average_deals_per_member: 0}
average_deals_per_member
:
0
center_leader
:
"伍晶晶"
*/
} catch (error) { } catch (error) {
console.error('获取全中心业绩排行榜失败:', error); console.error('获取全中心业绩排行榜失败:', error);
} }

View File

@@ -4,33 +4,41 @@
<div class="dashboard-header"> <div class="dashboard-header">
<h1>管理者数据看板</h1> <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>
<!-- 第一行核心业绩指标销售实时进度下发任务 --> <!-- 第一行核心业绩指标销售实时进度 -->
<div class="dashboard-row row-1"> <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" /> <sales-progress v-if="cardVisibility.salesProgress" :sales-data="realTimeProgress" />
<!-- 下发任务 --> <!-- 各中心营期阶段 -->
<task-list <period-stage v-if="cardVisibility.periodStage" />
:tasks="tasks"
:format-date="formatDate"
:get-task-status-text="getTaskStatusText"
@show-task-modal="showTaskModal = true"
/>
</div> </div>
<!-- 第二行 --> <!-- 第二行 -->
<div class="dashboard-row row-3"> <div class="dashboard-row row-3">
<!-- 转化漏斗 --> <!-- 转化漏斗 -->
<funnel-chart <funnel-chart
v-if="cardVisibility.funnelChart"
:funnel-data="formattedFunnelData" :funnel-data="formattedFunnelData"
:comparison-data="formattedComparisonData" :comparison-data="formattedComparisonData"
@time-range-change="handleTimeRangeChange" @time-range-change="handleTimeRangeChange"
/> />
<!-- 销售个人业绩排行榜 --> <!-- 销售个人业绩排行榜 -->
<personal-sales-ranking <personal-sales-ranking
v-if="cardVisibility.personalSalesRanking"
:ranking-data="formattedSalesRankingData" :ranking-data="formattedSalesRankingData"
:format-number="formatNumber" :format-number="formatNumber"
:get-rank-class="getRankClass" :get-rank-class="getRankClass"
@@ -39,7 +47,8 @@
/> />
<!-- 优质通话 --> <!-- 优质通话 -->
<quality-calls <quality-calls
:quality-calls="qualityCalls" v-if="cardVisibility.qualityCalls"
:quality-calls="excellentRecord"
@play-call="playCall" @play-call="playCall"
@download-call="downloadCall" @download-call="downloadCall"
/> />
@@ -48,80 +57,30 @@
<div class="dashboard-row row-3"> <div class="dashboard-row row-3">
<!-- 业绩排行榜 --> <!-- 业绩排行榜 -->
<ranking-list <ranking-list
v-if="cardVisibility.rankingList"
:format-number="formatNumber" :format-number="formatNumber"
:get-rank-class="getRankClass" :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>
<!-- 第四行详细数据表格和数据详情 --> <!-- 第四行详细数据表格和数据详情 -->
<div class="dashboard-row" v-show="false"> <div class="dashboard-row" v-show="false">
<CampManagement /> <CampManagement v-if="cardVisibility.campManagement" />
</div> </div>
<!-- 第五行 --> <!-- 第五行 -->
<div class="dashboard-row" > <div class="dashboard-row" >
<DetailedDataTable <DetailedDataTable
v-if="cardVisibility.detailedDataTable"
:table-data="detailData" :table-data="detailData"
:level-tree="levelTree" :level-tree="levelTree"
v-model:selected-person="selectedPerson" v-model:selected-person="selectedPerson"
@filter-change="handleFilterChange" @filter-change="handleFilterChange"
/> />
</div> </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 assigneeOptions"
:key="employee.wechat_id"
:value="employee.wechat_id"
>
{{ 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> </div>
</template> </template>
@@ -144,7 +103,6 @@ import axios from "axios";
import UserDropdown from "@/components/UserDropdown.vue"; import UserDropdown from "@/components/UserDropdown.vue";
import KpiMetrics from "./components/KpiMetrics.vue"; import KpiMetrics from "./components/KpiMetrics.vue";
import SalesProgress from "./components/SalesProgress.vue"; import SalesProgress from "./components/SalesProgress.vue";
import TaskList from "./components/TaskList.vue";
import FunnelChart from "./components/FunnelChart.vue"; import FunnelChart from "./components/FunnelChart.vue";
import CustomerProfile from "./components/CustomerProfile.vue"; import CustomerProfile from "./components/CustomerProfile.vue";
import CustomerType from "./components/CustomerType.vue"; import CustomerType from "./components/CustomerType.vue";
@@ -157,8 +115,105 @@ import QualityCalls from "./components/QualityCalls.vue";
import DataDetail from "./components/DataDetail.vue"; import DataDetail from "./components/DataDetail.vue";
import CampManagement from "./components/CampManagement.vue"; import CampManagement from "./components/CampManagement.vue";
import DetailedDataTable from "./components/DetailedDataTable.vue"; import DetailedDataTable from "./components/DetailedDataTable.vue";
import PeriodStage from "./components/PeriodStage.vue";
import { getOverallCompanyPerformance,getCompanyDepositConversionRate,getCompanyTotalCallCount,getCompanyNewCustomer,getCompanyConversionRate,getCompanyRealTimeProgress import { getOverallCompanyPerformance,getCompanyDepositConversionRate,getCompanyTotalCallCount,getCompanyNewCustomer,getCompanyConversionRate,getCompanyRealTimeProgress
,getCompanyConversionRateVsLast,getSalesMonthlyPerformance,getCustomerTypeDistribution,getUrgentNeedToAddress,getLevelTree,getDetailedDataTable,assignTasks } 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 rankingPeriod = ref("month");
const rankingData = ref([ const rankingData = ref([
@@ -173,131 +228,40 @@ const sortField = ref("dealRate");
const sortOrder = ref("desc"); const sortOrder = ref("desc");
const selectedPerson = ref(null); const selectedPerson = ref(null);
const tasks = ref([]); const userStore = useUserStore();
const employees = ref([ // 卡片显示状态管理
{ id: 1, name: "张三" } const cardVisibility = ref({
]); kpiMetrics: true,
salesProgress: true,
const showTaskModal = ref(false); periodStage: true,
const newTask = reactive({ funnelChart: true,
title: "", personalSalesRanking: true,
assignee: "", qualityCalls: true,
deadline: "", rankingList: true,
description: "", customerType: true,
problemRanking: true,
campManagement: true,
detailedDataTable: true
}); });
// 获取任务列表
const getTaskList = async () => {
try {
const res = await axios.post('http://192.168.15.60:8890/api/v1/level_five/overview/view_tasks', {}, {
headers: {
'Authorization': 'Bearer ' + localStorage.getItem('token')
}
})
console.log(888888,res)
if (res.data.code === 200) {
const apiTasks = res.data.data.tasks || res.data.data
// 将API数据格式转换为TaskList组件期望的格式
tasks.value = apiTasks.map(task => ({
id: task.task_id,
title: task.task_title,
assignee: task.assignee || '未分配',
deadline: task.expiration_date,
status: task.state === '待处理' ? 'pending' : task.state === '正在处理' ? 'in-progress' : 'completed',
description: task.task_content,
created_at: task.created_at
}))
console.log(777777,tasks.value)
/**
* tasks
:
[,…]
0
:
{task_id: "1755748690560728_22d55cc618784537973481228a15956a", task_title: "55", task_content: "222",…}
created_at
:
"2025-08-21 11:58:10"
expiration_date
:
"20250808"
state
:
"待处理"
task_content
:
"222"
task_id
:
"1755748690560728_22d55cc618784537973481228a15956a"
task_title
:
"55"
1
:
{task_id: "1755745331126891_650206e5b6d345699de3e3e406a2600e", task_title: "测试任务",…}
created_at
:
"2025-08-21 11:02:11"
expiration_date
:
"121221"
state
:
"待处理"
task_content
:
"测试任务"
task_id
:
"1755745331126891_650206e5b6d345699de3e3e406a2600e"
task_title
:
"测试任务"
2
:
{task_id: "1755745330094989_528dd87dc13a4a5bb33c9c272fb1a482", task_title: "测试任务",…}
created_at
:
"2025-08-21 11:02:10"
expiration_date
:
"121221"
state
:
"已完成"
task_content
:
"测试任务"
task_id
:
"1755745330094989_528dd87dc13a4a5bb33c9c272fb1a482"
task_title
:
"测试任务"
*/
}
} catch (error) {
console.error('获取任务列表失败:', error)
}
}
// 下拉框人员
const assigneeOptions = ref([]);
async function name() {
try {
console.log('开始获取下属人员列表...');
const res = await axios.get('http://192.168.15.60:8890/api/v1/level_five/overview/get_subordinates',{
headers: {
'Authorization': 'Bearer ' + localStorage.getItem('token')
}
});
assigneeOptions.value = res.data.data;
console.log('assigneeOptions设置后:', assigneeOptions.value); // FeedbackForm 控制变量
} catch (error) { const showFeedbackForm = ref(false);
console.error('获取下属人员列表失败:', error);
} // FeedbackForm 控制方法
} const showFeedbackFormModal = () => {
showFeedbackForm.value = true;
};
const closeFeedbackFormModal = () => {
showFeedbackForm.value = false;
};
// 更新卡片显示状态
const updateCardVisibility = (newVisibility) => {
Object.assign(cardVisibility.value, newVisibility);
};
// 计算属性 // 计算属性
const filteredTableData = computed(() => { const filteredTableData = computed(() => {
let filtered = tableData.value; let filtered = tableData.value;
@@ -328,9 +292,9 @@ const filteredTableData = computed(() => {
}); });
// 方法 // 方法
const refreshData = () => { const refreshData = async () => {
// 刷新数据逻辑 // 强制刷新所有数据
console.log("刷新数据"); await forceRefreshAllData();
}; };
// 处理时间范围变化 // 处理时间范围变化
@@ -418,14 +382,7 @@ const selectPerson = (person) => {
selectedPerson.value = person; selectedPerson.value = person;
}; };
const getTaskStatusText = (status) => {
const statusMap = {
pending: "待处理",
"in-progress": "进行中",
completed: "已完成",
};
return statusMap[status] || status;
};
const playCall = (callId) => { const playCall = (callId) => {
console.log("播放通话录音:", callId); console.log("播放通话录音:", callId);
@@ -435,69 +392,39 @@ const downloadCall = (callId) => {
console.log("下载通话录音:", callId); console.log("下载通话录音:", callId);
}; };
const createTask = async () => {
if (!newTask.title || !newTask.assignee || !newTask.deadline) {
alert("请填写完整信息");
return;
}
try {
// 构造API请求参数
const params = {
task_title: newTask.title,
task_assignee: [newTask.assignee], // 转换为数组格式
expiration_date: newTask.deadline.replace(/-/g, ''), // 移除日期中的横线
task_content: newTask.description || newTask.title
};
// 调用API
const response = await assignTasks(params);
console.log('任务创建成功:', response);
// 创建本地任务对象用于显示
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;
alert('任务创建成功!');
} catch (error) {
console.error('创建任务失败:', error);
alert('创建任务失败,请重试');
}
};
// 核心数据 // 核心数据
const totalDeals = ref({}); const totalDeals = ref({});
// 核心数据--总成交金额 // 核心数据--总成交金额
async function getTotalDeals() { async function getTotalDeals() {
try { try {
const cacheKey = getCacheKey('getTotalDeals');
const cachedResult = getCache(cacheKey);
if (cachedResult) {
console.log('使用缓存数据: getTotalDeals');
totalDeals.value = cachedResult;
return;
}
const res1 = await getOverallCompanyPerformance() const res1 = await getOverallCompanyPerformance()
const res2=await getCompanyDepositConversionRate() const res2=await getCompanyDepositConversionRate()
const res3=await getCompanyTotalCallCount() const res3=await getCompanyTotalCallCount()
const res4=await getCompanyNewCustomer() const res4=await getCompanyNewCustomer()
const res5=await getCompanyConversionRate() const res5=await getCompanyConversionRate()
totalDeals.value={
const result = {
totalDeal:res1.data, //总成交单数 totalDeal:res1.data, //总成交单数
DingconversionRate:res2.data, //定金转化率 DingconversionRate:res2.data, //定金转化率
totalCallCount:res3.data, // 总通话 totalCallCount:res3.data, // 总通话
newCustomer:res4.data, //新客户 newCustomer:res4.data, //新客户
conversionRate:res5.data,//转化率 conversionRate:res5.data,//转化率
} };
totalDeals.value = result;
setCache(cacheKey, result);
console.log('缓存新数据: getTotalDeals');
} catch (error) { } catch (error) {
console.error("获取总成交金额失败:", error); console.error("获取总成交金额失败:", error);
@@ -505,11 +432,17 @@ async function getTotalDeals() {
} }
// 实时进度 // 实时进度
const realTimeProgress = ref({}); const realTimeProgress = ref({});
async function getRealTimeProgress() { async function getRealTimeProgress() {
try { try {
const res = await getCompanyRealTimeProgress() const cacheKey = getCacheKey('getRealTimeProgress');
// console.log(111111,res) const result = await withCache(cacheKey, async () => {
realTimeProgress.value = res.data const res = await getCompanyRealTimeProgress();
return res.data;
});
realTimeProgress.value = result;
} catch (error) { } catch (error) {
console.error("获取实时进度失败:", error); console.error("获取实时进度失败:", error);
} }
@@ -587,9 +520,12 @@ async function getConversionComparison(data) {
check_type:data //month periods check_type:data //month periods
} }
try { try {
const res = await getCompanyConversionRateVsLast(params) const cacheKey = getCacheKey('getConversionComparison', params);
console.log(111111,res) const result = await withCache(cacheKey, async () => {
conversionComparison.value = res.data const res = await getCompanyConversionRateVsLast(params);
return res.data;
});
conversionComparison.value = result;
} catch (error) { } catch (error) {
console.error("获取转化对比失败:", error); console.error("获取转化对比失败:", error);
} }
@@ -638,8 +574,12 @@ async function getCompanySalesRank(Rank) {
rank_type:Rank, rank_type:Rank,
} }
try { try {
const res = await getSalesMonthlyPerformance(params) const cacheKey = getCacheKey('getCompanySalesRank', params);
companySalesRank.value = res.data const result = await withCache(cacheKey, async () => {
const res = await getSalesMonthlyPerformance(params);
return res.data;
});
companySalesRank.value = result;
} catch (error) { } catch (error) {
console.error("获取销售月度业绩红黑榜失败:", error); console.error("获取销售月度业绩红黑榜失败:", error);
} }
@@ -652,8 +592,12 @@ async function getCustomerTypeRatio(data) {
distribution_type:data // child_education territory occupation distribution_type:data // child_education territory occupation
} }
try { try {
const res = await getCustomerTypeDistribution(params) const cacheKey = getCacheKey('getCustomerTypeRatio', params);
customerTypeRatio.value = res.data const result = await withCache(cacheKey, async () => {
const res = await getCustomerTypeDistribution(params);
return res.data;
});
customerTypeRatio.value = result;
} catch (error) { } catch (error) {
console.error("获取客户类型占比失败:", error); console.error("获取客户类型占比失败:", error);
} }
@@ -664,12 +608,17 @@ const problemRankingData = ref([]);
async function getCustomerUrgency() { async function getCustomerUrgency() {
try { try {
const res = await getUrgentNeedToAddress() const cacheKey = getCacheKey('getCustomerUrgency');
customerUrgency.value = res.data const result = await withCache(cacheKey, async () => {
const res = await getUrgentNeedToAddress();
return res.data;
});
customerUrgency.value = result;
// 将API返回的数据转换为ProblemRanking组件需要的格式 // 将API返回的数据转换为ProblemRanking组件需要的格式
if (res.data && res.data.company_urgent_issue_ratio) { if (result && result.company_urgent_issue_ratio) {
problemRankingData.value = Object.entries(res.data.company_urgent_issue_ratio).map(([name, value]) => ({ problemRankingData.value = Object.entries(result.company_urgent_issue_ratio).map(([name, value]) => ({
name, name,
value value
})); }));
@@ -682,8 +631,12 @@ async function getCustomerUrgency() {
const levelTree = ref({}); const levelTree = ref({});
async function CusotomGetLevelTree() { async function CusotomGetLevelTree() {
try { try {
const res = await getLevelTree() const cacheKey = getCacheKey('CusotomGetLevelTree');
levelTree.value = res.data const result = await withCache(cacheKey, async () => {
const res = await getLevelTree();
return res.data;
});
levelTree.value = result;
} catch (error) { } catch (error) {
console.error("获取级别树失败:", error); console.error("获取级别树失败:", error);
} }
@@ -691,22 +644,18 @@ async function CusotomGetLevelTree() {
// 获取详细数据表格 // 获取详细数据表格
const detailData = ref({}); const detailData = ref({});
async function getDetailData(params) { async function getDetailData(params) {
if(params?.center_leader){
try { try {
const res = await getDetailedDataTable(params) const cacheKey = getCacheKey('getDetailData', params || {});
detailData.value = res.data const result = await withCache(cacheKey, async () => {
const res = params?.center_leader
? await getDetailedDataTable(params)
: await getDetailedDataTable();
return res.data;
});
detailData.value = result;
} catch (error) { } catch (error) {
console.error("获取详细数据表格失败:", error); console.error("获取详细数据表格失败:", error);
} }
}else{
try {
const res = await getDetailedDataTable()
detailData.value = res.data
} catch (error) {
console.error("获取详细数据表格失败:", error);
}
}
} }
// 处理筛选器变化 // 处理筛选器变化
@@ -714,19 +663,53 @@ const handleFilterChange = (filterParams) => {
console.log('筛选器变化:', filterParams) console.log('筛选器变化:', filterParams)
getDetailData(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() => { onMounted(async() => {
// 页面初始化逻辑 // 页面初始化逻辑
await getRealTimeProgress() console.log('页面初始化,开始加载数据...');
await getTotalDeals()
await getTaskList() getRealTimeProgress()
await getConversionComparison('month') getTotalDeals()
await getCompanySalesRank('red') getConversionComparison('month')
await getCustomerTypeRatio('child_education') getCompanySalesRank('red')
await getCustomerUrgency() getCustomerTypeRatio('child_education')
await CusotomGetLevelTree() getCustomerUrgency()
await getDetailData() CusotomGetLevelTree()
await name() // 获取下属人员列表 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> </script>
@@ -2200,4 +2183,20 @@ button {
-ms-user-select: text; -ms-user-select: text;
user-select: text; user-select: text;
} }
/* 意见反馈按钮样式 */
.feedback-btn {
background-color: #4299e1;
color: white;
border: none;
border-radius: 6px;
padding: 0.5rem 1rem;
font-size: 0.9rem;
cursor: pointer;
transition: background-color 0.2s;
}
.feedback-btn:hover {
background-color: #3182ce;
}
</style> </style>