Compare commits

..

41 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
34 changed files with 6076 additions and 2792 deletions

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

View File

@@ -7,7 +7,7 @@ export const getProblemDistribution = (params) => {
// 今日通话 /api/v1/more_level_screening/today_call
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
@@ -84,5 +84,5 @@ export const getCallSuccessRate = (params) => {
// 二阶分析报告
export const getSecondOrderAnalysisReport = (params) => {
return https.post('/api/v1/sales/get_call_text', params)
return https.post('/api/v1/sales/get_second_analysis_report', params)
}

View File

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

View File

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

View File

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

View File

@@ -69,8 +69,8 @@ export const getDetailedDataTable = (params) => {
export const getPeriodStage = (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) => {
return https.post('/api/v1/common/get_excellent_record_file', params)
return https.post('/api/v1/level_five/overview/get_excellent_record_file', params)
}

View File

@@ -124,12 +124,12 @@ const handleSubmit = async () => {
const token = localStorage.getItem('token') || '';
// 发送 POST 请求到后端接口
const response = await axios.post('https://mldash.nycjy.cn/api/v1/submit_feedback', {type: formData.type, content: formData.content}, {
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);
// console.log('响应状态8888:', response.data.message);
// 提交成功
submitStatus.value = 'success';
// 触发父组件的事件,并传递数据

View File

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

View File

@@ -31,7 +31,6 @@ service.interceptors.request.use(
}
// 显示加载状态
if (config.showLoading !== false) {
console.log('显示加载中...')
}
return config
},

File diff suppressed because it is too large Load Diff

View File

@@ -48,15 +48,43 @@
</div>
</div>
<!-- 新增通话分类统计模块 -->
<div class="call-analysis-section">
<div class="analysis-header" @click="toggleCallAnalysisCollapse">
<h3>📊 通话分类统计</h3>
<div class="collapse-toggle" :class="{ 'collapsed': isCallAnalysisCollapsed }">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M8 4l4 4H4l4-4z"/>
</svg>
</div>
</div>
<div class="analysis-content" v-show="!isCallAnalysisCollapsed" :class="{ 'collapsing': isCallAnalysisCollapsed }">
<div v-if="processedCallStats.length > 0" class="analysis-list">
<div v-for="stat in processedCallStats" :key="stat.tag" class="analysis-item">
<div class="analysis-label">
<span class="tag-name">{{ stat.tag }}</span>
<span class="call-count">{{ stat.count }} </span>
</div>
<div class="progress-bar-container">
<div class="progress-bar" :style="{ width: stat.percentage + '%' }"></div>
</div>
<div class="analysis-stats">
<span class="percentage">{{ stat.percentage.toFixed(1) }}%</span>
<span class="avg-time">均长: {{ stat.avgTime.toFixed(1) }} 分钟</span>
</div>
</div>
</div>
<div v-else class="no-data">
<p>暂无通话分类数据</p>
</div>
</div>
</div>
<!-- 新增模块结束 -->
<!-- 指导建议 -->
<div class="guidance-section">
<div class="guidance-header" @click="toggleGuidanceCollapse">
<h3>💡 指导建议</h3>
<div class="period-switcher">
<button @click="switchAnalysisPeriod('day')" :class="{ active: analysisPeriod === 'day' }">当日</button>
<button @click="switchAnalysisPeriod('camp')" :class="{ active: analysisPeriod === 'camp' }">当期</button>
<button @click="switchAnalysisPeriod('month')" :class="{ active: analysisPeriod === 'month' }">当月</button>
</div>
<div class="collapse-toggle" :class="{ 'collapsed': isGuidanceCollapsed }">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M8 4l4 4H4l4-4z"/>
@@ -66,69 +94,20 @@
<div class="guidance-content" v-show="!isGuidanceCollapsed" :class="{ 'collapsing': isGuidanceCollapsed }">
<!-- 分析报告内容 -->
<div class="analysis-report">
<div v-if="isReportLoading" class="loading">正在生成分析报告...</div>
<div v-if="isReportLoading" class="loading-message">正在生成分析报告...</div>
<div v-else-if="!analysisReport || (Array.isArray(analysisReport) && analysisReport.length === 0)" class="empty-message">
暂无分析报告数据
</div>
<div v-else-if="Array.isArray(analysisReport)">
<div v-for="(report, index) in analysisReport" :key="index" class="report-section">
<h4 class="report-title">{{ report.name }} ({{ report.start_time }} - {{ report.end_time }})</h4>
<div class="report-content" v-html="report.report.replace(/\n/g, '<br>')"></div>
</div>
</div>
<div v-else class="report-content">{{ analysisReport }}</div>
</div>
<!-- 原有指导建议内容 -->
<div class="guidance-cards">
<div class="guidance-card" v-if="getGuidanceForMember(selectedMember).length > 0">
<div class="guidance-item" v-for="(guidance, index) in getGuidanceForMember(selectedMember)" :key="index">
<div class="guidance-icon" :class="guidance.type">
{{ guidance.icon }}
</div>
<div class="guidance-content">
<h4 class="guidance-title">{{ guidance.title }}</h4>
<p class="guidance-description">{{ guidance.description }}</p>
<div class="guidance-action" v-if="guidance.action">
<span class="action-label">建议行动:</span>
<span class="action-text">{{ guidance.action }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 录音列表 -->
<!-- <div class="recordings-section" >
<div class="recordings-header" @click="toggleRecordingsCollapse">
<h3>🎧 通话录音</h3>
<div class="collapse-toggle" :class="{ 'collapsed': isRecordingsCollapsed }">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M8 4l4 4H4l4-4z"/>
</svg>
</div>
</div>
<div class="recordings-content" v-show="!isRecordingsCollapsed" :class="{ 'collapsing': isRecordingsCollapsed }">
<div class="recordings-list" v-if="getRecordingsForMember(selectedMember).length > 0">
<div class="recording-item" v-for="(recording, index) in getRecordingsForMember(selectedMember)" :key="index">
<div class="recording-info">
<div class="recording-title">{{ recording.title }}</div>
<div class="recording-meta">
<span class="recording-date">{{ recording.date }}</span>
<span class="recording-duration">{{ recording.duration }}</span>
<span class="recording-type" :class="recording.type">{{ recording.typeLabel }}</span>
</div>
</div>
<button class="download-btn" @click="downloadRecording(recording)">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M8 12l-4-4h3V4h2v4h3l-4 4z"/>
<path d="M2 14h12v1H2z"/>
</svg>
下载
</button>
</div>
</div>
<div class="no-recordings" v-else>
<div class="no-data-icon">📞</div>
<h4>暂无录音</h4>
<p>{{ selectedMember?.user_name || selectedMember?.name || '张三' }} 还没有通话录音记录</p>
</div>
</div>
</div> -->
<!-- Tooltip 组件 -->
<Tooltip
:visible="tooltip.visible"
@@ -141,9 +120,9 @@
</template>
<script setup>
import { ref, defineProps, watch, nextTick, reactive, onMounted } from 'vue'
import { ref, defineProps, watch, nextTick, reactive, computed } from 'vue'
import Tooltip from '@/components/Tooltip.vue'
import { GetSecondOrderAnalysisReport } from '@/api/manager.js'
import { GetSecondOrderAnalysisReport, getMemberCallClassify } from '@/api/manager.js'
import { SimpleChatService } from '@/utils/ChatService.js'
// 定义props
@@ -164,8 +143,14 @@ const isDetailsCollapsed = ref(false)
// 指导建议折叠状态(默认展开)
const isGuidanceCollapsed = ref(false)
// 录音列表折叠状态(默认展开)
const isRecordingsCollapsed = ref(true)
// 【新增】通话分类统计折叠状态
const isCallAnalysisCollapsed = ref(false)
// 【修改】存放通话分类API数据的内部状态
const callClassificationData = ref({
call_count_by_tag: {},
call_avg_time: {}
});
// 分析周期
const analysisPeriod = ref('day') // 默认周期为当日
@@ -184,26 +169,11 @@ const tooltip = reactive({
// 指标描述
const metricDescriptions = {
totalCalls: {
title: '通话次数计算方式',
description: '统计在选定时间范围内的所有外呼和接听通话的总次数,包括有效通话和无效通话。'
},
callTime: {
title: '通话时长计算方式',
description: '累计所有有效通话的时长,以小时为单位显示,精确到小数点后一位。'
},
newClients: {
title: '新增客户计算方式',
description: '在选定时间范围内新建档的客户数量,不包括重复录入的客户。'
},
deals: {
title: '成交单数计算方式',
description: '在选定时间范围内成功签约的订单数量,包括全款和定金订单。'
},
conversionRate: {
title: '转化率计算方式',
description: '成交单数 ÷ 新增客户数 × 100%'
}
totalCalls: { title: '总通话次数计算方式', description: '统计在选定时间范围内的所有外呼和接听通话的总次数,包括有效通话和无效通话。' },
callTime: { title: '通话时长计算方式', description: '累计所有有效通话的时长,以小时为单位显示,精确到小数点后一位。' },
newClients: { title: '新增客户计算方式', description: '在选定时间范围内新建档的客户数量,不包括重复录入的客户。' },
deals: { title: '成交单数计算方式', description: '在选定时间范围内成功签约的订单数量,包括全款和定金订单。' },
conversionRate: { title: '转化率计算方式', description: '成交单数 ÷ 新增客户数 × 100%' }
}
// 切换详细数据折叠状态
@@ -211,74 +181,39 @@ const toggleDetailsCollapse = () => {
isDetailsCollapsed.value = !isDetailsCollapsed.value
}
// 切换分析周期
const switchAnalysisPeriod = (period) => {
analysisPeriod.value = period
// 切换周期后重新获取分析报告
CenterGetSecondOrderAnalysisReport(analysisPeriod.value )
// 【新增】切换通话分析折叠状态
const toggleCallAnalysisCollapse = () => {
isCallAnalysisCollapsed.value = !isCallAnalysisCollapsed.value
}
// 获取二阶分析报告
async function CenterGetSecondOrderAnalysisReport(time) {
// 如果聊天服务实例不存在,则创建一个新的实例
if (!chatService_02.value) {
// 注意这里使用与PersonalDashboard.vue相同的API密钥
chatService_02.value = new SimpleChatService('app-MGaBOx5QFblsMZ7dSkxKJDKm')
// 【修改】处理通话统计数据的 computed 属性,现在它依赖于内部状态
const processedCallStats = computed(() => {
const counts = callClassificationData.value?.call_count_by_tag;
const avgs = callClassificationData.value?.call_avg_time;
if (!counts || !avgs || Object.keys(counts).length === 0) {
return [];
}
// 设置加载状态
isReportLoading.value = true
analysisReport.value = ''
try {
// 准备请求参数
const params = {
user_name:props.memberDetails?.user_name,
time: time // 使用当前选择的周期
}
// 调用API获取分析报告数据
const response = await GetSecondOrderAnalysisReport(params)
// 检查数据是否为空
if (!response.data.records || response.data.records.length === 0) {
console.error('数据为空')
analysisReport.value = '数据为空'
isReportLoading.value = false
return
const totalCalls = Object.values(counts).reduce((sum, count) => sum + count, 0);
if (totalCalls === 0) {
return [];
}
// 将记录数组转换为字符串
const records = response.data.records.join('\n')
// 使用聊天服务发送消息并处理流式响应
chatService_02.value.sendMessage(
records, // 使用API返回的文本数据
(update) => {
// 实时更新报告内容
analysisReport.value = update.content
},
() => {
// 流结束时的操作
isReportLoading.value = false
}
)
} catch (error) {
console.error('获取二阶分析报告失败:', error)
analysisReport.value = '获取分析报告失败,请稍后重试。'
isReportLoading.value = false
}
}
return Object.keys(counts).map(tag => ({
tag: tag,
count: counts[tag],
avgTime: avgs[tag] || 0,
percentage: (counts[tag] / totalCalls) * 100,
})).sort((a, b) => b.count - a.count); // 按通话次数降序排列
});
// 切换指导建议折叠状态
const toggleGuidanceCollapse = () => {
isGuidanceCollapsed.value = !isGuidanceCollapsed.value
}
// 切换录音列表折叠状态
const toggleRecordingsCollapse = () => {
isRecordingsCollapsed.value = !isRecordingsCollapsed.value
}
// 显示工具提示
const showTooltip = (event, metricType) => {
const metric = metricDescriptions[metricType]
@@ -296,137 +231,65 @@ const hideTooltip = () => {
tooltip.visible = false
}
// 格式化金额
const formatAmount = (amount) => {
if (typeof amount === 'number') {
return amount.toLocaleString()
// 【修改】函数现在从API获取数据并更新内部状态
async function updateCallClassificationData() {
if (props.selectedMember && props.selectedMember.user_name) {
try {
const response = await getMemberCallClassify({
user_name: props.selectedMember.user_name
});
console.log('获取通话分类数据:', response.data);
// 将获取到的数据赋值给内部状态
callClassificationData.value = {
call_count_by_tag: response.call_count_by_tag || {},
call_avg_time: response.call_avg_time || {}
};
} catch (error) {
console.error('获取通话分类失败:', error);
// 出错时清空数据
callClassificationData.value = { call_count_by_tag: {}, call_avg_time: {} };
}
} else {
// 如果没有选中的成员,则清空数据
callClassificationData.value = { call_count_by_tag: {}, call_avg_time: {} };
}
return amount || '0'
}
// 获取成员录音列表
const getRecordingsForMember = (member) => {
// 模拟录音数据实际项目中应该从API获取
const recordings = []
// 根据成员ID返回对应的录音这里简化处理
return recordings.slice(0, Math.min(3, member?.call_count || 0))
}
// 下载录音文件
const downloadRecording = (recording) => {
// 创建下载链接
const link = document.createElement('a')
link.href = recording.fileUrl
link.download = `${recording.title}_${recording.date.replace(/[:\s]/g, '_')}.m4a`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
// 实际项目中可能需要调用API来获取下载链接
console.log('下载录音:', recording.title)
}
// 获取成员指导建议
const getGuidanceForMember = (member) => {
const guidance = []
// 检查member是否存在
if (!member) {
return guidance
// 获取二阶分析报告数据
async function fetchAnalysisReport() {
if (props.selectedMember && props.selectedMember.user_name) {
isReportLoading.value = true;
try {
const response = await GetSecondOrderAnalysisReport({
user_name: props.selectedMember.user_name
});
console.log('获取分析报告数据:', response.data);
// 将获取到的数据赋值给分析报告变量
analysisReport.value = response.data;
} catch (error) {
console.error('获取分析报告失败:', error);
analysisReport.value = '';
} finally {
isReportLoading.value = false;
}
// 业绩相关建议
if (member.month_order_count === 0) {
guidance.push({
type: 'urgent',
icon: '🚨',
title: '业绩突破',
description: '当前还未有成交记录,需要重点关注转化技巧和客户跟进。',
action: '建议参加销售技巧培训,加强客户需求挖掘'
})
} else if (member.month_order_count < 5) {
guidance.push({
type: 'warning',
icon: '📈',
title: '业绩提升',
description: '业绩有提升空间,可以通过优化沟通策略来提高转化率。',
action: '分析高业绩同事的沟通技巧,制定个人提升计划'
})
}
// 转化率相关建议
if (member.conversion < 3.0) {
guidance.push({
type: 'urgent',
icon: '🎯',
title: '转化率优化',
description: '转化率偏低,需要提升客户沟通和需求挖掘能力。',
action: '重点学习客户心理分析和异议处理技巧'
})
} else if (member.conversion < 6.0) {
guidance.push({
type: 'info',
icon: '💬',
title: '沟通技巧',
description: '转化率还有提升空间,建议优化沟通话术和客户关系维护。',
action: '观摩优秀同事的通话录音,学习有效沟通技巧'
})
}
// 通话相关建议
if (member.call_count < 100) {
guidance.push({
type: 'warning',
icon: '📞',
title: '通话量提升',
description: '通话量偏少,增加客户接触频次有助于提升业绩。',
action: '制定每日通话计划,确保充足的客户接触量'
})
}
// 客户开发建议
if (member.add_customer_count < 5) {
guidance.push({
type: 'info',
icon: '👥',
title: '客户开发',
description: '新客户开发数量较少,可以拓展更多潜在客户渠道。',
action: '利用社交媒体和转介绍扩大客户来源'
})
}
// 平均单价建议
if (member.avgDealValue > 0 && member.avgDealValue < 25000) {
guidance.push({
type: 'success',
icon: '💰',
title: '客单价提升',
description: '可以尝试推荐更高价值的课程套餐,提升平均客单价。',
action: '学习产品组合销售技巧,挖掘客户更深层次需求'
})
}
return guidance.slice(0, 3) // 最多显示3个建议
}
// 监听selectedMember变化
watch(() => props.selectedMember, (newMember) => {
if (newMember) {
// 成员变化时,获取新的通话分类数据
updateCallClassificationData();
// 获取分析报告数据
fetchAnalysisReport();
// 重置滚动位置
nextTick(() => {
const container = document.querySelector('.member-details')
if (container) {
container.scrollTop = 0
}
if (container) container.scrollTop = 0;
})
}
})
}, { immediate: true, deep: true }) // 使用 immediate: true 和 deep: true
// 组件挂载时初始化分析报告
onMounted(() => {
// 初始化时获取当日的分析报告
CenterGetSecondOrderAnalysisReport()
})
</script>
<style lang="scss" scoped>
@@ -563,7 +426,7 @@ onMounted(() => {
}
}
.details-grid {
.details-grid, .analysis-content, .guidance-content { // 【修改】统一折叠动画
transition: all 0.3s ease;
&.collapsing {
@@ -572,12 +435,11 @@ onMounted(() => {
}
}
// 录音列表样式
.recordings-section {
/* 【新增】通话分类统计样式 */
.call-analysis-section {
margin-top: 1.5rem;
}
.recordings-header {
.analysis-header {
display: flex;
align-items: center;
justify-content: space-between;
@@ -603,141 +465,87 @@ onMounted(() => {
align-items: center;
gap: 0.5rem;
}
}
.recordings-content {
transition: all 0.3s ease;
&.collapsing {
opacity: 0;
transform: translateY(-10px);
}
}
.recordings-list {
.analysis-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.recording-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 8px;
transition: all 0.2s ease;
&:hover {
background: #f1f5f9;
border-color: #cbd5e1;
gap: 1rem;
}
}
.recording-info {
flex: 1;
}
.analysis-item {
display: grid;
grid-template-columns: 1fr auto;
grid-template-areas:
"label stats"
"progress progress";
gap: 0.25rem 1rem;
align-items: center;
}
.recording-title {
.analysis-label {
grid-area: label;
display: flex;
justify-content: space-between;
align-items: baseline;
font-size: 0.9rem;
color: #334155;
.tag-name {
font-weight: 500;
}
.call-count {
font-size: 0.8rem;
color: #64748b;
}
}
.progress-bar-container {
grid-area: progress;
width: 100%;
background-color: #e2e8f0;
border-radius: 4px;
height: 8px;
overflow: hidden;
}
.progress-bar {
height: 100%;
background-color: #3b82f6;
border-radius: 4px;
transition: width 0.3s ease-in-out;
}
.analysis-stats {
grid-area: stats;
display: flex;
align-items: baseline;
gap: 0.75rem;
font-size: 0.85rem;
.percentage {
font-weight: 600;
color: #1e293b;
margin-bottom: 0.5rem;
}
min-width: 45px;
text-align: right;
}
.recording-meta {
display: flex;
align-items: center;
gap: 1rem;
font-size: 0.8rem;
}
.recording-date {
.avg-time {
color: #64748b;
}
.recording-duration {
color: #475569;
font-weight: 500;
}
.recording-type {
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 500;
&.consultation {
background: #dbeafe;
color: #1e40af;
}
}
&.followup {
background: #fef3c7;
color: #92400e;
}
&.deal {
background: #d1fae5;
color: #065f46;
}
}
.download-btn {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: #3b82f6;
color: white;
border: none;
border-radius: 6px;
font-size: 0.85rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: #2563eb;
transform: translateY(-1px);
}
&:active {
transform: translateY(0);
}
svg {
flex-shrink: 0;
}
}
.no-recordings {
.no-data {
text-align: center;
padding: 2rem 1rem;
padding: 1.5rem;
background: #f8fafc;
border-radius: 8px;
border: 1px solid #e2e8f0;
.no-data-icon {
font-size: 2rem;
margin-bottom: 0.5rem;
}
h4 {
font-size: 1rem;
font-weight: 600;
color: #64748b;
margin: 0 0 0.5rem 0;
}
p {
font-size: 0.9rem;
color: #94a3b8;
margin: 0;
}
}
/* 【新增】样式结束 */
// 指导建议样式
.guidance-section {
@@ -781,14 +589,80 @@ onMounted(() => {
border: 1px solid #e2e8f0;
min-height: 100px;
.loading {
.loading-message {
text-align: center;
color: #64748b;
font-style: italic;
padding: 1rem;
}
.empty-message {
text-align: center;
color: #94a3b8;
padding: 2rem;
font-style: italic;
}
.report-section {
margin-bottom: 1.5rem;
padding-bottom: 1.5rem;
border-bottom: 1px dashed #e2e8f0;
&:last-child {
margin-bottom: 0;
padding-bottom: 0;
border-bottom: none;
}
.report-title {
font-size: 1rem;
font-weight: 600;
color: #1e293b;
margin: 0 0 0.75rem 0;
padding: 0.5rem 0.75rem;
background: #ffffff;
border-left: 3px solid #3b82f6;
border-radius: 2px;
}
.report-content {
white-space: pre-wrap;
line-height: 1.6;
color: #334155;
font-size: 0.9rem;
padding: 0 0.5rem;
h1, h2, h3 {
color: #1e293b;
margin: 1.25rem 0 0.75rem 0;
}
h3 {
font-size: 1.1rem;
border-bottom: 1px solid #e2e8f0;
padding-bottom: 0.25rem;
}
p {
margin: 0.5rem 0;
}
ul, ol {
margin: 0.75rem 0;
padding-left: 1.5rem;
}
li {
margin: 0.25rem 0;
}
strong {
font-weight: 600;
}
}
}
.report-content:not(.report-section .report-content) {
white-space: pre-wrap;
line-height: 1.5;
color: #1e293b;
@@ -922,32 +796,6 @@ onMounted(() => {
}
}
.no-guidance {
text-align: center;
padding: 2rem 1rem;
background: white;
border-radius: 8px;
border: 1px solid #e2e8f0;
.celebration-icon {
font-size: 2rem;
margin-bottom: 0.5rem;
}
h4 {
font-size: 1rem;
font-weight: 600;
color: #059669;
margin: 0 0 0.5rem 0;
}
p {
font-size: 0.9rem;
color: #64748b;
margin: 0;
}
}
// 移动端适配
@media (max-width: 768px) {
.member-details {
@@ -978,68 +826,32 @@ onMounted(() => {
}
}
// 录音列表适配
.recordings-section {
/* 【新增】通话分类统计响应式样式 */
.call-analysis-section {
margin-top: 1rem;
.recordings-header {
h3 {
.analysis-header h3 {
font-size: 1rem;
}
}
.recordings-list {
display: grid;
grid-template-columns: repeat(2, 1fr);
.analysis-item {
grid-template-areas:
"label label"
"progress stats";
gap: 0.5rem;
}
.recording-item {
flex-direction: column;
align-items: stretch;
gap: 0.5rem;
padding: 0.75rem;
.analysis-label {
.tag-name { font-size: 0.85rem; }
.call-count { font-size: 0.75rem; }
}
.recording-info {
.recording-title {
font-size: 0.8rem;
margin-bottom: 0.25rem;
line-height: 1.3;
}
.recording-meta {
flex-direction: column;
align-items: flex-start;
gap: 0.25rem;
.recording-date {
font-size: 0.7rem;
}
.recording-duration {
font-size: 0.7rem;
}
.recording-type {
align-self: flex-start;
font-size: 0.65rem;
padding: 0.2rem 0.4rem;
}
}
}
.download-btn {
align-self: center;
padding: 0.5rem 0.8rem;
.analysis-stats {
font-size: 0.75rem;
svg {
width: 12px;
height: 12px;
gap: 0.5rem;
justify-content: flex-end;
.percentage {
min-width: 40px;
}
}
}
/* 【新增】响应式样式结束 */
// 指导建议适配
.guidance-section {
@@ -1092,33 +904,4 @@ onMounted(() => {
}
}
}
@media (max-width: 480px) {
.member-details {
padding: 0.75rem;
border-radius: 8px;
}
.recording-item {
padding: 0.6rem;
.recording-meta {
gap: 0.25rem;
font-size: 0.75rem;
}
.download-btn {
padding: 0.5rem 1rem;
font-size: 0.75rem;
}
}
.guidance-item {
.guidance-icon {
width: 24px;
height: 24px;
font-size: 0.8rem;
}
}
}
</style>

View File

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

View File

@@ -37,9 +37,11 @@
<!-- Top Section - Team Alerts and Today's Report -->
<div class="top-section">
<!-- Team Alerts -->
<TeamAlerts :abnormalData="groupAbnormalResponse" />
<!-- <TeamAlerts :abnormalData="groupAbnormalResponse" /> -->
<GoodMusic :quality-calls="excellentRecord"
/>
<!-- Today's Team Report -->
<TeamReport :weekTotalData="weekTotalData" />
<TeamReport :weekTotalData="weekTotalData" @show-team-analysis="fetchTeamAnalysis" />
</div>
<!-- Sales Funnel Section -->
@@ -65,11 +67,30 @@
</div>
</main>
</div>
<!-- 团队分析弹窗 -->
<div v-if="showTeamAnalysisModal" class="modal-overlay" @click="showTeamAnalysisModal = false">
<div class="modal-content" @click.stop>
<div class="modal-header">
<h3>团队整体三阶分析报告</h3>
<button class="close-button" @click="showTeamAnalysisModal = false">×</button>
</div>
<div class="modal-body">
<div v-for="(report, index) in teamAnalysisData" :key="index" class="report-item">
<div class="report-meta">
<span class="time-range">{{ report.start_time }} {{ report.end_time }}</span>
<span class="created-at">生成时间: {{ report.created_at }}</span>
</div>
<div class="report-content" v-html="formatReportContent(report.report)"></div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, computed } from "vue";
import TeamAlerts from "./components/TeamAlerts.vue";
import GoodMusic from "./components/GoodMusic.vue";
import TeamReport from "./components/TeamReport.vue";
import SalesFunnel from "./components/SalesFunnel.vue";
import PerformanceRanking from "./components/PerformanceRanking.vue";
@@ -81,7 +102,7 @@ import CustomerDetail from "../person/components/CustomerDetail.vue";
import { useUserStore } from "@/stores/user";
import { useRouter } from "vue-router";
import {getGroupAbnormalResponse, getWeekTotalCall, getWeekAddCustomerTotal, getWeekAddDealTotal,
getWeekAddFeeTotal, getGroupFunnel,getPayDepositToMoneyRate,getGroupRanking, getGroupCallDuration,getGroupDetail} from "@/api/manager.js";
getWeekAddFeeTotal, getGroupFunnel,getPayDepositToMoneyRate,getGroupRanking, getGroupCallDuration,getGroupDetail, getGroupEntiretyThirdReport,getExcellentRecordFile} from "@/api/manager.js";
// 团队成员数据
const teamMembers = [
@@ -108,9 +129,10 @@ const userStore = useUserStore();
// 获取通用请求参数的函数
const getRequestParams = () => {
const params = {}
// 从路由参数获取
// 从路由参数获取
const routeUserLevel = router.currentRoute.value.query.user_level || router.currentRoute.value.params.user_level
const routeUserName = router.currentRoute.value.query.user_name || router.currentRoute.value.params.user_name
// 如果路由有参数,使用路由参数
if (routeUserLevel) {
params.user_level = routeUserLevel.toString()
@@ -119,6 +141,14 @@ const getRequestParams = () => {
params.user_name = routeUserName
}
// 如果没有路由参数,使用当前登录用户的信息
if (!params.user_level && userStore.userInfo?.user_level) {
params.user_level = userStore.userInfo.user_level.toString()
}
if (!params.user_name && userStore.userInfo?.username) {
params.user_name = userStore.userInfo.username
}
return params
}
@@ -146,7 +176,7 @@ const weekTotalData = ref({
pay_deposit_to_money_rate: {},
group_funnel: {},
group_call_duration: {},
});
})
// 团队异常预警
const groupAbnormalResponse = ref({})
async function TeamGetGroupAbnormalResponse() {
@@ -161,9 +191,9 @@ async function TeamGetGroupAbnormalResponse() {
let alertId = 1
// 处理严重超时异常人员
const timeoutPersons = rawData.erious_timeout_rate_abnorma || []
const timeoutPersons = rawData?.serious_timeout_rate_abnorma || []
// 处理表格填写异常人员
const fillingPersons = rawData.table_filling_abnormal || []
const fillingPersons = rawData?.table_filling_abnormal || []
// 为每个异常人员生成独立的预警消息
@@ -234,7 +264,48 @@ async function TeamGetWeekAddDealTotal() {
weekTotalData.value.week_add_deal_total = res.data
}
}
// 月度总业绩
// 优秀录音
// 获取优秀录音
const excellentRecord = ref([]);
// 获取优秀录音文件
async function CentergetGoodRecord() {
console.log('CentergetGoodRecord 开始执行')
try {
const params = getRequestParams()
const params1 = {
user_level: userStore.userInfo?.user_level?.toString() || '',
user_name: userStore.userInfo?.username || ''
}
// 检查参数是否有效
const hasParams = params.user_name && params.user_level
const requestParams = hasParams ? {
...params,
} : params1
console.log('CentergetGoodRecord request params:', requestParams)
// 验证必要参数是否存在
if (!requestParams.user_name || !requestParams.user_level) {
console.error("缺少必要的请求参数:", requestParams);
return;
}
// 直接发送请求,不使用缓存
const res = await getExcellentRecordFile(requestParams)
console.log(972872132,res)
if (res && res.code === 200 && res.data) {
excellentRecord.value = res.data || []
console.log('获取优秀录音成功:', res.data)
} else {
console.error("获取优秀录音失败,响应数据不完整:", res);
excellentRecord.value = []
}
} catch (error) {
console.error("获取优秀录音失败:", error);
excellentRecord.value = []
}
}
// 定金转化
@@ -286,6 +357,10 @@ async function TeamGetGroupRanking() {
const memberDetails = ref({})
// 团队分析数据
const teamAnalysisData = ref([])
const showTeamAnalysisModal = ref(false)
// 当前选中的成员,默认为空
const selectedMember = ref(null);
@@ -318,18 +393,85 @@ week_order_count
}
}
// 获取团队分析数据
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 () => {
await TeamGetGroupAbnormalResponse()
await TeamGetWeekTotalCall()
await TeamGetGroupCallDuration()
await TeamGetWeekAddCustomerTotal()
await TeamGetWeekAddDealTotal()
await TeamGetWeekAddFeeTotal()
await TeamGetGroupFunnel()
await TeamGetGroupRanking()
CentergetGoodRecord()
TeamGetGroupAbnormalResponse()
TeamGetWeekTotalCall()
TeamGetGroupCallDuration()
TeamGetWeekAddCustomerTotal()
TeamGetWeekAddDealTotal()
TeamGetWeekAddFeeTotal()
TeamGetGroupFunnel()
TeamGetGroupRanking()
})
</script>
@@ -646,12 +788,12 @@ onMounted(async () => {
.top-section {
display: grid;
grid-template-columns: 1fr 3fr;
gap: 1rem;
gap: 0.5rem;
// PC端保持一致布局
@media (min-width: 1024px) {
grid-template-columns: 1fr 3fr;
gap: 1.5rem;
gap: 1rem;
}
// 平板端适配
@@ -676,7 +818,7 @@ onMounted(async () => {
.analytics-section {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
gap: 0.5rem;
margin-bottom: 1rem;
// PC端保持一致布局
@@ -1850,5 +1992,267 @@ onMounted(async () => {
}
}
}
/* 团队分析弹窗样式 */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.modal-content {
background: white;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
max-width: 90vw;
max-height: 90vh;
overflow: hidden;
display: flex;
flex-direction: column;
}
.modal-header {
padding: 1rem 1.5rem;
border-bottom: 1px solid #eee;
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-header h3 {
margin: 0;
font-size: 1.25rem;
color: #333;
}
.close-button {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: #999;
padding: 0;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
}
.close-button:hover {
color: #333;
}
.modal-body {
padding: 1.5rem;
overflow-y: auto;
max-height: calc(90vh - 80px);
}
.report-item {
margin-bottom: 2rem;
}
.report-item:last-child {
margin-bottom: 0;
}
.report-meta {
display: flex;
justify-content: space-between;
margin-bottom: 1rem;
font-size: 0.9rem;
color: #666;
}
.report-content {
line-height: 1.6;
}
.report-content :deep(h1),
.report-content :deep(h2),
.report-content :deep(h3),
.report-content :deep(h4),
.report-content :deep(h5),
.report-content :deep(h6) {
margin: 1.5rem 0 1rem 0;
font-weight: 600;
}
.report-content :deep(h1) {
font-size: 1.75rem;
border-bottom: 2px solid #eee;
padding-bottom: 0.5rem;
}
.report-content :deep(h2) {
font-size: 1.5rem;
border-bottom: 1px solid #eee;
padding-bottom: 0.5rem;
}
.report-content :deep(h3) {
font-size: 1.25rem;
}
.report-content :deep(p) {
margin: 0.75rem 0;
}
.report-content :deep(ul),
.report-content :deep(ol) {
margin: 0.75rem 0;
padding-left: 1.5rem;
}
.report-content :deep(li) {
margin: 0.25rem 0;
}
.report-content :deep(strong) {
font-weight: 600;
}
.report-content :deep(em) {
font-style: italic;
}
}
/* 团队分析弹窗 */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.modal-content {
background: white;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
max-width: 90vw;
max-height: 90vh;
overflow: hidden;
display: flex;
flex-direction: column;
}
.modal-header {
padding: 1rem 1.5rem;
border-bottom: 1px solid #eee;
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-header h3 {
margin: 0;
font-size: 1.25rem;
color: #333;
}
.close-button {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: #999;
padding: 0;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
}
.close-button:hover {
color: #333;
}
.modal-body {
padding: 1.5rem;
overflow-y: auto;
max-height: calc(90vh - 80px);
}
.report-item {
margin-bottom: 2rem;
}
.report-item:last-child {
margin-bottom: 0;
}
.report-meta {
display: flex;
justify-content: space-between;
margin-bottom: 1rem;
font-size: 0.9rem;
color: #666;
}
.report-content {
line-height: 1.6;
}
.report-content :deep(h1),
.report-content :deep(h2),
.report-content :deep(h3),
.report-content :deep(h4),
.report-content :deep(h5),
.report-content :deep(h6) {
margin: 1.5rem 0 1rem 0;
font-weight: 600;
}
.report-content :deep(h1) {
font-size: 1.75rem;
border-bottom: 2px solid #eee;
padding-bottom: 0.5rem;
}
.report-content :deep(h2) {
font-size: 1.5rem;
border-bottom: 1px solid #eee;
padding-bottom: 0.5rem;
}
.report-content :deep(h3) {
font-size: 1.25rem;
}
.report-content :deep(p) {
margin: 0.75rem 0;
}
.report-content :deep(ul),
.report-content :deep(ol) {
margin: 0.75rem 0;
padding-left: 1.5rem;
}
.report-content :deep(li) {
margin: 0.25rem 0;
}
.report-content :deep(strong) {
font-weight: 600;
}
.report-content :deep(em) {
font-style: italic;
}
</style>

View File

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

View File

@@ -14,7 +14,7 @@
<div class="kpi-grid">
<div class="kpi-item">
<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 class="kpi-item">
<div class="kpi-value">{{ props.kpiData.successRate }}</div>
@@ -111,33 +111,19 @@
<div class="modal-container">
<div class="modal-header">
<h3 class="modal-title">阶段分析报告</h3>
<div class="period-switcher">
<button @click="switchAnalysisPeriod('day')" :class="{ active: analysisPeriod === 'day' }">当日</button>
<button @click="switchAnalysisPeriod('camp')" :class="{ active: analysisPeriod === 'camp' }">当期</button>
<button @click="switchAnalysisPeriod('month')" :class="{ active: analysisPeriod === 'month' }">当月</button>
</div>
<button class="modal-close-btn" @click="closeAnalysisModal">&times;</button>
</div>
<div class="modal-body">
<div class="analysis-content">
<div v-if="analysisPeriod === 'day'">
<h4>当日分析报告</h4>
<div v-if="analysisReport.day === '数据为空'" class="error-message">数据为空</div>
<div v-else-if="analysisReport.day" v-html="analysisReport.day.replace(/\n/g, '<br>')"></div>
<p v-else>正在生成分析报告...</p>
<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 v-if="analysisPeriod === 'camp'">
<h4>当期分析报告</h4>
<div v-if="analysisReport.camp === '数据为空'" class="error-message">数据为空</div>
<div v-else-if="analysisReport.camp" v-html="analysisReport.camp.replace(/\n/g, '<br>')"></div>
<p v-else>正在生成分析报告...</p>
</div>
<div v-if="analysisPeriod === 'month'">
<h4>当月分析报告</h4>
<div v-if="analysisReport.month === '数据为空'" class="error-message">数据为空</div>
<div v-else-if="analysisReport.month" v-html="analysisReport.month.replace(/\n/g, '<br>')"></div>
<p v-else>正在生成分析报告...</p>
</div>
<div v-else class="error-message">数据格式错误</div>
</div>
</div>
</div>
@@ -220,49 +206,20 @@ const props = defineProps({
}
});
async function CenterGetSecondOrderAnalysisReport(time) {
async function CenterGetSecondOrderAnalysisReport() {
const params = getRequestParams()
const hasParams = {...params,time:time}
const res = await getSecondOrderAnalysisReport(hasParams)
const res = await getSecondOrderAnalysisReport(params)
if (res.code === 200) {
const records = res.data.records.join('\n')
// 检查数据是否为空
if (!records) {
console.error('数据为空')
// 将错误信息存储到对应的响应式变量中
analysisReport.value[time] = '数据为空'
return
}
const prompt = `请分析以下数据:\n${records}\n请提供一个阶段分析报告。`
console.log(prompt)
// 使用sendMessage方法替代chat方法
try {
await chatService_02.sendMessage(
prompt,
(update) => {
// 实时更新回调
if (!update.isStreaming) {
console.log('阶段分析报告:', update.content)
// 将结果存储到对应的响应式变量中
analysisReport.value[time] = update.content
}
},
() => {
// 流结束回调
console.log('阶段分析报告生成完成')
}
)
} catch (error) {
console.error('获取阶段分析报告失败:', error)
}
console.log(11111,res.data)
analysisReport.value = res.data
}
}
// Chart.js 实例
const chartInstances = {};
// 添加组件挂载状态跟踪
const isComponentMounted = ref(true);
// DOM 元素引用
const personalFunnelChartCanvas = ref(null);
const contactTimeChartCanvas = ref(null);
@@ -279,8 +236,8 @@ const tooltip = reactive({
// 指标说明配置
const kpiDescriptions = {
totalCalls: {
title: '今日通话',
description: '今日总共通话的次数。'
title: '本期通话',
description: '本期总共通话的次数。'
},
successRate: {
title: '电话接通率',
@@ -288,7 +245,7 @@ const kpiDescriptions = {
},
avgDuration: {
title: '平均通话时长',
description: '所有通话总时长 ÷ 拨打电话次数。'
description: '所有通话总时长 ÷ 拨打电话次数。'
},
conversionRate: {
title: '成交转化率',
@@ -327,7 +284,8 @@ const createOrUpdateChart = (chartId, canvasRef, config) => {
if (chartInstances[chartId]) {
chartInstances[chartId].destroy();
}
if (canvasRef.value) {
// 确保组件仍然挂载且canvas引用存在
if (isComponentMounted.value && canvasRef.value) {
const ctx = canvasRef.value.getContext('2d');
chartInstances[chartId] = new Chart(ctx, config);
}
@@ -335,6 +293,9 @@ const createOrUpdateChart = (chartId, canvasRef, config) => {
// Chart.js: 渲染销售漏斗图
const renderPersonalFunnelChart = () => {
// 确保组件仍然挂载
if (!isComponentMounted.value) return;
const config = {
type: 'bar',
data: {
@@ -359,6 +320,9 @@ const renderPersonalFunnelChart = () => {
// Chart.js: 渲染黄金联络时段图
const renderContactTimeChart = () => {
// 确保组件仍然挂载
if (!isComponentMounted.value) return;
if (!props.contactTimeData || !props.contactTimeData.gold_contact_success_rate) {
return;
}
@@ -417,17 +381,12 @@ const hideTooltip = () => {
// 阶段分析报告模态框状态
const showAnalysisModal = ref(false);
const analysisPeriod = ref('day'); // 'day', 'camp', 'month'
const analysisReport = ref({
day: '',
camp: '',
month: ''
});
// 阶段分析报告数据
const analysisReport = ref({});
// 显示阶段分析报告模态框
const showSecondOrderAnalysisReport = () => {
showAnalysisModal.value = true;
CenterGetSecondOrderAnalysisReport(analysisPeriod.value)
CenterGetSecondOrderAnalysisReport()
};
// 关闭阶段分析报告模态框
@@ -435,14 +394,6 @@ const closeAnalysisModal = () => {
showAnalysisModal.value = false;
};
// 切换分析周期
const switchAnalysisPeriod = (period) => {
analysisPeriod.value = period;
CenterGetSecondOrderAnalysisReport(period)
};
watch(() => props.contactTimeData, () => {
renderContactTimeChart();
}, { deep: true });
@@ -450,11 +401,13 @@ watch(() => props.contactTimeData, () => {
// --- 生命周期钩子 ---
onMounted(() => {
isComponentMounted.value = true;
renderPersonalFunnelChart();
renderContactTimeChart();
});
onBeforeUnmount(() => {
isComponentMounted.value = false;
Object.values(chartInstances).forEach(chart => chart.destroy());
});
</script>
@@ -646,7 +599,7 @@ $white: #ffffff;
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 20px 16px;
padding: 10px 20px 10px;
border-bottom: 1px solid #ebeef5;
h3 { margin: 0; color: $slate-900; font-size: 18px; font-weight: 600; }
}
@@ -1009,4 +962,31 @@ $white: #ffffff;
border-radius: 4px;
background-color: #fef0f0;
}
.loading-message {
text-align: center;
padding: 20px;
color: #909399;
}
.report-section {
margin-bottom: 20px;
padding: 15px;
border: 1px solid #ebeef5;
border-radius: 4px;
background-color: #f9fafc;
}
.report-section:last-child {
margin-bottom: 0;
}
.report-section h4 {
margin-top: 0;
margin-bottom: 15px;
color: #303133;
font-size: 16px;
border-bottom: 1px solid #ebeef5;
padding-bottom: 8px;
}
</style>

View File

@@ -14,16 +14,30 @@
</svg>
</div>
<h3 class="card-title">表单信息</h3>
<div class="form-filter">
<button
v-for="option in formFilterOptions"
:key="option"
class="form-filter-btn"
:class="{ active: formFilter === option }"
@click="formFilter = option"
>
{{ option }}
</button>
</div>
</div>
<div class="card-content">
<div v-for="(section, sectionIndex) in displayedFormSections" :key="sectionIndex" class="form-section">
<div v-if="section.title" class="form-section-title">{{ section.title }}</div>
<div class="form-data-list">
<div v-for="(field, index) in formFields" :key="index" class="form-field">
<div v-for="(field, index) in section.fields" :key="index" class="form-field">
<span class="field-label">{{ field.label }}:</span>
<span class="field-value">{{ field.value }}</span>
</div>
</div>
</div>
</div>
</div>
<!-- 聊天记录和通话录音卡片 -->
<div class="data-card communication-card">
@@ -70,7 +84,7 @@
<span class="content-time">最新: {{ chatData.lastMessage }}</span>
</div>
<div class="message-list">
<div v-for="(message, index) in props.chatInfo.messages" :key="index" class="message-item">
<div v-for="(message, index) in props.chatInfo?.messages" :key="index" class="message-item">
<div class="message-header">
<span class="message-sender">{{ message.format_direction }}</span>
<span class="message-time">{{ message.format_add_time }}</span>
@@ -89,13 +103,23 @@
<div class="call-list">
<div v-for="(call, index) in callRecords" :key="index" class="call-item">
<div class="call-header">
<div class="header-main" style="display: flex; flex-direction: row;">
<div class="user-info">
<span class="call-type">用户: {{ call.user_name }}</span>
<span class="call-duration">客户: {{ call.customer_name }}</span>
</div>
<div class="header-tags">
<span class="call-tag" :class="{
'tag-20min': call.record_tag === '20分钟通话',
'tag-other': call.record_tag === '其他'
'tag-invalid': call.record_tag === '无效通话',
'tag-other': call.record_tag !== '20分钟通话' && call.record_tag !== '无效通话'
}" v-if="call.record_tag">{{ call.record_tag }}</span>
<!-- 分数移到类型标签后面 -->
<span class="stat-value" :class="getScoreClass(call.score)">{{ call.score }}</span>
</div>
</div>
<div class="call-time">{{ formatDateTime(call.record_create_time) }}</div>
</div>
<div class="call-actions">
<div class="action-buttons">
<button class="action-btn download-btn" @click="downloadRecording(call)">
@@ -106,9 +130,8 @@
<i class="icon-view"></i>
查看原文
</button>
</div>
<div class="call-time-info">
<span class="call-duration">{{ formatDateTime(call.record_create_time) }}</span>
<!-- 时长移到操作按钮后面 -->
<span class="call-duration">{{ formatCallDuration(call.call_duration) }}</span>
</div>
</div>
</div>
@@ -120,12 +143,10 @@
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import { ref, computed, watch } from 'vue'
import axios from 'axios'
// Props
@@ -135,8 +156,8 @@ const props = defineProps({
default: () => ({})
},
formInfo: {
type: Object,
default: () => ({})
type: Array,
default: () => []
},
chatInfo: {
type: Object,
@@ -149,33 +170,60 @@ const props = defineProps({
})
// Emits
const emit = defineEmits(['analyze-sop', 'show-modal'])
const emit = defineEmits(['analyze-sop', 'show-modal', 'show-download-modal'])
// 当前激活的tab
const activeTab = ref('chat')
// 聊天消息列表
const chatMessages = computed(() => {
return props.chatInfo?.messages || []
})
// 表单字段数据
const formFields = computed(() => {
const formFilter = ref('全部')
const formSections = computed(() => {
const formData = props.formInfo
if (!formData || Object.keys(formData).length === 0) {
return [
const emptyFields = [
{ label: '姓名', value: '暂无数据' },
{ label: '联系方式', value: '暂无数据' },
{ label: '孩子信息', value: '暂无数据' },
{ label: '地区', value: '暂无数据' }
]
const makeSection = (fields, title = '表单信息') => [{ title, fields }]
const buildFieldsFromAnswers = (answers = []) => {
return answers.map(item => ({
label: item.question_label,
value: item.answer || '暂无回答'
}))
}
let dataArray = null
if (Array.isArray(formData)) {
dataArray = formData
} else if (formData && Array.isArray(formData.data)) {
dataArray = formData.data
}
if (Array.isArray(dataArray) && dataArray.length > 0) {
if (dataArray[0].form_title && Array.isArray(dataArray[0].answers)) {
return dataArray.map(form => ({
title: form.form_title || '表单信息',
fields: buildFieldsFromAnswers(form.answers || [])
}))
}
if (dataArray[0].question_label && Object.prototype.hasOwnProperty.call(dataArray[0], 'answer')) {
return makeSection(buildFieldsFromAnswers(dataArray))
}
}
if (!formData || (typeof formData === 'object' && Object.keys(formData).length === 0)) {
return makeSection(emptyFields)
}
let fields = []
// 检查是否为第一种格式包含name, mobile等字段
if (formData.name || formData.mobile || formData.child_name) {
const customerInfo = [formData.name, formData.mobile, formData.child_relation, formData.occupation].filter(item => item && item !== '暂无').join(' | ')
const childInfo = [formData.child_name, formData.child_gender, formData.child_education].filter(item => item && item !== '暂无').join(' | ')
@@ -186,7 +234,6 @@ const formFields = computed(() => {
{ label: '地区', value: formData.territory || '暂无' }
]
// 如果有additional_info添加所有问题
if (formData.additional_info && Array.isArray(formData.additional_info)) {
formData.additional_info.forEach((item) => {
fields.push({
@@ -196,7 +243,6 @@ const formFields = computed(() => {
})
}
} else {
// 第二种格式expandXXX字段
const customerInfo = [formData.expandTwentyOne, formData.expandOne].filter(item => item && item !== '暂无').join(' | ')
const childInfo = [formData.expandTwentyNine, formData.expandTwentyFive, formData.expandTwo].filter(item => item && item !== '暂无').join(' | ')
@@ -212,12 +258,30 @@ const formFields = computed(() => {
{ label: '预期时间', value: formData.expandThirty || '暂无' }
]
}
// 合并表单数据和聊天数据
const allFields = [...fields]
return allFields
return makeSection(fields)
})
const formFilterOptions = computed(() => {
const titles = formSections.value.map(section => section.title).filter(Boolean)
const uniqueTitles = Array.from(new Set(titles))
if (uniqueTitles.length <= 1) return ['全部']
return ['全部', ...uniqueTitles]
})
const displayedFormSections = computed(() => {
if (formFilter.value === '全部') return formSections.value
const filtered = formSections.value.filter(section => section.title === formFilter.value)
return filtered.length > 0 ? filtered : formSections.value
})
watch(formFilterOptions, (options) => {
if (!options.includes(formFilter.value)) {
formFilter.value = options[0] || '全部'
}
}, { immediate: true })
// 聊天数据
const chatData = computed(() => ({
count: props.chatInfo?.messages?.length || 0,
@@ -232,7 +296,6 @@ const callData = computed(() => ({
// 通话记录列表
const callRecords = computed(() => {
// 从 props.callInfo 中获取真实的通话记录数据
if (props.callInfo && Array.isArray(props.callInfo)) {
console.log('通话记录:', props.callInfo)
@@ -252,6 +315,28 @@ const callRecords = computed(() => {
return []
})
// 新增:格式化通话时长的方法
const formatCallDuration = (durationInMinutes) => {
if (typeof durationInMinutes !== 'number' || durationInMinutes < 0) {
return '暂无';
}
const totalSeconds = Math.round(durationInMinutes * 60);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${minutes}${seconds}`;
};
// 新增根据分数获取CSS类的方法
const getScoreClass = (score) => {
if (score >= 80) {
return 'score-high';
} else if (score >= 60) {
return 'score-medium';
} else {
return 'score-low';
}
};
// 录音下载方法
const downloadRecording = async (call) => {
console.log('下载录音:', call)
@@ -464,6 +549,58 @@ const formatDateTime = (dateTimeString) => {
flex: 1;
}
.form-filter {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.form-filter-btn {
padding: 6px 12px;
border-radius: 999px;
border: 1px solid #e5e7eb;
background: #ffffff;
font-size: 12px;
font-weight: 600;
color: #6b7280;
cursor: pointer;
transition: all 0.2s ease;
}
.form-filter-btn.active {
border-color: #10b981;
color: #059669;
background: #ecfdf5;
}
.form-filter-btn:hover {
color: #059669;
border-color: #a7f3d0;
}
.form-section {
display: flex;
flex-direction: column;
gap: 8px;
}
.form-section + .form-section {
margin-top: 16px;
padding-top: 12px;
border-top: 1px dashed #e5e7eb;
}
.form-section-title {
font-size: 14px;
font-weight: 600;
color: #111827;
background: #f0fdf4;
border: 1px solid #dcfce7;
padding: 4px 10px;
border-radius: 999px;
width: fit-content;
}
// 表单字段样式
.form-data-list {
.form-field {
@@ -595,42 +732,61 @@ const formatDateTime = (dateTimeString) => {
.call-list {
.call-item {
margin-bottom: 16px;
padding: 16px;
border-radius: 8px;
background: #f9fafb;
border-left: 4px solid #3b82f6;
padding: 20px;
border-radius: 12px;
background: #ffffff;
border: 1px solid #e5e7eb;
transition: all 0.3s ease;
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
&:hover {
box-shadow: 0 6px 16px rgba(0,0,0,0.1);
transform: translateY(-2px);
}
.call-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
align-items: flex-start;
.header-main {
display: flex;
flex-direction: column;
gap: 8px;
flex: 1;
.user-info {
display: flex;
gap: 12px;
flex-wrap: wrap;
.call-type {
font-size: 12px;
font-size: 13px;
font-weight: 600;
padding: 4px 8px;
border-radius: 4px;
padding: 5px 10px;
border-radius: 20px;
background: #dbeafe;
color: #3b82f6;
}
.call-duration {
font-size: 12px;
.call-customer {
font-size: 13px;
font-weight: 500;
color: #6b7280;
align-self: center;
}
}
.call-time {
font-size: 12px;
color: #9ca3af;
}
.header-tags {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
.call-tag {
font-size: 11px;
font-size: 12px;
font-weight: 600;
padding: 3px 8px;
border-radius: 12px;
padding: 4px 10px;
border-radius: 20px;
&.tag-20min {
background: #dcfce7;
@@ -638,12 +794,52 @@ const formatDateTime = (dateTimeString) => {
border: 1px solid #bbf7d0;
}
&.tag-invalid {
background: #fee2e2;
color: #dc2626;
border: 1px solid #fecaca;
}
&.tag-other {
background: #fef3c7;
color: #d97706;
border: 1px solid #fed7aa;
}
}
// 分数样式
.stat-value {
font-size: 14px;
font-weight: 700;
padding: 4px 10px;
border-radius: 20px;
&.score-high {
color: #16a34a; // 绿色
background-color: #dcfce7;
}
&.score-medium {
color: #d97706; // 橙色
background-color: #fef3c7;
}
&.score-low {
color: #dc2626; // 红色
background-color: #fee2e2;
}
}
}
}
.call-time {
font-size: 12px;
color: #9ca3af;
font-weight: 500;
white-space: nowrap;
padding: 4px 8px;
background: #f9fafb;
border-radius: 4px;
align-self: flex-start;
}
}
.call-actions {
@@ -651,25 +847,24 @@ const formatDateTime = (dateTimeString) => {
justify-content: space-between;
align-items: center;
margin-top: 12px;
padding-top: 8px;
border-top: 1px solid #f3f4f6;
.action-buttons {
display: flex;
gap: 8px;
}
gap: 12px;
align-items: center;
.action-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
padding: 8px 14px;
border: none;
border-radius: 6px;
font-size: 11px;
border-radius: 8px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
&.download-btn {
background: #dbeafe;
@@ -677,8 +872,12 @@ const formatDateTime = (dateTimeString) => {
&:hover {
background: #bfdbfe;
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(59, 130, 246, 0.2);
transform: translateY(-2px);
box-shadow: 0 4px 6px rgba(59, 130, 246, 0.2);
}
&:active {
transform: translateY(0);
}
}
@@ -688,14 +887,18 @@ const formatDateTime = (dateTimeString) => {
&:hover {
background: #a7f3d0;
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(5, 150, 105, 0.2);
transform: translateY(-2px);
box-shadow: 0 4px 6px rgba(5, 150, 105, 0.2);
}
&:active {
transform: translateY(0);
}
}
i {
width: 12px;
height: 12px;
font-style: normal;
font-size: 14px;
&.icon-download::before {
content: '⬇';
@@ -707,14 +910,14 @@ const formatDateTime = (dateTimeString) => {
}
}
.call-time-info {
// 通话时长样式
.call-duration {
font-size: 11px;
font-size: 13px;
color: #6b7280;
font-weight: 500;
background: #f9fafb;
padding: 4px 8px;
border-radius: 4px;
padding: 6px 12px;
border-radius: 20px;
border: 1px solid #e5e7eb;
}
}
@@ -722,77 +925,6 @@ const formatDateTime = (dateTimeString) => {
}
}
.card-content {
margin-bottom: 20px;
}
.card-description {
color: #6b7280;
font-size: 14px;
margin: 0 0 16px 0;
line-height: 1.5;
}
.card-stats {
display: flex;
gap: 20px;
@media (max-width: 480px) {
flex-direction: column;
gap: 12px;
}
}
.stat-item {
display: flex;
flex-direction: column;
gap: 4px;
}
.stat-label {
font-size: 12px;
color: #9ca3af;
font-weight: 500;
}
.stat-value {
font-size: 14px;
color: #111827;
font-weight: 600;
}
.card-action {
border-top: 1px solid #f3f4f6;
padding-top: 16px;
margin-top: 16px;
}
.view-btn {
display: flex;
align-items: center;
gap: 8px;
background: none;
border: none;
color: #6b7280;
font-size: 14px;
font-weight: 500;
cursor: pointer;
padding: 8px 0;
transition: color 0.2s ease;
&:hover {
color: #111827;
}
svg {
transition: transform 0.2s ease;
}
&:hover svg {
transform: translateX(2px);
}
}
@media (max-width: 768px) {
.raw-data-cards {
margin: 20px 0;
@@ -840,11 +972,10 @@ const formatDateTime = (dateTimeString) => {
font-size: 14px;
}
.card-description {
font-size: 13px;
.call-actions {
flex-direction: column;
align-items: flex-start;
gap: 10px;
}
}
// 弹框样式
</style>

View File

@@ -98,9 +98,6 @@
<main class="main-content">
<!-- 客户详情区域 -->
<section v-if="cardVisibility.customerDetail && selectedContact" class="detail-section">
<div class="section-header">
<h2>客户详情</h2>
</div>
<div class="section-content">
<CustomerDetail
ref="customerDetailRef"
@@ -326,7 +323,7 @@ const payMoneyCustomersList = ref([]);
const payMoneyCustomersCount = ref(0);
// 表单信息
const formInfo = ref({});
const formInfo = ref([]);
// 通话记录
const callRecords = ref([]);
// 聊天记录
@@ -369,25 +366,37 @@ async function getCoreKpi() {
const params = getRequestParams()
const hasParams = params.user_name
// 并发请求所有KPI接口
const [
todayCallRes,
conversionRes,
avgCallTimeRes,
callSuccessRateRes
] = await Promise.all([
getTodayCall(hasParams ? params : undefined),
getConversionRateAndAllocatedData(hasParams ? params : undefined),
getAvgCallTime(hasParams ? params : undefined),
getCallSuccessRate(hasParams ? params : undefined)
])
// 今日通话数据
const res = await getTodayCall(hasParams ? params : undefined)
if (res.code === 200) {
kpiDataState.totalCalls = res.data.today_call
if (todayCallRes.code === 200) {
kpiDataState.totalCalls = todayCallRes.data.call_count
}
// 转化率、分配数据量、加微率
const conversionRes = await getConversionRateAndAllocatedData(hasParams ? params : undefined)
if (conversionRes.code === 200) {
kpiDataState.conversionRate = conversionRes.data.conversion_rate || 0
kpiDataState.assignedData = conversionRes.data.all_count || 0
kpiDataState.wechatAddRate = conversionRes.data.plus_v_conversion_rate || 0
}
// 平均通话时长
const avgCallTimeRes = await getAvgCallTime(hasParams ? params : undefined)
if (avgCallTimeRes.code === 200) {
kpiDataState.avgDuration = avgCallTimeRes.data.call_time || 0
}
// 电话接通率
const callSuccessRateRes = await getCallSuccessRate(hasParams ? params : undefined)
if (callSuccessRateRes.code === 200) {
kpiDataState.successRate = callSuccessRateRes.data.call_success_rate || 0
}
@@ -404,26 +413,35 @@ async function getStatisticsData() {
const params = getRequestParams()
const hasParams = params.user_name
// 获取表单填写率
const fillingRateRes = await getTableFillingRate(hasParams ? params : undefined)
// 并发请求所有统计数据
const [
fillingRateRes,
avgResponseRes,
communicationRes,
timeoutRes
] = await Promise.all([
getTableFillingRate(hasParams ? params : undefined),
getAverageResponseTime(hasParams ? params : undefined),
getWeeklyActiveCommunicationRate(hasParams ? params : undefined),
getTimeoutResponseRate(hasParams ? params : undefined)
])
// 处理表单填写率
if (fillingRateRes.code === 200) {
statisticsData.formCompletionRate = fillingRateRes.data.filling_rate
}
// 获取平均响应时间
const avgResponseRes = await getAverageResponseTime(hasParams ? params : undefined)
// 处理平均响应时间
if (avgResponseRes.code === 200) {
statisticsData.averageResponseTime = avgResponseRes.data.average_minutes
}
// 获取客户沟通率
const communicationRes = await getWeeklyActiveCommunicationRate(hasParams ? params : undefined)
// 处理客户沟通率
if (communicationRes.code === 200) {
statisticsData.customerCommunicationRate = communicationRes.data.communication_rate
}
// 获取超时响应率
const timeoutRes = await getTimeoutResponseRate(hasParams ? params : undefined)
// 处理超时响应率
if (timeoutRes.code === 200) {
statisticsData.timeoutResponseRate = timeoutRes.data.overtime_rate_600
statisticsData.severeTimeoutRate = timeoutRes.data.overtime_rate_800
@@ -581,12 +599,13 @@ async function getCustomerForm() {
const routeParams = getRequestParams()
const params = {
user_name: routeParams.user_name || userStore.userInfo.username,
customer_name: selectedContact.value.name,
phone: selectedContact.value.phone,
}
try {
const res = await getCustomerFormInfo(params)
console.log('获取客户表单数据:', res)
if(res.code === 200) {
formInfo.value = res.data
formInfo.value = res.data || []
}
} catch (error) {
// 静默处理错误
@@ -600,7 +619,7 @@ async function getCustomerChat() {
const routeParams = getRequestParams()
const params = {
user_name: routeParams.user_name || userStore.userInfo.username,
customer_name: selectedContact.value.name,
phone: selectedContact.value.phone,
}
try {
const res = await getCustomerChatInfo(params)
@@ -621,14 +640,12 @@ async function getCustomerCall() {
const routeParams = getRequestParams()
const params = {
user_name: routeParams.user_name || userStore.userInfo.username,
customer_name: selectedContact.value.name,
phone: selectedContact.value.phone,
}
try {
const res = await getCustomerCallInfo(params)
if(res.code === 200) {
callRecords.value = res.data
console.log('Call Records Data from API:', res.data)
console.log('callRecords.value after assignment:', callRecords.value)
}
} catch (error) {
// 静默处理错误
@@ -638,7 +655,6 @@ async function getCustomerCall() {
const kpiData = computed(() => kpiDataState);
// COMPUTED PROPERTIES
const selectedContact = computed(() => {
console.log(999999999,formattedCustomersList.value);
// 优先从API数据中查找
if (formattedCustomersList.value.length > 0) {
return formattedCustomersList.value.find((c) => c.id === selectedContactId.value) || null;
@@ -924,19 +940,6 @@ async function CenterGetSalesFunnel() {
const res = await getSalesFunnel(hasParams ? params : undefined)
if(res.code === 200){
SalesFunnel.value = res.data
/**
* "data": {
"user_name": "常琳",
"user_level": 1,
"sale_funnel": {
"线索总数": 11,
"有效沟通": 9,
"到课数据": 8,
"预付定金": 0,
"成功签单": 0
}
}
*/
}
}
// 黄金联络时间段
@@ -974,15 +977,15 @@ async function forceRefreshAllData() {
onMounted(async () => {
try {
isPageLoading.value = true
await getStatisticsData()
await getCoreKpi()
await CenterGetGoldContactTime()
await CenterGetSalesFunnel()
await getCustomerForm()
await getCustomerChat()
await getUrgentProblem()
await getCustomerCall()
await getTimeline()
getStatisticsData()
getCoreKpi()
CenterGetGoldContactTime()
CenterGetSalesFunnel()
getCustomerForm()
getCustomerChat()
getUrgentProblem()
getCustomerCall()
getTimeline()
// 开发环境下暴露数据刷新函数到全局对象,方便调试
if (process.env.NODE_ENV === 'development') {
@@ -1077,9 +1080,9 @@ $primary: #3b82f6;
}
// 主要布局
.main-layout {
width: 100vw;
margin: 0 auto;
padding: 1rem;
width: 99vw;
margin-bottom: 1rem;
// padding: 1rem;
display: flex;
flex-direction: column;
gap: 1rem;
@@ -1838,10 +1841,10 @@ $primary: #3b82f6;
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: 600px;
max-width: 1200px;
width: 90%;
// 设置最大高度,防止弹窗超出屏幕
max-height: 35vh;
max-height: 80vh;
// 防止内容溢出容器,配合内部滚动
overflow: hidden;
// 使用 Flexbox 布局,让 .modal-body 可以伸缩

View File

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

View File

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

View File

@@ -90,12 +90,14 @@ async function exportData() {
try {
ElMessage.info('正在导出数据,请稍候...')
console.log('导出参数:', params)
const res = await exportCustomers(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 || '',
@@ -103,18 +105,104 @@ async function exportData() {
'用户ID': customer.mantis_user_id || '',
}
// 处理微信表单信息
if (customer.wechat_form) {
flatData['家长姓名'] = customer.wechat_form.name || ''
flatData['孩子姓名'] = customer.wechat_form.child_name || ''
flatData['孩子性别'] = customer.wechat_form.child_gender || ''
flatData['职业'] = customer.wechat_form.occupation || ''
flatData['孩子教育阶段'] = customer.wechat_form.child_education || ''
flatData['与孩子关系'] = customer.wechat_form.child_relation || ''
flatData['联系电话'] = customer.wechat_form.mobile || ''
flatData['地区'] = customer.wechat_form.territory || ''
flatData['创建时间'] = customer.wechat_form.created_at ? new Date(customer.wechat_form.created_at).toLocaleString() : ''
flatData['更新时间'] = customer.wechat_form.updated_at ? new Date(customer.wechat_form.updated_at).toLocaleString() : ''
const 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() : ''
}
// 处理到课情况
@@ -126,9 +214,12 @@ async function exportData() {
}
// 处理问卷调查信息
if (customer.wechat_form && customer.wechat_form.additional_info) {
customer.wechat_form.additional_info.forEach((item) => {
flatData[item.topic || ''] = item.answer || ''
if (parsedFormData && parsedFormData.additional_info) {
parsedFormData.additional_info.forEach((item) => {
const key = ensureUniqueKey(item.topic || '')
if (key) {
flatData[key] = item.answer || ''
}
})
}
@@ -137,27 +228,18 @@ async function exportData() {
// 创建工作簿
const wb = XLSX.utils.book_new()
const ws = XLSX.utils.json_to_sheet(exportData)
const allKeys = Array.from(new Set(exportData.flatMap(item => Object.keys(item))))
const ws = XLSX.utils.json_to_sheet(exportData, { header: allKeys })
// 设置列宽
const colWidths = [
{ wch: 10 }, // 昵称
{ wch: 6 }, // 性别
{ wch: 12 }, // 跟进人
{ wch: 15 }, // 手机号
{ wch: 10 }, // 是否入群
{ wch: 20 }, // 用户ID
{ wch: 12 }, // 家长姓名
{ wch: 12 }, // 孩子姓名
{ wch: 8 }, // 孩子性别
{ wch: 12 }, // 职业
{ wch: 12 }, // 孩子教育阶段
{ wch: 15 }, // 与孩子关系
{ wch: 15 }, // 联系电话
{ wch: 20 }, // 地区
{ wch: 20 }, // 创建时间
{ wch: 20 }, // 更新时间
]
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
// 添加工作表到工作簿
@@ -196,7 +278,7 @@ async function exportData() {
}
.chart-header {
padding: 20px 20px 16px;
padding: 10px 20px 10px;
border-bottom: 1px solid #ebeef5;
display: flex;
justify-content: space-between;

View File

@@ -112,6 +112,22 @@
<span class="value">{{ selectedGroup.conversionRate }}%</span>
</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 class="members-grid">
@@ -178,7 +194,7 @@
</main>
<!-- Loading 组件 -->
<Loading :visible="isLoading" text="数据加载中..." />
<!-- <Loading :visible="isLoading" text="数据加载中..." /> -->
</div>
</template>
@@ -283,6 +299,9 @@ const cardVisibility = ref({
// FeedbackForm 控制变量
const showFeedbackForm = ref(false)
// 团队整体分析弹窗控制变量
const showTeamAnalysis = ref(false)
// 更新卡片显示状态
const updateCardVisibility = (newVisibility) => {
Object.assign(cardVisibility.value, newVisibility)
@@ -297,6 +316,15 @@ const showFeedbackFormModal = () => {
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
const centerData = ref({
@@ -860,31 +888,46 @@ const conversionRateVsAverage = ref({})
})
}
// 获取优秀录音
const excellentRecord = ref({});
const excellentRecord = ref([]);
// 获取优秀录音文件
// async function CentergetGoodRecord() {
// const params = getRequestParams()
// const params1 = {
// user_level:userStore.userInfo.user_level.toString(),
// user_name:userStore.userInfo.username
// }
// const hasParams = params.user_name
// const requestParams = hasParams ? {
// ...params,
// } : params1
// console.log(188811111,requestParams)
async function CentergetGoodRecord() {
console.log('CentergetGoodRecord 开始执行')
try {
const params = getRequestParams()
const params1 = {
user_level: userStore.userInfo?.user_level?.toString() || '',
user_name: userStore.userInfo?.username || ''
}
// try {
// const res = await withCache('CentergetGoodRecord',
// () => getExcellentRecordFile(requestParams),
// requestParams
// )
// excellentRecord.value = res.data.excellent_record_list
// console.log(111111,res.data.excellent_record_list)
// } catch (error) {
// console.error("获取优秀录音失败:", error);
// }
// }
// 检查参数是否有效
const hasParams = params.user_name && params.user_level
const requestParams = hasParams ? {
...params,
} : params1
console.log('CentergetGoodRecord request params:', requestParams)
// 验证必要参数是否存在
if (!requestParams.user_name || !requestParams.user_level) {
console.error("缺少必要的请求参数:", requestParams);
return;
}
// 直接发送请求,不使用缓存
const res = await getExcellentRecordFile(requestParams)
if (res && res.code === 200 && res.data) {
excellentRecord.value = res.data || []
console.log('获取优秀录音成功:', res.data)
} else {
console.error("获取优秀录音失败,响应数据不完整:", res);
excellentRecord.value = []
}
} catch (error) {
console.error("获取优秀录音失败:", error);
excellentRecord.value = []
}
}
// 缓存管理功能
// 清除所有缓存
@@ -939,20 +982,21 @@ const excellentRecord = ref({});
(currentQuery.user_name && currentQuery.user_level)
if (!isFromRoute) {
await CenterCampPeriodAdmin()
CenterCampPeriodAdmin()
}
CentergetGoodRecord()
CenterOverallCenterPerformance()
CenterTotalGroupCount()
CenterConversionRate()
CenterTotalCallCount()
CenterNewCustomer()
CenterDepositConversionRate()
CenterCustomerType()
CenterUrgentNeedToAddress()
CenterConversionRateVsAverage()
await CenterOverallCenterPerformance()
await CenterTotalGroupCount()
await CenterConversionRate()
await CenterTotalCallCount()
await CenterNewCustomer()
await CenterDepositConversionRate()
await CenterCustomerType()
await CenterUrgentNeedToAddress()
await CenterConversionRateVsAverage()
await CenterSeniorManagerList()
await CenterGroupList('all')
CenterSeniorManagerList()
CenterGroupList('all')
console.log('[强制刷新] 所有数据已重新加载')
} catch (error) {
@@ -980,6 +1024,7 @@ const excellentRecord = ref({});
await CenterTotalGroupCount()
await CenterConversionRate()
await CenterTotalCallCount()
await CentergetGoodRecord()
await CenterNewCustomer()
await CenterDepositConversionRate()
await CenterCustomerType()
@@ -1619,6 +1664,76 @@ const hideTooltip = () => {
}
}
/* 团队分析弹窗样式 */
.team-analysis-modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.team-analysis-modal .modal-content {
background-color: white;
border-radius: 8px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
width: 80%;
max-width: 600px;
max-height: 80vh;
overflow: hidden;
display: flex;
flex-direction: column;
}
.team-analysis-modal .modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
border-bottom: 1px solid #e2e8f0;
}
.team-analysis-modal .modal-header h3 {
margin: 0;
color: #1a202c;
font-size: 1.25rem;
}
.team-analysis-modal .close-btn {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: #718096;
padding: 0;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
}
.team-analysis-modal .close-btn:hover {
color: #1a202c;
}
.team-analysis-modal .modal-body {
padding: 1rem;
overflow-y: auto;
flex: 1;
}
.team-analysis-modal .modal-body p {
margin: 0;
color: #4a5568;
line-height: 1.5;
}
// 路由导航顶栏样式
.route-header {
display: flex;

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -525,7 +525,6 @@ async function getConversionComparison(data) {
const res = await getCompanyConversionRateVsLast(params);
return res.data;
});
console.log(111111,result);
conversionComparison.value = result;
} catch (error) {
console.error("获取转化对比失败:", error);
@@ -665,7 +664,7 @@ const handleFilterChange = (filterParams) => {
getDetailData(filterParams)
}
// 优秀录音
const excellentRecord = ref({});
const excellentRecord = ref([]);
async function CenterExcellentRecord() {
const params={
user_level:userStore.userInfo.user_level.toString(),
@@ -675,10 +674,9 @@ const params={
const cacheKey = getCacheKey('CenterExcellentRecord', params);
const result = await withCache(cacheKey, async () => {
const res = await getExcellentRecordFile(params);
return res.data.excellent_record_list;
return res.data;
});
excellentRecord.value = result;
console.log(111111,result);
} catch (error) {
console.error("获取优秀录音失败:", error);
}
@@ -687,15 +685,15 @@ onMounted(async() => {
// 页面初始化逻辑
console.log('页面初始化,开始加载数据...');
await getRealTimeProgress()
await getTotalDeals()
await getConversionComparison('month')
await getCompanySalesRank('red')
await getCustomerTypeRatio('child_education')
await getCustomerUrgency()
await CusotomGetLevelTree()
await getDetailData()
await CenterExcellentRecord()
getRealTimeProgress()
getTotalDeals()
getConversionComparison('month')
getCompanySalesRank('red')
getCustomerTypeRatio('child_education')
getCustomerUrgency()
CusotomGetLevelTree()
getDetailData()
CenterExcellentRecord()
// 输出缓存状态信息
const cacheInfo = getCacheInfo();