Compare commits
114 Commits
570f59f93f
...
Breach
| Author | SHA1 | Date | |
|---|---|---|---|
| b2ce9d93db | |||
| 8260b345dc | |||
| 2cd59adfc9 | |||
| baa89001c8 | |||
| 9b3c5da105 | |||
| a4c0aca1c2 | |||
| 9f19b8fb66 | |||
| 14ddd2839b | |||
| 6d2d3bda67 | |||
| b48b7749f2 | |||
| 2ba88eff08 | |||
| 6cf6829334 | |||
| 9d52b99414 | |||
| b9f74dc810 | |||
| 1647970bed | |||
| e696f768e6 | |||
| 6b76d36946 | |||
| 8e5f7335d8 | |||
| 5ff42dbbad | |||
| b0c2f28d7f | |||
| 51091d097e | |||
| 86cf54b9de | |||
| 10fb9dd4f2 | |||
| 2979e7e216 | |||
| 288a525537 | |||
| db6433693a | |||
| 99efa8de75 | |||
| d75fd9beb8 | |||
| 094f655634 | |||
| 10c8c7b796 | |||
| ea32a16e5d | |||
| 57be345996 | |||
| 3ed490d6dc | |||
| 1fdd8fe12a | |||
| a6f4c96f1f | |||
| 73c84f7b8d | |||
| 3a529bafa8 | |||
| 822afb422c | |||
| 7e8f272dfe | |||
| 93febd0964 | |||
| 9555bb66fd | |||
| 575a08ed3a | |||
| b3f5178470 | |||
| 859821dfb3 | |||
| d661b77afa | |||
| 676b213a7d | |||
| 600684570a | |||
| 6f0d10b881 | |||
| 4885674f23 | |||
| 3033326def | |||
| 11c1bcc626 | |||
| 2447985cb2 | |||
| 62a4eb0319 | |||
| 1d63829ed6 | |||
| 87c926ebb1 | |||
| 1e6f987172 | |||
| a00a20c4ee | |||
| b030a201a7 | |||
| 5a930ac084 | |||
| be3c724a5e | |||
| f47211b0b0 | |||
| e94ea6b592 | |||
| 328ae8cd55 | |||
| e9a8605073 | |||
| d5792be702 | |||
| e7f9abcc19 | |||
| 7af2ee9e25 | |||
| c261594a25 | |||
| fa2754e124 | |||
| c10b514779 | |||
| d204c7befe | |||
| beec8c6cfb | |||
| 4c06067dd4 | |||
| f87c6b8252 | |||
| d6db489f80 | |||
| 4ad91cabe3 | |||
| d0ad4e56dd | |||
| 04b19bc45d | |||
| 2827d70f65 | |||
| 2f380b1fe5 | |||
| 21ef158ce4 | |||
| 0627caf37c | |||
| 6d22aecc29 | |||
| 4eea8f8a8a | |||
| 2a52e8f0f6 | |||
| 2ba97b83ec | |||
| 71d2432df5 | |||
| 14a536bd1c | |||
| 787703fa12 | |||
| f79fa1dd3d | |||
| 41eeb55815 | |||
| 865ee46334 | |||
| 4069ae3a91 | |||
| 152f5c2b4a | |||
| ecf63b74cb | |||
| d4daed2ec1 | |||
| dd913f4d14 | |||
| d0b8159274 | |||
| abadcf2494 | |||
| 14ee188856 | |||
| 6779db176c | |||
| 11e686d4d9 | |||
| b7d46c3dde | |||
| d7c8a9e173 | |||
| 58c6ec1c53 | |||
| 87cc0e4976 | |||
| 41058a7ab9 | |||
| 962b430a75 | |||
| 0347da9cdc | |||
| f1fe585fc4 | |||
| d385d22cf5 | |||
| 5e29aa77d6 | |||
| af07a1e175 | |||
| 0e0d297da7 |
@@ -1,10 +1,11 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="zh">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<link rel="icon" href="/NYC_logo.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Vite + Vue</title>
|
||||
<title>暖洋葱家庭教育数据看板</title>
|
||||
<script defer src="https://umami.nycjy.cn/script.js" data-website-id="0d851950-9420-4c3e-a12a-c221fcf039b5"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
6386
my-vue-app/package-lock.json
generated
Normal file
6386
my-vue-app/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.1",
|
||||
"@fullcalendar/core": "^6.1.19",
|
||||
"axios": "^1.10.0",
|
||||
"chart.js": "^4.5.0",
|
||||
"dompurify": "^3.2.6",
|
||||
@@ -18,11 +19,12 @@
|
||||
"markdown-it": "^14.1.0",
|
||||
"marked": "^16.1.1",
|
||||
"pinia": "^3.0.2",
|
||||
"pinia-plugin-persistedstate": "^3.2.3",
|
||||
"pinia-plugin-persistedstate": "^4.5.0",
|
||||
"vue": "^3.5.17",
|
||||
"vue-chartjs": "^5.3.2",
|
||||
"vue-echarts": "^7.0.3",
|
||||
"vue-router": "^4.5.0"
|
||||
"vue-router": "^4.5.0",
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/node22": "^22.0.1",
|
||||
|
||||
1486
my-vue-app/pnpm-lock.yaml
generated
1486
my-vue-app/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
BIN
my-vue-app/public/NYC_logo.png
Normal file
BIN
my-vue-app/public/NYC_logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 121 KiB |
@@ -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
|
||||
@@ -73,6 +73,16 @@ export const getSalesFunnel = (params) => {
|
||||
export const getGoldContactTime = (params) => {
|
||||
return https.post('/api/v1/sales/get_gold_contact_time', params)
|
||||
}
|
||||
// 平均通话时长 /api/v1/sales/get_avg_call_time
|
||||
export const getAvgCallTime = (params) => {
|
||||
return https.post('/api/v1/sales/get_avg_call_time', params)
|
||||
}
|
||||
// 电话接通率 /api/v1/sales/get_call_success_rate
|
||||
export const getCallSuccessRate = (params) => {
|
||||
return https.post('/api/v1/sales/get_call_success_rate', params)
|
||||
}
|
||||
|
||||
|
||||
|
||||
// 二阶分析报告
|
||||
export const getSecondOrderAnalysisReport = (params) => {
|
||||
return https.post('/api/v1/sales/get_second_analysis_report', params)
|
||||
}
|
||||
@@ -10,8 +10,6 @@ export const getWeekTotalCall = (params) => {
|
||||
return https.post('/api/v1/manager/week_total_call', params)
|
||||
}
|
||||
|
||||
// 有效通话时长
|
||||
|
||||
// 新增意向客户 /api/v1/manager/week_add_customer_total
|
||||
export const getWeekAddCustomerTotal = (params) => {
|
||||
return https.post('/api/v1/manager/week_add_customer_total', params)
|
||||
@@ -38,11 +36,30 @@ export const getGroupFunnel = (params) => {
|
||||
export const getGroupRanking = (params) => {
|
||||
return https.post('/api/v1/manager/group_ranking', params)
|
||||
}
|
||||
|
||||
// 团队成员业绩详情 /api/v1/manager/group_detail
|
||||
export const getGroupDetail = (params) => {
|
||||
return https.post('/api/v1/manager/group_detail', params)
|
||||
}
|
||||
// 有效通话时长 /api/v1/manager/group_call_duration
|
||||
export const getGroupCallDuration = (params) => {
|
||||
return https.post('/api/v1/manager/group_call_duration', params)
|
||||
}
|
||||
// 二阶分析报告 /api/v1/sales/get_call_text
|
||||
export const GetSecondOrderAnalysisReport = (params) => {
|
||||
return https.post('/api/v1/manager/group_second_report', params)
|
||||
}
|
||||
|
||||
// 通话分类数据 /api/v1/manager/get_member_call_classify
|
||||
export const getMemberCallClassify = (params) => {
|
||||
return https.post('/api/v1/manager/get_member_call_classify', params)
|
||||
}
|
||||
|
||||
// 团队整体三阶分析报告 /api/v1/manager/group_entirety_third_report
|
||||
export const getGroupEntiretyThirdReport = (params) => {
|
||||
return https.post('/api/v1/manager/group_entirety_third_report', params)
|
||||
}
|
||||
|
||||
// 获取优秀录音文件 /api/v1/level_five/overview/get_excellent_record_file
|
||||
export const getExcellentRecordFile = (params) => {
|
||||
return https.post('/api/v1/level_five/overview/get_excellent_record_file', params)
|
||||
}
|
||||
@@ -68,8 +68,27 @@ export const getCampPeriodAdmin = (params) => {
|
||||
export const getExcellentRecordFile = (params) => {
|
||||
return https.post('/api/v1/level_four/overview/get_excellent_record_file', params)
|
||||
}
|
||||
|
||||
|
||||
// 修改营期 /api/v1/level_four/overview/change_camp_period
|
||||
export const changeCampPeriod = (params) => {
|
||||
return https.post('/api/v1/level_four/overview/change_camp_period', params)
|
||||
}
|
||||
// 获取历史营期 /api/v1/level_four/overview/get_history_camp_period
|
||||
export const getHistoryCampPeriod = (params) => {
|
||||
return https.post('/api/v1/level_four/overview/get_history_camp_period', params)
|
||||
}
|
||||
// 切换历史营期 /api/v1/level_four/overview/switch_history_camp_period
|
||||
export const switchHistoryCampPeriod = (params) => {
|
||||
return https.post('/api/v1/level_four/overview/switch_history_camp_period', params)
|
||||
}
|
||||
// 返回当前营期 /api/v1/level_four/overview/cancel_switch_history_camp_period
|
||||
export const cancelSwitchHistoryCampPeriod = (params) => {
|
||||
return https.post('/api/v1/level_four/overview/cancel_switch_history_camp_period', params)
|
||||
}
|
||||
|
||||
// 一键导出 api/v1/level_four/overview/export_customers
|
||||
export const exportCustomers = (params) => {
|
||||
return https.post('/api/v1/level_four/overview/export_all_customers_under_sales', params)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -45,14 +45,18 @@ 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) => {
|
||||
return https.post('/api/v1/level_three/overview/urgent_need_to_address', params)
|
||||
}
|
||||
|
||||
// 团队业绩排名 /api/v1/level_three/overview/team_ranking
|
||||
// 团队业绩排名 /api/v1/level_three/overview/team_ranking
|
||||
export const getTeamRanking = (params) => {
|
||||
return https.post('/api/v1/level_three/overview/team_ranking', 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)
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -65,12 +65,12 @@ export const getDetailedDataTable = (params) => {
|
||||
return https.post('/api/v1/level_five/overview/detailed_data_table', params)
|
||||
}
|
||||
|
||||
// 下发任务 /api/v1/level_five/overview/assign_tasks
|
||||
export const assignTasks = (params) => {
|
||||
return https.post('http://192.168.15.60:8890/api/v1/level_five/overview/assign_tasks', params)
|
||||
// 获取各中心营期阶段 /api/v1/level_five/overview/get_period_stage
|
||||
export const getPeriodStage = (params) => {
|
||||
return https.get('/api/v1/level_five/overview/get_period_stage', params)
|
||||
}
|
||||
// 获取优秀录音文件 /api/v1/level_four/overview/get_excellent_record_file
|
||||
// 获取优秀录音文件 /api/v1/level_five/overview/get_excellent_record_file
|
||||
export const getExcellentRecordFile = (params) => {
|
||||
return https.post('/api/v1/level_four/overview/get_excellent_record_file', params)
|
||||
return https.post('/api/v1/level_five/overview/get_excellent_record_file', params)
|
||||
}
|
||||
|
||||
|
||||
334
my-vue-app/src/components/FeedbackForm.vue
Normal file
334
my-vue-app/src/components/FeedbackForm.vue
Normal file
@@ -0,0 +1,334 @@
|
||||
<template>
|
||||
<div v-if="isVisible" class="modal-overlay" @click="closeModal">
|
||||
<div class="modal-container" @click.stop>
|
||||
<div class="modal-header">
|
||||
<h3>意见反馈</h3>
|
||||
<button class="modal-close-btn" @click="closeModal">×</button>
|
||||
</div>
|
||||
<div class="feedback-form-container">
|
||||
<h2>意见反馈</h2>
|
||||
<p class="subtitle">我们期待听到您的声音,不断改进我们的产品。</p>
|
||||
|
||||
<!-- 提交成功或失败的提示信息 -->
|
||||
<div v-if="submitStatus === 'success'" class="feedback-message success">
|
||||
✓ 感谢您的反馈!我们已经收到您的宝贵意见。
|
||||
</div>
|
||||
<div v-if="submitStatus === 'error'" class="feedback-message error">
|
||||
✗ 提交失败,请检查网络或稍后重试。
|
||||
</div>
|
||||
|
||||
<form v-if="submitStatus !== 'success'" @submit.prevent="handleSubmit">
|
||||
<!-- 反馈类型 -->
|
||||
<div class="form-group">
|
||||
<label for="feedback-type">反馈类型</label>
|
||||
<select id="feedback-type" v-model="formData.type">
|
||||
<option>功能建议</option>
|
||||
<option>界面优化</option>
|
||||
<option>Bug 反馈</option>
|
||||
<option>其他</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- 反馈内容 -->
|
||||
<div class="form-group">
|
||||
<label for="feedback-content">反馈内容 (必填)</label>
|
||||
<textarea
|
||||
id="feedback-content"
|
||||
v-model="formData.content"
|
||||
placeholder="请详细描述您的问题或建议..."
|
||||
rows="6"
|
||||
required
|
||||
></textarea>
|
||||
</div>
|
||||
<!-- 提交按钮 -->
|
||||
<div class="form-actions">
|
||||
<button type="submit" :disabled="isSubmitting">
|
||||
<span v-if="isSubmitting">正在提交...</span>
|
||||
<span v-else>提交反馈</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import axios from 'axios';
|
||||
import { ref, reactive } from 'vue';
|
||||
|
||||
// 定义组件的props
|
||||
const props = defineProps({
|
||||
isVisible: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
});
|
||||
|
||||
// 定义组件要触发的事件
|
||||
const emit = defineEmits(['submit-feedback', 'close']);
|
||||
|
||||
// 使用 reactive 创建响应式表单数据对象
|
||||
const formData = reactive({
|
||||
type: '功能建议', // 默认值
|
||||
content: '',
|
||||
contact: '',
|
||||
screenshot: null, // 存储文件对象
|
||||
});
|
||||
|
||||
// ref 用于独立的响应式值
|
||||
const isSubmitting = ref(false); // 是否正在提交
|
||||
const submitStatus = ref(null); // 'success', 'error', or null
|
||||
const imagePreviewUrl = ref(null); // 图片预览 URL
|
||||
const fileInputRef = ref(null); // 用于引用文件输入元素
|
||||
|
||||
// 文件选择变化时的处理函数
|
||||
const handleFileChange = (event) => {
|
||||
const file = event.target.files[0];
|
||||
if (file) {
|
||||
formData.screenshot = file;
|
||||
// 创建一个临时的 URL 用于图片预览
|
||||
imagePreviewUrl.value = URL.createObjectURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
// 移除已选图片
|
||||
const removeImage = () => {
|
||||
formData.screenshot = null;
|
||||
imagePreviewUrl.value = null;
|
||||
// 清空文件输入框的值,以便用户可以再次选择相同的文件
|
||||
if (fileInputRef.value) {
|
||||
fileInputRef.value.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
// 关闭模态框
|
||||
const closeModal = () => {
|
||||
emit('close');
|
||||
};
|
||||
|
||||
// 表单提交处理函数
|
||||
const handleSubmit = async () => {
|
||||
// 简单验证
|
||||
if (!formData.content.trim()) {
|
||||
alert('反馈内容不能为空!');
|
||||
return;
|
||||
}
|
||||
|
||||
isSubmitting.value = true;
|
||||
submitStatus.value = null;
|
||||
|
||||
try {
|
||||
// 创建 FormData 对象
|
||||
// 获取 token (假设存储在 localStorage 中)
|
||||
const token = localStorage.getItem('token') || '';
|
||||
|
||||
// 发送 POST 请求到后端接口
|
||||
const response = await axios.post('https://feedback.api.nycjy.cn/api/v1/feedback/submit_feedback', {project:'mldash',type: formData.type, content: formData.content}, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
// console.log('响应状态8888:', response.data.message);
|
||||
// 提交成功
|
||||
submitStatus.value = 'success';
|
||||
// 触发父组件的事件,并传递数据
|
||||
emit('submit-feedback', { ...formData });
|
||||
} catch (error) {
|
||||
console.error('提交反馈失败:', error);
|
||||
// 提交失败
|
||||
submitStatus.value = 'error';
|
||||
} finally {
|
||||
// 请求结束
|
||||
isSubmitting.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal-container {
|
||||
background-color: #ffffff;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
max-width: 600px;
|
||||
width: 90%;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem 2rem;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.modal-header h3 {
|
||||
margin: 0;
|
||||
color: #1a202c;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.modal-close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
color: #718096;
|
||||
padding: 0;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.modal-close-btn:hover {
|
||||
color: #1a202c;
|
||||
}
|
||||
|
||||
.feedback-form-container {
|
||||
padding: 2rem;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
}
|
||||
|
||||
h2 {
|
||||
text-align: center;
|
||||
color: #1a202c;
|
||||
margin-top: 0;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
text-align: center;
|
||||
color: #718096;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 600;
|
||||
color: #4a5568;
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
select,
|
||||
textarea {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid #cbd5e0;
|
||||
border-radius: 6px;
|
||||
font-size: 1rem;
|
||||
color: #2d3748;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
input[type="text"]:focus,
|
||||
select:focus,
|
||||
textarea:focus {
|
||||
outline: none;
|
||||
border-color: #4299e1;
|
||||
box-shadow: 0 0 0 3px rgba(66, 153, 225, 0.5);
|
||||
}
|
||||
|
||||
input[type="file"] {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.image-preview {
|
||||
position: relative;
|
||||
margin-top: 1rem;
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
.image-preview img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.remove-image-btn {
|
||||
position: absolute;
|
||||
top: -10px;
|
||||
right: -10px;
|
||||
background-color: #e53e3e;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
button[type="submit"] {
|
||||
padding: 0.75rem 2rem;
|
||||
background-color: #4299e1;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
button[type="submit"]:hover:not(:disabled) {
|
||||
background-color: #3182ce;
|
||||
}
|
||||
|
||||
button[type="submit"]:disabled {
|
||||
background-color: #a0aec0;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.feedback-message {
|
||||
padding: 1rem;
|
||||
border-radius: 6px;
|
||||
text-align: center;
|
||||
margin-bottom: 1.5rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.feedback-message.success {
|
||||
background-color: #c6f6d5;
|
||||
color: #2f855a;
|
||||
border: 1px solid #9ae6b4;
|
||||
}
|
||||
|
||||
.feedback-message.error {
|
||||
background-color: #fed7d7;
|
||||
color: #c53030;
|
||||
border: 1px solid #feb2b2;
|
||||
}
|
||||
</style>
|
||||
@@ -25,6 +25,13 @@
|
||||
</svg>
|
||||
修改密码
|
||||
</div>
|
||||
<div class="dropdown-item" @click="handleDisplaySettings">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" style="margin-right: 8px;">
|
||||
<path d="M8 4.754a3.246 3.246 0 1 0 0 6.492 3.246 3.246 0 0 0 0-6.492zM5.754 8a2.246 2.246 0 1 1 4.492 0 2.246 2.246 0 0 1-4.492 0z" fill="currentColor"/>
|
||||
<path d="M9.796 1.343c-.527-1.79-3.065-1.79-3.592 0l-.094.319a.873.873 0 0 1-1.255.52l-.292-.16c-1.64-.892-3.433.902-2.54 2.541l.159.292a.873.873 0 0 1-.52 1.255l-.319.094c-1.79.527-1.79 3.065 0 3.592l.319.094a.873.873 0 0 1 .52 1.255l-.16.292c-.892 1.64.901 3.434 2.541 2.54l.292-.159a.873.873 0 0 1 1.255.52l.094.319c.527 1.79 3.065 1.79 3.592 0l.094-.319a.873.873 0 0 1 1.255-.52l.292.16c1.64.893 3.434-.902 2.54-2.541l-.159-.292a.873.873 0 0 1 .52-1.255l.319-.094c1.79-.527 1.79-3.065 0-3.592l-.319-.094a.873.873 0 0 1-.52-1.255l.16-.292c.893-1.64-.902-3.433-2.541-2.54l-.292.159a.873.873 0 0 1-1.255-.52l-.094-.319zm-2.633.283c.246-.835 1.428-.835 1.674 0l.094.319a1.873 1.873 0 0 0 2.693 1.115l.291-.16c.764-.415 1.6.42 1.184 1.185l-.159.292a1.873 1.873 0 0 0 1.116 2.692l.318.094c.835.246.835 1.428 0 1.674l-.319.094a1.873 1.873 0 0 0-1.115 2.693l.16.291c.415.764-.42 1.6-1.185 1.184l-.291-.159a1.873 1.873 0 0 0-2.693 1.116l-.094.318c-.246.835-1.428.835-1.674 0l-.094-.319a1.873 1.873 0 0 0-2.692-1.115l-.292.16c-.764.415-1.6-.42-1.184-1.185l.159-.291A1.873 1.873 0 0 0 1.945 8.93l-.319-.094c-.835-.246-.835-1.428 0-1.674l.319-.094A1.873 1.873 0 0 0 3.06 4.377l-.16-.292c-.415-.764.42-1.6 1.185-1.184l.292.159a1.873 1.873 0 0 0 2.692-1.115l.094-.319z" fill="currentColor"/>
|
||||
</svg>
|
||||
显示设置
|
||||
</div>
|
||||
<div class="dropdown-item logout-item" @click="handleLogout">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" style="margin-right: 8px;">
|
||||
<path d="M6 2a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h4a1 1 0 0 0 1-1V3a1 1 0 0 0-1-1H6zM5 3a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V3z" fill="currentColor"/>
|
||||
@@ -148,6 +155,49 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 显示设置弹窗 -->
|
||||
<div v-if="showDisplayModal" class="display-modal-overlay" @click="cancelDisplaySettings">
|
||||
<div class="display-modal" @click.stop>
|
||||
<div class="display-modal-header">
|
||||
<h2>显示设置</h2>
|
||||
<p>选择要显示的模块</p>
|
||||
</div>
|
||||
|
||||
<div class="display-modal-body">
|
||||
<div class="checkbox-group">
|
||||
<label v-for="(visible, key) in localCardVisibility" :key="key" class="checkbox-item">
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="localCardVisibility[key]"
|
||||
:disabled="displayLoading"
|
||||
/>
|
||||
<span class="checkbox-label">{{ getCardDisplayName(key) }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="display-modal-footer">
|
||||
<button
|
||||
type="button"
|
||||
class="btn-cancel"
|
||||
@click="cancelDisplaySettings"
|
||||
:disabled="displayLoading"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-confirm"
|
||||
@click="handleDisplaySubmit"
|
||||
:disabled="displayLoading"
|
||||
>
|
||||
<span v-if="displayLoading" class="loading-spinner"></span>
|
||||
{{ displayLoading ? '应用中...' : '确认应用' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 退出登录确认弹窗 -->
|
||||
<div v-if="showLogoutModal" class="logout-modal-overlay" @click="cancelLogout">
|
||||
<div class="logout-modal" @click.stop>
|
||||
@@ -166,11 +216,28 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { ref, reactive, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import http from '@/utils/https'
|
||||
|
||||
// Props
|
||||
const props = defineProps({
|
||||
cardVisibility: {
|
||||
type: Object,
|
||||
default: () => ({
|
||||
timeline: true,
|
||||
rawData: true,
|
||||
customerDetail: true,
|
||||
analytics: true,
|
||||
weekAnalysis: true
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Emits
|
||||
const emit = defineEmits(['update-card-visibility'])
|
||||
|
||||
// 路由实例
|
||||
const router = useRouter()
|
||||
|
||||
@@ -192,6 +259,50 @@ const passwordForm = ref({
|
||||
newPassword: ''
|
||||
}) // 修改密码表单数据
|
||||
const passwordLoading = ref(false) // 修改密码加载状态
|
||||
const showDisplayModal = ref(false) // 显示设置弹窗显示状态
|
||||
const displayLoading = ref(false) // 显示设置加载状态
|
||||
const localCardVisibility = reactive({}) // 本地卡片显示状态
|
||||
|
||||
// 监听props变化,同步到本地状态
|
||||
watch(() => props.cardVisibility, (newVal) => {
|
||||
Object.assign(localCardVisibility, newVal)
|
||||
}, { immediate: true, deep: true })
|
||||
|
||||
// 获取卡片显示名称
|
||||
const getCardDisplayName = (key) => {
|
||||
const nameMap = {
|
||||
// sale.vue 页面的卡片
|
||||
timeline: '销售时间线',
|
||||
rawData: '原始数据',
|
||||
customerDetail: '客户详情',
|
||||
analytics: '数据分析',
|
||||
weekAnalysis: '周期分析',
|
||||
// seniorManager.vue 页面的卡片
|
||||
centerOverview: '中心概览',
|
||||
teamAlerts: '团队预警',
|
||||
statisticalIndicators: '统计指标',
|
||||
groupRanking: '组别排名',
|
||||
problemRanking: '问题排名',
|
||||
groupComparison: '组别对比',
|
||||
teamDetail: '团队详情',
|
||||
// secondTop.vue 页面的卡片
|
||||
actionItems: '行动项目',
|
||||
customerType: '客户类型',
|
||||
goodMusic: '优秀录音',
|
||||
// topone.vue 页面的卡片
|
||||
kpiMetrics: '核心业绩指标',
|
||||
salesProgress: '销售实时进度',
|
||||
periodStage: '各中心营期阶段',
|
||||
funnelChart: '转化漏斗',
|
||||
personalSalesRanking: '销售个人业绩排行榜',
|
||||
qualityCalls: '优质通话',
|
||||
rankingList: '业绩排行榜',
|
||||
problemRanking: '客户迫切解决的问题排行榜',
|
||||
campManagement: '营期管理',
|
||||
detailedDataTable: '详细数据表格'
|
||||
}
|
||||
return nameMap[key] || key
|
||||
}
|
||||
|
||||
// 切换下拉菜单显示状态
|
||||
const toggleDropdown = () => {
|
||||
@@ -309,6 +420,39 @@ const cancelPasswordChange = () => {
|
||||
passwordForm.value.newPassword = ''
|
||||
}
|
||||
|
||||
// 显示设置
|
||||
const handleDisplaySettings = () => {
|
||||
console.log('显示设置')
|
||||
showDropdown.value = false
|
||||
showDisplayModal.value = true
|
||||
}
|
||||
|
||||
// 显示设置处理函数
|
||||
const handleDisplaySubmit = async () => {
|
||||
displayLoading.value = true
|
||||
|
||||
try {
|
||||
// 发送事件给父组件更新卡片显示状态
|
||||
emit('update-card-visibility', { ...localCardVisibility })
|
||||
|
||||
// 模拟异步操作
|
||||
await new Promise(resolve => setTimeout(resolve, 300))
|
||||
|
||||
showDisplayModal.value = false
|
||||
} catch (error) {
|
||||
console.error('显示设置失败:', error)
|
||||
} finally {
|
||||
displayLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 取消显示设置
|
||||
const cancelDisplaySettings = () => {
|
||||
showDisplayModal.value = false
|
||||
// 恢复到原始状态
|
||||
Object.assign(localCardVisibility, props.cardVisibility)
|
||||
}
|
||||
|
||||
// 退出登录
|
||||
const handleLogout = () => {
|
||||
showDropdown.value = false
|
||||
@@ -319,15 +463,21 @@ const handleLogout = () => {
|
||||
const confirmLogout = () => {
|
||||
console.log('用户确认退出登录')
|
||||
|
||||
// 清除用户信息(如果有的话)
|
||||
// localStorage.removeItem('token')
|
||||
// sessionStorage.clear()
|
||||
|
||||
// 关闭弹窗
|
||||
showLogoutModal.value = false
|
||||
|
||||
// 跳转到登录页面
|
||||
router.push('/login')
|
||||
try {
|
||||
// 清除用户状态
|
||||
userStore.logout()
|
||||
|
||||
// 关闭弹窗
|
||||
showLogoutModal.value = false
|
||||
|
||||
// 跳转到登录页面
|
||||
router.push('/login')
|
||||
} catch (error) {
|
||||
console.error('退出登录失败:', error)
|
||||
// 即使出错也要关闭弹窗并跳转
|
||||
showLogoutModal.value = false
|
||||
router.push('/login')
|
||||
}
|
||||
}
|
||||
|
||||
// 取消退出登录
|
||||
@@ -649,6 +799,142 @@ const cancelLogout = () => {
|
||||
}
|
||||
}
|
||||
|
||||
/* 显示设置弹窗样式 */
|
||||
.display-modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 9999;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.display-modal {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||
min-width: 400px;
|
||||
max-width: 500px;
|
||||
animation: modalSlideIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
.display-modal-header {
|
||||
padding: 24px 24px 16px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
.display-modal-header h2 {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.display-modal-header p {
|
||||
font-size: 14px;
|
||||
color: #64748b;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.display-modal-body {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.checkbox-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.checkbox-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
padding: 8px;
|
||||
border-radius: 6px;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.checkbox-item:hover {
|
||||
background-color: #f8fafc;
|
||||
}
|
||||
|
||||
.checkbox-item input[type="checkbox"] {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin-right: 12px;
|
||||
cursor: pointer;
|
||||
accent-color: #667eea;
|
||||
}
|
||||
|
||||
.checkbox-item input[type="checkbox"]:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
font-size: 14px;
|
||||
color: #374151;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.display-modal-footer {
|
||||
padding: 16px 24px 24px 24px;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.display-modal-footer .btn-cancel,
|
||||
.display-modal-footer .btn-confirm {
|
||||
padding: 12px 24px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
border: none;
|
||||
min-width: 80px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.display-modal-footer .btn-cancel {
|
||||
background: #f8fafc;
|
||||
color: #64748b;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.display-modal-footer .btn-cancel:hover:not(:disabled) {
|
||||
background: #f1f5f9;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.display-modal-footer .btn-confirm {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.display-modal-footer .btn-confirm:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
|
||||
.display-modal-footer .btn-cancel:disabled,
|
||||
.display-modal-footer .btn-confirm:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* 修改密码弹窗样式 */
|
||||
.password-modal-overlay {
|
||||
position: fixed;
|
||||
|
||||
@@ -11,43 +11,43 @@ const routes = [
|
||||
{
|
||||
path: '/',
|
||||
name: 'Home',
|
||||
redirect: '/sale'
|
||||
redirect: '/login'
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
name: 'Login',
|
||||
component: Login,
|
||||
// meta: { requiresAuth: false }
|
||||
meta: { requiresAuth: false }
|
||||
},
|
||||
{
|
||||
path: '/sale',
|
||||
name: 'Sale',
|
||||
component: Sale,
|
||||
// meta: { requiresAuth: true, minLevel: 1 }
|
||||
meta: { requiresAuth: true, minLevel: 1 }
|
||||
},
|
||||
{
|
||||
path: '/manager',
|
||||
name: 'Manager',
|
||||
component: Manager,
|
||||
// meta: { requiresAuth: true, minLevel: 2 }
|
||||
meta: { requiresAuth: true, minLevel: 2 }
|
||||
},
|
||||
{
|
||||
path: '/senior-manager',
|
||||
name: 'SeniorManager',
|
||||
component: SeniorManager,
|
||||
// meta: { requiresAuth: true, minLevel: 3 }
|
||||
meta: { requiresAuth: true, minLevel: 3 }
|
||||
},
|
||||
{
|
||||
path: '/second-top',
|
||||
name: 'SecondTop',
|
||||
component: SecondTop,
|
||||
// meta: { requiresAuth: true, minLevel: 4 }
|
||||
meta: { requiresAuth: true, minLevel: 4 }
|
||||
},
|
||||
{
|
||||
path: '/top',
|
||||
name: 'Top',
|
||||
component: TopOne,
|
||||
// meta: { requiresAuth: true, minLevel: 5 }
|
||||
meta: { requiresAuth: true, minLevel: 5 }
|
||||
}
|
||||
]
|
||||
|
||||
@@ -57,15 +57,59 @@ const router = createRouter({
|
||||
})
|
||||
|
||||
// 路由守卫
|
||||
router.beforeEach((to, from, next) => {
|
||||
router.beforeEach(async (to, from, next) => {
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 检查URL参数中是否包含token和用户信息(来自其他页面的跳转)
|
||||
const urlToken = to.query.token
|
||||
const urlUsername = to.query.username
|
||||
const urlLevel = to.query.level
|
||||
|
||||
// 如果URL中包含token和用户信息,进行自动登录
|
||||
if (urlToken && urlUsername && urlLevel) {
|
||||
try {
|
||||
// 解码用户名(URL编码的中文)
|
||||
const decodedUsername = decodeURIComponent(urlUsername)
|
||||
|
||||
// 直接设置用户信息到store(跳过API验证,因为token来自可信源)
|
||||
userStore.login(urlToken, decodedUsername, parseInt(urlLevel), '', '')
|
||||
|
||||
// 根据用户等级跳转到对应页面
|
||||
const defaultRoutes = {
|
||||
1: '/sale',
|
||||
2: '/manager',
|
||||
3: '/senior-manager',
|
||||
4: '/second-top',
|
||||
5: '/top'
|
||||
}
|
||||
|
||||
const targetRoute = defaultRoutes[parseInt(urlLevel)] || '/sale'
|
||||
|
||||
// 如果当前路由就是目标路由,直接通过;否则重定向
|
||||
if (to.path === targetRoute) {
|
||||
next()
|
||||
} else {
|
||||
next(targetRoute)
|
||||
}
|
||||
return
|
||||
} catch (error) {
|
||||
console.error('自动登录失败:', error)
|
||||
// 如果自动登录失败,继续正常的路由守卫逻辑
|
||||
}
|
||||
}
|
||||
|
||||
// 如果路由不需要认证,直接通过
|
||||
if (!to.meta.requiresAuth) {
|
||||
next()
|
||||
return
|
||||
}
|
||||
|
||||
// 如果访问登录页面,始终允许访问(允许重新登录)
|
||||
if (to.path === '/login') {
|
||||
next()
|
||||
return
|
||||
}
|
||||
|
||||
// 检查是否已登录
|
||||
if (!userStore.isLoggedIn || !userStore.userInfo) {
|
||||
next('/login')
|
||||
|
||||
135
my-vue-app/src/stores/cache.js
Normal file
135
my-vue-app/src/stores/cache.js
Normal file
@@ -0,0 +1,135 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
export const useCacheStore = defineStore('cache', () => {
|
||||
// 缓存Map
|
||||
const cache = ref(new Map())
|
||||
const CACHE_DURATION = 30 * 60 * 1000 // 30分钟缓存时长
|
||||
|
||||
// 生成缓存键
|
||||
const getCacheKey = (apiName, params = {}) => {
|
||||
const sortedParams = Object.keys(params)
|
||||
.sort()
|
||||
.reduce((result, key) => {
|
||||
result[key] = params[key]
|
||||
return result
|
||||
}, {})
|
||||
return `${apiName}_${JSON.stringify(sortedParams)}`
|
||||
}
|
||||
|
||||
// 检查缓存是否有效
|
||||
const isValidCache = (cacheData) => {
|
||||
return cacheData && (Date.now() - cacheData.timestamp) < CACHE_DURATION
|
||||
}
|
||||
|
||||
// 设置缓存
|
||||
const setCache = (key, data) => {
|
||||
cache.value.set(key, {
|
||||
data,
|
||||
timestamp: Date.now()
|
||||
})
|
||||
}
|
||||
|
||||
// 获取缓存
|
||||
const getCache = (key) => {
|
||||
const cacheData = cache.value.get(key)
|
||||
if (isValidCache(cacheData)) {
|
||||
return cacheData.data
|
||||
}
|
||||
// 如果缓存过期,删除它
|
||||
if (cacheData) {
|
||||
cache.value.delete(key)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// 缓存包装器函数
|
||||
const withCache = async (apiName, apiFunction, params = {}) => {
|
||||
const cacheKey = getCacheKey(apiName, params)
|
||||
const cachedData = getCache(cacheKey)
|
||||
|
||||
if (cachedData) {
|
||||
console.log(`[缓存命中] ${apiName}:`, cachedData)
|
||||
return cachedData
|
||||
}
|
||||
|
||||
console.log(`[API调用] ${apiName}`)
|
||||
const result = await apiFunction(params)
|
||||
setCache(cacheKey, result)
|
||||
return result
|
||||
}
|
||||
|
||||
// 清除所有缓存
|
||||
const clearCache = () => {
|
||||
cache.value.clear()
|
||||
console.log('所有缓存已清除')
|
||||
}
|
||||
|
||||
// 清除特定缓存
|
||||
const clearSpecificCache = (apiName, params = {}) => {
|
||||
const key = getCacheKey(apiName, params)
|
||||
cache.value.delete(key)
|
||||
console.log(`已清除缓存: ${key}`)
|
||||
}
|
||||
|
||||
// 获取缓存信息并清理过期缓存
|
||||
const getCacheInfo = () => {
|
||||
const now = Date.now()
|
||||
const validCaches = []
|
||||
const expiredCaches = []
|
||||
|
||||
for (const [key, data] of cache.value.entries()) {
|
||||
if (isValidCache(data)) {
|
||||
validCaches.push({
|
||||
key,
|
||||
timestamp: data.timestamp,
|
||||
age: Math.round((now - data.timestamp) / 1000) + 's'
|
||||
})
|
||||
} else {
|
||||
expiredCaches.push(key)
|
||||
cache.value.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
validCount: validCaches.length,
|
||||
expiredCount: expiredCaches.length,
|
||||
validCaches,
|
||||
expiredCaches
|
||||
}
|
||||
}
|
||||
|
||||
// 清除过期缓存
|
||||
const clearExpiredCache = () => {
|
||||
const info = getCacheInfo()
|
||||
console.log(`清理了 ${info.expiredCount} 个过期缓存`)
|
||||
return info
|
||||
}
|
||||
|
||||
// 获取缓存统计信息
|
||||
const getCacheStats = () => {
|
||||
return {
|
||||
totalCount: cache.value.size,
|
||||
duration: CACHE_DURATION / (1000 * 60) + '分钟',
|
||||
...getCacheInfo()
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// 状态
|
||||
cache,
|
||||
CACHE_DURATION,
|
||||
|
||||
// 方法
|
||||
getCacheKey,
|
||||
isValidCache,
|
||||
setCache,
|
||||
getCache,
|
||||
withCache,
|
||||
clearCache,
|
||||
clearSpecificCache,
|
||||
getCacheInfo,
|
||||
clearExpiredCache,
|
||||
getCacheStats
|
||||
}
|
||||
})
|
||||
@@ -5,7 +5,8 @@ import { useUserStore } from '@/stores/user'
|
||||
|
||||
// 创建axios实例
|
||||
const service = axios.create({
|
||||
baseURL: 'http://192.168.15.54:8890' || '', // API基础路径,支持完整URL
|
||||
baseURL: 'https://mldash.nycjy.cn/' || '', // API基础路径,支持完整URL
|
||||
// baseURL: 'http://192.168.15.121:8890' || '', // API基础路径,支持完整URL
|
||||
timeout: 100000, // 请求超时时间
|
||||
headers: {
|
||||
'Content-Type': 'application/json;charset=UTF-8'
|
||||
@@ -15,9 +16,6 @@ const service = axios.create({
|
||||
// 请求拦截器
|
||||
service.interceptors.request.use(
|
||||
config => {
|
||||
// 在发送请求之前做些什么
|
||||
// console.log('发送请求:', config)
|
||||
|
||||
// 添加token到请求头
|
||||
const userStore = useUserStore()
|
||||
const token = userStore.token
|
||||
@@ -31,13 +29,9 @@ service.interceptors.request.use(
|
||||
_t: Date.now()
|
||||
}
|
||||
}
|
||||
|
||||
// 显示加载状态
|
||||
if (config.showLoading !== false) {
|
||||
// 可以在这里添加全局loading
|
||||
console.log('显示加载中...')
|
||||
}
|
||||
|
||||
}
|
||||
return config
|
||||
},
|
||||
error => {
|
||||
@@ -47,16 +41,10 @@ service.interceptors.request.use(
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
// 响应拦截器
|
||||
service.interceptors.response.use(
|
||||
response => {
|
||||
// 隐藏加载状态
|
||||
// console.log('隐藏加载中...')
|
||||
|
||||
// 对响应数据做点什么
|
||||
// console.log('收到响应:', response)
|
||||
|
||||
|
||||
const { data, status } = response
|
||||
|
||||
// HTTP状态码检查
|
||||
|
||||
@@ -260,12 +260,15 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import http from '@/utils/https'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
|
||||
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 响应式数据
|
||||
@@ -392,8 +395,12 @@ const handleLogin = async () => {
|
||||
loading.value = true
|
||||
errorMessage.value = ''
|
||||
|
||||
// 清除本地存储的用户数据,确保使用最新的登录信息
|
||||
userStore.logout()
|
||||
|
||||
try {
|
||||
// 调用登录API
|
||||
// token检测
|
||||
const response = await http.post('/api/v1/login', {
|
||||
username: loginForm.value.username,
|
||||
password: loginForm.value.password
|
||||
@@ -546,6 +553,64 @@ const handleSetSecurity = async () => {
|
||||
const cancelSecuritySetup = () => {
|
||||
alert('首次登录必须设置密保问题')
|
||||
}
|
||||
|
||||
// Token验证登录函数
|
||||
const handleTokenLogin = async (token, username = null, userLevel = null) => {
|
||||
loading.value = true
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
// 如果URL中包含用户信息,直接使用(跳过API验证)
|
||||
if (username && userLevel) {
|
||||
// 解码用户名
|
||||
const decodedUsername = decodeURIComponent(username)
|
||||
|
||||
// 直接设置用户信息到store
|
||||
userStore.login(token, decodedUsername, parseInt(userLevel), '', '')
|
||||
|
||||
// 根据用户等级跳转到对应页面
|
||||
navigateToUserPage(parseInt(userLevel))
|
||||
return
|
||||
}
|
||||
|
||||
// 使用token进行API验证登录
|
||||
const response = await http.post('/api/v1/token_login', {
|
||||
token: token
|
||||
})
|
||||
|
||||
if (response.code === 200 || response.success) {
|
||||
// 保存登录响应数据
|
||||
loginResponseData = response
|
||||
|
||||
// 使用Pinia存储用户信息和token
|
||||
if (response && response.token) {
|
||||
userStore.login(response.token, response.name, response.user_level, response.department, response.department_id)
|
||||
}
|
||||
|
||||
// 检查用户是否重名
|
||||
await checkUserDuplicate()
|
||||
} else {
|
||||
errorMessage.value = response.message || 'Token验证失败'
|
||||
}
|
||||
} catch (error) {
|
||||
errorMessage.value = error.message || 'Token验证失败,请重试'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 组件挂载时检查路由参数中的token
|
||||
onMounted(() => {
|
||||
const token = route.query.token
|
||||
const username = route.query.username
|
||||
const userLevel = route.query.level
|
||||
|
||||
if (token) {
|
||||
// 如果路由参数中有token,进行token验证登录
|
||||
// 如果同时有用户信息,直接使用;否则通过API验证
|
||||
handleTokenLogin(token, username, userLevel)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
1144
my-vue-app/src/views/maneger/components/GoodMusic.vue
Normal file
1144
my-vue-app/src/views/maneger/components/GoodMusic.vue
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -14,7 +14,7 @@
|
||||
v-for="(member, index) in displayMembers"
|
||||
:key="member.user_name || member.id"
|
||||
class="table-row"
|
||||
:class="{ active: selectedMember && (selectedMember.user_name === member.user_name || selectedMember.id === member.id) }"
|
||||
:class="{ active: selectedMember && selectedMember === member }"
|
||||
@click="selectMember(member)"
|
||||
@dblclick="handleDoubleClick(member)"
|
||||
>
|
||||
|
||||
@@ -62,8 +62,7 @@ const aggregatedAlerts = computed(() => {
|
||||
.alert-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
max-height: 300px;
|
||||
max-height: 270px;
|
||||
overflow-y: auto;
|
||||
|
||||
// 自定义滚动条样式
|
||||
@@ -85,14 +84,14 @@ const aggregatedAlerts = computed(() => {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.alert-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
gap: 0.2rem;
|
||||
padding: 0.25rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 0.2rem;
|
||||
|
||||
&.warning {
|
||||
background: #fef3c7;
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
<template>
|
||||
<div class="team-report">
|
||||
<h2>今日团队实时战报</h2>
|
||||
<div class="header-container">
|
||||
<h2>今日团队实时战报</h2>
|
||||
<button class="analysis-button" @click="showTeamAnalysis">团队分析</button>
|
||||
</div>
|
||||
<div class="report-grid">
|
||||
<div class="report-card">
|
||||
<div class="card-header">
|
||||
@@ -12,9 +15,9 @@
|
||||
<div class="report-card">
|
||||
<div class="card-header">
|
||||
<span class="card-title">有效通话时长 <i class="info-icon" @mouseenter="showTooltip('callDuration', $event)" @mouseleave="hideTooltip">ⓘ</i></span>
|
||||
<span class="card-trend negative">{{ weekTotalData.week_total_call?.team_data?.current_rate_last_current || '0%' }} vs 上期</span>
|
||||
<span class="card-trend negative">{{ weekTotalData.group_call_duration?.group_data?.current_rate_last_current || '0%' }} vs 上期</span>
|
||||
</div>
|
||||
<div class="card-value">{{ formatDuration(weekTotalData.week_total_call?.team_data?.total_call_duration)||0 }} 小时</div>
|
||||
<div class="card-value">{{ formatDuration(weekTotalData.group_call_duration.group_data?.current_total_call_time_hour)||0 }} 小时</div>
|
||||
</div>
|
||||
<div class="report-card">
|
||||
<div class="card-header">
|
||||
@@ -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>
|
||||
@@ -70,11 +73,15 @@ const props = defineProps({
|
||||
week_add_customer_total: {},
|
||||
week_add_deal_total: {},
|
||||
week_add_fee_total: {},
|
||||
pay_deposit_to_money_rate: {}
|
||||
pay_deposit_to_money_rate: {},
|
||||
group_call_duration: {}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 定义emit
|
||||
const emit = defineEmits(['show-team-analysis'])
|
||||
|
||||
// 监听数据变化,用于调试
|
||||
watch(() => props.weekTotalData, (newData) => {
|
||||
console.log('TeamReport 收到的数据:', newData)
|
||||
@@ -121,12 +128,12 @@ const metricDescriptions = {
|
||||
description: '本期新增的成交订单数量,已确认付款或签约的客户订单。'
|
||||
},
|
||||
monthlyRevenue: {
|
||||
title: '月度总业绩',
|
||||
description: '本月团队累计完成的销售业绩总额,包括所有已确认的订单金额。'
|
||||
title: '本月成交单数',
|
||||
description: '本月团队累计完成的销售订单数量,包括所有已确认的订单。'
|
||||
},
|
||||
conversionRate: {
|
||||
title: '定金转化率',
|
||||
description: '支付定金的客户数 ÷ 意向客户总数,反映客户从意向到付费的转化效果。'
|
||||
description: '支付定金的客户数 ÷ 意向客户总数'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,6 +152,11 @@ const showTooltip = (metricType, event) => {
|
||||
const hideTooltip = () => {
|
||||
tooltip.visible = false
|
||||
}
|
||||
|
||||
// 显示团队分析
|
||||
const showTeamAnalysis = () => {
|
||||
emit('show-team-analysis')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@@ -155,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 {
|
||||
|
||||
@@ -37,9 +37,11 @@
|
||||
<!-- Top Section - Team Alerts and Today's Report -->
|
||||
<div class="top-section">
|
||||
<!-- Team Alerts -->
|
||||
<TeamAlerts :abnormalData="groupAbnormalResponse" />
|
||||
<!-- Today's Team Report -->
|
||||
<TeamReport :weekTotalData="weekTotalData" />
|
||||
<!-- <TeamAlerts :abnormalData="groupAbnormalResponse" /> -->
|
||||
<GoodMusic :quality-calls="excellentRecord"
|
||||
/>
|
||||
<!-- Today's Team Report -->
|
||||
<TeamReport :weekTotalData="weekTotalData" @show-team-analysis="fetchTeamAnalysis" />
|
||||
|
||||
</div>
|
||||
<!-- Sales Funnel Section -->
|
||||
@@ -60,27 +62,47 @@
|
||||
<!-- Right Section -->
|
||||
<div class="right-section">
|
||||
<!-- Member Details -->
|
||||
<MemberDetails :selected-member="selectedMember" />
|
||||
<MemberDetails :selected-member="selectedMember" :memberDetails="memberDetails" />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<!-- 团队分析弹窗 -->
|
||||
<div v-if="showTeamAnalysisModal" class="modal-overlay" @click="showTeamAnalysisModal = false">
|
||||
<div class="modal-content" @click.stop>
|
||||
<div class="modal-header">
|
||||
<h3>团队整体三阶分析报告</h3>
|
||||
<button class="close-button" @click="showTeamAnalysisModal = false">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div v-for="(report, index) in teamAnalysisData" :key="index" class="report-item">
|
||||
<div class="report-meta">
|
||||
<span class="time-range">{{ report.start_time }} 至 {{ report.end_time }}</span>
|
||||
<span class="created-at">生成时间: {{ report.created_at }}</span>
|
||||
</div>
|
||||
<div class="report-content" v-html="formatReportContent(report.report)"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from "vue";
|
||||
import TeamAlerts from "./components/TeamAlerts.vue";
|
||||
import GoodMusic from "./components/GoodMusic.vue";
|
||||
import TeamReport from "./components/TeamReport.vue";
|
||||
import SalesFunnel from "./components/SalesFunnel.vue";
|
||||
import PerformanceRanking from "./components/PerformanceRanking.vue";
|
||||
import MemberDetails from "./components/MemberDetails.vue";
|
||||
import Sale from "../person/Sale.vue";
|
||||
import Sale from "../person/sale.vue";
|
||||
import SalesTimelineWithTaskList from "../person/components/SalesTimelineWithTaskList.vue";
|
||||
import RawDataCards from "../person/components/RawDataCards.vue";
|
||||
import CustomerDetail from "../person/components/CustomerDetail.vue";
|
||||
import { useUserStore } from "@/stores/user";
|
||||
import { useRouter } from "vue-router";
|
||||
import {getGroupAbnormalResponse, getWeekTotalCall, getWeekAddCustomerTotal, getWeekAddDealTotal, getWeekAddFeeTotal, getGroupFunnel,getPayDepositToMoneyRate,getGroupRanking } from "@/api/manager.js";
|
||||
import {getGroupAbnormalResponse, getWeekTotalCall, getWeekAddCustomerTotal, getWeekAddDealTotal,
|
||||
getWeekAddFeeTotal, getGroupFunnel,getPayDepositToMoneyRate,getGroupRanking, getGroupCallDuration,getGroupDetail, getGroupEntiretyThirdReport,getExcellentRecordFile} from "@/api/manager.js";
|
||||
|
||||
// 团队成员数据
|
||||
const teamMembers = [
|
||||
@@ -95,54 +117,6 @@ const teamMembers = [
|
||||
newClients: 12,
|
||||
deals: 5,
|
||||
avgDealValue: 24000,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "张明",
|
||||
rank: 2,
|
||||
performance: 85000,
|
||||
conversion: 5.0,
|
||||
calls: 142,
|
||||
callTime: 6.2,
|
||||
newClients: 8,
|
||||
deals: 3,
|
||||
avgDealValue: 28333,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: "王强",
|
||||
rank: 3,
|
||||
performance: 65000,
|
||||
conversion: 4.0,
|
||||
calls: 128,
|
||||
callTime: 5.8,
|
||||
newClients: 6,
|
||||
deals: 2,
|
||||
avgDealValue: 32500,
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: "赵静",
|
||||
rank: 4,
|
||||
performance: 0,
|
||||
conversion: 0.0,
|
||||
calls: 89,
|
||||
callTime: 3.2,
|
||||
newClients: 2,
|
||||
deals: 0,
|
||||
avgDealValue: 0,
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: "刘洋",
|
||||
rank: 5,
|
||||
performance: 0,
|
||||
conversion: 0.0,
|
||||
calls: 76,
|
||||
callTime: 2.8,
|
||||
newClients: 1,
|
||||
deals: 0,
|
||||
avgDealValue: 0,
|
||||
}
|
||||
];
|
||||
|
||||
@@ -155,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()
|
||||
@@ -165,6 +140,14 @@ const getRequestParams = () => {
|
||||
if (routeUserName) {
|
||||
params.user_name = routeUserName
|
||||
}
|
||||
|
||||
// 如果没有路由参数,使用当前登录用户的信息
|
||||
if (!params.user_level && userStore.userInfo?.user_level) {
|
||||
params.user_level = userStore.userInfo.user_level.toString()
|
||||
}
|
||||
if (!params.user_name && userStore.userInfo?.username) {
|
||||
params.user_name = userStore.userInfo.username
|
||||
}
|
||||
|
||||
return params
|
||||
}
|
||||
@@ -192,17 +175,53 @@ const weekTotalData = ref({
|
||||
week_add_fee_total: {},
|
||||
pay_deposit_to_money_rate: {},
|
||||
group_funnel: {},
|
||||
week_add_fee_total: {},
|
||||
});
|
||||
group_call_duration: {},
|
||||
})
|
||||
// 团队异常预警
|
||||
const groupAbnormalResponse = ref({})
|
||||
async function TeamGetGroupAbnormalResponse() {
|
||||
const params = getRequestParams()
|
||||
const hasParams = params.user_name
|
||||
const res = await getGroupAbnormalResponse(hasParams ? params : undefined)
|
||||
console.log(res)
|
||||
if (res.code === 200) {
|
||||
groupAbnormalResponse.value = res.data
|
||||
try {
|
||||
const response = await getGroupAbnormalResponse(hasParams ? params : undefined)
|
||||
const rawData = response.data
|
||||
|
||||
// 转换数据格式,生成预警消息
|
||||
const processedAlerts = []
|
||||
let alertId = 1
|
||||
|
||||
// 处理严重超时异常人员
|
||||
const timeoutPersons = rawData?.serious_timeout_rate_abnorma || []
|
||||
// 处理表格填写异常人员
|
||||
const fillingPersons = rawData?.table_filling_abnormal || []
|
||||
|
||||
// 为每个异常人员生成独立的预警消息
|
||||
|
||||
// 处理严重超时率异常人员
|
||||
timeoutPersons.forEach(person => {
|
||||
processedAlerts.push({
|
||||
id: `timeout-${alertId++}`,
|
||||
type: 'warning',
|
||||
icon: '⚠',
|
||||
message: `${person}严重超时率过高`
|
||||
})
|
||||
})
|
||||
|
||||
// 处理表格填写率异常人员
|
||||
fillingPersons.forEach(person => {
|
||||
processedAlerts.push({
|
||||
id: `filling-${alertId++}`,
|
||||
type: 'warning',
|
||||
icon: '⚠',
|
||||
message: `${person}表格填写率过低`
|
||||
})
|
||||
})
|
||||
|
||||
// 设置处理后的数据
|
||||
groupAbnormalResponse.value = { processedAlerts }
|
||||
|
||||
} catch (error) {
|
||||
console.error('获取团队异常预警失败:', error)
|
||||
}
|
||||
}
|
||||
// 团队总通话
|
||||
@@ -215,6 +234,16 @@ async function TeamGetWeekTotalCall() {
|
||||
weekTotalData.value.week_total_call = res.data
|
||||
}
|
||||
}
|
||||
// 有效通话时长
|
||||
async function TeamGetGroupCallDuration() {
|
||||
const params = getRequestParams()
|
||||
const hasParams = params.user_name
|
||||
const res = await getGroupCallDuration(hasParams ? params : undefined)
|
||||
console.log(res)
|
||||
if (res.code === 200) {
|
||||
weekTotalData.value.group_call_duration = res.data
|
||||
}
|
||||
}
|
||||
// 新增客户
|
||||
async function TeamGetWeekAddCustomerTotal() {
|
||||
const params = getRequestParams()
|
||||
@@ -235,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 = []
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 定金转化
|
||||
@@ -281,66 +351,127 @@ async function TeamGetGroupRanking() {
|
||||
console.log(res)
|
||||
if (res.code === 200) {
|
||||
groupRanking.value = res.data
|
||||
/**
|
||||
* "data": {
|
||||
"user_name": "马然",
|
||||
"user_level": 2,
|
||||
"team_data": {
|
||||
"group_list": [
|
||||
{
|
||||
"user_name": "马然",
|
||||
"week_amount": 0,
|
||||
"conversion_rate": "0%",
|
||||
"plus_v_rate": "0%",
|
||||
"group_rate": "0%"
|
||||
},
|
||||
{
|
||||
"user_name": "程慧仟",
|
||||
"week_amount": 7100.0,
|
||||
"conversion_rate": "0.00%",
|
||||
"plus_v_rate": "0.00%",
|
||||
"group_rate": "0.00%"
|
||||
},
|
||||
{
|
||||
"user_name": "常琳",
|
||||
"week_amount": 14500.0,
|
||||
"conversion_rate": "3.51%",
|
||||
"plus_v_rate": "54.39%",
|
||||
"group_rate": "49.12%"
|
||||
},
|
||||
{
|
||||
"user_name": "王娟娟",
|
||||
"week_amount": 600.0,
|
||||
"conversion_rate": "0.00%",
|
||||
"plus_v_rate": "3.08%",
|
||||
"group_rate": "0.00%"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
*/
|
||||
}
|
||||
}
|
||||
// 成员详细数据
|
||||
const memberDetails = ref({})
|
||||
|
||||
|
||||
// 当前选中的成员,默认为第一名
|
||||
const selectedMember = ref(teamMembers[0]);
|
||||
// 团队分析数据
|
||||
const teamAnalysisData = ref([])
|
||||
const showTeamAnalysisModal = ref(false)
|
||||
|
||||
// 当前选中的成员,默认为空
|
||||
const selectedMember = ref(null);
|
||||
|
||||
// 选择成员函数
|
||||
const selectMember = (member) => {
|
||||
selectedMember.value = member;
|
||||
console.log(122331,member)
|
||||
TeamGetGroupDetail(member.user_name)
|
||||
};
|
||||
onMounted(async () => {
|
||||
await TeamGetGroupAbnormalResponse()
|
||||
await TeamGetWeekTotalCall()
|
||||
await TeamGetWeekAddCustomerTotal()
|
||||
await TeamGetWeekAddDealTotal()
|
||||
await TeamGetWeekAddFeeTotal()
|
||||
await TeamGetGroupFunnel()
|
||||
await TeamGetGroupRanking()
|
||||
// 成员详细数据
|
||||
async function TeamGetGroupDetail(member) {
|
||||
const res = await getGroupDetail({user_name:member})
|
||||
console.log(res)
|
||||
if (res.code === 200) {
|
||||
memberDetails.value = res.data
|
||||
/**
|
||||
* add_customer_count:32
|
||||
call_count:96
|
||||
month_order_count:5
|
||||
total_call_duration_hour
|
||||
:
|
||||
1.92
|
||||
user_name
|
||||
:
|
||||
"李晓雪"
|
||||
week_order_count
|
||||
:
|
||||
2
|
||||
*/
|
||||
}
|
||||
}
|
||||
|
||||
// 获取团队分析数据
|
||||
const fetchTeamAnalysis = async () => {
|
||||
try {
|
||||
showTeamAnalysisModal.value = true
|
||||
|
||||
const params = getRequestParams()
|
||||
const response = await getGroupEntiretyThirdReport(params)
|
||||
|
||||
// 根据API响应结构调整数据处理逻辑
|
||||
if (response.data) {
|
||||
if (Array.isArray(response.data)) {
|
||||
// 如果response.data本身就是数组
|
||||
teamAnalysisData.value = response.data
|
||||
} else if (response.data.data && Array.isArray(response.data.data)) {
|
||||
// 如果response.data.data是数组
|
||||
teamAnalysisData.value = response.data.data
|
||||
} else {
|
||||
// 其他情况,可能是单个对象
|
||||
teamAnalysisData.value = [response.data]
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取团队分析数据失败:', error)
|
||||
teamAnalysisData.value = []
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化报告内容
|
||||
const formatReportContent = (content) => {
|
||||
if (!content || content === "None") {
|
||||
return "<p>暂无分析报告内容</p>";
|
||||
}
|
||||
|
||||
// 处理报告内容,保留换行和基本格式
|
||||
let formattedContent = content
|
||||
// 替换连续的换行符
|
||||
.replace(/\n\s*\n/g, '</p><p>')
|
||||
// 替换单个换行符为<br>
|
||||
.replace(/\n/g, '<br>')
|
||||
// 替换Markdown风格的标题为HTML标签
|
||||
.replace(/^### (.*?)(<br>|$)/gim, '<h3>$1</h3>')
|
||||
.replace(/^## (.*?)(<br>|$)/gim, '<h2>$1</h2>')
|
||||
.replace(/^# (.*?)(<br>|$)/gim, '<h1>$1</h1>')
|
||||
// 替换粗体
|
||||
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
|
||||
// 替换斜体
|
||||
.replace(/\*(.*?)\*/g, '<em>$1</em>')
|
||||
// 替换无序列表项
|
||||
.replace(/^\* (.*?)(<br>|$)/gim, '<li>$1</li>');
|
||||
|
||||
// 包装列表项到<ul>标签中
|
||||
formattedContent = formattedContent.replace(/(<li>.*?<\/li>)+/g, '<ul>$&</ul>');
|
||||
|
||||
// 处理段落
|
||||
if (!formattedContent.startsWith('<p>')) {
|
||||
formattedContent = '<p>' + formattedContent;
|
||||
}
|
||||
if (!formattedContent.endsWith('</p>')) {
|
||||
formattedContent = formattedContent + '</p>';
|
||||
}
|
||||
|
||||
// 清理多余的<br>标签
|
||||
formattedContent = formattedContent.replace(/<br><\/p>/g, '</p>');
|
||||
|
||||
return formattedContent;
|
||||
}
|
||||
|
||||
// 团队异常预警
|
||||
|
||||
onMounted(async () => {
|
||||
CentergetGoodRecord()
|
||||
TeamGetGroupAbnormalResponse()
|
||||
TeamGetWeekTotalCall()
|
||||
TeamGetGroupCallDuration()
|
||||
TeamGetWeekAddCustomerTotal()
|
||||
TeamGetWeekAddDealTotal()
|
||||
TeamGetWeekAddFeeTotal()
|
||||
TeamGetGroupFunnel()
|
||||
TeamGetGroupRanking()
|
||||
})
|
||||
|
||||
</script>
|
||||
@@ -657,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;
|
||||
}
|
||||
|
||||
// 平板端适配
|
||||
@@ -687,7 +818,7 @@ onMounted(async () => {
|
||||
.analytics-section {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
// PC端保持一致布局
|
||||
@@ -1861,5 +1992,267 @@ onMounted(async () => {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 团队分析弹窗样式 */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
max-width: 90vw;
|
||||
max-height: 90vh;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 1px solid #eee;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.modal-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.close-button {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
color: #999;
|
||||
padding: 0;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.close-button:hover {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 1.5rem;
|
||||
overflow-y: auto;
|
||||
max-height: calc(90vh - 80px);
|
||||
}
|
||||
|
||||
.report-item {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.report-item:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.report-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.9rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.report-content {
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.report-content :deep(h1),
|
||||
.report-content :deep(h2),
|
||||
.report-content :deep(h3),
|
||||
.report-content :deep(h4),
|
||||
.report-content :deep(h5),
|
||||
.report-content :deep(h6) {
|
||||
margin: 1.5rem 0 1rem 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.report-content :deep(h1) {
|
||||
font-size: 1.75rem;
|
||||
border-bottom: 2px solid #eee;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.report-content :deep(h2) {
|
||||
font-size: 1.5rem;
|
||||
border-bottom: 1px solid #eee;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.report-content :deep(h3) {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.report-content :deep(p) {
|
||||
margin: 0.75rem 0;
|
||||
}
|
||||
|
||||
.report-content :deep(ul),
|
||||
.report-content :deep(ol) {
|
||||
margin: 0.75rem 0;
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
||||
.report-content :deep(li) {
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
.report-content :deep(strong) {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.report-content :deep(em) {
|
||||
font-style: italic;
|
||||
}
|
||||
}
|
||||
|
||||
/* 团队分析弹窗 */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
max-width: 90vw;
|
||||
max-height: 90vh;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 1px solid #eee;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.modal-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.close-button {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
color: #999;
|
||||
padding: 0;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.close-button:hover {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 1.5rem;
|
||||
overflow-y: auto;
|
||||
max-height: calc(90vh - 80px);
|
||||
}
|
||||
|
||||
.report-item {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.report-item:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.report-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.9rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.report-content {
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.report-content :deep(h1),
|
||||
.report-content :deep(h2),
|
||||
.report-content :deep(h3),
|
||||
.report-content :deep(h4),
|
||||
.report-content :deep(h5),
|
||||
.report-content :deep(h6) {
|
||||
margin: 1.5rem 0 1rem 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.report-content :deep(h1) {
|
||||
font-size: 1.75rem;
|
||||
border-bottom: 2px solid #eee;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.report-content :deep(h2) {
|
||||
font-size: 1.5rem;
|
||||
border-bottom: 1px solid #eee;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.report-content :deep(h3) {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.report-content :deep(p) {
|
||||
margin: 0.75rem 0;
|
||||
}
|
||||
|
||||
.report-content :deep(ul),
|
||||
.report-content :deep(ol) {
|
||||
margin: 0.75rem 0;
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
||||
.report-content :deep(li) {
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
.report-content :deep(strong) {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.report-content :deep(em) {
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,9 @@
|
||||
<template>
|
||||
<div class="personal-dashboard">
|
||||
<!-- 头部标题 -->
|
||||
<div class="dashboard-header">
|
||||
<div class="dashboard-header" style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<h2>个人工作仪表板</h2>
|
||||
<button @click="showSecondOrderAnalysisReport">阶段分析报告</button>
|
||||
</div>
|
||||
|
||||
<!-- 核心KPI & 统计卡片 -->
|
||||
@@ -13,10 +14,10 @@
|
||||
<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>
|
||||
<div class="kpi-value">{{ props.kpiData.successRate }}</div>
|
||||
<p>电话接通率 <i class="info-icon" @mouseenter="showTooltip('successRate', $event)" @mouseleave="hideTooltip">ⓘ</i></p>
|
||||
</div>
|
||||
<div class="kpi-item">
|
||||
@@ -38,7 +39,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<!-- 统计指标 -->
|
||||
<StatisticData
|
||||
<StatisticData
|
||||
:customerCommunicationRate="props.statisticsData.customerCommunicationRate"
|
||||
:averageResponseTime="props.statisticsData.averageResponseTime"
|
||||
:timeoutResponseRate="props.statisticsData.timeoutResponseRate"
|
||||
@@ -95,7 +96,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- 指标说明 Tooltip -->
|
||||
<Tooltip
|
||||
:visible="tooltip.visible"
|
||||
@@ -104,7 +105,30 @@
|
||||
:title="tooltip.title"
|
||||
:description="tooltip.description"
|
||||
/>
|
||||
|
||||
|
||||
<!-- 阶段分析报告弹框 -->
|
||||
<div v-if="showAnalysisModal" class="modal-overlay" @click.self="closeAnalysisModal">
|
||||
<div class="modal-container">
|
||||
<div class="modal-header">
|
||||
<h3 class="modal-title">阶段分析报告</h3>
|
||||
<button class="modal-close-btn" @click="closeAnalysisModal">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="analysis-content">
|
||||
<div v-if="!analysisReport || Object.keys(analysisReport).length === 0" class="loading-message">正在生成分析报告...</div>
|
||||
<div v-else-if="Array.isArray(analysisReport) && analysisReport.length === 0" class="error-message">数据为空</div>
|
||||
<div v-else-if="Array.isArray(analysisReport)">
|
||||
<div v-for="(report, index) in analysisReport" :key="index" class="report-section">
|
||||
<h4>{{ report.name }} ({{ report.start_time }} 至 {{ report.end_time }})</h4>
|
||||
<div v-html="report.report.replace(/\n/g, '<br>')"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="error-message">数据格式错误</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -114,11 +138,34 @@ import { ref, reactive, onMounted, onBeforeUnmount, computed, watch } from 'vue'
|
||||
import StatisticData from './StatisticData.vue';
|
||||
import * as echarts from 'echarts';
|
||||
import Chart from 'chart.js/auto';
|
||||
import {getTableFillingRate,getAverageResponseTime,getWeeklyActiveCommunicationRate,getTimeoutResponseRate} from "@/api/api.js"
|
||||
import {getSecondOrderAnalysisReport} from "@/api/api.js"
|
||||
import { useUserStore } from "@/stores/user";
|
||||
import { useRouter } from "vue-router";
|
||||
import { SimpleChatService } from '@/utils/ChatService.js';
|
||||
|
||||
// 用户store
|
||||
const userStore = useUserStore();
|
||||
// 路由实例
|
||||
const router = useRouter();
|
||||
|
||||
const Dify_API_Key_02 = 'app-MGaBOx5QFblsMZ7dSkxKJDKm'
|
||||
const chatService_02= new SimpleChatService(Dify_API_Key_02)
|
||||
|
||||
// 获取通用请求参数的函数
|
||||
const getRequestParams = () => {
|
||||
const params = {}
|
||||
// 只从路由参数获取
|
||||
const routeUserLevel = router.currentRoute.value.query.user_level || router.currentRoute.value.params.user_level
|
||||
const routeUserName = router.currentRoute.value.query.user_name || router.currentRoute.value.params.user_name
|
||||
// 如果路由有参数,使用路由参数
|
||||
if (routeUserLevel) {
|
||||
params.user_level = routeUserLevel.toString()
|
||||
}
|
||||
if (routeUserName) {
|
||||
params.user_name = routeUserName
|
||||
}
|
||||
return params
|
||||
}
|
||||
// 定义props
|
||||
const props = defineProps({
|
||||
kpiData: {
|
||||
@@ -159,9 +206,20 @@ const props = defineProps({
|
||||
}
|
||||
});
|
||||
|
||||
async function CenterGetSecondOrderAnalysisReport() {
|
||||
const params = getRequestParams()
|
||||
const res = await getSecondOrderAnalysisReport(params)
|
||||
if (res.code === 200) {
|
||||
console.log(11111,res.data)
|
||||
analysisReport.value = res.data
|
||||
}
|
||||
}
|
||||
// Chart.js 实例
|
||||
const chartInstances = {};
|
||||
|
||||
// 添加组件挂载状态跟踪
|
||||
const isComponentMounted = ref(true);
|
||||
|
||||
// DOM 元素引用
|
||||
const personalFunnelChartCanvas = ref(null);
|
||||
const contactTimeChartCanvas = ref(null);
|
||||
@@ -177,18 +235,26 @@ const tooltip = reactive({
|
||||
|
||||
// 指标说明配置
|
||||
const kpiDescriptions = {
|
||||
totalCalls: {
|
||||
title: '本期通话',
|
||||
description: '本期总共通话的次数。'
|
||||
},
|
||||
successRate: {
|
||||
title: '电话接通率',
|
||||
description: '拨通电话 ÷ 拨打的电话'
|
||||
},
|
||||
avgDuration: {
|
||||
title: '平均通话时长',
|
||||
description: '所有通话总时长 ÷ 拨打电话次数。'
|
||||
description: '所有通话总时长 ÷ 拨打的电话次数。'
|
||||
},
|
||||
conversionRate: {
|
||||
title: '成交转化率',
|
||||
description: '成交人数 ÷ 本期总数据。'
|
||||
},
|
||||
assignedData: {
|
||||
title: '本期分配数据',
|
||||
description: '本期内分配到的数据总量。'
|
||||
},
|
||||
wechatAddRate: {
|
||||
title: '加微率',
|
||||
description: '加微人数 ÷ 本期数据总人数'
|
||||
@@ -218,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);
|
||||
}
|
||||
@@ -226,6 +293,9 @@ const createOrUpdateChart = (chartId, canvasRef, config) => {
|
||||
|
||||
// Chart.js: 渲染销售漏斗图
|
||||
const renderPersonalFunnelChart = () => {
|
||||
// 确保组件仍然挂载
|
||||
if (!isComponentMounted.value) return;
|
||||
|
||||
const config = {
|
||||
type: 'bar',
|
||||
data: {
|
||||
@@ -250,6 +320,9 @@ const renderPersonalFunnelChart = () => {
|
||||
|
||||
// Chart.js: 渲染黄金联络时段图
|
||||
const renderContactTimeChart = () => {
|
||||
// 确保组件仍然挂载
|
||||
if (!isComponentMounted.value) return;
|
||||
|
||||
if (!props.contactTimeData || !props.contactTimeData.gold_contact_success_rate) {
|
||||
return;
|
||||
}
|
||||
@@ -306,7 +379,20 @@ const hideTooltip = () => {
|
||||
tooltip.visible = false;
|
||||
};
|
||||
|
||||
// 阶段分析报告模态框状态
|
||||
const showAnalysisModal = ref(false);
|
||||
// 阶段分析报告数据
|
||||
const analysisReport = ref({});
|
||||
// 显示阶段分析报告模态框
|
||||
const showSecondOrderAnalysisReport = () => {
|
||||
showAnalysisModal.value = true;
|
||||
CenterGetSecondOrderAnalysisReport()
|
||||
};
|
||||
|
||||
// 关闭阶段分析报告模态框
|
||||
const closeAnalysisModal = () => {
|
||||
showAnalysisModal.value = false;
|
||||
};
|
||||
|
||||
watch(() => props.contactTimeData, () => {
|
||||
renderContactTimeChart();
|
||||
@@ -315,11 +401,13 @@ watch(() => props.contactTimeData, () => {
|
||||
// --- 生命周期钩子 ---
|
||||
|
||||
onMounted(() => {
|
||||
isComponentMounted.value = true;
|
||||
renderPersonalFunnelChart();
|
||||
renderContactTimeChart();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
isComponentMounted.value = false;
|
||||
Object.values(chartInstances).forEach(chart => chart.destroy());
|
||||
});
|
||||
</script>
|
||||
@@ -464,7 +552,7 @@ $white: #ffffff;
|
||||
justify-content: center;
|
||||
min-height: 120px;
|
||||
padding: 1rem 0.5rem;
|
||||
|
||||
|
||||
.stat-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
@@ -475,14 +563,14 @@ $white: #ffffff;
|
||||
font-size: 18px;
|
||||
color: $white;
|
||||
margin-bottom: 0.75rem;
|
||||
|
||||
|
||||
&.customer-rate { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); }
|
||||
&.response-time { background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); }
|
||||
&.timeout-rate { background: linear-gradient(135deg, #ffecd2 0%, #fcb69f 100%); }
|
||||
&.form-rate { background: linear-gradient(135deg, #a8edea 0%, #fed6e3 100%); }
|
||||
&.severe-timeout-rate { background: linear-gradient(135deg, #ff6b6b 0%, #ee5a52 100%); }
|
||||
}
|
||||
|
||||
|
||||
.kpi-value {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
@@ -511,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; }
|
||||
}
|
||||
@@ -586,19 +674,19 @@ $white: #ffffff;
|
||||
.personal-dashboard { padding: 15px; }
|
||||
.stats-grid, .charts-section { grid-template-columns: 1fr; }
|
||||
.stat-card { flex-direction: row; }
|
||||
|
||||
|
||||
.dashboard-header {
|
||||
padding: 16px;
|
||||
h2 {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.kpi-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
|
||||
.kpi-item {
|
||||
padding: 0.5rem;
|
||||
.kpi-value {
|
||||
@@ -608,49 +696,49 @@ $white: #ffffff;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.stats-grid-inner {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
|
||||
.stat-item {
|
||||
min-height: 100px;
|
||||
padding: 0.75rem 0.25rem;
|
||||
|
||||
|
||||
.stat-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
font-size: 16px;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
|
||||
.kpi-value {
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
|
||||
p {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.chart-container {
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
|
||||
.chart-header {
|
||||
padding: 16px 16px 12px;
|
||||
h3 {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.chart-content {
|
||||
padding-left: 16px;
|
||||
padding-right: 16px;
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -659,53 +747,60 @@ $white: #ffffff;
|
||||
.personal-dashboard {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
|
||||
.dashboard-header {
|
||||
padding: 12px;
|
||||
h2 {
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.kpi-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
|
||||
.kpi-item {
|
||||
padding: 0.75rem;
|
||||
.kpi-value {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.stats-grid-inner {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
|
||||
.stat-item {
|
||||
min-height: 80px;
|
||||
padding: 1rem;
|
||||
flex-direction: row;
|
||||
text-align: left;
|
||||
|
||||
|
||||
.stat-icon {
|
||||
margin-bottom: 0;
|
||||
margin-right: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.chart-container {
|
||||
min-height: 250px;
|
||||
}
|
||||
|
||||
|
||||
.charts-section {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
|
||||
.modal-header {
|
||||
padding-left: 15px;
|
||||
padding-right: 15px;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -719,7 +814,7 @@ $white: #ffffff;
|
||||
opacity: 0.7;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
color: #007bff;
|
||||
@@ -735,4 +830,163 @@ $white: #ffffff;
|
||||
position: relative;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
/* 模态框样式 */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal-container {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
width: 90%;
|
||||
max-width: 600px;
|
||||
max-height: 60vh;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
margin-right: auto; // 将标题推到最左边
|
||||
}
|
||||
|
||||
.modal-close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
color: #909399;
|
||||
padding: 0;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-left: 16px; // 与按钮组保持间距
|
||||
flex-shrink: 0;
|
||||
|
||||
|
||||
&:hover {
|
||||
color: #303133;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 20px;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.period-switcher {
|
||||
display: flex;
|
||||
flex-shrink: 0; // 防止按钮组在空间不足时被压缩
|
||||
}
|
||||
|
||||
.period-switcher button {
|
||||
padding: 6px 14px;
|
||||
border: 1px solid #dcdfe6;
|
||||
background: white;
|
||||
border-radius: 0;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
transition: all 0.3s ease;
|
||||
margin-left: -1px; // 让边框重叠,形成一体化效果
|
||||
|
||||
&:first-child {
|
||||
border-radius: 4px 0 0 4px;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
border-radius: 0 4px 4px 0;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: #a0cfff;
|
||||
color: #409eff;
|
||||
z-index: 1;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: #409eff;
|
||||
border-color: #409eff;
|
||||
color: white;
|
||||
z-index: 2;
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.analysis-content h4 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 15px;
|
||||
color: #303133;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.analysis-content p {
|
||||
color: #606266;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: #f56c6c;
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
border: 1px solid #f56c6c;
|
||||
border-radius: 4px;
|
||||
background-color: #fef0f0;
|
||||
}
|
||||
|
||||
.loading-message {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.report-section {
|
||||
margin-bottom: 20px;
|
||||
padding: 15px;
|
||||
border: 1px solid #ebeef5;
|
||||
border-radius: 4px;
|
||||
background-color: #f9fafc;
|
||||
}
|
||||
|
||||
.report-section:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.report-section h4 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 15px;
|
||||
color: #303133;
|
||||
font-size: 16px;
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -6,12 +6,12 @@
|
||||
<div class="kpi-item stat-item" >
|
||||
<div class="stat-icon customer-rate"><i class="el-icon-chat-dot-round"></i></div>
|
||||
<div class="kpi-value">{{ customerCommunicationRate }}</div>
|
||||
<p>活跃客户沟通率 <i class="info-icon" @mouseenter="showTooltip('customerCommunicationRate', $event)" @mouseleave="hideTooltip">ⓘ</i></p>
|
||||
<p>客户沟通率 <i class="info-icon" @mouseenter="showTooltip('customerCommunicationRate', $event)" @mouseleave="hideTooltip">ⓘ</i></p>
|
||||
</div>
|
||||
<div class="kpi-item stat-item" >
|
||||
<div class="stat-icon response-time"><i class="el-icon-timer"></i></div>
|
||||
<div class="kpi-value">{{ averageResponseTime }}<span class="kpi-unit">分钟</span></div>
|
||||
<p>平均应答时间 <i class="info-icon" @mouseenter="showTooltip('averageResponseTime', $event)" @mouseleave="hideTooltip">ⓘ</i></p>
|
||||
<div class="kpi-value">{{ averageResponseTime }}<span class="kpi-unit">分</span></div>
|
||||
<p>均响应时间 <i class="info-icon" @mouseenter="showTooltip('averageResponseTime', $event)" @mouseleave="hideTooltip">ⓘ</i></p>
|
||||
</div>
|
||||
<div class="kpi-item stat-item" >
|
||||
<div class="stat-icon timeout-rate"><i class="el-icon-warning"></i></div>
|
||||
@@ -21,7 +21,7 @@
|
||||
<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">{{ severeTimeoutRate }}</div>
|
||||
<p>严重超时应答率 <i class="info-icon" @mouseenter="showTooltip('severeTimeoutRate', $event)" @mouseleave="hideTooltip">ⓘ</i></p>
|
||||
<p>严重超时率 <i class="info-icon" @mouseenter="showTooltip('severeTimeoutRate', $event)" @mouseleave="hideTooltip">ⓘ</i></p>
|
||||
</div>
|
||||
<div class="kpi-item stat-item">
|
||||
<div class="stat-icon form-rate"><i class="el-icon-document"></i></div>
|
||||
@@ -89,7 +89,6 @@ const hideTooltip = () => {
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/* ... 您的样式代码不变 ... */
|
||||
</style>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
475
my-vue-app/src/views/person/components/WeekAnalize.vue
Normal file
475
my-vue-app/src/views/person/components/WeekAnalize.vue
Normal file
@@ -0,0 +1,475 @@
|
||||
<template>
|
||||
<div class="week-analyze">
|
||||
<div class="analyze-header">
|
||||
<h3>本周综合表现分析</h3>
|
||||
<p class="analyze-subtitle">基于本周销售数据的综合分析报告</p>
|
||||
</div>
|
||||
|
||||
<div class="analyze-content">
|
||||
<!-- 周期表现概览 -->
|
||||
<div class="performance-overview">
|
||||
<div class="overview-card">
|
||||
<div class="card-icon">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M12 2L15.09 8.26L22 9L17 14L18.18 21L12 17.77L5.82 21L7 14L2 9L8.91 8.26L12 2Z" fill="#FFD700"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<h4>综合评分</h4>
|
||||
<div class="score">{{ overallScore }}<span class="score-unit">/100</span></div>
|
||||
<div class="score-trend" :class="scoreTrend.type">
|
||||
{{ scoreTrend.text }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overview-card">
|
||||
<div class="card-icon">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M16 6L18.29 8.29L13.41 13.17L9.41 9.17L2 16.59L3.41 18L9.41 12L13.41 16L19.71 9.71L22 12V6H16Z" fill="#4CAF50"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<h4>目标完成率</h4>
|
||||
<div class="score">{{ targetCompletion }}<span class="score-unit">%</span></div>
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" :style="{ width: targetCompletion + '%' }"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overview-card">
|
||||
<div class="card-icon">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12S6.48 22 12 22 22 17.52 22 12 17.52 2 12 2ZM13 17H11V15H13V17ZM13 13H11V7H13V13Z" fill="#FF9800"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<h4>改进建议</h4>
|
||||
<div class="suggestions-count">{{ suggestions.length }}项</div>
|
||||
<div class="suggestions-preview">{{ suggestions[0]?.title || '暂无建议' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 详细分析 -->
|
||||
<div class="detailed-analysis">
|
||||
<div class="analysis-section">
|
||||
<h4>关键指标表现</h4>
|
||||
<div class="metrics-grid">
|
||||
<div v-for="metric in keyMetrics" :key="metric.name" class="metric-item">
|
||||
<div class="metric-header">
|
||||
<span class="metric-name">{{ metric.name }}</span>
|
||||
<span class="metric-trend" :class="metric.trend">
|
||||
{{ metric.trendText }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="metric-value">
|
||||
<span class="current-value">{{ metric.current }}</span>
|
||||
<span class="target-value">/ {{ metric.target }}</span>
|
||||
</div>
|
||||
<div class="metric-progress">
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" :style="{ width: (metric.current / metric.target * 100) + '%' }"></div>
|
||||
</div>
|
||||
<span class="progress-text">{{ Math.round(metric.current / metric.target * 100) }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="analysis-section">
|
||||
<h4>改进建议</h4>
|
||||
<div class="suggestions-list">
|
||||
<div v-for="suggestion in suggestions" :key="suggestion.id" class="suggestion-item">
|
||||
<div class="suggestion-priority" :class="suggestion.priority"></div>
|
||||
<div class="suggestion-content">
|
||||
<h5>{{ suggestion.title }}</h5>
|
||||
<p>{{ suggestion.description }}</p>
|
||||
<div class="suggestion-actions">
|
||||
<span class="action-tag" v-for="action in suggestion.actions" :key="action">{{ action }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
|
||||
// Props
|
||||
const props = defineProps({
|
||||
weekData: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
})
|
||||
|
||||
// 响应式数据
|
||||
const overallScore = ref(78)
|
||||
const targetCompletion = ref(65)
|
||||
|
||||
const scoreTrend = computed(() => {
|
||||
const score = overallScore.value
|
||||
if (score >= 80) {
|
||||
return { type: 'positive', text: '表现优秀' }
|
||||
} else if (score >= 60) {
|
||||
return { type: 'neutral', text: '表现良好' }
|
||||
} else {
|
||||
return { type: 'negative', text: '需要改进' }
|
||||
}
|
||||
})
|
||||
|
||||
const keyMetrics = ref([
|
||||
{
|
||||
name: '通话量',
|
||||
current: 85,
|
||||
target: 100,
|
||||
trend: 'positive',
|
||||
trendText: '↗ +12%'
|
||||
},
|
||||
{
|
||||
name: '接通率',
|
||||
current: 68,
|
||||
target: 80,
|
||||
trend: 'neutral',
|
||||
trendText: '→ 持平'
|
||||
},
|
||||
{
|
||||
name: '转化率',
|
||||
current: 12,
|
||||
target: 15,
|
||||
trend: 'negative',
|
||||
trendText: '↘ -3%'
|
||||
},
|
||||
{
|
||||
name: '客户满意度',
|
||||
current: 4.2,
|
||||
target: 4.5,
|
||||
trend: 'positive',
|
||||
trendText: '↗ +0.2'
|
||||
}
|
||||
])
|
||||
|
||||
const suggestions = ref([
|
||||
{
|
||||
id: 1,
|
||||
title: '提升通话转化率',
|
||||
description: '当前转化率低于目标,建议优化话术和跟进策略',
|
||||
priority: 'high',
|
||||
actions: ['话术优化', '跟进策略', '客户画像分析']
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: '增加客户互动频次',
|
||||
description: '客户响应时间较长,建议增加主动联系频次',
|
||||
priority: 'medium',
|
||||
actions: ['主动联系', '内容营销', '社群运营']
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: '优化时间管理',
|
||||
description: '通话时长分布不均,建议优化时间分配',
|
||||
priority: 'low',
|
||||
actions: ['时间规划', '效率工具', '任务优先级']
|
||||
}
|
||||
])
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
// 可以在这里根据传入的数据计算分析结果
|
||||
console.log('WeekAnalize组件已挂载', props.weekData)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.week-analyze {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.analyze-header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.analyze-header h3 {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.analyze-subtitle {
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.performance-overview {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 20px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.overview-card {
|
||||
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
border: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.card-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.card-content h4 {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
margin: 0 0 8px 0;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.score {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: #1a1a1a;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.score-unit {
|
||||
font-size: 16px;
|
||||
color: #666;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.score-trend {
|
||||
font-size: 12px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.score-trend.positive {
|
||||
color: #4CAF50;
|
||||
}
|
||||
|
||||
.score-trend.neutral {
|
||||
color: #FF9800;
|
||||
}
|
||||
|
||||
.score-trend.negative {
|
||||
color: #f44336;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
background: #e9ecef;
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #4CAF50 0%, #8BC34A 100%);
|
||||
border-radius: 3px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.suggestions-count {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #FF9800;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.suggestions-preview {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.detailed-analysis {
|
||||
display: grid;
|
||||
gap: 32px;
|
||||
}
|
||||
|
||||
.analysis-section h4 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
margin: 0 0 16px 0;
|
||||
}
|
||||
|
||||
.metrics-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.metric-item {
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
border: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.metric-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.metric-name {
|
||||
font-size: 14px;
|
||||
color: #1a1a1a;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.metric-trend {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.metric-trend.positive {
|
||||
color: #4CAF50;
|
||||
}
|
||||
|
||||
.metric-trend.neutral {
|
||||
color: #FF9800;
|
||||
}
|
||||
|
||||
.metric-trend.negative {
|
||||
color: #f44336;
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.current-value {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
.target-value {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.metric-progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.metric-progress .progress-bar {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.progress-text {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
min-width: 35px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.suggestions-list {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.suggestion-item {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.suggestion-priority {
|
||||
width: 4px;
|
||||
border-radius: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.suggestion-priority.high {
|
||||
background: #f44336;
|
||||
}
|
||||
|
||||
.suggestion-priority.medium {
|
||||
background: #FF9800;
|
||||
}
|
||||
|
||||
.suggestion-priority.low {
|
||||
background: #4CAF50;
|
||||
}
|
||||
|
||||
.suggestion-content h5 {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.suggestion-content p {
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
margin: 0 0 12px 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.suggestion-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.action-tag {
|
||||
background: #e3f2fd;
|
||||
color: #1976d2;
|
||||
font-size: 11px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.week-analyze {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.performance-overview {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.metrics-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.overview-card {
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -4,7 +4,7 @@
|
||||
<Loading :visible="isPageLoading" text="正在加载数据..." />
|
||||
<!-- 顶部导航栏 -->
|
||||
<!-- 销售时间线区域 -->
|
||||
<section class="timeline-section">
|
||||
<section v-if="cardVisibility.timeline" class="timeline-section">
|
||||
<div class="section-header">
|
||||
<!-- 动态顶栏:根据是否有路由参数显示不同内容 -->
|
||||
<!-- 路由跳转时的顶栏:面包屑 + 姓名 -->
|
||||
@@ -13,21 +13,36 @@
|
||||
<span class="breadcrumb-item" @click="goBack">团队管理 >{{ routeUserName }}</span>
|
||||
<span class="breadcrumb-item current"> 数据驾驶舱</span>
|
||||
</div>
|
||||
<div class="user-name">
|
||||
{{ routeUserName }}
|
||||
<div style="display: flex; align-items: center; gap: 20px;">
|
||||
<div class="user-name">
|
||||
{{ routeUserName }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 自己登录时的顶栏:原有样式 -->
|
||||
<template v-else>
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; width: 100%;">
|
||||
<h1 class="app-title">销售驾驶舱</h1>
|
||||
<h1 class="app-title">分析师驾驶舱</h1>
|
||||
<div
|
||||
class="quick-stats"
|
||||
style="display: flex; align-items: center; gap: 30px"
|
||||
>
|
||||
</div>
|
||||
<UserDropdown />
|
||||
<div style="display: flex; align-items: center; gap: 20px;">
|
||||
<button @click="showFeedbackFormModal" class="feedback-btn">意见反馈</button>
|
||||
<FeedbackForm
|
||||
:is-visible="showFeedbackForm"
|
||||
@close="closeFeedbackFormModal"
|
||||
@submit-feedback="closeFeedbackFormModal"
|
||||
/>
|
||||
<UserDropdown
|
||||
:card-visibility="cardVisibility"
|
||||
@update-card-visibility="updateCardVisibility"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -42,6 +57,7 @@
|
||||
v-else
|
||||
:data="timelineData"
|
||||
@stage-select="handleStageSelect"
|
||||
@sub-stage-select="handleSubStageSelect"
|
||||
:selected-stage="selectedStage"
|
||||
:contacts="filteredContacts"
|
||||
:selected-contact-id="selectedContactId"
|
||||
@@ -56,7 +72,7 @@
|
||||
</section>
|
||||
|
||||
<!-- 原始数据卡片区域 -->
|
||||
<section class="raw-data-section">
|
||||
<section v-if="cardVisibility.rawData && selectedContact" class="raw-data-section">
|
||||
<div class="section-header">
|
||||
<h2>原始数据</h2>
|
||||
<p class="section-subtitle">客户互动的原始记录和数据</p>
|
||||
@@ -70,7 +86,9 @@
|
||||
@view-form-data="handleViewFormData"
|
||||
@view-chat-data="handleViewChatData"
|
||||
@view-call-data="handleViewCallData"
|
||||
@analyze-sop="handleAnalyzeSop" />
|
||||
@analyze-sop="handleAnalyzeSop"
|
||||
@show-modal="handleShowModal"
|
||||
@show-download-modal="handleShowDownloadModal" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -79,10 +97,7 @@
|
||||
<!-- 主要工作区域 -->
|
||||
<main class="main-content">
|
||||
<!-- 客户详情区域 -->
|
||||
<section class="detail-section">
|
||||
<div class="section-header">
|
||||
<h2>客户详情</h2>
|
||||
</div>
|
||||
<section v-if="cardVisibility.customerDetail && selectedContact" class="detail-section">
|
||||
<div class="section-content">
|
||||
<CustomerDetail
|
||||
ref="customerDetailRef"
|
||||
@@ -94,7 +109,7 @@
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
<section class="analytics-section-full" style="width: 100%;">
|
||||
<section v-if="cardVisibility.analytics" class="analytics-section-full" style="width: 100%;">
|
||||
|
||||
<div class="section-content">
|
||||
<!-- 数据分析区域加载状态 -->
|
||||
@@ -113,6 +128,46 @@
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 自定义弹框 -->
|
||||
<div v-if="showModal" class="modal-overlay" @click="closeModal" @wheel.prevent @touchmove.prevent>
|
||||
<div class="modal-container" @click.stop @wheel.stop @touchmove.stop>
|
||||
<div class="modal-header">
|
||||
<h3 class="modal-title">{{ modalTitle }}</h3>
|
||||
<button class="modal-close-btn" @click="closeModal">
|
||||
<i class="icon-close">×</i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="modal-content">
|
||||
{{ modalContent }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="modal-btn modal-btn-primary" @click="closeModal">确定</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 下载弹框 -->
|
||||
<div v-if="showDownloadModal" class="modal-overlay" @click="closeDownloadModal" @wheel.prevent @touchmove.prevent>
|
||||
<div class="modal-container" @click.stop @wheel.stop @touchmove.stop>
|
||||
<div class="modal-header">
|
||||
<h3 class="modal-title">{{ downloadModalTitle }}</h3>
|
||||
<button class="modal-close-btn" @click="closeDownloadModal">
|
||||
<i class="icon-close">×</i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="modal-content">
|
||||
{{ downloadModalContent }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="modal-btn modal-btn-primary" @click="closeDownloadModal">确定</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -124,11 +179,14 @@ import CustomerDetail from "./components/CustomerDetail.vue";
|
||||
import PersonalDashboard from "./components/PersonalDashboard.vue";
|
||||
import SalesTimelineWithTaskList from "./components/SalesTimelineWithTaskList.vue";
|
||||
import RawDataCards from "./components/RawDataCards.vue";
|
||||
import WeekAnalize from "./components/WeekAnalize.vue";
|
||||
import UserDropdown from "@/components/UserDropdown.vue";
|
||||
import Loading from "@/components/Loading.vue";
|
||||
import FeedbackForm from "@/components/FeedbackForm.vue";
|
||||
import {getCustomerAttendance,getTodayCall,getProblemDistribution,getTableFillingRate,getAverageResponseTime,
|
||||
getWeeklyActiveCommunicationRate,getTimeoutResponseRate,getCustomerCallInfo,getCustomerChatInfo,getCustomerFormInfo,
|
||||
getConversionRateAndAllocatedData,getCustomerAttendanceAfterClass4,getPayMoneyCustomers,getSalesFunnel,getGoldContactTime} from "@/api/api.js"
|
||||
getConversionRateAndAllocatedData,getCustomerAttendanceAfterClass4,getPayMoneyCustomers,getSalesFunnel,getGoldContactTime,
|
||||
getAvgCallTime,getCallSuccessRate,getSecondOrderAnalysisReport} from "@/api/api.js"
|
||||
|
||||
// 路由实例
|
||||
const router = useRouter();
|
||||
@@ -149,7 +207,6 @@ const getRequestParams = () => {
|
||||
if (routeUserName) {
|
||||
params.user_name = routeUserName
|
||||
}
|
||||
|
||||
return params
|
||||
}
|
||||
|
||||
@@ -180,6 +237,37 @@ const isStatisticsLoading = ref(false); // 统计数据加载状态
|
||||
const isUrgentProblemLoading = ref(false); // 紧急问题数据加载状态
|
||||
const isTimelineLoading = ref(false); // 时间线数据加载状态
|
||||
|
||||
// 卡片显示隐藏控制
|
||||
const cardVisibility = reactive({
|
||||
timeline: true, // 销售时间线
|
||||
rawData: true, // 原始数据卡片
|
||||
customerDetail: true, // 客户详情
|
||||
analytics: true, // 数据分析
|
||||
weekAnalysis: true // 周期分析
|
||||
});
|
||||
|
||||
// 切换卡片显示状态
|
||||
const toggleCardVisibility = (cardName) => {
|
||||
cardVisibility[cardName] = !cardVisibility[cardName];
|
||||
};
|
||||
|
||||
// 更新卡片显示状态(从UserDropdown组件接收)
|
||||
const updateCardVisibility = (newVisibility) => {
|
||||
Object.assign(cardVisibility, newVisibility);
|
||||
};
|
||||
|
||||
// 获取卡片显示名称
|
||||
const getCardDisplayName = (key) => {
|
||||
const nameMap = {
|
||||
timeline: '销售时间线',
|
||||
rawData: '原始数据',
|
||||
customerDetail: '客户详情',
|
||||
analytics: '数据分析',
|
||||
weekAnalysis: '周期分析'
|
||||
};
|
||||
return nameMap[key] || key;
|
||||
};
|
||||
|
||||
// KPI数据
|
||||
const kpiDataState = reactive({
|
||||
totalCalls: 85,
|
||||
@@ -202,9 +290,25 @@ const statisticsData = reactive({
|
||||
// 客户迫切解决的问题数据
|
||||
const urgentProblemData = ref([]);
|
||||
|
||||
// 弹框状态
|
||||
const showModal = ref(false)
|
||||
const modalContent = ref('')
|
||||
const modalTitle = ref('')
|
||||
|
||||
// FeedbackForm 弹框状态
|
||||
const showFeedbackForm = ref(false)
|
||||
|
||||
// 下载弹框状态
|
||||
const showDownloadModal = ref(false)
|
||||
const downloadModalContent = ref('')
|
||||
const downloadModalTitle = ref('')
|
||||
|
||||
// 时间线数据
|
||||
const timelineData = ref({});
|
||||
|
||||
// 周期分析数据
|
||||
const weekAnalysisData = ref({});
|
||||
|
||||
// 客户列表数据
|
||||
const customersList = ref([]);
|
||||
|
||||
@@ -219,11 +323,13 @@ const payMoneyCustomersList = ref([]);
|
||||
const payMoneyCustomersCount = ref(0);
|
||||
|
||||
// 表单信息
|
||||
const formInfo = ref({});
|
||||
const formInfo = ref([]);
|
||||
// 通话记录
|
||||
const callRecords = ref([]);
|
||||
// 聊天记录
|
||||
const chatRecords = ref([]);
|
||||
// 电话接通率
|
||||
const callSuccessRate = ref(0)
|
||||
|
||||
// MOCK DATA (Should ideally come from a store or API)
|
||||
const MOCK_DATA = reactive({
|
||||
@@ -259,21 +365,41 @@ async function getCoreKpi() {
|
||||
try {
|
||||
const params = getRequestParams()
|
||||
const hasParams = params.user_name
|
||||
|
||||
|
||||
// 并发请求所有KPI接口
|
||||
const [
|
||||
todayCallRes,
|
||||
conversionRes,
|
||||
avgCallTimeRes,
|
||||
callSuccessRateRes
|
||||
] = await Promise.all([
|
||||
getTodayCall(hasParams ? params : undefined),
|
||||
getConversionRateAndAllocatedData(hasParams ? params : undefined),
|
||||
getAvgCallTime(hasParams ? params : undefined),
|
||||
getCallSuccessRate(hasParams ? params : undefined)
|
||||
])
|
||||
|
||||
// 今日通话数据
|
||||
const res = await getTodayCall(hasParams ? params : undefined)
|
||||
if (res.code === 200) {
|
||||
kpiDataState.totalCalls = res.data.today_call
|
||||
if (todayCallRes.code === 200) {
|
||||
kpiDataState.totalCalls = todayCallRes.data.call_count
|
||||
}
|
||||
|
||||
|
||||
// 转化率、分配数据量、加微率
|
||||
const conversionRes = await getConversionRateAndAllocatedData(hasParams ? params : undefined)
|
||||
if (conversionRes.code === 200) {
|
||||
kpiDataState.conversionRate = conversionRes.data.conversion_rate || 0
|
||||
kpiDataState.assignedData = conversionRes.data.all_count || 0
|
||||
kpiDataState.wechatAddRate = conversionRes.data.plus_v_conversion_rate || 0
|
||||
}
|
||||
|
||||
// 平均通话时长
|
||||
if (avgCallTimeRes.code === 200) {
|
||||
kpiDataState.avgDuration = avgCallTimeRes.data.call_time || 0
|
||||
}
|
||||
|
||||
// 电话接通率
|
||||
if (callSuccessRateRes.code === 200) {
|
||||
kpiDataState.successRate = callSuccessRateRes.data.call_success_rate || 0
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取核心KPI数据失败:', error)
|
||||
} finally {
|
||||
@@ -287,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
|
||||
@@ -327,11 +462,8 @@ async function getUrgentProblem() {
|
||||
const res = await getProblemDistribution(hasParams ? params : undefined)
|
||||
if(res.code === 200) {
|
||||
// 将API返回的对象格式转换为数组格式
|
||||
const problemDistribution = res.data.problem_distribution
|
||||
urgentProblemData.value = Object.entries(problemDistribution).map(([name, percentage]) => ({
|
||||
name: name,
|
||||
value: parseInt(percentage.replace('%', '')) || 0
|
||||
}))
|
||||
const problemDistributionCount = res.data.problem_distribution_count
|
||||
urgentProblemData.value = Object.entries(problemDistributionCount).map(([name, value]) => ({ name, value }))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取紧急问题数据失败:', error)
|
||||
@@ -355,7 +487,6 @@ async function getTimeline() {
|
||||
value: parseInt(count) || 0
|
||||
}))
|
||||
}
|
||||
// 处理客户列表数据
|
||||
if (res.data.all_customers_list) {
|
||||
customersList.value = res.data.all_customers_list
|
||||
}
|
||||
@@ -371,10 +502,10 @@ async function getTimeline() {
|
||||
if (classRes.data.class_customers_list) {
|
||||
// 存储课1-4阶段的原始数据,根据pay_status设置正确的type
|
||||
courseCustomers.value['课1-4'] = classRes.data.class_customers_list.map(customer => {
|
||||
let customerType = '课1-4'; // 默认类型
|
||||
let customerType = ''; // 默认类型
|
||||
|
||||
// 根据pay_status设置具体的type
|
||||
if (customer.pay_status === '未支付' || customer.pay_status === '点击未支付') {
|
||||
if (customer.pay_status === '点击未支付') {
|
||||
customerType = '点击未支付';
|
||||
} else if (customer.pay_status === '付定金') {
|
||||
customerType = '付定金';
|
||||
@@ -400,7 +531,8 @@ async function getTimeline() {
|
||||
class_situation: customer.class_situation,
|
||||
class_num: Object.keys(customer.class_situation || {}), // 添加class_num字段
|
||||
pay_status: customer.pay_status,
|
||||
records: []
|
||||
records: [],
|
||||
time_and_camp_stage: customer.time_and_camp_stage || []
|
||||
};
|
||||
})
|
||||
|
||||
@@ -433,7 +565,8 @@ async function getTimeline() {
|
||||
class_situation: customer.class_situation,
|
||||
scrm_user_main_code: customer.scrm_user_main_code,
|
||||
weChat_avatar: customer.weChat_avatar,
|
||||
pay_status: customer.pay_status
|
||||
pay_status: customer.pay_status,
|
||||
time_and_camp_stage: customer.time_and_camp_stage || []
|
||||
})
|
||||
// 后三个阶段的客户数据已存储在courseCustomers['课1-4']中,不需要合并到customersList
|
||||
}
|
||||
@@ -466,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) {
|
||||
// 静默处理错误
|
||||
@@ -480,20 +614,17 @@ async function getCustomerForm() {
|
||||
// 聊天记录
|
||||
async function getCustomerChat() {
|
||||
if (!selectedContact.value || !selectedContact.value.name) {
|
||||
console.warn('无法获取客户聊天记录:客户信息不完整');
|
||||
return;
|
||||
}
|
||||
const routeParams = getRequestParams()
|
||||
const params = {
|
||||
user_name: routeParams.user_name || userStore.userInfo.username,
|
||||
customer_name: selectedContact.value.name,
|
||||
phone: selectedContact.value.phone,
|
||||
}
|
||||
try {
|
||||
const res = await getCustomerChatInfo(params)
|
||||
if(res.code === 200) {
|
||||
chatRecords.value = res.data
|
||||
console.log('聊天数据获取成功:', res.data)
|
||||
console.log('chatRecords.value:', chatRecords.value)
|
||||
} else {
|
||||
console.log('聊天数据获取失败:', res)
|
||||
}
|
||||
@@ -504,29 +635,17 @@ async function getCustomerChat() {
|
||||
// 通话记录
|
||||
async function getCustomerCall() {
|
||||
if (!selectedContact.value || !selectedContact.value.name) {
|
||||
console.warn('无法获取客户通话记录:客户信息不完整');
|
||||
return;
|
||||
}
|
||||
const routeParams = getRequestParams()
|
||||
const params = {
|
||||
user_name: routeParams.user_name || userStore.userInfo.username,
|
||||
customer_name: selectedContact.value.name,
|
||||
phone: selectedContact.value.phone,
|
||||
}
|
||||
try {
|
||||
const res = await getCustomerCallInfo(params)
|
||||
if(res.code === 200) {
|
||||
callRecords.value = res.data
|
||||
console.log('Call Records Data from API:', res.data)
|
||||
console.log('callRecords.value after assignment:', callRecords.value)
|
||||
/**
|
||||
* "data": {
|
||||
"user_name": "常琳",
|
||||
"customer_name": "191桐桐爸爸高一男(婧)",
|
||||
"record_file_addr_list": [
|
||||
"http://192.168.3.112:5000/api/record/download/杨振彦-20分钟通话-25-08-19_07-23-37-744009-835.mp3"
|
||||
]
|
||||
}
|
||||
*/
|
||||
}
|
||||
} catch (error) {
|
||||
// 静默处理错误
|
||||
@@ -576,7 +695,8 @@ const formattedCustomersList = computed(() => {
|
||||
return [];
|
||||
}
|
||||
|
||||
return customersList.value.map(customer => ({
|
||||
return customersList.value?.map(customer => ({
|
||||
wechat_id: customer.customer_wechat_id,
|
||||
id: customer.customer_name, // 使用客户姓名作为唯一标识
|
||||
name: customer.customer_name,
|
||||
phone: customer.phone,
|
||||
@@ -626,6 +746,7 @@ const selectContact = (id) => {
|
||||
// 当选中客户后,获取客户表单数据
|
||||
nextTick(async () => {
|
||||
if (selectedContact.value && selectedContact.value.name) {
|
||||
|
||||
await getCustomerForm();
|
||||
await getCustomerChat();
|
||||
await getCustomerCall();
|
||||
@@ -672,7 +793,8 @@ const handleStageSelect = (stage, extraData = null) => {
|
||||
scrm_user_main_code: customer.scrm_user_main_code,
|
||||
weChat_avatar: customer.weChat_avatar,
|
||||
class_situation: customer.class_situation,
|
||||
records: customer.records
|
||||
records: customer.records,
|
||||
time_and_camp_stage: customer.time_and_camp_stage || []
|
||||
}));
|
||||
|
||||
// 更新当前筛选的客户数据
|
||||
@@ -680,7 +802,7 @@ const handleStageSelect = (stage, extraData = null) => {
|
||||
|
||||
|
||||
} else if (extraData && extraData.isCourseStage) {
|
||||
// 处理课1-4阶段的课程数据(保持原有逻辑)
|
||||
// 处理课程阶段的数据(课1-4、课1、课2、课3、课4)
|
||||
|
||||
|
||||
const courseContacts = extraData.courseData.map(customer => ({
|
||||
@@ -690,8 +812,8 @@ const handleStageSelect = (stage, extraData = null) => {
|
||||
profession: customer.profession,
|
||||
education: customer.education,
|
||||
avatar: customer.avatar,
|
||||
type: customer.type || '课1-4', // 保持原有type字段,如果没有则默认为课1-4
|
||||
salesStage: customer.type || '课1-4', // 使用customer.type作为salesStage
|
||||
type: customer.type || stage, // 使用当前选中的阶段作为type
|
||||
salesStage: customer.type || stage, // 使用customer.type或当前阶段作为salesStage
|
||||
health: customer.health,
|
||||
customer_name: customer.customer_name,
|
||||
customer_occupation: customer.customer_occupation,
|
||||
@@ -701,7 +823,8 @@ const handleStageSelect = (stage, extraData = null) => {
|
||||
class_situation: customer.class_situation,
|
||||
class_num: customer.class_num, // 添加class_num字段
|
||||
pay_status: customer.pay_status, // 添加pay_status字段
|
||||
records: customer.records
|
||||
records: customer.records,
|
||||
time_and_camp_stage: customer.time_and_camp_stage || []
|
||||
}));
|
||||
|
||||
currentFilteredCustomers.value = courseContacts;
|
||||
@@ -711,52 +834,112 @@ const handleStageSelect = (stage, extraData = null) => {
|
||||
currentFilteredCustomers.value = [];
|
||||
}
|
||||
};
|
||||
|
||||
// 处理子时间轴阶段选择
|
||||
const handleSubStageSelect = (eventData) => {
|
||||
// 将筛选后的客户数据转换为contacts格式
|
||||
const filteredContacts = eventData.filteredCustomers.map(customer => ({
|
||||
id: customer.customer_name || customer.id,
|
||||
name: customer.customer_name || customer.name,
|
||||
phone: customer.phone,
|
||||
profession: customer.customer_occupation || customer.profession,
|
||||
education: customer.customer_child_education || customer.education,
|
||||
lastMessageTime: customer.latest_message_time || customer.time,
|
||||
avatarUrl: customer.customer_avatar_url || customer.avatar,
|
||||
avatar: customer.customer_avatar_url || customer.avatar || '/default-avatar.svg',
|
||||
type: customer.type || eventData.originalStageType,
|
||||
classNum: customer.class_num,
|
||||
class_num: customer.class_num,
|
||||
salesStage: eventData.stageType,
|
||||
priority: customer.type === '待联系' ? 'high' : 'normal',
|
||||
time: customer.latest_message_time || customer.time || '未知',
|
||||
health: customer.health || 75,
|
||||
// 保留原始数据
|
||||
customer_name: customer.customer_name,
|
||||
customer_occupation: customer.customer_occupation,
|
||||
customer_child_education: customer.customer_child_education,
|
||||
scrm_user_main_code: customer.scrm_user_main_code,
|
||||
weChat_avatar: customer.weChat_avatar,
|
||||
class_situation: customer.class_situation,
|
||||
records: customer.records,
|
||||
time_and_camp_stage: customer.time_and_camp_stage || []
|
||||
}));
|
||||
|
||||
// 更新当前筛选的客户数据,但保持selectedStage不变(保持子时间轴显示)
|
||||
currentFilteredCustomers.value = filteredContacts;
|
||||
|
||||
};
|
||||
|
||||
const handleViewFormData = async (contact) => {
|
||||
// 获取客户表单数据
|
||||
await getCustomerForm();
|
||||
console.log('表单数据已加载:', formInfo.value);
|
||||
};
|
||||
|
||||
const handleViewChatData = async (contact) => {
|
||||
console.log('查看聊天数据:', contact)
|
||||
await getCustomerChatInfo({
|
||||
customerId: selectedContact.value?.customerId || 1
|
||||
})
|
||||
console.log('聊天数据已更新:', chatRecords.value)
|
||||
};
|
||||
|
||||
const handleViewCallData = (contact) => {
|
||||
// TODO: 实现通话录音查看逻辑
|
||||
};
|
||||
|
||||
// 处理SOP分析事件
|
||||
const handleAnalyzeSop = (analyzeData) => {
|
||||
console.log('收到SOP分析请求:', analyzeData);
|
||||
if (customerDetailRef.value && analyzeData.content) {
|
||||
customerDetailRef.value.startSopAnalysis(analyzeData.content);
|
||||
}
|
||||
};
|
||||
// 处理弹框显示事件
|
||||
const handleShowModal = (title, content) => {
|
||||
console.log('handleShowModal0000', title)
|
||||
modalTitle.value = title.title
|
||||
modalContent.value = title.content
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
// 关闭弹框
|
||||
const closeModal = () => {
|
||||
showModal.value = false
|
||||
modalContent.value = ''
|
||||
modalTitle.value = ''
|
||||
}
|
||||
|
||||
// 处理下载弹框显示
|
||||
const handleShowDownloadModal = (title, content) => {
|
||||
downloadModalTitle.value = title
|
||||
downloadModalContent.value = content
|
||||
showDownloadModal.value = true
|
||||
}
|
||||
|
||||
// 关闭下载弹框
|
||||
const closeDownloadModal = () => {
|
||||
showDownloadModal.value = false
|
||||
downloadModalContent.value = ''
|
||||
downloadModalTitle.value = ''
|
||||
}
|
||||
|
||||
// 显示 FeedbackForm
|
||||
const showFeedbackFormModal = () => {
|
||||
showFeedbackForm.value = true
|
||||
}
|
||||
|
||||
// 关闭 FeedbackForm
|
||||
const closeFeedbackFormModal = () => {
|
||||
showFeedbackForm.value = false
|
||||
}
|
||||
|
||||
// // 处理SOP分析事件
|
||||
// const handleAnalyzeSop = (analyzeData) => {
|
||||
// console.log('handleAnalyzeSop', analyzeData)
|
||||
// console.log('analyzeData.content', customerDetailRef.value)
|
||||
// if (customerDetailRef.value && analyzeData.content) {
|
||||
// customerDetailRef.value.startSopAnalysis(analyzeData.content);
|
||||
// }
|
||||
// };
|
||||
// 销售漏斗
|
||||
const SalesFunnel = ref([])
|
||||
async function CenterGetSalesFunnel() {
|
||||
const params = getRequestParams()
|
||||
const hasParams = params.user_name
|
||||
const res = await getSalesFunnel(hasParams?params:undefined)
|
||||
const params = getRequestParams()
|
||||
const hasParams = params.user_name
|
||||
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
|
||||
}
|
||||
}
|
||||
*/
|
||||
}
|
||||
}
|
||||
// 黄金联络时间段
|
||||
@@ -764,25 +947,52 @@ const goldContactTime = ref([])
|
||||
async function CenterGetGoldContactTime() {
|
||||
const params = getRequestParams()
|
||||
const hasParams = params.user_name
|
||||
const res = await getGoldContactTime(hasParams?params:undefined)
|
||||
const res = await getGoldContactTime(hasParams ? params : undefined)
|
||||
if(res.code === 200){
|
||||
goldContactTime.value = res.data
|
||||
}
|
||||
}
|
||||
|
||||
// 强制刷新所有数据(重新调用所有API)
|
||||
async function forceRefreshAllData() {
|
||||
console.log('开始强制刷新所有数据...')
|
||||
|
||||
// 重新调用所有API
|
||||
await Promise.all([
|
||||
getCoreKpi(),
|
||||
getStatisticsData(),
|
||||
getUrgentProblem(),
|
||||
getTimeline(),
|
||||
CenterGetSalesFunnel(),
|
||||
CenterGetGoldContactTime(),
|
||||
// 客户相关数据需要在选中客户后才能获取
|
||||
selectedContact.value ? getCustomerForm() : Promise.resolve(),
|
||||
selectedContact.value ? getCustomerChat() : Promise.resolve(),
|
||||
selectedContact.value ? getCustomerCall() : Promise.resolve()
|
||||
])
|
||||
console.log('所有数据刷新完成')
|
||||
}
|
||||
|
||||
// LIFECYCLE HOOKS
|
||||
onMounted(async () => {
|
||||
try {
|
||||
try {
|
||||
isPageLoading.value = true
|
||||
await getCoreKpi()
|
||||
await CenterGetGoldContactTime()
|
||||
await CenterGetSalesFunnel()
|
||||
await getCustomerForm()
|
||||
await getCustomerChat()
|
||||
await getUrgentProblem()
|
||||
await getCustomerCall()
|
||||
await getTimeline()
|
||||
await getCustomerPayMoney()
|
||||
getStatisticsData()
|
||||
getCoreKpi()
|
||||
CenterGetGoldContactTime()
|
||||
CenterGetSalesFunnel()
|
||||
getCustomerForm()
|
||||
getCustomerChat()
|
||||
getUrgentProblem()
|
||||
getCustomerCall()
|
||||
getTimeline()
|
||||
|
||||
// 开发环境下暴露数据刷新函数到全局对象,方便调试
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
window.saleData = {
|
||||
forceRefreshAllData
|
||||
}
|
||||
}
|
||||
|
||||
// 等待数据加载完成后选择默认客户
|
||||
await nextTick();
|
||||
@@ -870,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;
|
||||
@@ -1610,4 +1820,191 @@ $primary: #3b82f6;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
// 弹框样式
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
// 使用 Flexbox 实现垂直和水平居中
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
backdrop-filter: blur(4px);
|
||||
animation: fadeIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
.modal-container {
|
||||
background: #ffffff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||
max-width: 1200px;
|
||||
width: 90%;
|
||||
// 设置最大高度,防止弹窗超出屏幕
|
||||
max-height: 80vh;
|
||||
// 防止内容溢出容器,配合内部滚动
|
||||
overflow: hidden;
|
||||
// 使用 Flexbox 布局,让 .modal-body 可以伸缩
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
animation: slideIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 20px 24px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.modal-close-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
background: #f3f4f6;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: #e5e7eb;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.icon-close {
|
||||
font-size: 18px;
|
||||
color: #6b7280;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
// 关键:让内容区域占据所有剩余空间
|
||||
flex: 1;
|
||||
// 关键:当内容超出时,只在垂直方向显示滚动条
|
||||
overflow-y: auto;
|
||||
// 防止滚动链传递到页面,仅在弹框内滚动
|
||||
overscroll-behavior: contain;
|
||||
// 为内容提供统一内边距
|
||||
padding: 24px;
|
||||
// 配合 flex: 1 使用,防止 flex item 在某些浏览器中无法正确收缩
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: #374151;
|
||||
// 支持长文本和换行
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
padding: 16px 24px;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
// flex-shrink: 0; // 确保 footer 不会被压缩
|
||||
}
|
||||
|
||||
.modal-btn {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&.modal-btn-primary {
|
||||
background: #3b82f6;
|
||||
color: #ffffff;
|
||||
|
||||
&:hover {
|
||||
background: #2563eb;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 8px rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-20px) scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
// 意见反馈按钮样式
|
||||
.feedback-btn {
|
||||
background-color: #4299e1;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.feedback-btn:hover {
|
||||
background-color: #3182ce;
|
||||
}
|
||||
|
||||
// 弹框响应式样式
|
||||
@media (max-width: 768px) {
|
||||
.modal-container {
|
||||
width: 95%;
|
||||
max-height: 85vh;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
padding: 16px 20px;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
padding: 12px 20px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,686 +1,31 @@
|
||||
<template>
|
||||
<div class="action-items">
|
||||
<div class="actions-header">
|
||||
<h2>待处理事项</h2>
|
||||
<div class="header-controls">
|
||||
<select v-model="filterPriority" class="priority-filter">
|
||||
<option value="all">全部状态</option>
|
||||
<option value="待处理">待处理</option>
|
||||
<option value="正在处理">正在处理</option>
|
||||
<option value="已完成">已完成</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="actions-list">
|
||||
<div v-if="filteredActions.length === 0" class="no-tasks">
|
||||
<p>暂无任务</p>
|
||||
</div>
|
||||
<div v-else class="task-list">
|
||||
<div
|
||||
v-for="task in filteredActions"
|
||||
:key="task.task_id"
|
||||
class="task-row"
|
||||
>
|
||||
<div class="task-info">
|
||||
<div class="task-row-1">
|
||||
<span class="task-title">{{ task.task_title }}</span>
|
||||
<span class="task-content">{{ task.task_content }}</span>
|
||||
</div>
|
||||
<div class="task-row-2">
|
||||
<span class="task-date">到期时间: {{ formatDueDate(task.expiration_date) }}</span>
|
||||
<span class="task-created">创建时间: {{ task.created_at }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="task-actions">
|
||||
<span class="status-tag" :class="getTaskStatusClass(task.state)">
|
||||
{{ task.state }}
|
||||
</span>
|
||||
<button
|
||||
class="status-btn"
|
||||
@click="changeTaskStatus(task)"
|
||||
>
|
||||
处理任务
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="action-items-container">
|
||||
<Calendar />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import axios from 'axios'
|
||||
|
||||
const props = defineProps({
|
||||
selectedGroup: {
|
||||
type: Object,
|
||||
default: null
|
||||
}
|
||||
})
|
||||
|
||||
// 筛选优先级
|
||||
const filterPriority = ref('all')
|
||||
|
||||
// 显示新增表单
|
||||
const showAddForm = ref(false)
|
||||
|
||||
// 新增事项表单数据
|
||||
const newAction = ref({
|
||||
title: '',
|
||||
description: '',
|
||||
priority: 'medium',
|
||||
dueDate: '',
|
||||
relatedGroup: ''
|
||||
})
|
||||
|
||||
// 待处理事项数据
|
||||
const actions = ref([])
|
||||
|
||||
// 筛选后的事项
|
||||
const filteredActions = computed(() => {
|
||||
console.log('计算filteredActions - actions.value:', actions.value)
|
||||
console.log('计算filteredActions - filterPriority.value:', filterPriority.value)
|
||||
|
||||
let filtered = actions.value
|
||||
|
||||
if (filterPriority.value !== 'all') {
|
||||
filtered = filtered.filter(task => task.state === filterPriority.value)
|
||||
}
|
||||
|
||||
console.log('计算filteredActions - filtered结果:', filtered)
|
||||
return filtered
|
||||
})
|
||||
|
||||
// 获取任务状态样式类
|
||||
const getTaskStatusClass = (state) => {
|
||||
switch (state) {
|
||||
case '待处理':
|
||||
return 'status-pending'
|
||||
case '正在处理':
|
||||
return 'status-processing'
|
||||
case '已完成':
|
||||
return 'status-completed'
|
||||
default:
|
||||
return 'status-default'
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化到期日期
|
||||
const formatDueDate = (dateStr) => {
|
||||
if (!dateStr) return '无'
|
||||
// 如果是YYYYMMDD格式,转换为YYYY-MM-DD
|
||||
if (dateStr.length === 8) {
|
||||
const year = dateStr.substring(0, 4)
|
||||
const month = dateStr.substring(4, 6)
|
||||
const day = dateStr.substring(6, 8)
|
||||
return `${year}-${month}-${day}`
|
||||
}
|
||||
return dateStr
|
||||
}
|
||||
// 获取任务列表
|
||||
const getTaskList = async () => {
|
||||
try {
|
||||
const res = await axios.post('http://192.168.15.60:8890/api/v1/level_five/overview/view_tasks', {}, {
|
||||
headers: {
|
||||
'Authorization': 'Bearer ' + localStorage.getItem('token')
|
||||
}
|
||||
})
|
||||
console.log(888888,res)
|
||||
if (res.data.code === 200) {
|
||||
actions.value = res.data.data.tasks || res.data.data
|
||||
console.log(777777,actions.value)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取任务列表失败:', error)
|
||||
}
|
||||
}
|
||||
// 修改任务状态按钮点击事件
|
||||
const changeTaskStatus = (task) => {
|
||||
const statusOptions = ['待处理', '正在处理', '已完成']
|
||||
const currentIndex = statusOptions.indexOf(task.state)
|
||||
const nextIndex = (currentIndex + 1) % statusOptions.length
|
||||
const newState = statusOptions[nextIndex]
|
||||
|
||||
updateTaskState(task.task_id, newState)
|
||||
}
|
||||
|
||||
// 修改任务状态 http://192.168.15.56:8890/api/v1/level_four/overview/update_task_state
|
||||
const updateTaskState = async (taskId, state) => {
|
||||
try {
|
||||
const res = await axios.put('http://192.168.15.60:8890/api/v1/level_four/overview/update_task_state', {
|
||||
task_ids: [taskId],
|
||||
new_state: state
|
||||
}, {
|
||||
headers: {
|
||||
'Authorization': 'Bearer ' + localStorage.getItem('token')
|
||||
}
|
||||
})
|
||||
if (res.data.code === 200) {
|
||||
console.log('任务状态更新成功')
|
||||
// 刷新任务列表
|
||||
await getTaskList()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('更新任务状态失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 编辑事项
|
||||
const editAction = (action) => {
|
||||
// 这里可以实现编辑功能
|
||||
console.log('编辑事项:', action)
|
||||
}
|
||||
|
||||
// 删除事项
|
||||
const deleteAction = (id) => {
|
||||
if (confirm('确定要删除这个事项吗?')) {
|
||||
const index = actions.value.findIndex(a => a.id === id)
|
||||
if (index > -1) {
|
||||
actions.value.splice(index, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 添加新事项
|
||||
const addAction = () => {
|
||||
const newId = Math.max(...actions.value.map(a => a.id)) + 1
|
||||
actions.value.push({
|
||||
id: newId,
|
||||
...newAction.value,
|
||||
progress: 0,
|
||||
tags: [],
|
||||
completed: false,
|
||||
createdAt: new Date().toISOString().split('T')[0]
|
||||
})
|
||||
|
||||
// 重置表单
|
||||
newAction.value = {
|
||||
title: '',
|
||||
description: '',
|
||||
priority: 'medium',
|
||||
dueDate: '',
|
||||
relatedGroup: ''
|
||||
}
|
||||
|
||||
showAddForm.value = false
|
||||
}
|
||||
onMounted(async () => {
|
||||
await getTaskList()
|
||||
})
|
||||
import Calendar from './Calendar.vue';
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.action-items {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
<style scoped>
|
||||
.action-items-container {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.actions-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
|
||||
h2 {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.header-controls {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
|
||||
.priority-filter {
|
||||
padding: 0.5rem;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
}
|
||||
|
||||
.add-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 事项列表
|
||||
.actions-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
|
||||
.no-tasks {
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
color: #999;
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
|
||||
.action-item {
|
||||
display: flex;
|
||||
padding: 1rem;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1rem;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
border-color: #d0d0d0;
|
||||
}
|
||||
|
||||
&.status-pending {
|
||||
border-left: 4px solid #f59e0b;
|
||||
}
|
||||
|
||||
&.status-processing {
|
||||
border-left: 4px solid #3b82f6;
|
||||
}
|
||||
|
||||
&.status-completed {
|
||||
border-left: 4px solid #10b981;
|
||||
opacity: 0.8;
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.action-content {
|
||||
flex: 1;
|
||||
|
||||
.action-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 0.5rem;
|
||||
|
||||
.action-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.action-meta {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
|
||||
.status-badge {
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 12px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
|
||||
&.status-pending {
|
||||
background: #fff3cd;
|
||||
color: #856404;
|
||||
border: 1px solid #ffeaa7;
|
||||
}
|
||||
|
||||
&.status-processing {
|
||||
background: #d1ecf1;
|
||||
color: #0c5460;
|
||||
border: 1px solid #bee5eb;
|
||||
}
|
||||
|
||||
&.status-completed {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
border: 1px solid #c3e6cb;
|
||||
}
|
||||
|
||||
&.status-default {
|
||||
background: #f8f9fa;
|
||||
color: #6c757d;
|
||||
border: 1px solid #dee2e6;
|
||||
}
|
||||
}
|
||||
|
||||
.due-date {
|
||||
font-size: 0.75rem;
|
||||
color: #666;
|
||||
background: #f8f9fa;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.action-description {
|
||||
font-size: 0.9rem;
|
||||
color: #6b7280;
|
||||
margin: 0 0 1rem 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.action-details {
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
|
||||
.detail-item {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
font-size: 0.8rem;
|
||||
color: #888;
|
||||
|
||||
.detail-label {
|
||||
font-weight: 500;
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// 空状态
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
|
||||
.empty-icon {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 1rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
h3 {
|
||||
font-size: 1.1rem;
|
||||
color: #374151;
|
||||
margin: 0 0 0.5rem 0;
|
||||
}
|
||||
|
||||
p {
|
||||
color: #6b7280;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 模态框
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
|
||||
.modal-content {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
width: 90%;
|
||||
max-width: 500px;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
color: #6b7280;
|
||||
|
||||
&:hover {
|
||||
color: #374151;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.add-form {
|
||||
.form-group {
|
||||
margin-bottom: 1rem;
|
||||
|
||||
label {
|
||||
display: block;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.form-input, .form-textarea, .form-select {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
}
|
||||
|
||||
.form-textarea {
|
||||
height: 80px;
|
||||
resize: vertical;
|
||||
}
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: flex-end;
|
||||
margin-top: 1.5rem;
|
||||
|
||||
.btn-cancel, .btn-submit {
|
||||
padding: 0.75rem 1.5rem;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-cancel {
|
||||
background: #f3f4f6;
|
||||
color: #374151;
|
||||
|
||||
&:hover {
|
||||
background: #e5e7eb;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-submit {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
|
||||
&:hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
max-height: 600px;
|
||||
overflow: hidden;
|
||||
padding: 5px;
|
||||
background: #ffffff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
// 任务列表样式
|
||||
.task-list {
|
||||
.task-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
|
||||
&:hover {
|
||||
background-color: #f9fafb;
|
||||
}
|
||||
|
||||
.task-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
margin-right: 16px;
|
||||
|
||||
.task-row-1, .task-row-2 {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.task-row-1 {
|
||||
.task-title {
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
font-size: 14px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.task-content {
|
||||
color: #6b7280;
|
||||
font-size: 13px;
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.task-row-2 {
|
||||
.task-date, .task-created {
|
||||
color: #9ca3af;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.task-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
.status-tag {
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
|
||||
&.pending {
|
||||
background-color: #fef3c7;
|
||||
color: #d97706;
|
||||
}
|
||||
|
||||
&.in-progress {
|
||||
background-color: #dbeafe;
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
&.completed {
|
||||
background-color: #d1fae5;
|
||||
color: #059669;
|
||||
}
|
||||
}
|
||||
|
||||
.status-btn {
|
||||
padding: 6px 12px;
|
||||
background-color: #3b82f6;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
|
||||
&:hover {
|
||||
background-color: #2563eb;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.no-tasks {
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
// 移动端适配
|
||||
@media (max-width: 768px) {
|
||||
.task-list {
|
||||
.task-row {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
|
||||
.task-actions {
|
||||
align-self: flex-end;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.calendar-title {
|
||||
margin: 0 0 20px 0;
|
||||
color: #303133;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
padding-bottom: 15px;
|
||||
border-bottom: 2px solid #f0f2f5;
|
||||
}
|
||||
</style>
|
||||
1994
my-vue-app/src/views/secondTop/components/Calendar.vue
Normal file
1994
my-vue-app/src/views/secondTop/components/Calendar.vue
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,19 +1,43 @@
|
||||
<template>
|
||||
<div class="center-overview">
|
||||
<h2>中心整体概览</h2>
|
||||
<div class="overview-header">
|
||||
<h2>中心整体概览</h2>
|
||||
<div class="stats-toggle">
|
||||
<button
|
||||
:class="['toggle-btn', { active: statsMode === 'period' }]"
|
||||
@click="switchStatsMode('period')"
|
||||
>
|
||||
按期统计
|
||||
</button>
|
||||
<button
|
||||
:class="['toggle-btn', { active: statsMode === 'month' }]"
|
||||
@click="switchStatsMode('month')"
|
||||
>
|
||||
按月统计
|
||||
</button>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div class="overview-grid">
|
||||
<div class="overview-card primary">
|
||||
<div class="card-header">
|
||||
<span class="card-title">中心总业绩</span>
|
||||
<span class="card-title">
|
||||
中心总业绩
|
||||
<i @mouseenter="showTooltip($event, 'centerPerformance')" @mouseleave="hideTooltip">ⓘ</i>
|
||||
</span>
|
||||
<span class="card-trend positive">{{ props.overallData.CenterPerformance?.center_monthly_vs_previous_deals }} vs 上期</span>
|
||||
</div>
|
||||
<div class="card-value">{{ props.overallData.CenterPerformance.center_monthly_deal_count || '552,000' }} 单</div>
|
||||
<div class="card-value">{{ props.overallData.CenterPerformance.center_monthly_deal_count || '0' }} 单</div>
|
||||
<div class="card-subtitle">月目标完成率: {{ props.overallData.CenterPerformance?.center_monthly_target_completion_rate || '56%' }}</div>
|
||||
</div>
|
||||
|
||||
<div class="overview-card">
|
||||
<div class="card-header">
|
||||
<span class="card-title">活跃组数</span>
|
||||
<span class="card-title">
|
||||
活跃组数
|
||||
<i @mouseenter="showTooltip($event, 'activeGroups')"
|
||||
@mouseleave="hideTooltip">ⓘ</i>
|
||||
</span>
|
||||
<span class="card-trend stable">{{ props.overallData.TotalGroupCount?.center_total_team_count}}/{{ props.overallData.TotalGroupCount?.center_total_team_count }} 组</span>
|
||||
</div>
|
||||
<div class="card-value">{{ props.overallData.TotalGroupCount?.center_total_team_count || '5' }} 组</div>
|
||||
@@ -22,7 +46,12 @@
|
||||
|
||||
<div class="overview-card">
|
||||
<div class="card-header">
|
||||
<span class="card-title">中心转化率</span>
|
||||
<span class="card-title">
|
||||
中心转化率
|
||||
<i
|
||||
@mouseenter="showTooltip($event, 'conversionRate')"
|
||||
@mouseleave="hideTooltip">ⓘ</i>
|
||||
</span>
|
||||
<span class="card-trend positive">{{ props.overallData.CenterConversionRate?.center_monthly_vs_previous_deals }}vs 上期</span>
|
||||
</div>
|
||||
<div class="card-value">{{ props.overallData.CenterConversionRate?.center_conversion_rate || '5.2' }}</div>
|
||||
@@ -31,16 +60,26 @@
|
||||
|
||||
<div class="overview-card">
|
||||
<div class="card-header">
|
||||
<span class="card-title">总通话次数</span>
|
||||
<span class="card-title">
|
||||
总通话次数
|
||||
<i
|
||||
@mouseenter="showTooltip($event, 'totalCalls')"
|
||||
@mouseleave="hideTooltip">ⓘ</i>
|
||||
</span>
|
||||
<span class="card-trend positive">{{ props.overallData.TotalCallCount?.total_call_count_vs_yesterday}} vs 上期</span>
|
||||
</div>
|
||||
<div class="card-value">{{ props.overallData.TotalCallCount?.total_call_count || '1,247' }} 次</div>
|
||||
<div class="card-subtitle">有效通话: {{ props.overallData.TotalCallCount?.center_effective_call_count || '892' }}次</div>
|
||||
<div class="card-value">{{ props.overallData.TotalCallCount?.total_call_count || '0' }} 次</div>
|
||||
<div class="card-subtitle">有效通话: {{ props.overallData.TotalCallCount?.center_effective_call_count || '0' }}次</div>
|
||||
</div>
|
||||
|
||||
<div class="overview-card">
|
||||
<div class="card-header">
|
||||
<span class="card-title">新增客户</span>
|
||||
<span class="card-title">
|
||||
新增客户
|
||||
<i
|
||||
@mouseenter="showTooltip($event, 'newCustomers')"
|
||||
@mouseleave="hideTooltip">ⓘ</i>
|
||||
</span>
|
||||
<span class="card-trend positive">{{ props.overallData.NewCustomer?.center_new_leads_vs_previous_period }} vs 上期</span>
|
||||
</div>
|
||||
<div class="card-value">{{ props.overallData.NewCustomer?.center_new_leads_count || '117' }} 人</div>
|
||||
@@ -49,18 +88,50 @@
|
||||
|
||||
<div class="overview-card">
|
||||
<div class="card-header">
|
||||
<span class="card-title">定金转化</span>
|
||||
<span class="card-title">
|
||||
定金转化
|
||||
<i
|
||||
@mouseenter="showTooltip($event, 'depositConversion')"
|
||||
@mouseleave="hideTooltip">ⓘ</i>
|
||||
</span>
|
||||
<span class="card-trend positive">{{ props.overallData.DepositConversionRate?.center_deposit_conversion_vs_previous }} vs 上期</span>
|
||||
</div>
|
||||
<div class="card-value">{{ props.overallData.DepositConversionRate?.center_current_deposit_conversion_rate || '0' }} </div>
|
||||
<div class="card-subtitle">平均定金转化率: {{ props.overallData.DepositConversionRate?.center_monthly_deposit_conversion_rate || '0' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tooltip组件 -->
|
||||
<Tooltip
|
||||
:visible="tooltip.visible"
|
||||
:x="tooltip.x"
|
||||
:y="tooltip.y"
|
||||
:title="tooltip.title"
|
||||
:description="tooltip.description"
|
||||
/>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, ref } from 'vue'
|
||||
import Tooltip from '@/components/Tooltip.vue'
|
||||
|
||||
// 统计模式状态
|
||||
const statsMode = ref('month') // 默认按月统计
|
||||
|
||||
// 定义emit事件
|
||||
const emit = defineEmits(['update-check-type'])
|
||||
|
||||
// 切换统计模式
|
||||
const switchStatsMode = (mode) => {
|
||||
statsMode.value = mode
|
||||
// 向父组件发送事件,修改CheckType的值
|
||||
const checkTypeValue = mode === 'period' ? 'period' : 'month'
|
||||
emit('update-check-type', checkTypeValue)
|
||||
console.log('切换统计模式:', mode, '发送CheckType值:', checkTypeValue)
|
||||
}
|
||||
|
||||
// 中心整体概览组件
|
||||
const props = defineProps({
|
||||
overallData: {
|
||||
@@ -75,6 +146,58 @@ const props = defineProps({
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Tooltip状态管理
|
||||
const tooltip = reactive({
|
||||
visible: false,
|
||||
x: 0,
|
||||
y: 0,
|
||||
title: '',
|
||||
description: ''
|
||||
})
|
||||
|
||||
// 指标描述信息
|
||||
const metricDescriptions = {
|
||||
centerPerformance: {
|
||||
title: '中心总业绩计算方式',
|
||||
description: '月目标完成率 = 当月成交单数 / 月度目标单数 × 100%'
|
||||
},
|
||||
activeGroups: {
|
||||
title: '活跃组数计算方式',
|
||||
description: '本月有通话记录或成交记录的团队。总人数为所有活跃团队的人员总和'
|
||||
},
|
||||
conversionRate: {
|
||||
title: '中心转化率计算方式',
|
||||
description: '中心转化率 = 总成交客户数 / 总接触客户数 × 100%'
|
||||
},
|
||||
totalCalls: {
|
||||
title: '总通话次数计算方式',
|
||||
description: '所有销售人员的通话总次数,包括接听和拨出。有效通话指通话时长超过30秒的通话记录'
|
||||
},
|
||||
newCustomers: {
|
||||
title: '新增客户计算方式',
|
||||
description: '本期新录入系统的客户数量。意向客户指经过初步沟通,有明确购买意向的客户数量'
|
||||
},
|
||||
depositConversion: {
|
||||
title: '定金转化计算方式',
|
||||
description: '定金转化率 = 缴纳定金客户数 / 意向客户总数 × 100%'
|
||||
}
|
||||
}
|
||||
|
||||
// 显示tooltip
|
||||
const showTooltip = (event, metricType) => {
|
||||
const rect = event.target.getBoundingClientRect()
|
||||
tooltip.visible = true
|
||||
tooltip.x = rect.left + rect.width / 2
|
||||
tooltip.y = rect.top - 10
|
||||
tooltip.title = metricDescriptions[metricType].title
|
||||
tooltip.description = metricDescriptions[metricType].description
|
||||
}
|
||||
|
||||
// 隐藏tooltip
|
||||
const hideTooltip = () => {
|
||||
tooltip.visible = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@@ -169,6 +292,39 @@ const props = defineProps({
|
||||
color: #94a3b8;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.info-icon {
|
||||
display: inline-block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: #409eff;
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
text-align: center;
|
||||
line-height: 16px;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
margin-left: 6px;
|
||||
cursor: pointer;
|
||||
opacity: 0.8;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
transform: scale(1.1);
|
||||
background: #66b3ff;
|
||||
}
|
||||
}
|
||||
|
||||
// 主要卡片中的图标样式
|
||||
&.primary .info-icon {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
color: white;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.trend-section {
|
||||
@@ -225,8 +381,69 @@ const props = defineProps({
|
||||
}
|
||||
}
|
||||
|
||||
// 切换按钮样式
|
||||
.overview-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
color: #1e293b;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.stats-toggle {
|
||||
display: flex;
|
||||
background: #f1f5f9;
|
||||
border-radius: 8px;
|
||||
padding: 4px;
|
||||
gap: 2px;
|
||||
|
||||
.toggle-btn {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #64748b;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: #e2e8f0;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: #ffffff;
|
||||
color: #0f172a;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 移动端适配
|
||||
@media (max-width: 768px) {
|
||||
.overview-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
|
||||
.stats-toggle {
|
||||
align-self: stretch;
|
||||
|
||||
.toggle-btn {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.center-overview {
|
||||
padding: 1rem;
|
||||
|
||||
|
||||
@@ -311,7 +311,7 @@ onBeforeUnmount(() => {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20px 20px 16px;
|
||||
padding: 10px 20px 10px;
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
h3 { margin: 0; color: #303133; font-size: 18px; font-weight: 600; }
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -32,7 +32,7 @@
|
||||
<div class="key-metrics">
|
||||
<div class="mini-metric">
|
||||
<span class="mini-label">业绩</span>
|
||||
<span class="mini-value">{{ formatCurrency(group.todayPerformance) }}</span>
|
||||
<span class="mini-value">{{ group.todayPerformance }}</span>
|
||||
</div>
|
||||
<div class="mini-metric">
|
||||
<span class="mini-label">转化</span>
|
||||
@@ -175,22 +175,22 @@ const processedGroups = computed(() => {
|
||||
})
|
||||
}
|
||||
|
||||
// 处理 formal_plural 数据
|
||||
// 处理 formal_plural 数据(业绩数据)
|
||||
if (props.groupList.formal_plural) {
|
||||
console.log('Processing formal_plural:', props.groupList.formal_plural)
|
||||
Object.entries(props.groupList.formal_plural).forEach(([managerName, teamData]) => {
|
||||
if (typeof teamData === 'object' && teamData !== null) {
|
||||
Object.entries(teamData).forEach(([teamName, count]) => {
|
||||
Object.entries(teamData).forEach(([teamName, performance]) => {
|
||||
const existingGroup = groups.find(g => g.id === `${managerName}-${teamName}` || g.id === managerName)
|
||||
if (existingGroup) {
|
||||
existingGroup.newClients = count || 0
|
||||
existingGroup.todayPerformance = performance || 0
|
||||
}
|
||||
})
|
||||
} else if (typeof teamData === 'number') {
|
||||
// 处理直接数值的情况
|
||||
const existingGroup = groups.find(g => g.id === managerName)
|
||||
if (existingGroup) {
|
||||
existingGroup.newClients = teamData || 0
|
||||
existingGroup.todayPerformance = teamData || 0
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -275,10 +275,10 @@ const getRankingClass = (index) => {
|
||||
// 处理部门双击事件,跳转到经理页面
|
||||
const navigateToManager = (group) => {
|
||||
router.push({
|
||||
path: '/senior-manager',
|
||||
path: '/manager',
|
||||
query: {
|
||||
user_name: group.id,
|
||||
user_level: 3
|
||||
user_name: group.leader,
|
||||
user_level: 2
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
<div class="chart-container">
|
||||
<div class="chart-header">
|
||||
<h3>客户迫切解决的问题排行榜</h3>
|
||||
<button @click="exportData" v-if="userStore.userInfo.user_level === 4">一键导出</button>
|
||||
</div>
|
||||
<div class="chart-content">
|
||||
<div v-if="sortedData.length > 0" class="problem-ranking">
|
||||
@@ -33,7 +34,13 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { computed,onMounted } from 'vue';
|
||||
import { exportCustomers, getExcellentRecordFile } from '@/api/secondTop';
|
||||
import { useUserStore } from "@/stores/user";
|
||||
import { ElMessage } from 'element-plus';
|
||||
import * as XLSX from 'xlsx';
|
||||
// 用户store实例
|
||||
const userStore = useUserStore();
|
||||
|
||||
// 定义Props,接收一个包含 { name: string, value: string | number } 的数组
|
||||
const props = defineProps({
|
||||
@@ -73,6 +80,190 @@ const getRankingClass = (index) => {
|
||||
const getRankBadgeClass = (index) => {
|
||||
return ['badge-gold', 'badge-silver', 'badge-bronze'][index] || 'badge-default';
|
||||
};
|
||||
|
||||
async function exportData() {
|
||||
const params = {
|
||||
user_name: userStore.userInfo.username,
|
||||
user_level: userStore.userInfo.user_level.toString(),
|
||||
}
|
||||
|
||||
try {
|
||||
ElMessage.info('正在导出数据,请稍候...')
|
||||
console.log('导出参数:', params)
|
||||
const res = await exportCustomers()
|
||||
if (res.code === 200 && res.data && res.data.length > 0) {
|
||||
ElMessage.success('数据导出成功')
|
||||
// 处理数据,将复杂的嵌套对象展平
|
||||
const exportData = res.data.map(customer => {
|
||||
const flatData = {
|
||||
'昵称': customer.nickname || '',
|
||||
'客户姓名': customer.customer_name || '',
|
||||
'性别': customer.gender || '',
|
||||
'跟进人': customer.follow_up_name || '',
|
||||
'手机号': customer.phone || '',
|
||||
'是否入群': customer.is_in_group || '',
|
||||
'用户ID': customer.mantis_user_id || '',
|
||||
}
|
||||
|
||||
const parseFormData = (formData) => {
|
||||
if (typeof formData === 'string') {
|
||||
try {
|
||||
return JSON.parse(formData)
|
||||
} catch (e) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
return formData || null
|
||||
}
|
||||
|
||||
const normalizeFormArray = (formData) => {
|
||||
const data = parseFormData(formData)
|
||||
console.log('解析后的表单数据:', data)
|
||||
if (Array.isArray(data)) return data
|
||||
if (data && Array.isArray(data.data)) return data.data
|
||||
if (data && typeof data === 'object' && !data.answers) {
|
||||
const numericKeys = Object.keys(data).filter(key => String(Number(key)) === key)
|
||||
if (numericKeys.length > 0) {
|
||||
return numericKeys
|
||||
.sort((a, b) => Number(a) - Number(b))
|
||||
.map(key => data[key])
|
||||
.filter(Boolean)
|
||||
}
|
||||
}
|
||||
if (data && data.answers) return [data]
|
||||
return data ? [data] : []
|
||||
}
|
||||
|
||||
const ensureUniqueKey = (key) => {
|
||||
if (!flatData[key]) return key
|
||||
let index = 2
|
||||
while (flatData[`${key}(${index})`]) {
|
||||
index += 1
|
||||
}
|
||||
return `${key}(${index})`
|
||||
}
|
||||
|
||||
const parsedFormData = parseFormData(customer.wechat_form)
|
||||
const formArray = normalizeFormArray(parsedFormData)
|
||||
if (formArray.length > 0) {
|
||||
console.log('表单数组:', formArray)
|
||||
const isAnswerList = formArray.every(item => item && item.question_label && Object.prototype.hasOwnProperty.call(item, 'answer'))
|
||||
const isFormWithAnswers = formArray.every(item => item && Array.isArray(item.answers))
|
||||
console.log('是否为答案列表:', isAnswerList)
|
||||
console.log('是否为表单含 answers:', isFormWithAnswers)
|
||||
if (isAnswerList) {
|
||||
let formAnswerCount = 0
|
||||
formArray.forEach((answerItem) => {
|
||||
const label = String(answerItem.question_label || '').trim()
|
||||
if (!label) return
|
||||
const key = ensureUniqueKey(label)
|
||||
flatData[key] = answerItem.answer ?? ''
|
||||
formAnswerCount += 1
|
||||
})
|
||||
if (formAnswerCount === 0 && formArray.length > 0) {
|
||||
const fallbackKey = ensureUniqueKey('表单答案')
|
||||
flatData[fallbackKey] = formArray.map(item => `${item.question_label || ''}: ${item.answer || ''}`).join(' | ')
|
||||
}
|
||||
} else if (isFormWithAnswers) {
|
||||
console.log('表单含 answers:', formArray)
|
||||
formArray.forEach((formItem) => {
|
||||
const formTitle = formItem.form_title || '表单'
|
||||
if (Array.isArray(formItem.answers)) {
|
||||
let formAnswerCount = 0
|
||||
formItem.answers.forEach((answerItem) => {
|
||||
const label = String(answerItem.question_label || '').trim()
|
||||
if (!label) return
|
||||
const key = ensureUniqueKey(`${formTitle}-${label}`)
|
||||
flatData[key] = answerItem.answer ?? ''
|
||||
formAnswerCount += 1
|
||||
})
|
||||
if (formAnswerCount === 0 && formItem.answers.length > 0) {
|
||||
const fallbackKey = ensureUniqueKey(`${formTitle}-表单答案`)
|
||||
flatData[fallbackKey] = formItem.answers.map(item => `${item.question_label || ''}: ${item.answer || ''}`).join(' | ')
|
||||
}
|
||||
}
|
||||
if (formItem.created_at) {
|
||||
const key = ensureUniqueKey(`${formTitle}-创建时间`)
|
||||
flatData[key] = new Date(formItem.created_at).toLocaleString()
|
||||
}
|
||||
if (formItem.updated_at) {
|
||||
const key = ensureUniqueKey(`${formTitle}-更新时间`)
|
||||
flatData[key] = new Date(formItem.updated_at).toLocaleString()
|
||||
}
|
||||
})
|
||||
}
|
||||
} else if (parsedFormData && typeof parsedFormData === 'object') {
|
||||
flatData['家长姓名'] = parsedFormData.name || ''
|
||||
flatData['孩子姓名'] = parsedFormData.child_name || ''
|
||||
flatData['孩子性别'] = parsedFormData.child_gender || ''
|
||||
flatData['职业'] = parsedFormData.occupation || ''
|
||||
flatData['孩子教育阶段'] = parsedFormData.child_education || ''
|
||||
flatData['与孩子关系'] = parsedFormData.child_relation || ''
|
||||
flatData['联系电话'] = parsedFormData.mobile || ''
|
||||
flatData['地区'] = parsedFormData.territory || ''
|
||||
flatData['创建时间'] = parsedFormData.created_at ? new Date(parsedFormData.created_at).toLocaleString() : ''
|
||||
flatData['更新时间'] = parsedFormData.updated_at ? new Date(parsedFormData.updated_at).toLocaleString() : ''
|
||||
}
|
||||
|
||||
// 处理到课情况
|
||||
if (customer.live) {
|
||||
flatData['课一到课情况'] = customer.live['课一'] || ''
|
||||
flatData['课二到课情况'] = customer.live['课二'] || ''
|
||||
flatData['课三到课情况'] = customer.live['课三'] || ''
|
||||
flatData['课四到课情况'] = customer.live['课四'] || ''
|
||||
}
|
||||
|
||||
// 处理问卷调查信息
|
||||
if (parsedFormData && parsedFormData.additional_info) {
|
||||
parsedFormData.additional_info.forEach((item) => {
|
||||
const key = ensureUniqueKey(item.topic || '')
|
||||
if (key) {
|
||||
flatData[key] = item.answer || ''
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return flatData
|
||||
})
|
||||
|
||||
// 创建工作簿
|
||||
const wb = XLSX.utils.book_new()
|
||||
const allKeys = Array.from(new Set(exportData.flatMap(item => Object.keys(item))))
|
||||
const ws = XLSX.utils.json_to_sheet(exportData, { header: allKeys })
|
||||
|
||||
// 设置列宽
|
||||
const colWidths = allKeys.map(key => {
|
||||
const maxCellLength = exportData.reduce((max, row) => {
|
||||
const value = row[key]
|
||||
const length = value === null || value === undefined ? 0 : String(value).length
|
||||
return Math.max(max, length)
|
||||
}, 0)
|
||||
return { wch: Math.min(50, Math.max(10, key.length, maxCellLength)) }
|
||||
})
|
||||
ws['!cols'] = colWidths
|
||||
|
||||
// 添加工作表到工作簿
|
||||
XLSX.utils.book_append_sheet(wb, ws, '客户数据')
|
||||
|
||||
// 生成文件名(包含当前时间)
|
||||
const now = new Date()
|
||||
const fileName = `客户数据导出_${now.getFullYear()}${(now.getMonth() + 1).toString().padStart(2, '0')}${now.getDate().toString().padStart(2, '0')}_${now.getHours().toString().padStart(2, '0')}${now.getMinutes().toString().padStart(2, '0')}.xlsx`
|
||||
|
||||
// 导出文件
|
||||
XLSX.writeFile(wb, fileName)
|
||||
|
||||
ElMessage.success(`导出成功!共导出 ${exportData.length} 条数据`)
|
||||
} else {
|
||||
alert('暂无数据可导出')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('导出失败:', error)
|
||||
ElMessage.error('导出失败,请稍后重试')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@@ -87,14 +278,42 @@ const getRankBadgeClass = (index) => {
|
||||
}
|
||||
|
||||
.chart-header {
|
||||
padding: 20px 20px 16px;
|
||||
padding: 10px 20px 10px;
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
color: #303133;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 8px 16px;
|
||||
background: linear-gradient(135deg, #409eff, #3a8ee6);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 2px 4px rgba(64, 158, 255, 0.3);
|
||||
|
||||
&:hover {
|
||||
background: linear-gradient(135deg, #3a8ee6, #337ecc);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 8px rgba(64, 158, 255, 0.4);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
box-shadow: 0 2px 4px rgba(64, 158, 255, 0.3);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.chart-content {
|
||||
@@ -219,4 +438,4 @@ const getRankBadgeClass = (index) => {
|
||||
color: #909399;
|
||||
font-size: 16px;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -24,28 +24,22 @@
|
||||
<p>统筹多组运营,优化资源配置,驱动业绩增长,实现团队协同发展。</p>
|
||||
</div>
|
||||
|
||||
<!-- 营期阶段信息与调控 -->
|
||||
<div class="stage-info" style="margin-left: 100px;">
|
||||
<span class="stage-label">营期所属阶段:</span>
|
||||
<span class="stage-value">{{ currentStage }}</span>
|
||||
|
||||
<!-- 仅在"接数据"阶段显示调控UI -->
|
||||
<div v-if="isDataReceivingStage" class="stage-control">
|
||||
<span class="control-label">调整"接数据"天数:</span>
|
||||
<input type="number" v-model.number="dataReceivingStage.days" min="1" class="days-input" />
|
||||
<button @click="saveCampSettings" class="save-button">保存</button>
|
||||
</div>
|
||||
|
||||
<!-- 非接数据阶段显示结束营期按钮 -->
|
||||
<div v-if="!isDataReceivingStage" class="stage-control">
|
||||
<button @click="finishCamp" class="finish-camp-button">结束营期</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="!isRouteNavigation">
|
||||
<!-- 用户下拉菜单 -->
|
||||
<UserDropdown />
|
||||
<div style="display: flex; align-items: center; gap: 20px;">
|
||||
<button @click="showFeedbackFormModal" class="feedback-btn">意见反馈</button>
|
||||
<FeedbackForm
|
||||
:is-visible="showFeedbackForm"
|
||||
@close="closeFeedbackFormModal"
|
||||
@submit-feedback="closeFeedbackFormModal"
|
||||
/>
|
||||
<UserDropdown
|
||||
:card-visibility="cardVisibility"
|
||||
@update-card-visibility="updateCardVisibility"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -56,37 +50,52 @@
|
||||
<!-- Top Section - Center Overview and Action Items -->
|
||||
<div class="top-section">
|
||||
<!-- Center Performance Overview -->
|
||||
<CenterOverview :overall-data="overallCenterPerformance" />
|
||||
<CenterOverview
|
||||
v-if="cardVisibility.centerOverview"
|
||||
:key="CheckType"
|
||||
:overall-data="overallCenterPerformance"
|
||||
@update-check-type="updateCheckType"
|
||||
/>
|
||||
|
||||
<!-- Action Items (Compact) -->
|
||||
<div class="action-items-compact">
|
||||
<div v-if="cardVisibility.actionItems" class="action-items-compact">
|
||||
<ActionItems :selected-group="selectedGroup" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="BB-section">
|
||||
<!--客户类型占比-->
|
||||
<CustomerType :customer-data="customerTypeDistribution" @category-change="handleCustomerTypeChange" />
|
||||
<CustomerType
|
||||
v-if="cardVisibility.customerType"
|
||||
:customer-data="customerTypeDistribution"
|
||||
@category-change="handleCustomerTypeChange"
|
||||
/>
|
||||
<!-- 优秀录音 -->
|
||||
<GoodMusic />
|
||||
<GoodMusic
|
||||
v-if="cardVisibility.goodMusic"
|
||||
:quality-calls="excellentRecord"
|
||||
/>
|
||||
<!-- 客户问题排行 -->
|
||||
<ProblemRanking :ranking-data="formattedUrgentNeedData" />
|
||||
<ProblemRanking
|
||||
v-if="cardVisibility.problemRanking"
|
||||
:ranking-data="formattedUrgentNeedData"
|
||||
/>
|
||||
</div>
|
||||
<!-- Bottom Section -->
|
||||
<div class="bottom-section">
|
||||
<!-- Left Section - Group Performance Ranking -->
|
||||
<div class="left-section">
|
||||
<div v-if="cardVisibility.groupRanking" class="left-section">
|
||||
<GroupRanking :groups="groups" :selected-group="selectedGroup" :conversion-data="conversionRateVsAverage" @select-group="selectGroup" />
|
||||
</div>
|
||||
|
||||
<!-- Right Section - Group Comparison -->
|
||||
<div class="right-section">
|
||||
<div v-if="cardVisibility.groupComparison" class="right-section">
|
||||
<GroupComparison :groups="groups" :senior-manager-data="seniorManagerList" :group-list="groupList" @select-group="selectGroup" @manager-change="handleManagerChange" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Team Members Detail Section -->
|
||||
<div class="team-detail-section" v-if="selectedGroup">
|
||||
<div class="team-detail-section" v-if="selectedGroup && cardVisibility.teamDetail">
|
||||
<div class="team-detail-header">
|
||||
<h2>{{ selectedGroup.name }} - 团队成员详情</h2>
|
||||
<div class="team-summary">
|
||||
@@ -103,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">
|
||||
@@ -134,13 +159,21 @@
|
||||
|
||||
<div class="metric-row">
|
||||
<div class="metric-item">
|
||||
<span class="metric-label">转化率</span>
|
||||
<span class="metric-label">转化率<i class="info-icon" @mouseenter="showTooltip($event, 'teamPerformance')" @mouseleave="hideTooltip">ⓘ</i></span>
|
||||
<span class="metric-value">{{ member.conversionRate }}%</span>
|
||||
</div>
|
||||
<div class="metric-item">
|
||||
<span class="metric-label">通话次数</span>
|
||||
<span class="metric-value">{{ member.callCount }}</span>
|
||||
</div>
|
||||
<!-- Tooltip 组件 -->
|
||||
<Tooltip
|
||||
:visible="tooltip.visible"
|
||||
:x="tooltip.x"
|
||||
:y="tooltip.y"
|
||||
:title="tooltip.title"
|
||||
:description="tooltip.description"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="metric-row">
|
||||
@@ -161,12 +194,71 @@
|
||||
</main>
|
||||
|
||||
<!-- Loading 组件 -->
|
||||
<Loading :visible="isLoading" text="数据加载中..." />
|
||||
<!-- <Loading :visible="isLoading" text="数据加载中..." /> -->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { ref, onMounted, computed,reactive } from 'vue'
|
||||
import FeedbackForm from "@/components/FeedbackForm.vue";
|
||||
// 30分钟数据缓存系统
|
||||
const cache = new Map()
|
||||
const CACHE_DURATION = 30 * 60 * 1000 // 30分钟
|
||||
|
||||
// 生成缓存键
|
||||
const getCacheKey = (functionName, params = {}) => {
|
||||
const sortedParams = Object.keys(params).sort().reduce((result, key) => {
|
||||
result[key] = params[key]
|
||||
return result
|
||||
}, {})
|
||||
return `${functionName}_${JSON.stringify(sortedParams)}`
|
||||
}
|
||||
|
||||
// 检查缓存是否有效
|
||||
const isValidCache = (cacheData) => {
|
||||
return cacheData && (Date.now() - cacheData.timestamp) < CACHE_DURATION
|
||||
}
|
||||
|
||||
// 设置缓存
|
||||
const setCache = (key, data) => {
|
||||
cache.set(key, {
|
||||
data,
|
||||
timestamp: Date.now()
|
||||
})
|
||||
}
|
||||
|
||||
// 获取缓存
|
||||
const getCache = (key) => {
|
||||
const cacheData = cache.get(key)
|
||||
if (isValidCache(cacheData)) {
|
||||
return cacheData.data
|
||||
}
|
||||
cache.delete(key) // 删除过期缓存
|
||||
return null
|
||||
}
|
||||
|
||||
// 带缓存的API调用包装器
|
||||
const withCache = async (functionName, apiCall, params = {}) => {
|
||||
const cacheKey = getCacheKey(functionName, params)
|
||||
const cachedData = getCache(cacheKey)
|
||||
|
||||
if (cachedData) {
|
||||
console.log(`[缓存命中] ${functionName}:`, cachedData)
|
||||
return cachedData
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await apiCall()
|
||||
if (result && result.code === 200) {
|
||||
setCache(cacheKey, result)
|
||||
console.log(`[缓存设置] ${functionName}:`, result)
|
||||
}
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error(`[API调用失败] ${functionName}:`, error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
import CenterOverview from './components/CenterOverview.vue'
|
||||
import GroupComparison from './components/GroupComparison.vue'
|
||||
@@ -179,6 +271,7 @@
|
||||
import seniorManager from './components/seniorManager.vue'
|
||||
import UserDropdown from '@/components/UserDropdown.vue'
|
||||
import Loading from '@/components/Loading.vue'
|
||||
import Tooltip from '@/components/Tooltip.vue'
|
||||
import {
|
||||
getOverallCenterPerformance, getTotalGroupCount, getCenterConversionRate, getTotalCallCount, getNewCustomer
|
||||
, getDepositConversionRate, getCustomerTypeDistribution, getUrgentNeedToAddress, getCenterAdvancedManagerList, getTeamRanking,
|
||||
@@ -189,6 +282,49 @@
|
||||
const router = useRouter();
|
||||
// 用户store实例
|
||||
const userStore = useUserStore();
|
||||
const CheckType = ref('month')
|
||||
|
||||
// 卡片显示状态
|
||||
const cardVisibility = ref({
|
||||
centerOverview: true,
|
||||
actionItems: true,
|
||||
customerType: true,
|
||||
goodMusic: true,
|
||||
problemRanking: true,
|
||||
groupRanking: true,
|
||||
groupComparison: true,
|
||||
teamDetail: true
|
||||
})
|
||||
|
||||
// FeedbackForm 控制变量
|
||||
const showFeedbackForm = ref(false)
|
||||
|
||||
// 团队整体分析弹窗控制变量
|
||||
const showTeamAnalysis = ref(false)
|
||||
|
||||
// 更新卡片显示状态
|
||||
const updateCardVisibility = (newVisibility) => {
|
||||
Object.assign(cardVisibility.value, newVisibility)
|
||||
console.log('卡片显示状态已更新:', cardVisibility.value)
|
||||
}
|
||||
|
||||
// FeedbackForm 控制方法
|
||||
const showFeedbackFormModal = () => {
|
||||
showFeedbackForm.value = true
|
||||
}
|
||||
|
||||
const closeFeedbackFormModal = () => {
|
||||
showFeedbackForm.value = false
|
||||
}
|
||||
|
||||
// 团队整体分析弹窗控制方法
|
||||
const showTeamAnalysisModal = () => {
|
||||
showTeamAnalysis.value = true
|
||||
}
|
||||
|
||||
const closeTeamAnalysisModal = () => {
|
||||
showTeamAnalysis.value = false
|
||||
}
|
||||
// 营期调控逻辑
|
||||
// This would ideally come from a prop or API call based on the logged-in user
|
||||
const centerData = ref({
|
||||
@@ -253,11 +389,9 @@ const centerData = ref({
|
||||
// 保存营期
|
||||
const saveCampSettings = async () => {
|
||||
recalculateStageDates();
|
||||
|
||||
// 准备API请求参数
|
||||
const params = {
|
||||
user_name: userStore.userInfo.username,
|
||||
user_level: userStore.userInfo.user_level.toString(),
|
||||
...getRequestParams(),
|
||||
receipt_data_time: dataReceivingStage.value.days.toString()
|
||||
};
|
||||
|
||||
@@ -274,30 +408,6 @@ const centerData = ref({
|
||||
alert('保存失败,请重试');
|
||||
}
|
||||
};
|
||||
|
||||
// 结束营期
|
||||
const finishCamp = async () => {
|
||||
try {
|
||||
const params = {
|
||||
user_name: userStore.userInfo.username,
|
||||
user_level: userStore.userInfo.user_level.toString(),
|
||||
is_camp_finish: "true"
|
||||
};
|
||||
|
||||
const res = await getCampPeriodAdmin(params);
|
||||
if (res.code === 200) {
|
||||
console.log('营期结束成功:', res.data);
|
||||
alert('营期已成功结束!');
|
||||
// 可以在这里添加页面跳转或其他后续操作
|
||||
} else {
|
||||
alert('结束营期失败,请重试');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('结束营期失败:', error);
|
||||
alert('结束营期失败,请重试!');
|
||||
}
|
||||
};
|
||||
|
||||
// console.log('currentStage', userStore.userInfo)
|
||||
|
||||
// 获取,修改当前营期
|
||||
@@ -424,8 +534,16 @@ const centerData = ref({
|
||||
async function CenterOverallCenterPerformance() {
|
||||
const params = getRequestParams()
|
||||
const hasParams = params.user_name
|
||||
const requestParams = hasParams ? {
|
||||
...params,
|
||||
check_type: CheckType.value
|
||||
} : {check_type: CheckType.value}
|
||||
|
||||
try {
|
||||
const res = await getOverallCenterPerformance(hasParams ? params : undefined)
|
||||
const res = await withCache('CenterOverallCenterPerformance',
|
||||
() => getOverallCenterPerformance(requestParams),
|
||||
requestParams
|
||||
)
|
||||
if (res.code === 200) {
|
||||
overallCenterPerformance.value.CenterPerformance = res.data
|
||||
}
|
||||
@@ -437,8 +555,16 @@ const centerData = ref({
|
||||
async function CenterTotalGroupCount() {
|
||||
const params = getRequestParams()
|
||||
const hasParams = params.user_name
|
||||
const requestParams = hasParams ? {
|
||||
...params,
|
||||
check_type: CheckType.value
|
||||
} : {check_type: CheckType.value}
|
||||
|
||||
try {
|
||||
const res = await getTotalGroupCount(hasParams ? params : undefined)
|
||||
const res = await withCache('CenterTotalGroupCount',
|
||||
() => getTotalGroupCount(requestParams),
|
||||
requestParams
|
||||
)
|
||||
if (res.code === 200) {
|
||||
overallCenterPerformance.value.TotalGroupCount = res.data
|
||||
}
|
||||
@@ -450,8 +576,16 @@ const centerData = ref({
|
||||
async function CenterConversionRate() {
|
||||
const params = getRequestParams()
|
||||
const hasParams = params.user_name
|
||||
const requestParams = hasParams ? {
|
||||
...params,
|
||||
check_type: CheckType.value
|
||||
} : {check_type: CheckType.value}
|
||||
|
||||
try {
|
||||
const res = await getCenterConversionRate(hasParams ? params : undefined)
|
||||
const res = await withCache('CenterConversionRate',
|
||||
() => getCenterConversionRate(requestParams),
|
||||
requestParams
|
||||
)
|
||||
if (res.code === 200) {
|
||||
overallCenterPerformance.value.CenterConversionRate = res.data
|
||||
}
|
||||
@@ -463,8 +597,16 @@ const centerData = ref({
|
||||
async function CenterTotalCallCount() {
|
||||
const params = getRequestParams()
|
||||
const hasParams = params.user_name
|
||||
const requestParams = hasParams ? {
|
||||
...params,
|
||||
check_type: CheckType.value
|
||||
} : {check_type: CheckType.value}
|
||||
|
||||
try {
|
||||
const res = await getTotalCallCount(hasParams ? params : undefined)
|
||||
const res = await withCache('CenterTotalCallCount',
|
||||
() => getTotalCallCount(requestParams),
|
||||
requestParams
|
||||
)
|
||||
if (res.code === 200) {
|
||||
overallCenterPerformance.value.TotalCallCount = res.data
|
||||
}
|
||||
@@ -476,8 +618,16 @@ const centerData = ref({
|
||||
async function CenterNewCustomer() {
|
||||
const params = getRequestParams()
|
||||
const hasParams = params.user_name
|
||||
const requestParams = hasParams ? {
|
||||
...params,
|
||||
check_type: CheckType.value
|
||||
} : {check_type: CheckType.value}
|
||||
|
||||
try {
|
||||
const res = await getNewCustomer(hasParams ? params : undefined)
|
||||
const res = await withCache('CenterNewCustomer',
|
||||
() => getNewCustomer(requestParams),
|
||||
requestParams
|
||||
)
|
||||
if (res.code === 200) {
|
||||
overallCenterPerformance.value.NewCustomer = res.data
|
||||
}
|
||||
@@ -489,8 +639,16 @@ const centerData = ref({
|
||||
async function CenterDepositConversionRate() {
|
||||
const params = getRequestParams()
|
||||
const hasParams = params.user_name
|
||||
const requestParams = hasParams ? {
|
||||
...params,
|
||||
check_type: CheckType.value
|
||||
} : {check_type: CheckType.value}
|
||||
|
||||
try {
|
||||
const res = await getDepositConversionRate(hasParams ? params : undefined)
|
||||
const res = await withCache('CenterDepositConversionRate',
|
||||
() => getDepositConversionRate(requestParams),
|
||||
requestParams
|
||||
)
|
||||
if (res.code === 200) {
|
||||
overallCenterPerformance.value.DepositConversionRate = res.data
|
||||
}
|
||||
@@ -504,8 +662,12 @@ const centerData = ref({
|
||||
const hasParams = params.user_name
|
||||
// 添加distribution_type参数
|
||||
const requestParams = hasParams ? { ...params, distribution_type: distributionType } : { distribution_type: distributionType }
|
||||
|
||||
try {
|
||||
const res = await getCustomerTypeDistribution(requestParams)
|
||||
const res = await withCache('CenterCustomerType',
|
||||
() => getCustomerTypeDistribution(requestParams),
|
||||
requestParams
|
||||
)
|
||||
if (res.code === 200) {
|
||||
customerTypeDistribution.value = res.data
|
||||
}
|
||||
@@ -522,8 +684,13 @@ const centerData = ref({
|
||||
async function CenterUrgentNeedToAddress() {
|
||||
const params = getRequestParams()
|
||||
const hasParams = params.user_name
|
||||
const requestParams = hasParams ? params : {}
|
||||
|
||||
try {
|
||||
const res = await getUrgentNeedToAddress(hasParams ? params : undefined)
|
||||
const res = await withCache('CenterUrgentNeedToAddress',
|
||||
() => getUrgentNeedToAddress(hasParams ? params : undefined),
|
||||
requestParams
|
||||
)
|
||||
if (res.code === 200) {
|
||||
urgentNeedToAddress.value = res.data
|
||||
}
|
||||
@@ -536,8 +703,13 @@ const conversionRateVsAverage = ref({})
|
||||
async function CenterConversionRateVsAverage() {
|
||||
const params = getRequestParams()
|
||||
const hasParams = params.user_name
|
||||
const requestParams = hasParams ? params : {}
|
||||
|
||||
try {
|
||||
const res = await getConversionRateVsAverage(hasParams ? params : undefined)
|
||||
const res = await withCache('CenterConversionRateVsAverage',
|
||||
() => getConversionRateVsAverage(hasParams ? params : undefined),
|
||||
requestParams
|
||||
)
|
||||
if (res.code === 200) {
|
||||
conversionRateVsAverage.value = res.data
|
||||
}
|
||||
@@ -551,8 +723,13 @@ const conversionRateVsAverage = ref({})
|
||||
async function CenterSeniorManagerList() {
|
||||
const params = getRequestParams()
|
||||
const hasParams = params.user_name
|
||||
const requestParams = hasParams ? params : {}
|
||||
|
||||
try {
|
||||
const res = await getCenterAdvancedManagerList(hasParams ? params : undefined)
|
||||
const res = await withCache('CenterSeniorManagerList',
|
||||
() => getCenterAdvancedManagerList(hasParams ? params : undefined),
|
||||
requestParams
|
||||
)
|
||||
if (res.code === 200) {
|
||||
seniorManagerList.value = res.data
|
||||
}
|
||||
@@ -579,8 +756,12 @@ const conversionRateVsAverage = ref({})
|
||||
requestParams.team_leader_name = selectedManager
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await getTeamRanking(requestParams)
|
||||
const res = await withCache('CenterGroupList',
|
||||
() => getTeamRanking(requestParams),
|
||||
requestParams
|
||||
)
|
||||
console.log('API Response:', res)
|
||||
if (res.code === 200) {
|
||||
groupList.value = res.data
|
||||
@@ -596,7 +777,10 @@ const conversionRateVsAverage = ref({})
|
||||
|
||||
// 根据传来的组名字来获取组业绩详情
|
||||
async function CenterGroupPerformance(groupName) {
|
||||
const params = getRequestParams()
|
||||
const routeParams = getRequestParams()
|
||||
const params = routeParams.user_name
|
||||
? routeParams
|
||||
: {user_name: userStore.userInfo.username, user_level: userStore.userInfo.user_level.toString()}
|
||||
const hasParams = params.user_name
|
||||
const requestParams = hasParams ? {
|
||||
...params,
|
||||
@@ -606,7 +790,10 @@ const conversionRateVsAverage = ref({})
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await getTeamRankingInfo(requestParams)
|
||||
const res = await withCache('CenterGroupPerformance',
|
||||
() => getTeamRankingInfo(requestParams),
|
||||
requestParams
|
||||
)
|
||||
|
||||
if (res.code === 200) {
|
||||
groupPerformance.value = res.data
|
||||
@@ -647,7 +834,7 @@ const conversionRateVsAverage = ref({})
|
||||
deals: member.deals_this_period || 0,
|
||||
rank: member.rank || 0
|
||||
}))
|
||||
.sort((a, b) => b.deals - a.deals) // 根据成交单数从高到低排序
|
||||
.sort((a, b) => a.rank - b.rank) // 根据排名降序排列(排名数字越大越靠后)
|
||||
|
||||
// 更新selectedGroup的members数据
|
||||
selectedGroup.value = {
|
||||
@@ -701,22 +888,121 @@ const conversionRateVsAverage = ref({})
|
||||
})
|
||||
}
|
||||
// 获取优秀录音
|
||||
const goodRecord = ref([])
|
||||
const excellentRecord = ref([]);
|
||||
// 获取优秀录音文件
|
||||
async function getGoodRecord() {
|
||||
const params = getRequestParams()
|
||||
const hasParams = params.user_name
|
||||
const requestParams = hasParams ? {
|
||||
...params,
|
||||
} : {
|
||||
}
|
||||
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)
|
||||
if (res.code === 200) {
|
||||
goodRecord.value = res.data
|
||||
|
||||
if (res && res.code === 200 && res.data) {
|
||||
excellentRecord.value = res.data || []
|
||||
console.log('获取优秀录音成功:', res.data)
|
||||
} else {
|
||||
console.error("获取优秀录音失败,响应数据不完整:", res);
|
||||
excellentRecord.value = []
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取优秀录音失败:', error)
|
||||
console.error("获取优秀录音失败:", error);
|
||||
excellentRecord.value = []
|
||||
}
|
||||
}
|
||||
|
||||
// 缓存管理功能
|
||||
// 清除所有缓存
|
||||
const clearCache = () => {
|
||||
cache.clear()
|
||||
console.log('[缓存清除] 所有缓存已清除')
|
||||
}
|
||||
|
||||
// 清除特定缓存
|
||||
const clearSpecificCache = (functionName, params = {}) => {
|
||||
const cacheKey = getCacheKey(functionName, params)
|
||||
cache.delete(cacheKey)
|
||||
console.log(`[缓存清除] ${functionName} 缓存已清除`)
|
||||
}
|
||||
|
||||
// 获取缓存信息
|
||||
const getCacheInfo = () => {
|
||||
const cacheInfo = {
|
||||
totalCount: cache.size,
|
||||
validCount: 0,
|
||||
expiredCount: 0,
|
||||
cacheKeys: []
|
||||
}
|
||||
|
||||
cache.forEach((value, key) => {
|
||||
if (isValidCache(value)) {
|
||||
cacheInfo.validCount++
|
||||
cacheInfo.cacheKeys.push({
|
||||
key,
|
||||
timestamp: value.timestamp,
|
||||
remainingTime: CACHE_DURATION - (Date.now() - value.timestamp)
|
||||
})
|
||||
} else {
|
||||
cacheInfo.expiredCount++
|
||||
cache.delete(key) // 清除过期缓存
|
||||
}
|
||||
})
|
||||
|
||||
console.log('[缓存信息]', cacheInfo)
|
||||
return cacheInfo
|
||||
}
|
||||
|
||||
// 强制刷新所有数据(清除缓存并重新调用API)
|
||||
const forceRefreshAllData = async () => {
|
||||
clearCache()
|
||||
isLoading.value = true
|
||||
|
||||
try {
|
||||
const currentQuery = router.currentRoute.value.query
|
||||
const isFromRoute = currentQuery.fromRoute ||
|
||||
sessionStorage.getItem('fromRoute') ||
|
||||
(currentQuery.user_name && currentQuery.user_level)
|
||||
|
||||
if (!isFromRoute) {
|
||||
CenterCampPeriodAdmin()
|
||||
}
|
||||
CentergetGoodRecord()
|
||||
CenterOverallCenterPerformance()
|
||||
CenterTotalGroupCount()
|
||||
CenterConversionRate()
|
||||
CenterTotalCallCount()
|
||||
CenterNewCustomer()
|
||||
CenterDepositConversionRate()
|
||||
CenterCustomerType()
|
||||
CenterUrgentNeedToAddress()
|
||||
CenterConversionRateVsAverage()
|
||||
|
||||
CenterSeniorManagerList()
|
||||
CenterGroupList('all')
|
||||
|
||||
console.log('[强制刷新] 所有数据已重新加载')
|
||||
} catch (error) {
|
||||
console.error('[强制刷新] 数据加载失败:', error)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -724,9 +1010,6 @@ const conversionRateVsAverage = ref({})
|
||||
onMounted(async () => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
|
||||
// 判断页面进入方式:如果是通过路由跳转进入(URL中有user_name和user_level参数),则不发送CenterCampPeriodAdmin请求
|
||||
// 如果是直接登录进入页面,则发送请求
|
||||
const currentQuery = router.currentRoute.value.query
|
||||
const isFromRoute = currentQuery.fromRoute ||
|
||||
sessionStorage.getItem('fromRoute') ||
|
||||
@@ -741,15 +1024,29 @@ const conversionRateVsAverage = ref({})
|
||||
await CenterTotalGroupCount()
|
||||
await CenterConversionRate()
|
||||
await CenterTotalCallCount()
|
||||
await CentergetGoodRecord()
|
||||
await CenterNewCustomer()
|
||||
await CenterDepositConversionRate()
|
||||
await CenterCustomerType()
|
||||
await CenterUrgentNeedToAddress()
|
||||
await CenterConversionRateVsAverage()
|
||||
await CenterSeniorManagerList()
|
||||
// 获取优秀录音
|
||||
await getGoodRecord()
|
||||
await CenterGroupList('all') // 初始化加载全部高级经理数据
|
||||
|
||||
// 输出缓存信息
|
||||
getCacheInfo()
|
||||
|
||||
// 开发环境下暴露缓存管理函数到全局
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
window.secondTopCache = {
|
||||
clearCache,
|
||||
clearSpecificCache,
|
||||
getCacheInfo,
|
||||
forceRefreshAllData,
|
||||
cache
|
||||
}
|
||||
console.log('[开发模式] 缓存管理函数已暴露到 window.secondTopCache')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('数据加载失败:', error)
|
||||
} finally {
|
||||
@@ -757,6 +1054,48 @@ const conversionRateVsAverage = ref({})
|
||||
}
|
||||
})
|
||||
|
||||
// 更新CheckType并重新获取数据
|
||||
const updateCheckType = async (newValue) => {
|
||||
CheckType.value = newValue
|
||||
console.log('CheckType已更新为:', newValue)
|
||||
|
||||
// 使用强制刷新功能重新获取数据
|
||||
await forceRefreshAllData()
|
||||
}
|
||||
|
||||
// 工具提示状态
|
||||
const tooltip = reactive({
|
||||
visible: false,
|
||||
x: 0,
|
||||
y: 0,
|
||||
title: '',
|
||||
description: ''
|
||||
})
|
||||
|
||||
// 指标描述
|
||||
const metricDescriptions = {
|
||||
teamPerformance: {
|
||||
title: '转化率',
|
||||
description: '本期最终成交/本期客户总数'
|
||||
}
|
||||
}
|
||||
|
||||
// 显示工具提示
|
||||
const showTooltip = (event, metricType) => {
|
||||
const metric = metricDescriptions[metricType]
|
||||
if (metric) {
|
||||
tooltip.title = metric.title
|
||||
tooltip.description = metric.description
|
||||
tooltip.x = event.clientX
|
||||
tooltip.y = event.clientY
|
||||
tooltip.visible = true
|
||||
}
|
||||
}
|
||||
|
||||
// 隐藏工具提示
|
||||
const hideTooltip = () => {
|
||||
tooltip.visible = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@@ -1174,7 +1513,20 @@ const conversionRateVsAverage = ref({})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.info-icon {
|
||||
color: #94a3b8;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
opacity: 0.7;
|
||||
transition: all 0.2s ease;
|
||||
margin-left: 0.25rem;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
color: #3b82f6;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
// 客户详情区域
|
||||
.customer-detail-section {
|
||||
background: white;
|
||||
@@ -1311,7 +1663,77 @@ const conversionRateVsAverage = ref({})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 团队分析弹窗样式 */
|
||||
.team-analysis-modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.team-analysis-modal .modal-content {
|
||||
background-color: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
width: 80%;
|
||||
max-width: 600px;
|
||||
max-height: 80vh;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.team-analysis-modal .modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.team-analysis-modal .modal-header h3 {
|
||||
margin: 0;
|
||||
color: #1a202c;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.team-analysis-modal .close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
color: #718096;
|
||||
padding: 0;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.team-analysis-modal .close-btn:hover {
|
||||
color: #1a202c;
|
||||
}
|
||||
|
||||
.team-analysis-modal .modal-body {
|
||||
padding: 1rem;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.team-analysis-modal .modal-body p {
|
||||
margin: 0;
|
||||
color: #4a5568;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
// 路由导航顶栏样式
|
||||
.route-header {
|
||||
display: flex;
|
||||
@@ -1359,6 +1781,22 @@ const conversionRateVsAverage = ref({})
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* 意见反馈按钮样式 */
|
||||
.feedback-btn {
|
||||
background-color: #4299e1;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
|
||||
&:hover {
|
||||
background-color: #3182ce;
|
||||
}
|
||||
}
|
||||
}
|
||||
.stage-control {
|
||||
margin-left: 20px;
|
||||
|
||||
@@ -1,6 +1,22 @@
|
||||
<template>
|
||||
<div class="center-overview">
|
||||
<h2>整体概览</h2>
|
||||
<div class="overview-header">
|
||||
<h2>整体概览</h2>
|
||||
<div class="stats-toggle">
|
||||
<button
|
||||
:class="['toggle-btn', { active: statsMode === 'monthly' }]"
|
||||
@click="switchStatsMode('monthly')"
|
||||
>
|
||||
按月统计
|
||||
</button>
|
||||
<button
|
||||
:class="['toggle-btn', { active: statsMode === 'period' }]"
|
||||
@click="switchStatsMode('period')"
|
||||
>
|
||||
按期统计
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="overview-grid">
|
||||
<div class="overview-card primary">
|
||||
<div class="card-header">
|
||||
@@ -8,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">
|
||||
@@ -32,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>
|
||||
@@ -87,9 +103,24 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, reactive } from 'vue'
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import Tooltip from '@/components/Tooltip.vue'
|
||||
|
||||
// 统计模式状态
|
||||
const statsMode = ref('monthly') // 默认按月统计
|
||||
|
||||
// 定义emit事件
|
||||
const emit = defineEmits(['update-check-type'])
|
||||
|
||||
// 切换统计模式
|
||||
const switchStatsMode = (mode) => {
|
||||
statsMode.value = mode
|
||||
// 向父组件发送事件,修改CheckType的值
|
||||
const checkTypeValue = mode === 'monthly' ? 'month' : 'period'
|
||||
emit('update-check-type', checkTypeValue)
|
||||
console.log('切换统计模式:', mode, '发送CheckType值:', checkTypeValue)
|
||||
}
|
||||
|
||||
// 中心整体概览组件
|
||||
const props = defineProps({
|
||||
overallTeamPerformance: {
|
||||
@@ -104,7 +135,6 @@ const props = defineProps({
|
||||
})
|
||||
}
|
||||
})
|
||||
console.log(99999,props.overallTeamPerformance)
|
||||
// 计算属性
|
||||
const totalPerformance = computed(() => {
|
||||
return props.overallTeamPerformance.totalPerformance
|
||||
@@ -127,7 +157,6 @@ const newCustomers = computed(() => {
|
||||
})
|
||||
|
||||
const depositConversions = computed(() => {
|
||||
console.log(999991111,props.overallTeamPerformance.depositConversions)
|
||||
return props.overallTeamPerformance.depositConversions
|
||||
})
|
||||
|
||||
@@ -144,27 +173,27 @@ const tooltip = reactive({
|
||||
const metricDescriptions = {
|
||||
teamPerformance: {
|
||||
title: '团队总业绩计算方式',
|
||||
description: '统计所有团队成员在选定时间范围内的成交金额总和,包括全款订单和定金订单的累计业绩。'
|
||||
description: '所有团队成员在选定时间范围内的成交金额总和,包括全款订单和定金订单的累计业绩。'
|
||||
},
|
||||
activeGroups: {
|
||||
title: '活跃组数计算方式',
|
||||
description: '统计当前有成员在线且有业务活动的团队组数,以及各组的总人数统计。'
|
||||
description: '当前有成员在线且有业务活动的团队组数,以及各组的总人数统计。'
|
||||
},
|
||||
conversionRate: {
|
||||
title: '团队转化率计算方式',
|
||||
description: '团队总成交单数 ÷ 团队总新增客户数 × 100%,反映整个团队将潜在客户转化为成交客户的综合能力。'
|
||||
description: '团队总成交单数 ÷ 团队总新增客户数 × 100%'
|
||||
},
|
||||
totalCalls: {
|
||||
title: '总通话次数计算方式',
|
||||
description: '统计所有团队成员在选定时间范围内的通话总次数,包括外呼、接听等所有通话记录。'
|
||||
description: '所有团队成员在选定时间范围内的通话总次数,包括外呼、接听等所有通话记录。'
|
||||
},
|
||||
newCustomers: {
|
||||
title: '新增客户计算方式',
|
||||
description: '统计所有团队成员在选定时间范围内新建档的客户总数,包括意向客户和潜在客户。'
|
||||
description: '所有团队成员在选定时间范围内新建档的客户总数,包括意向客户和潜在客户。'
|
||||
},
|
||||
depositConversion: {
|
||||
title: '定金转化计算方式',
|
||||
description: '定金订单数 ÷ 总成交单数 × 100%,反映客户从意向到实际付定金的转化效果。'
|
||||
description: '定金订单数 ÷ 总成交单数 × 100%'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,6 +222,44 @@ const hideTooltip = () => {
|
||||
padding: 0.5rem;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
|
||||
.overview-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
.stats-toggle {
|
||||
display: flex;
|
||||
background: #f8fafc;
|
||||
border-radius: 8px;
|
||||
padding: 4px;
|
||||
gap: 2px;
|
||||
|
||||
.toggle-btn {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 6px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: #e2e8f0;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.3rem;
|
||||
font-weight: 600;
|
||||
@@ -365,6 +432,19 @@ const hideTooltip = () => {
|
||||
.center-overview {
|
||||
padding: 1rem;
|
||||
|
||||
.overview-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
|
||||
.stats-toggle {
|
||||
.toggle-btn {
|
||||
padding: 6px 12px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.overview-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 0.75rem;
|
||||
|
||||
@@ -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>
|
||||
|
||||
1144
my-vue-app/src/views/senorManger/components/GoodMusic.vue
Normal file
1144
my-vue-app/src/views/senorManger/components/GoodMusic.vue
Normal file
File diff suppressed because it is too large
Load Diff
@@ -11,6 +11,7 @@
|
||||
class="ranking-card"
|
||||
:class="getRankingClass(index)"
|
||||
@click="$emit('select-group', group)"
|
||||
@dblclick="$emit('team-double-click', group)"
|
||||
>
|
||||
<div class="rank-badge">{{ index + 1 }}</div>
|
||||
<div class="group-info">
|
||||
@@ -20,7 +21,7 @@
|
||||
<div class="key-metrics">
|
||||
<div class="mini-metric">
|
||||
<span class="mini-label">业绩</span>
|
||||
<span class="mini-value">{{ formatCurrency(group.todayPerformance) }}</span>
|
||||
<span class="mini-value">{{ group.todayPerformance }}单</span>
|
||||
</div>
|
||||
<div class="mini-metric">
|
||||
<span class="mini-label">转化</span>
|
||||
@@ -48,7 +49,7 @@ const props = defineProps({
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['select-group'])
|
||||
const emit = defineEmits(['select-group', 'team-double-click'])
|
||||
|
||||
|
||||
|
||||
@@ -83,7 +84,7 @@ const processedGroups = computed(() => {
|
||||
id: index + 1,
|
||||
name: name,
|
||||
leader: leader,
|
||||
todayPerformance: performance * 10000, // 假设单位转换
|
||||
todayPerformance: performance, // 假设单位转换
|
||||
conversionRate: conversionRate,
|
||||
newClients: Math.floor(performance * 2.5), // 根据业绩估算
|
||||
deals: performance,
|
||||
|
||||
@@ -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(() => {
|
||||
renderPersonalFunnelChart()
|
||||
isComponentMounted.value = true
|
||||
// 处理初始传入的teamSalesFunnel数据
|
||||
if (props.teamSalesFunnel && Object.keys(props.teamSalesFunnel).length > 0) {
|
||||
const order = ['线索', '加微', '到课', '定金', '成交']
|
||||
funnelData.data = order.map(key => props.teamSalesFunnel[key] || 0)
|
||||
}
|
||||
if (isComponentMounted.value) {
|
||||
renderPersonalFunnelChart()
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
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;
|
||||
|
||||
@@ -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>
|
||||
@@ -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 {
|
||||
|
||||
@@ -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: () => ({})
|
||||
}
|
||||
});
|
||||
|
||||
@@ -87,23 +87,23 @@ const tooltip = reactive({
|
||||
const metricDescriptions = {
|
||||
customerCommunicationRate: {
|
||||
title: '活跃客户沟通率计算方式',
|
||||
description: '有效沟通的活跃客户数 ÷ 总活跃客户数 × 100%,反映团队与活跃客户的沟通覆盖程度。'
|
||||
description: '有效沟通的活跃客户数 ÷ 总活跃客户数 × 100%'
|
||||
},
|
||||
averageResponseTime: {
|
||||
title: '平均应答时间计算方式',
|
||||
description: '所有通话的应答时间总和 ÷ 通话总次数,以分钟为单位,反映团队的响应效率。'
|
||||
description: '所有通话的应答时间总和 ÷ 通话总次数'
|
||||
},
|
||||
timeoutResponseRate: {
|
||||
title: '超时应答率计算方式',
|
||||
description: '超时应答的通话次数 ÷ 总通话次数 × 100%,超时标准通常为30秒以上。'
|
||||
description: '超时应答的通话次数 ÷ 总通话次数 × 100%'
|
||||
},
|
||||
severeTimeoutRate: {
|
||||
title: '严重超时应答率计算方式',
|
||||
description: '严重超时应答的通话次数 ÷ 总通话次数 × 100%,严重超时标准通常为60秒以上。'
|
||||
description: '严重超时应答的通话次数 ÷ 总通话次数 × 100%'
|
||||
},
|
||||
formCompletionRate: {
|
||||
title: '表格填写率计算方式',
|
||||
description: '已完成填写的表格数量 ÷ 应填写的表格总数 × 100%,反映团队的工作完成度。'
|
||||
description: '已完成填写的表格数量 ÷ 应填写的表格总数 × 100%'
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -42,11 +42,11 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<th>人员</th>
|
||||
<th @click="sortBy('conversion_rate')" class="sortable">成交率 <span class="sort-icon" :class="{ active: sortField === 'conversion_rate' }">{{ sortOrder === 'desc' ? '↓' : '↑' }}</span></th>
|
||||
<th @click="sortBy('total_deals')" class="sortable">成交单数 <span class="sort-icon" :class="{ active: sortField === 'total_deals' }">{{ sortOrder === 'desc' ? '↓' : '↑' }}</span></th>
|
||||
<th>加微率</th>
|
||||
<th>入群率</th>
|
||||
<th>表单填写率</th>
|
||||
<th @click="sortBy('conversion_rate')" class="sortable">成交率 <i class="info-icon" @mouseenter="showTooltip($event, 'conversionRate')" @mouseleave="hideTooltip" @click.stop>ⓘ</i> <span class="sort-icon" :class="{ active: sortField === 'conversion_rate' }">{{ sortOrder === 'desc' ? '↓' : '↑' }}</span></th>
|
||||
<th @click="sortBy('total_deals')" class="sortable">成交单数 <i class="info-icon" @mouseenter="showTooltip($event, 'totalDeals')" @mouseleave="hideTooltip" @click.stop>ⓘ</i> <span class="sort-icon" :class="{ active: sortField === 'total_deals' }">{{ sortOrder === 'desc' ? '↓' : '↑' }}</span></th>
|
||||
<th>加微率 <i class="info-icon" @mouseenter="showTooltip($event, 'plusVRate')" @mouseleave="hideTooltip">ⓘ</i></th>
|
||||
<th>入群率 <i class="info-icon" @mouseenter="showTooltip($event, 'groupRate')" @mouseleave="hideTooltip">ⓘ</i></th>
|
||||
<th>表单填写率 <i class="info-icon" @mouseenter="showTooltip($event, 'formFillingRate')" @mouseleave="hideTooltip">ⓘ</i></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -74,12 +74,22 @@
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tooltip 组件 -->
|
||||
<Tooltip
|
||||
:visible="tooltip.visible"
|
||||
:x="tooltip.x"
|
||||
:y="tooltip.y"
|
||||
:title="tooltip.title"
|
||||
:description="tooltip.description"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { ref, computed, reactive } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import Tooltip from '@/components/Tooltip.vue';
|
||||
|
||||
const props = defineProps({
|
||||
tableData: { type: Array, required: true },
|
||||
@@ -93,6 +103,56 @@ const filters = ref({ centerLeader: '', advancedManager: '', manager: '', dealSt
|
||||
const sortField = ref('conversion_rate');
|
||||
const sortOrder = ref('desc');
|
||||
|
||||
// Tooltip 状态管理
|
||||
const tooltip = reactive({
|
||||
visible: false,
|
||||
x: 0,
|
||||
y: 0,
|
||||
title: '',
|
||||
description: ''
|
||||
});
|
||||
|
||||
// 指标计算方式描述
|
||||
const metricDescriptions = {
|
||||
conversionRate: {
|
||||
title: '成交率计算方式',
|
||||
description: '成交单数 ÷ 本期总客户数 × 100%'
|
||||
},
|
||||
totalDeals: {
|
||||
title: '成交单数计算方式',
|
||||
description: '在选定时间范围内成功签约的订单总数,包括所有已确认的成交订单。'
|
||||
},
|
||||
plusVRate: {
|
||||
title: '加微率计算方式',
|
||||
description: '成功添加微信的客户数 ÷ 本期全部客户数 × 100%'
|
||||
},
|
||||
groupRate: {
|
||||
title: '入群率计算方式',
|
||||
description: '成功邀请进入微信群的客户数 ÷ 本期全部客户数 × 100%'
|
||||
},
|
||||
formFillingRate: {
|
||||
title: '表单填写率计算方式',
|
||||
description: '填写表单的客户数 ÷ 本期总客户数 × 100%'
|
||||
}
|
||||
};
|
||||
|
||||
// 显示工具提示
|
||||
const showTooltip = (event, metricType) => {
|
||||
const metric = metricDescriptions[metricType];
|
||||
if (metric) {
|
||||
tooltip.title = metric.title;
|
||||
tooltip.description = metric.description;
|
||||
tooltip.x = event.clientX;
|
||||
tooltip.y = event.clientY;
|
||||
tooltip.visible = true;
|
||||
}
|
||||
};
|
||||
|
||||
// 隐藏工具提示
|
||||
const hideTooltip = () => {
|
||||
tooltip.visible = false;
|
||||
};
|
||||
|
||||
const centerLeaders = computed(() => {
|
||||
return props.levelTree?.level_tree?.center_leaders || [];
|
||||
});
|
||||
@@ -227,6 +287,8 @@ th { background: #f7fafc; padding: 12px 16px; text-align: left; font-weight: 600
|
||||
th.sortable { cursor: pointer; }
|
||||
.sort-icon { margin-left: 4px; opacity: 0.5; }
|
||||
.sort-icon.active { opacity: 1; color: #4299e1; }
|
||||
.info-icon { margin-left: 4px; color: #666; cursor: pointer; font-style: normal; font-size: 12px; transition: color 0.2s; }
|
||||
.info-icon:hover { color: #409eff; }
|
||||
td { padding: 16px; border-bottom: 1px solid #f1f5f9; vertical-align: middle; }
|
||||
tr { cursor: pointer; transition: background-color 0.2s ease; }
|
||||
tr:hover { background-color: #f8fafc; }
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
<h3>转化对比图</h3>
|
||||
<div class="time-selector">
|
||||
<select v-model="selectedTimeRange" @change="handleTimeRangeChange" class="time-select">
|
||||
<option value="periods">本期 vs 上期</option>
|
||||
<option value="month">本月 vs 上月</option>
|
||||
<!-- <option value="periods">本期 vs 上期</option> -->
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -67,7 +67,7 @@ const props = defineProps({
|
||||
|
||||
const emit = defineEmits(['time-range-change']);
|
||||
|
||||
const selectedTimeRange = ref('periods');
|
||||
const selectedTimeRange = ref('month');
|
||||
|
||||
// 计算属性:当前和上一期的标签
|
||||
const currentPeriodLabel = computed(() => {
|
||||
|
||||
@@ -17,7 +17,9 @@
|
||||
<!-- 1. 主卡片:中心总业绩 -->
|
||||
<div class="kpi-card primary">
|
||||
<div class="card-header">
|
||||
<span class="card-label">总成交单数</span>
|
||||
<span class="card-label">
|
||||
总成交单数
|
||||
</span>
|
||||
<span class="card-trend" :class="getTrendClass(kpiData.totalSales.trend)">
|
||||
{{ formatTrend(kpiData.totalSales.trend) }} vs 上期
|
||||
</span>
|
||||
@@ -34,7 +36,12 @@
|
||||
<!-- 2. 定金转化率 -->
|
||||
<div class="kpi-card">
|
||||
<div class="card-header">
|
||||
<span class="card-label">定金转化率</span>
|
||||
<span class="card-label">
|
||||
定金转化率
|
||||
<span class="info-icon"
|
||||
@mouseenter="showTooltip($event, 'depositConversion')"
|
||||
@mouseleave="hideTooltip">!</span>
|
||||
</span>
|
||||
<span class="card-trend" :class="getTrendClass(kpiData.activeTeams.trend)">
|
||||
{{ formatTrend(kpiData.activeTeams.trend, true) }} vs 上期
|
||||
</span>
|
||||
@@ -51,7 +58,12 @@
|
||||
<!-- 3. 总通话次数 -->
|
||||
<div class="kpi-card">
|
||||
<div class="card-header">
|
||||
<span class="card-label">总通话</span>
|
||||
<span class="card-label">
|
||||
总通话
|
||||
<span class="info-icon"
|
||||
@mouseenter="showTooltip($event, 'totalCalls')"
|
||||
@mouseleave="hideTooltip">!</span>
|
||||
</span>
|
||||
<span class="card-trend" :class="getTrendClass(kpiData.totalCalls.trend)">
|
||||
{{ formatTrend(kpiData.totalCalls.trend) }} vs 上期
|
||||
</span>
|
||||
@@ -68,7 +80,9 @@
|
||||
<!-- 4. 新增客户 -->
|
||||
<div class="kpi-card">
|
||||
<div class="card-header">
|
||||
<span class="card-label">新增客户</span>
|
||||
<span class="card-label">
|
||||
今日新增客户
|
||||
</span>
|
||||
<span class="card-trend" :class="getTrendClass(kpiData.newCustomers.trend)">
|
||||
{{ formatTrend(kpiData.newCustomers.trend) }} vs 上期
|
||||
</span>
|
||||
@@ -85,7 +99,12 @@
|
||||
<!-- 5. 中心转化率 -->
|
||||
<div class="kpi-card">
|
||||
<div class="card-header">
|
||||
<span class="card-label">转化率</span>
|
||||
<span class="card-label">
|
||||
转化率
|
||||
<span class="info-icon"
|
||||
@mouseenter="showTooltip($event, 'conversionRate')"
|
||||
@mouseleave="hideTooltip">!</span>
|
||||
</span>
|
||||
<span class="card-trend" :class="getTrendClass(kpiData.conversionRate.trend)">
|
||||
{{ formatTrend(kpiData.conversionRate.trend, true) }} vs 上期
|
||||
</span>
|
||||
@@ -96,11 +115,21 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tooltip组件 -->
|
||||
<Tooltip
|
||||
:visible="tooltip.visible"
|
||||
:x="tooltip.x"
|
||||
:y="tooltip.y"
|
||||
:title="tooltip.title"
|
||||
:description="tooltip.description"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { ref, computed, watch, reactive } from 'vue';
|
||||
import Tooltip from '@/components/Tooltip.vue';
|
||||
|
||||
// 定义props
|
||||
const props = defineProps({
|
||||
@@ -117,6 +146,50 @@ const props = defineProps({
|
||||
const isLoading = ref(false);
|
||||
const error = ref(null);
|
||||
|
||||
// Tooltip状态管理
|
||||
const tooltip = reactive({
|
||||
visible: false,
|
||||
x: 0,
|
||||
y: 0,
|
||||
title: '',
|
||||
description: ''
|
||||
});
|
||||
|
||||
// 指标描述
|
||||
const metricDescriptions = {
|
||||
depositConversion: {
|
||||
title: '定金转化率计算方式',
|
||||
description: '定金转化率 = (支付定金客户数 / 意向客户总数) × 100%'
|
||||
},
|
||||
totalCalls: {
|
||||
title: '总通话次数计算方式',
|
||||
description: '有效通话为接通电话次数,总通话为接通电话次数'
|
||||
},
|
||||
newCustomers: {
|
||||
title: '新增客户计算方式',
|
||||
description: '统计新建档的客户数量,不包括重复录入的客户,按首次录入时间计算。'
|
||||
},
|
||||
conversionRate: {
|
||||
title: '转化率计算方式',
|
||||
description: '转化率 = (成交客户数 / 总客户数) × 100%'
|
||||
}
|
||||
};
|
||||
|
||||
// 显示tooltip
|
||||
function showTooltip(event, metricType) {
|
||||
const rect = event.target.getBoundingClientRect();
|
||||
tooltip.visible = true;
|
||||
tooltip.x = rect.left + rect.width / 2;
|
||||
tooltip.y = rect.top - 10;
|
||||
tooltip.title = metricDescriptions[metricType].title;
|
||||
tooltip.description = metricDescriptions[metricType].description;
|
||||
}
|
||||
|
||||
// 隐藏tooltip
|
||||
function hideTooltip() {
|
||||
tooltip.visible = false;
|
||||
}
|
||||
|
||||
// 计算属性:将API数据转换为组件需要的格式
|
||||
const kpiData = computed(() => {
|
||||
const data = props.kpiData;
|
||||
@@ -355,4 +428,39 @@ function formatTrend(trend, isPercentagePoint = false) {
|
||||
font-size: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 感叹号图标样式 */
|
||||
.info-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
color: #fff;
|
||||
border-radius: 50%;
|
||||
font-size: 10px;
|
||||
font-weight: bold;
|
||||
margin-left: 6px;
|
||||
cursor: pointer;
|
||||
opacity: 0.7;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.info-icon:hover {
|
||||
opacity: 1;
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
/* 非主卡片中的图标样式 */
|
||||
.kpi-card:not(.primary) .info-icon {
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.kpi-card:not(.primary) .info-icon:hover {
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
color: #333;
|
||||
}
|
||||
</style>
|
||||
273
my-vue-app/src/views/topOne/components/PeriodStage.vue
Normal file
273
my-vue-app/src/views/topOne/components/PeriodStage.vue
Normal file
@@ -0,0 +1,273 @@
|
||||
<template>
|
||||
<div class="period-stage-container">
|
||||
<div class="period-stage-header">
|
||||
<h3>各中心营期阶段</h3>
|
||||
</div>
|
||||
|
||||
<div class="period-stage-content">
|
||||
<div class="center-list">
|
||||
<div
|
||||
v-for="(center, index) in periodStageData"
|
||||
:key="index"
|
||||
class="center-item"
|
||||
>
|
||||
<div class="center-info">
|
||||
<div class="center-name">{{ center.center_name }}</div>
|
||||
<div class="center-leader">负责人:{{ center.center_leader_name }}</div>
|
||||
</div>
|
||||
<div class="stage-badge" :class="getStageClass(center.period_stage)">
|
||||
{{ center.period_stage }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { getPeriodStage } from '@/api/top'
|
||||
|
||||
// 营期阶段数据
|
||||
const periodStageData = ref([])
|
||||
|
||||
// 获取营期阶段数据
|
||||
const fetchPeriodStageData = async () => {
|
||||
try {
|
||||
console.log('开始获取营期阶段数据...')
|
||||
const res = await getPeriodStage()
|
||||
console.log('API响应:', res)
|
||||
if (res && res.data) {
|
||||
periodStageData.value = res.data.period_stage
|
||||
console.log('数据设置成功:', periodStageData.value)
|
||||
} else {
|
||||
console.log('API响应无数据,使用默认数据')
|
||||
setDefaultData()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取营期阶段数据失败:', error)
|
||||
setDefaultData()
|
||||
}
|
||||
}
|
||||
|
||||
// 设置默认数据
|
||||
const setDefaultData = () => {
|
||||
periodStageData.value = [
|
||||
{
|
||||
center_name: '一中心',
|
||||
center_leader_name: '张三丰',
|
||||
period_stage: '接数据'
|
||||
},
|
||||
{
|
||||
center_name: '二中心',
|
||||
center_leader_name: '朱一航',
|
||||
period_stage: '未知阶段'
|
||||
},
|
||||
{
|
||||
center_name: '三中心',
|
||||
center_leader_name: '程琦',
|
||||
period_stage: '课1'
|
||||
}
|
||||
]
|
||||
console.log('已设置默认数据:', periodStageData.value)
|
||||
}
|
||||
|
||||
// 获取阶段状态样式类
|
||||
const getStageClass = (stage) => {
|
||||
const stageMap = {
|
||||
'接数据': 'stage-data',
|
||||
'课1': 'stage-course1',
|
||||
'课2': 'stage-course2',
|
||||
'课3': 'stage-course3',
|
||||
'课4': 'stage-course4',
|
||||
'休息': 'stage-rest',
|
||||
'未知阶段': 'stage-unknown'
|
||||
}
|
||||
return stageMap[stage] || 'stage-default'
|
||||
}
|
||||
|
||||
// 组件挂载时获取数据
|
||||
onMounted(() => {
|
||||
fetchPeriodStageData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.period-stage-container {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.period-stage-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.period-stage-header h3 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.stage-legend {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.legend-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.legend-dot.active {
|
||||
background-color: #1890ff;
|
||||
}
|
||||
|
||||
.legend-dot.completed {
|
||||
background-color: #52c41a;
|
||||
}
|
||||
|
||||
.legend-dot.pending {
|
||||
background-color: #d9d9d9;
|
||||
}
|
||||
|
||||
.period-stage-content {
|
||||
margin-top: 16px;
|
||||
max-height: 250px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.center-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.center-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 6px;
|
||||
background: #f8fafc;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e2e8f0;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.center-item:hover {
|
||||
background: #f1f5f9;
|
||||
border-color: #cbd5e1;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.center-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.center-name {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.center-leader {
|
||||
font-size: 14px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.stage-badge {
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.stage-data {
|
||||
background: #dbeafe;
|
||||
color: #1e40af;
|
||||
}
|
||||
|
||||
.stage-course1 {
|
||||
background: #dcfce7;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.stage-course2 {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.stage-course3 {
|
||||
background: #fed7d7;
|
||||
color: #c53030;
|
||||
}
|
||||
|
||||
.stage-course4 {
|
||||
background: #e9d5ff;
|
||||
color: #7c3aed;
|
||||
}
|
||||
|
||||
.stage-rest {
|
||||
background: #f3f4f6;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.stage-unknown {
|
||||
background: #fecaca;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.stage-default {
|
||||
background: #e5e7eb;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.period-stage-container {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.period-stage-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.stage-legend {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.stage-item {
|
||||
padding-left: 32px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.stage-timeline::before {
|
||||
left: 10px;
|
||||
}
|
||||
|
||||
.stage-dot {
|
||||
left: 6px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -4,14 +4,6 @@
|
||||
<div class="chart-container">
|
||||
<div class="chart-header">
|
||||
<h3>优秀录音</h3>
|
||||
<button class="upload-icon-btn" @click="triggerFileUpload" title="上传录音">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M14 2H6C4.9 2 4 2.9 4 4V20C4 21.1 4.89 22 5.99 22H18C19.1 22 20 21.1 20 20V8L14 2Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<polyline points="14,2 14,8 20,8" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<line x1="12" y1="18" x2="12" y2="12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<polyline points="9,15 12,12 15,15" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="chart-content">
|
||||
<div class="recording-section">
|
||||
@@ -26,10 +18,10 @@
|
||||
:class="{ active: selectedRecording === index }"
|
||||
@click="selectRecording(index)"
|
||||
>
|
||||
<span class="recording-index">{{ recording.score}}</span>
|
||||
<div class="recording-info">
|
||||
<div class="recording-name" :title="recording.name">{{ recording.name.length > 10 ? recording.name.substring(0, 10) + '...' : recording.name }}</div>
|
||||
<div class="recording-meta">
|
||||
<span class="file-size">{{ formatFileSize(recording.size) }}</span>
|
||||
<span class="upload-time">{{ recording.uploadTime }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -67,9 +59,8 @@
|
||||
<div class="result-header">
|
||||
<button class="back-btn" @click="backToRecordings">
|
||||
<i class="el-icon-arrow-left"></i>
|
||||
返回录音列表
|
||||
返回
|
||||
</button>
|
||||
<h4>{{ isConverting ? '正在转换...' : (currentViewType === 'transcript' ? '转换文本' : '录音分析') }}</h4>
|
||||
<div class="header-actions">
|
||||
<!-- 视图切换按钮 -->
|
||||
<div class="view-toggle" v-if="currentTranscript && !isConverting">
|
||||
@@ -90,7 +81,7 @@
|
||||
</div>
|
||||
<button class="expand-btn" @click="showExpandDialog" v-if="(currentTranscript && currentViewType === 'transcript') || (analysisResult && currentViewType === 'analysis')">
|
||||
<i class="el-icon-full-screen"></i>
|
||||
展开查看
|
||||
展开
|
||||
</button>
|
||||
<button class="copy-btn" @click="copyText" v-if="currentTranscript && currentViewType === 'transcript'">
|
||||
<i class="el-icon-document-copy"></i>
|
||||
@@ -174,277 +165,313 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { SimpleChatService } from '@/utils/ChatService.js'
|
||||
import MarkdownIt from 'markdown-it'
|
||||
|
||||
export default {
|
||||
name: 'QualityCalls',
|
||||
data() {
|
||||
return {
|
||||
recordings: [
|
||||
{
|
||||
id: 1,
|
||||
name: '常家硕-张三丰-亮剑二部-20分钟通话-25-07-16_18-23-04-44196-215.mp3',
|
||||
size: 2048576, // 2MB
|
||||
duration: '00:03:45',
|
||||
date: '2024-01-15',
|
||||
url: '/recordings/sample_call_1.mp3',
|
||||
transcription: null
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: '常家硕-张三丰-亮剑二部-20分钟通话-25-07-16_18-23-01-439240-599.mp3',
|
||||
size: 3145728, // 3MB
|
||||
duration: '00:05:20',
|
||||
date: '2024-01-14',
|
||||
url: '/recordings/sample_call_2.mp3',
|
||||
transcription: null
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: '常家硕-张三丰-亮剑二部-20分钟通话-25-07-16_18-23-02-754615-508.mp3',
|
||||
size: 2048576, // 2MB
|
||||
duration: '00:03:45',
|
||||
date: '2024-01-15',
|
||||
url: '/recordings/sample_call_1.mp3',
|
||||
transcription: null
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: '丁传辉-丁传辉-勇士二部-20分钟通话-25-07-10_10-32-54-813815-322.mp3',
|
||||
size: 3145728, // 3MB
|
||||
duration: '00:05:20',
|
||||
date: '2024-01-14',
|
||||
url: '/recordings/sample_call_2.mp3',
|
||||
transcription: null
|
||||
}
|
||||
],
|
||||
selectedRecording: null,
|
||||
currentAudio: null,
|
||||
showTranscriptView: false,
|
||||
isConverting: false,
|
||||
currentTranscript: null,
|
||||
showDialog: false,
|
||||
// 录音分析相关
|
||||
showAnalysisView: false,
|
||||
isAnalyzing: false,
|
||||
analysisResult: '',
|
||||
currentViewType: 'transcript', // 'transcript' 或 'analysis'
|
||||
// Dify API配置
|
||||
DIFY_API_KEY_02: 'app-h4uBo5kOGoiYhjuBF1AHZi8b', // 通话录音分析
|
||||
chatService_02: null,
|
||||
md: null
|
||||
}
|
||||
},
|
||||
created() {
|
||||
// 初始化服务
|
||||
this.chatService_02 = new SimpleChatService(this.DIFY_API_KEY_02)
|
||||
this.md = new MarkdownIt({
|
||||
html: true,
|
||||
linkify: true,
|
||||
typographer: true
|
||||
})
|
||||
},
|
||||
computed: {
|
||||
// 格式化分析结果
|
||||
formattedAnalysisResult() {
|
||||
if (!this.analysisResult) return ''
|
||||
return this.md.render(this.analysisResult)
|
||||
}
|
||||
},
|
||||
beforeUnmount() {
|
||||
if (this.currentAudio) {
|
||||
this.currentAudio.pause()
|
||||
this.currentAudio = null
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
// 录音文件选择
|
||||
handleFileSelect(event) {
|
||||
const file = event.target.files[0]
|
||||
if (file) {
|
||||
const recording = {
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
uploadTime: new Date().toLocaleString(),
|
||||
url: URL.createObjectURL(file),
|
||||
isPlaying: false,
|
||||
isConverting: false,
|
||||
transcript: null
|
||||
}
|
||||
this.recordings.push(recording)
|
||||
// 清空input以便重复选择同一文件
|
||||
event.target.value = ''
|
||||
}
|
||||
},
|
||||
// 选择录音
|
||||
selectRecording(index) {
|
||||
this.selectedRecording = index
|
||||
},
|
||||
// 播放/暂停录音
|
||||
togglePlay(index) {
|
||||
const recording = this.recordings[index]
|
||||
|
||||
// 停止当前播放的音频
|
||||
if (this.currentAudio) {
|
||||
this.currentAudio.pause()
|
||||
this.recordings.forEach(r => r.isPlaying = false)
|
||||
}
|
||||
|
||||
if (!recording.isPlaying) {
|
||||
this.currentAudio = new Audio(recording.url)
|
||||
this.currentAudio.play()
|
||||
recording.isPlaying = true
|
||||
|
||||
this.currentAudio.onended = () => {
|
||||
recording.isPlaying = false
|
||||
this.currentAudio = null
|
||||
}
|
||||
}
|
||||
},
|
||||
// 转换为文本
|
||||
async convertToText(index) {
|
||||
const recording = this.recordings[index]
|
||||
this.selectedRecording = index
|
||||
this.showTranscriptView = true
|
||||
this.isConverting = true
|
||||
this.currentTranscript = null
|
||||
this.currentViewType = 'transcript'
|
||||
|
||||
try {
|
||||
// 模拟转换过程
|
||||
await new Promise(resolve => setTimeout(resolve, 2000))
|
||||
|
||||
// 这里应该调用实际的语音转文本API
|
||||
// 目前使用模拟数据
|
||||
recording.transcript = `这是 ${recording.name} 的转换文本示例。在实际应用中,这里会显示真实的语音转文本结果。您可以集成百度、阿里云、腾讯云等语音识别服务来实现真正的语音转文本功能。`
|
||||
this.currentTranscript = recording.transcript
|
||||
|
||||
// 转换完成后自动开始录音分析
|
||||
this.startRecordingAnalysis(recording)
|
||||
|
||||
// 添加转换完成的动画效果
|
||||
const resultElement = document.querySelector('.conversion-result')
|
||||
if (resultElement) {
|
||||
resultElement.classList.add('show-result')
|
||||
setTimeout(() => {
|
||||
resultElement.classList.remove('show-result')
|
||||
}, 1000)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('转换失败:', error)
|
||||
alert('转换失败,请重试')
|
||||
this.showTranscriptView = false
|
||||
} finally {
|
||||
this.isConverting = false
|
||||
}
|
||||
},
|
||||
|
||||
// 开始通话录音分析
|
||||
async startRecordingAnalysis(recording) {
|
||||
this.isAnalyzing = true
|
||||
this.analysisResult = ''
|
||||
|
||||
// 构建通话录音分析查询
|
||||
const recordingQuery = `请对录音文件 ${recording.name} 进行通话录音分析,包括:
|
||||
1. 通话质量评估
|
||||
2. 客户情绪分析
|
||||
3. 沟通效果评价
|
||||
4. 关键信息提取
|
||||
5. 改进建议
|
||||
// Props定义
|
||||
const props = defineProps({
|
||||
qualityCalls: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
})
|
||||
|
||||
录音信息:
|
||||
文件名:${recording.name}
|
||||
文件大小:${this.formatFileSize(recording.size)}
|
||||
转换文本:${recording.transcript}`
|
||||
|
||||
try {
|
||||
await this.chatService_02.sendMessage(
|
||||
recordingQuery,
|
||||
(update) => {
|
||||
// 实时更新通话录音分析结果
|
||||
this.analysisResult = update.content
|
||||
},
|
||||
() => {
|
||||
// 流结束回调
|
||||
console.log('通话录音分析完成')
|
||||
this.isAnalyzing = false
|
||||
}
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('通话录音分析失败:', error)
|
||||
this.analysisResult = '通话录音分析失败,请重试。'
|
||||
this.isAnalyzing = false
|
||||
}
|
||||
},
|
||||
// 响应式数据
|
||||
const staticRecordings = ref([
|
||||
{
|
||||
id: 1,
|
||||
name: '常家硕-张三丰-亮剑二部-20分钟通话-25-07-16_18-23-04-44196-215.mp3',
|
||||
size: 2048576, // 2MB
|
||||
duration: '00:03:45',
|
||||
date: '2024-01-15',
|
||||
url: '/recordings/sample_call_1.mp3',
|
||||
transcription: null
|
||||
}
|
||||
])
|
||||
|
||||
const selectedRecording = ref(null)
|
||||
const currentAudio = ref(null)
|
||||
const showTranscriptView = ref(false)
|
||||
const isConverting = ref(false)
|
||||
const currentTranscript = ref(null)
|
||||
const showDialog = ref(false)
|
||||
// 录音分析相关
|
||||
const showAnalysisView = ref(false)
|
||||
const isAnalyzing = ref(false)
|
||||
const analysisResult = ref('')
|
||||
const currentViewType = ref('transcript') // 'transcript' 或 'analysis'
|
||||
// Dify API配置
|
||||
const DIFY_API_KEY_02 = 'app-h4uBo5kOGoiYhjuBF1AHZi8b' // 通话录音分析
|
||||
const chatService_02 = ref(null)
|
||||
const md = ref(null)
|
||||
|
||||
// 初始化服务
|
||||
onMounted(() => {
|
||||
chatService_02.value = new SimpleChatService(DIFY_API_KEY_02)
|
||||
md.value = new MarkdownIt({
|
||||
html: true,
|
||||
linkify: true,
|
||||
typographer: true
|
||||
})
|
||||
})
|
||||
// 计算属性
|
||||
// 处理传入的录音数据
|
||||
const recordings = computed(() => {
|
||||
if (!props.qualityCalls ) {
|
||||
return staticRecordings.value;
|
||||
}
|
||||
const recordingsList = [];
|
||||
props.qualityCalls.forEach((record, index) => {
|
||||
recordingsList.push({
|
||||
id: recordingsList.length + 1,
|
||||
name: record.record_name ? record.record_name : `${record.sale_name}-录音-${index + 1}`,
|
||||
date: new Date().toISOString().split('T')[0],
|
||||
url: record.record_file_addr,
|
||||
transcription: record.record_context || null,
|
||||
score: record.record_score,
|
||||
sop: record.record_report,
|
||||
sale_name: record.record_name,
|
||||
size: 2048576, // 默认文件大小 2MB
|
||||
uploadTime: record.created_at,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
return recordingsList;
|
||||
})
|
||||
|
||||
// 格式化分析结果
|
||||
const formattedAnalysisResult = computed(() => {
|
||||
if (!analysisResult.value) return ''
|
||||
return md.value.render(analysisResult.value)
|
||||
})
|
||||
// 生命周期钩子
|
||||
onBeforeUnmount(() => {
|
||||
if (currentAudio.value) {
|
||||
currentAudio.value.pause()
|
||||
currentAudio.value = null
|
||||
}
|
||||
})
|
||||
// 方法定义
|
||||
// 录音文件选择
|
||||
const handleFileSelect = (event) => {
|
||||
const file = event.target.files[0]
|
||||
if (file) {
|
||||
const recording = {
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
uploadTime: new Date().toLocaleString(),
|
||||
url: URL.createObjectURL(file),
|
||||
isPlaying: false,
|
||||
isConverting: false,
|
||||
transcript: null
|
||||
}
|
||||
staticRecordings.value.push(recording)
|
||||
// 清空input以便重复选择同一文件
|
||||
event.target.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
// 选择录音
|
||||
const selectRecording = (index) => {
|
||||
selectedRecording.value = index
|
||||
}
|
||||
|
||||
// 播放/暂停录音
|
||||
const togglePlay = (index) => {
|
||||
const recording = recordings.value[index]
|
||||
|
||||
// 停止当前播放的音频
|
||||
if (currentAudio.value) {
|
||||
currentAudio.value.pause()
|
||||
recordings.value.forEach(r => r.isPlaying = false)
|
||||
}
|
||||
|
||||
if (!recording.isPlaying) {
|
||||
currentAudio.value = new Audio(recording.url)
|
||||
currentAudio.value.play()
|
||||
recording.isPlaying = true
|
||||
|
||||
// 切换视图类型
|
||||
switchViewType(type) {
|
||||
this.currentViewType = type
|
||||
},
|
||||
currentAudio.value.onended = () => {
|
||||
recording.isPlaying = false
|
||||
currentAudio.value = null
|
||||
}
|
||||
}
|
||||
}
|
||||
// 转换为文字
|
||||
const convertToText = async (index) => {
|
||||
const recording = recordings.value[index]
|
||||
selectedRecording.value = index
|
||||
showTranscriptView.value = true
|
||||
isConverting.value = true
|
||||
currentTranscript.value = null
|
||||
currentViewType.value = 'transcript'
|
||||
|
||||
try {
|
||||
// 模拟转换过程
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
|
||||
// 返回录音列表
|
||||
backToRecordings() {
|
||||
this.showTranscriptView = false
|
||||
this.currentTranscript = null
|
||||
this.analysisResult = ''
|
||||
this.currentViewType = 'transcript'
|
||||
this.isAnalyzing = false
|
||||
},
|
||||
// 复制文本
|
||||
copyText() {
|
||||
if (this.currentTranscript) {
|
||||
navigator.clipboard.writeText(this.currentTranscript)
|
||||
// 使用从API获取的transcription数据
|
||||
if (recording.transcription) {
|
||||
recording.transcript = recording.transcription
|
||||
currentTranscript.value = recording.transcription
|
||||
} else {
|
||||
// 如果没有transcription数据,显示提示信息
|
||||
recording.transcript = '暂无转换文本数据'
|
||||
currentTranscript.value = '暂无转换文本数据'
|
||||
}
|
||||
|
||||
// 添加转换完成的动画效果
|
||||
const resultElement = document.querySelector('.conversion-result')
|
||||
if (resultElement) {
|
||||
resultElement.classList.add('show-result')
|
||||
setTimeout(() => {
|
||||
resultElement.classList.remove('show-result')
|
||||
}, 1000)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('转换失败:', error)
|
||||
alert('转换失败,请重试')
|
||||
showTranscriptView.value = false
|
||||
} finally {
|
||||
isConverting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 开始通话录音分析
|
||||
const startRecordingAnalysis = async (recording) => {
|
||||
isAnalyzing.value = true
|
||||
|
||||
try {
|
||||
// 使用从API获取的sop数据作为录音分析结果
|
||||
if (recording.sop) {
|
||||
analysisResult.value = recording.sop
|
||||
} else {
|
||||
analysisResult.value = '暂无录音分析数据'
|
||||
}
|
||||
|
||||
// 模拟分析过程
|
||||
await new Promise(resolve => setTimeout(resolve, 500))
|
||||
|
||||
console.log('录音分析完成')
|
||||
} catch (error) {
|
||||
console.error('录音分析失败:', error)
|
||||
analysisResult.value = '录音分析失败,请重试。'
|
||||
} finally {
|
||||
isAnalyzing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 切换视图类型
|
||||
const switchViewType = (type) => {
|
||||
currentViewType.value = type
|
||||
|
||||
// 如果切换到录音分析视图,且还没有分析结果,则开始分析
|
||||
if (type === 'analysis' && !analysisResult.value && selectedRecording.value !== null) {
|
||||
const recording = recordings.value[selectedRecording.value]
|
||||
startRecordingAnalysis(recording)
|
||||
}
|
||||
}
|
||||
|
||||
// 返回录音列表
|
||||
const backToRecordings = () => {
|
||||
showTranscriptView.value = false
|
||||
currentTranscript.value = null
|
||||
analysisResult.value = ''
|
||||
currentViewType.value = 'transcript'
|
||||
isAnalyzing.value = false
|
||||
}
|
||||
|
||||
// 复制文本
|
||||
const copyText = async () => {
|
||||
if (currentTranscript.value) {
|
||||
try {
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
await navigator.clipboard.writeText(currentTranscript.value)
|
||||
alert('文本已复制到剪贴板')
|
||||
} else {
|
||||
// 降级方案:使用传统的复制方法
|
||||
const textArea = document.createElement('textarea')
|
||||
textArea.value = currentTranscript.value
|
||||
document.body.appendChild(textArea)
|
||||
textArea.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(textArea)
|
||||
alert('文本已复制到剪贴板')
|
||||
}
|
||||
},
|
||||
// 复制分析结果
|
||||
copyAnalysisText() {
|
||||
if (this.analysisResult) {
|
||||
navigator.clipboard.writeText(this.analysisResult)
|
||||
} catch (error) {
|
||||
console.error('复制失败:', error)
|
||||
alert('复制失败,请手动复制')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 复制分析结果
|
||||
const copyAnalysisText = async () => {
|
||||
if (analysisResult.value) {
|
||||
try {
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
await navigator.clipboard.writeText(analysisResult.value)
|
||||
alert('分析结果已复制到剪贴板')
|
||||
} else {
|
||||
// 降级方案:使用传统的复制方法
|
||||
const textArea = document.createElement('textarea')
|
||||
textArea.value = analysisResult.value
|
||||
document.body.appendChild(textArea)
|
||||
textArea.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(textArea)
|
||||
alert('分析结果已复制到剪贴板')
|
||||
}
|
||||
},
|
||||
// 显示展开弹框
|
||||
showExpandDialog() {
|
||||
this.showDialog = true
|
||||
},
|
||||
// 关闭弹框
|
||||
closeDialog() {
|
||||
this.showDialog = false
|
||||
},
|
||||
// 格式化文件大小
|
||||
formatFileSize(bytes) {
|
||||
if (bytes === 0) return '0 Bytes'
|
||||
const k = 1024
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||||
},
|
||||
triggerFileUpload() {
|
||||
const fileInput = document.createElement('input')
|
||||
fileInput.type = 'file'
|
||||
fileInput.accept = 'audio/*'
|
||||
fileInput.style.display = 'none'
|
||||
fileInput.addEventListener('change', this.handleFileSelect)
|
||||
document.body.appendChild(fileInput)
|
||||
fileInput.click()
|
||||
document.body.removeChild(fileInput)
|
||||
},
|
||||
downloadRecording(index) {
|
||||
const recording = this.recordings[index]
|
||||
if (recording && recording.url) {
|
||||
const link = document.createElement('a')
|
||||
link.href = recording.url
|
||||
link.download = recording.name
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('复制失败:', error)
|
||||
alert('复制失败,请手动复制')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 显示展开弹框
|
||||
const showExpandDialog = () => {
|
||||
showDialog.value = true
|
||||
}
|
||||
|
||||
// 关闭弹框
|
||||
const closeDialog = () => {
|
||||
showDialog.value = false
|
||||
}
|
||||
// 格式化文件大小
|
||||
const formatFileSize = (bytes) => {
|
||||
if (bytes === 0) return '0 Bytes'
|
||||
const k = 1024
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
const triggerFileUpload = () => {
|
||||
const fileInput = document.createElement('input')
|
||||
fileInput.type = 'file'
|
||||
fileInput.accept = 'audio/*'
|
||||
fileInput.style.display = 'none'
|
||||
fileInput.addEventListener('change', handleFileSelect)
|
||||
document.body.appendChild(fileInput)
|
||||
fileInput.click()
|
||||
document.body.removeChild(fileInput)
|
||||
}
|
||||
|
||||
const downloadRecording = (index) => {
|
||||
const recording = recordings.value[index]
|
||||
if (recording && recording.url) {
|
||||
const link = document.createElement('a')
|
||||
link.href = recording.url
|
||||
link.download = recording.name
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -484,7 +511,7 @@ export default {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20px 20px 0;
|
||||
padding: 10px 20px 0;
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
}
|
||||
|
||||
@@ -521,20 +548,19 @@ export default {
|
||||
}
|
||||
|
||||
.chart-content {
|
||||
padding: 20px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.recording-section {
|
||||
width: 100%;
|
||||
min-height: 300px;
|
||||
max-height: 500px;
|
||||
min-height: 200px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.recording-list {
|
||||
margin-bottom: 20px;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.recording-item {
|
||||
@@ -574,6 +600,39 @@ export default {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.recording-index {
|
||||
/* 基础分数样式 */
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
background-color: #e9ecef;
|
||||
color: #495057;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
/* 第一名样式 */
|
||||
.recording-item:first-child .recording-index {
|
||||
background: linear-gradient(135deg, #FFD700, #FFA500);
|
||||
color: #fff;
|
||||
box-shadow: 0 2px 4px rgba(255, 215, 0, 0.3);
|
||||
}
|
||||
|
||||
/* 第二名样式 */
|
||||
.recording-item:nth-child(2) .recording-index {
|
||||
background: linear-gradient(135deg, #C0C0C0, #A9A9A9);
|
||||
color: #fff;
|
||||
box-shadow: 0 2px 4px rgba(192, 192, 192, 0.3);
|
||||
}
|
||||
|
||||
/* 第三名样式 */
|
||||
.recording-item:nth-child(3) .recording-index {
|
||||
background: linear-gradient(135deg, #CD7F32, #A0522D);
|
||||
color: #fff;
|
||||
box-shadow: 0 2px 4px rgba(205, 127, 50, 0.3);
|
||||
}
|
||||
|
||||
|
||||
.recording-meta {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<div class="card-header">
|
||||
<h3>团队业绩排行榜</h3>
|
||||
<select v-model="rankingPeriod" class="periods-select" @change="onPeriodChange">
|
||||
<option value="periods">本期</option>
|
||||
<!-- <option value="periods">本期</option> -->
|
||||
<option value="month">月度</option>
|
||||
<option value="year">年度</option>
|
||||
</select>
|
||||
@@ -39,7 +39,7 @@ const props = defineProps({
|
||||
|
||||
const emit = defineEmits(['periods-change']);
|
||||
|
||||
const rankingPeriod = ref('periods');
|
||||
const rankingPeriod = ref('month');
|
||||
const centerSalesRank = ref({});
|
||||
|
||||
// 计算属性:转换 centerSalesRank 数据格式
|
||||
@@ -82,77 +82,6 @@ async function getCenterSalesRank(data) {
|
||||
const res = await getCenterPerformanceRank(params);
|
||||
console.log('获取中心业绩排行榜:', res);
|
||||
centerSalesRank.value = res.data;
|
||||
/**
|
||||
* 0
|
||||
:
|
||||
{center_leader: "潘加俊", total_deals: 0, average_deals_per_member: 0}
|
||||
average_deals_per_member
|
||||
:
|
||||
0
|
||||
center_leader
|
||||
:
|
||||
"潘加俊"
|
||||
total_deals
|
||||
:
|
||||
0
|
||||
1
|
||||
:
|
||||
{center_leader: "张三丰", total_deals: 44, average_deals_per_member: 1}
|
||||
average_deals_per_member
|
||||
:
|
||||
1
|
||||
center_leader
|
||||
:
|
||||
"张三丰"
|
||||
total_deals
|
||||
:
|
||||
44
|
||||
2
|
||||
:
|
||||
{center_leader: "朱一航", total_deals: 0, average_deals_per_member: 0}
|
||||
average_deals_per_member
|
||||
:
|
||||
0
|
||||
center_leader
|
||||
:
|
||||
"朱一航"
|
||||
total_deals
|
||||
:
|
||||
0
|
||||
3
|
||||
:
|
||||
{center_leader: "程琦", total_deals: 0, average_deals_per_member: 0}
|
||||
average_deals_per_member
|
||||
:
|
||||
0
|
||||
center_leader
|
||||
:
|
||||
"程琦"
|
||||
total_deals
|
||||
:
|
||||
0
|
||||
4
|
||||
:
|
||||
{center_leader: "王卓琳", total_deals: 6, average_deals_per_member: 0}
|
||||
average_deals_per_member
|
||||
:
|
||||
0
|
||||
center_leader
|
||||
:
|
||||
"王卓琳"
|
||||
total_deals
|
||||
:
|
||||
6
|
||||
5
|
||||
:
|
||||
{center_leader: "伍晶晶", total_deals: 5, average_deals_per_member: 0}
|
||||
average_deals_per_member
|
||||
:
|
||||
0
|
||||
center_leader
|
||||
:
|
||||
"伍晶晶"
|
||||
*/
|
||||
} catch (error) {
|
||||
console.error('获取全中心业绩排行榜失败:', error);
|
||||
}
|
||||
|
||||
@@ -4,33 +4,41 @@
|
||||
<div class="dashboard-header">
|
||||
<h1>管理者数据看板</h1>
|
||||
<!-- 头像 -->
|
||||
<UserDropdown />
|
||||
<div style="display: flex; align-items: center; gap: 20px;">
|
||||
<button @click="showFeedbackFormModal" class="feedback-btn">意见反馈</button>
|
||||
<FeedbackForm
|
||||
:is-visible="showFeedbackForm"
|
||||
@close="closeFeedbackFormModal"
|
||||
@submit-feedback="closeFeedbackFormModal"
|
||||
/>
|
||||
<UserDropdown
|
||||
:card-visibility="cardVisibility"
|
||||
@update-card-visibility="updateCardVisibility"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 第一行:核心业绩指标、销售实时进度、下发任务 -->
|
||||
<!-- 第一行:核心业绩指标、销售实时进度 -->
|
||||
<div class="dashboard-row row-1">
|
||||
<!-- 核心业绩指标 -->
|
||||
<kpi-metrics :kpi-data="totalDeals" :format-number="formatNumber" />
|
||||
<kpi-metrics v-if="cardVisibility.kpiMetrics" :kpi-data="totalDeals" :format-number="formatNumber" />
|
||||
<!-- 销售实时进度 -->
|
||||
<sales-progress :sales-data="realTimeProgress" />
|
||||
<!-- 下发任务 -->
|
||||
<task-list
|
||||
:tasks="tasks"
|
||||
:format-date="formatDate"
|
||||
:get-task-status-text="getTaskStatusText"
|
||||
@show-task-modal="showTaskModal = true"
|
||||
/>
|
||||
<sales-progress v-if="cardVisibility.salesProgress" :sales-data="realTimeProgress" />
|
||||
<!-- 各中心营期阶段 -->
|
||||
<period-stage v-if="cardVisibility.periodStage" />
|
||||
</div>
|
||||
<!-- 第二行 -->
|
||||
<div class="dashboard-row row-3">
|
||||
<!-- 转化漏斗 -->
|
||||
<funnel-chart
|
||||
v-if="cardVisibility.funnelChart"
|
||||
:funnel-data="formattedFunnelData"
|
||||
:comparison-data="formattedComparisonData"
|
||||
@time-range-change="handleTimeRangeChange"
|
||||
/>
|
||||
<!-- 销售个人业绩排行榜 -->
|
||||
<personal-sales-ranking
|
||||
v-if="cardVisibility.personalSalesRanking"
|
||||
:ranking-data="formattedSalesRankingData"
|
||||
:format-number="formatNumber"
|
||||
:get-rank-class="getRankClass"
|
||||
@@ -39,7 +47,8 @@
|
||||
/>
|
||||
<!-- 优质通话 -->
|
||||
<quality-calls
|
||||
:quality-calls="qualityCalls"
|
||||
v-if="cardVisibility.qualityCalls"
|
||||
:quality-calls="excellentRecord"
|
||||
@play-call="playCall"
|
||||
@download-call="downloadCall"
|
||||
/>
|
||||
@@ -48,80 +57,30 @@
|
||||
<div class="dashboard-row row-3">
|
||||
<!-- 业绩排行榜 -->
|
||||
<ranking-list
|
||||
v-if="cardVisibility.rankingList"
|
||||
:format-number="formatNumber"
|
||||
:get-rank-class="getRankClass"
|
||||
/>
|
||||
<!-- 客户类型占比 -->
|
||||
<customer-type :customer-data="customerTypeRatio" @category-change="getCustomerTypeRatio" />
|
||||
<customer-type v-if="cardVisibility.customerType" :customer-data="customerTypeRatio" @category-change="getCustomerTypeRatio" />
|
||||
<!-- 客户迫切解决的问题排行榜 -->
|
||||
<problem-ranking :ranking-data="problemRankingData" />
|
||||
<problem-ranking v-if="cardVisibility.problemRanking" :ranking-data="problemRankingData" />
|
||||
</div>
|
||||
<!-- 第四行:详细数据表格和数据详情 -->
|
||||
<div class="dashboard-row" v-show="false">
|
||||
<CampManagement />
|
||||
<CampManagement v-if="cardVisibility.campManagement" />
|
||||
</div>
|
||||
<!-- 第五行 -->
|
||||
<div class="dashboard-row" >
|
||||
<DetailedDataTable
|
||||
v-if="cardVisibility.detailedDataTable"
|
||||
:table-data="detailData"
|
||||
:level-tree="levelTree"
|
||||
v-model:selected-person="selectedPerson"
|
||||
@filter-change="handleFilterChange"
|
||||
/>
|
||||
</div>
|
||||
<!-- 新建任务模态框 -->
|
||||
<div
|
||||
v-if="showTaskModal"
|
||||
class="modal-overlay"
|
||||
@click="showTaskModal = false"
|
||||
>
|
||||
<div class="modal-content" @click.stop>
|
||||
<div class="modal-header">
|
||||
<h3>新建任务</h3>
|
||||
<button class="close-btn" @click="showTaskModal = false">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label>任务标题</label>
|
||||
<input
|
||||
v-model="newTask.title"
|
||||
type="text"
|
||||
placeholder="请输入任务标题"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>分配给</label>
|
||||
<select v-model="newTask.assignee">
|
||||
<option value="">请选择员工</option>
|
||||
<option
|
||||
v-for="employee in assigneeOptions"
|
||||
:key="employee.wechat_id"
|
||||
:value="employee.wechat_id"
|
||||
>
|
||||
{{ employee.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>截止日期</label>
|
||||
<input v-model="newTask.deadline" type="date" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>任务描述</label>
|
||||
<textarea
|
||||
v-model="newTask.description"
|
||||
placeholder="请输入任务描述"
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="cancel-btn" @click="showTaskModal = false">
|
||||
取消
|
||||
</button>
|
||||
<button class="confirm-btn" @click="createTask">创建任务</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -144,7 +103,6 @@ import axios from "axios";
|
||||
import UserDropdown from "@/components/UserDropdown.vue";
|
||||
import KpiMetrics from "./components/KpiMetrics.vue";
|
||||
import SalesProgress from "./components/SalesProgress.vue";
|
||||
import TaskList from "./components/TaskList.vue";
|
||||
import FunnelChart from "./components/FunnelChart.vue";
|
||||
import CustomerProfile from "./components/CustomerProfile.vue";
|
||||
import CustomerType from "./components/CustomerType.vue";
|
||||
@@ -157,8 +115,105 @@ import QualityCalls from "./components/QualityCalls.vue";
|
||||
import DataDetail from "./components/DataDetail.vue";
|
||||
import CampManagement from "./components/CampManagement.vue";
|
||||
import DetailedDataTable from "./components/DetailedDataTable.vue";
|
||||
import PeriodStage from "./components/PeriodStage.vue";
|
||||
import { getOverallCompanyPerformance,getCompanyDepositConversionRate,getCompanyTotalCallCount,getCompanyNewCustomer,getCompanyConversionRate,getCompanyRealTimeProgress
|
||||
,getCompanyConversionRateVsLast,getSalesMonthlyPerformance,getCustomerTypeDistribution,getUrgentNeedToAddress,getLevelTree,getDetailedDataTable,assignTasks } from "@/api/top";
|
||||
,getCompanyConversionRateVsLast,getSalesMonthlyPerformance,getCustomerTypeDistribution,getUrgentNeedToAddress,getLevelTree,getDetailedDataTable,getExcellentRecordFile } from "@/api/top";
|
||||
import { useUserStore } from "@/stores/user.js";
|
||||
import FeedbackForm from "@/components/FeedbackForm.vue";
|
||||
|
||||
// 缓存系统
|
||||
const cache = new Map();
|
||||
const CACHE_DURATION = 30 * 60 * 1000; // 30分钟
|
||||
|
||||
// 缓存工具函数
|
||||
const getCacheKey = (functionName, params = {}) => {
|
||||
return `${functionName}_${JSON.stringify(params)}`;
|
||||
};
|
||||
|
||||
const isValidCache = (cacheItem) => {
|
||||
return cacheItem && (Date.now() - cacheItem.timestamp) < CACHE_DURATION;
|
||||
};
|
||||
|
||||
const setCache = (key, data) => {
|
||||
cache.set(key, {
|
||||
data,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
};
|
||||
|
||||
const getCache = (key) => {
|
||||
const cacheItem = cache.get(key);
|
||||
if (isValidCache(cacheItem)) {
|
||||
return cacheItem.data;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// 带缓存的API调用包装器
|
||||
const withCache = async (cacheKey, apiCall) => {
|
||||
const cachedData = getCache(cacheKey);
|
||||
if (cachedData) {
|
||||
console.log(`使用缓存数据: ${cacheKey}`);
|
||||
return cachedData;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await apiCall();
|
||||
setCache(cacheKey, result);
|
||||
console.log(`缓存新数据: ${cacheKey}`);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error(`API调用失败: ${cacheKey}`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// 清除缓存函数
|
||||
const clearCache = () => {
|
||||
cache.clear();
|
||||
console.log('所有缓存已清除');
|
||||
};
|
||||
|
||||
// 清除特定缓存
|
||||
const clearSpecificCache = (functionName, params = {}) => {
|
||||
const cacheKey = getCacheKey(functionName, params);
|
||||
cache.delete(cacheKey);
|
||||
console.log(`已清除缓存: ${cacheKey}`);
|
||||
};
|
||||
|
||||
// 获取缓存状态信息
|
||||
const getCacheInfo = () => {
|
||||
const cacheEntries = Array.from(cache.entries());
|
||||
const validEntries = cacheEntries.filter(([key, value]) => isValidCache(value));
|
||||
const expiredEntries = cacheEntries.filter(([key, value]) => !isValidCache(value));
|
||||
|
||||
// 清除过期缓存
|
||||
expiredEntries.forEach(([key]) => cache.delete(key));
|
||||
|
||||
return {
|
||||
totalCached: validEntries.length,
|
||||
expiredCleaned: expiredEntries.length,
|
||||
cacheKeys: validEntries.map(([key]) => key)
|
||||
};
|
||||
};
|
||||
|
||||
// 强制刷新所有数据(清除缓存并重新获取)
|
||||
const forceRefreshAllData = async () => {
|
||||
clearCache();
|
||||
console.log('开始强制刷新所有数据...');
|
||||
|
||||
await getRealTimeProgress();
|
||||
await getTotalDeals();
|
||||
await getConversionComparison('month');
|
||||
await getCompanySalesRank('red');
|
||||
await getCustomerTypeRatio('child_education');
|
||||
await getCustomerUrgency();
|
||||
await CusotomGetLevelTree();
|
||||
await getDetailData();
|
||||
await CenterExcellentRecord();
|
||||
|
||||
console.log('所有数据刷新完成');
|
||||
};
|
||||
|
||||
const rankingPeriod = ref("month");
|
||||
const rankingData = ref([
|
||||
@@ -173,131 +228,40 @@ const sortField = ref("dealRate");
|
||||
const sortOrder = ref("desc");
|
||||
const selectedPerson = ref(null);
|
||||
|
||||
const tasks = ref([]);
|
||||
const userStore = useUserStore();
|
||||
|
||||
const employees = ref([
|
||||
{ id: 1, name: "张三" }
|
||||
]);
|
||||
|
||||
const showTaskModal = ref(false);
|
||||
const newTask = reactive({
|
||||
title: "",
|
||||
assignee: "",
|
||||
deadline: "",
|
||||
description: "",
|
||||
// 卡片显示状态管理
|
||||
const cardVisibility = ref({
|
||||
kpiMetrics: true,
|
||||
salesProgress: true,
|
||||
periodStage: true,
|
||||
funnelChart: true,
|
||||
personalSalesRanking: true,
|
||||
qualityCalls: true,
|
||||
rankingList: true,
|
||||
customerType: true,
|
||||
problemRanking: true,
|
||||
campManagement: true,
|
||||
detailedDataTable: true
|
||||
});
|
||||
// 获取任务列表
|
||||
const getTaskList = async () => {
|
||||
try {
|
||||
const res = await axios.post('http://192.168.15.60:8890/api/v1/level_five/overview/view_tasks', {}, {
|
||||
headers: {
|
||||
'Authorization': 'Bearer ' + localStorage.getItem('token')
|
||||
}
|
||||
})
|
||||
console.log(888888,res)
|
||||
if (res.data.code === 200) {
|
||||
const apiTasks = res.data.data.tasks || res.data.data
|
||||
// 将API数据格式转换为TaskList组件期望的格式
|
||||
tasks.value = apiTasks.map(task => ({
|
||||
id: task.task_id,
|
||||
title: task.task_title,
|
||||
assignee: task.assignee || '未分配',
|
||||
deadline: task.expiration_date,
|
||||
status: task.state === '待处理' ? 'pending' : task.state === '正在处理' ? 'in-progress' : 'completed',
|
||||
description: task.task_content,
|
||||
created_at: task.created_at
|
||||
}))
|
||||
console.log(777777,tasks.value)
|
||||
/**
|
||||
* tasks
|
||||
:
|
||||
[,…]
|
||||
0
|
||||
:
|
||||
{task_id: "1755748690560728_22d55cc618784537973481228a15956a", task_title: "55", task_content: "222",…}
|
||||
created_at
|
||||
:
|
||||
"2025-08-21 11:58:10"
|
||||
expiration_date
|
||||
:
|
||||
"20250808"
|
||||
state
|
||||
:
|
||||
"待处理"
|
||||
task_content
|
||||
:
|
||||
"222"
|
||||
task_id
|
||||
:
|
||||
"1755748690560728_22d55cc618784537973481228a15956a"
|
||||
task_title
|
||||
:
|
||||
"55"
|
||||
1
|
||||
:
|
||||
{task_id: "1755745331126891_650206e5b6d345699de3e3e406a2600e", task_title: "测试任务",…}
|
||||
created_at
|
||||
:
|
||||
"2025-08-21 11:02:11"
|
||||
expiration_date
|
||||
:
|
||||
"121221"
|
||||
state
|
||||
:
|
||||
"待处理"
|
||||
task_content
|
||||
:
|
||||
"测试任务"
|
||||
task_id
|
||||
:
|
||||
"1755745331126891_650206e5b6d345699de3e3e406a2600e"
|
||||
task_title
|
||||
:
|
||||
"测试任务"
|
||||
2
|
||||
:
|
||||
{task_id: "1755745330094989_528dd87dc13a4a5bb33c9c272fb1a482", task_title: "测试任务",…}
|
||||
created_at
|
||||
:
|
||||
"2025-08-21 11:02:10"
|
||||
expiration_date
|
||||
:
|
||||
"121221"
|
||||
state
|
||||
:
|
||||
"已完成"
|
||||
task_content
|
||||
:
|
||||
"测试任务"
|
||||
task_id
|
||||
:
|
||||
"1755745330094989_528dd87dc13a4a5bb33c9c272fb1a482"
|
||||
task_title
|
||||
:
|
||||
"测试任务"
|
||||
*/
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取任务列表失败:', error)
|
||||
}
|
||||
}
|
||||
// 下拉框人员
|
||||
const assigneeOptions = ref([]);
|
||||
async function name() {
|
||||
try {
|
||||
console.log('开始获取下属人员列表...');
|
||||
const res = await axios.get('http://192.168.15.60:8890/api/v1/level_five/overview/get_subordinates',{
|
||||
headers: {
|
||||
'Authorization': 'Bearer ' + localStorage.getItem('token')
|
||||
}
|
||||
});
|
||||
assigneeOptions.value = res.data.data;
|
||||
|
||||
console.log('assigneeOptions设置后:', assigneeOptions.value);
|
||||
} catch (error) {
|
||||
console.error('获取下属人员列表失败:', error);
|
||||
}
|
||||
}
|
||||
// FeedbackForm 控制变量
|
||||
const showFeedbackForm = ref(false);
|
||||
|
||||
// FeedbackForm 控制方法
|
||||
const showFeedbackFormModal = () => {
|
||||
showFeedbackForm.value = true;
|
||||
};
|
||||
|
||||
const closeFeedbackFormModal = () => {
|
||||
showFeedbackForm.value = false;
|
||||
};
|
||||
|
||||
// 更新卡片显示状态
|
||||
const updateCardVisibility = (newVisibility) => {
|
||||
Object.assign(cardVisibility.value, newVisibility);
|
||||
};
|
||||
|
||||
// 计算属性
|
||||
const filteredTableData = computed(() => {
|
||||
let filtered = tableData.value;
|
||||
@@ -328,9 +292,9 @@ const filteredTableData = computed(() => {
|
||||
});
|
||||
|
||||
// 方法
|
||||
const refreshData = () => {
|
||||
// 刷新数据逻辑
|
||||
console.log("刷新数据");
|
||||
const refreshData = async () => {
|
||||
// 强制刷新所有数据
|
||||
await forceRefreshAllData();
|
||||
};
|
||||
|
||||
// 处理时间范围变化
|
||||
@@ -418,14 +382,7 @@ const selectPerson = (person) => {
|
||||
selectedPerson.value = person;
|
||||
};
|
||||
|
||||
const getTaskStatusText = (status) => {
|
||||
const statusMap = {
|
||||
pending: "待处理",
|
||||
"in-progress": "进行中",
|
||||
completed: "已完成",
|
||||
};
|
||||
return statusMap[status] || status;
|
||||
};
|
||||
|
||||
|
||||
const playCall = (callId) => {
|
||||
console.log("播放通话录音:", callId);
|
||||
@@ -435,69 +392,39 @@ const downloadCall = (callId) => {
|
||||
console.log("下载通话录音:", callId);
|
||||
};
|
||||
|
||||
const createTask = async () => {
|
||||
if (!newTask.title || !newTask.assignee || !newTask.deadline) {
|
||||
alert("请填写完整信息");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 构造API请求参数
|
||||
const params = {
|
||||
task_title: newTask.title,
|
||||
task_assignee: [newTask.assignee], // 转换为数组格式
|
||||
expiration_date: newTask.deadline.replace(/-/g, ''), // 移除日期中的横线
|
||||
task_content: newTask.description || newTask.title
|
||||
};
|
||||
|
||||
// 调用API
|
||||
const response = await assignTasks(params);
|
||||
console.log('任务创建成功:', response);
|
||||
|
||||
// 创建本地任务对象用于显示
|
||||
const task = {
|
||||
id: Date.now(),
|
||||
title: newTask.title,
|
||||
assignee: newTask.assignee,
|
||||
deadline: newTask.deadline,
|
||||
status: "pending",
|
||||
};
|
||||
|
||||
tasks.value.unshift(task);
|
||||
|
||||
// 重置表单
|
||||
Object.assign(newTask, {
|
||||
title: "",
|
||||
assignee: "",
|
||||
deadline: "",
|
||||
description: "",
|
||||
});
|
||||
|
||||
showTaskModal.value = false;
|
||||
alert('任务创建成功!');
|
||||
} catch (error) {
|
||||
console.error('创建任务失败:', error);
|
||||
alert('创建任务失败,请重试');
|
||||
}
|
||||
};
|
||||
|
||||
// 核心数据
|
||||
const totalDeals = ref({});
|
||||
// 核心数据--总成交金额
|
||||
async function getTotalDeals() {
|
||||
try {
|
||||
const cacheKey = getCacheKey('getTotalDeals');
|
||||
const cachedResult = getCache(cacheKey);
|
||||
|
||||
if (cachedResult) {
|
||||
console.log('使用缓存数据: getTotalDeals');
|
||||
totalDeals.value = cachedResult;
|
||||
return;
|
||||
}
|
||||
|
||||
const res1 = await getOverallCompanyPerformance()
|
||||
const res2=await getCompanyDepositConversionRate()
|
||||
const res3=await getCompanyTotalCallCount()
|
||||
const res4=await getCompanyNewCustomer()
|
||||
const res5=await getCompanyConversionRate()
|
||||
totalDeals.value={
|
||||
|
||||
const result = {
|
||||
totalDeal:res1.data, //总成交单数
|
||||
DingconversionRate:res2.data, //定金转化率
|
||||
totalCallCount:res3.data, // 总通话
|
||||
newCustomer:res4.data, //新客户
|
||||
conversionRate:res5.data,//转化率
|
||||
}
|
||||
};
|
||||
|
||||
totalDeals.value = result;
|
||||
setCache(cacheKey, result);
|
||||
console.log('缓存新数据: getTotalDeals');
|
||||
|
||||
} catch (error) {
|
||||
console.error("获取总成交金额失败:", error);
|
||||
@@ -505,11 +432,17 @@ async function getTotalDeals() {
|
||||
}
|
||||
// 实时进度
|
||||
const realTimeProgress = ref({});
|
||||
|
||||
|
||||
|
||||
async function getRealTimeProgress() {
|
||||
try {
|
||||
const res = await getCompanyRealTimeProgress()
|
||||
// console.log(111111,res)
|
||||
realTimeProgress.value = res.data
|
||||
const cacheKey = getCacheKey('getRealTimeProgress');
|
||||
const result = await withCache(cacheKey, async () => {
|
||||
const res = await getCompanyRealTimeProgress();
|
||||
return res.data;
|
||||
});
|
||||
realTimeProgress.value = result;
|
||||
} catch (error) {
|
||||
console.error("获取实时进度失败:", error);
|
||||
}
|
||||
@@ -587,9 +520,12 @@ async function getConversionComparison(data) {
|
||||
check_type:data //month periods
|
||||
}
|
||||
try {
|
||||
const res = await getCompanyConversionRateVsLast(params)
|
||||
console.log(111111,res)
|
||||
conversionComparison.value = res.data
|
||||
const cacheKey = getCacheKey('getConversionComparison', params);
|
||||
const result = await withCache(cacheKey, async () => {
|
||||
const res = await getCompanyConversionRateVsLast(params);
|
||||
return res.data;
|
||||
});
|
||||
conversionComparison.value = result;
|
||||
} catch (error) {
|
||||
console.error("获取转化对比失败:", error);
|
||||
}
|
||||
@@ -638,8 +574,12 @@ async function getCompanySalesRank(Rank) {
|
||||
rank_type:Rank,
|
||||
}
|
||||
try {
|
||||
const res = await getSalesMonthlyPerformance(params)
|
||||
companySalesRank.value = res.data
|
||||
const cacheKey = getCacheKey('getCompanySalesRank', params);
|
||||
const result = await withCache(cacheKey, async () => {
|
||||
const res = await getSalesMonthlyPerformance(params);
|
||||
return res.data;
|
||||
});
|
||||
companySalesRank.value = result;
|
||||
} catch (error) {
|
||||
console.error("获取销售月度业绩红黑榜失败:", error);
|
||||
}
|
||||
@@ -652,8 +592,12 @@ async function getCustomerTypeRatio(data) {
|
||||
distribution_type:data // child_education territory occupation
|
||||
}
|
||||
try {
|
||||
const res = await getCustomerTypeDistribution(params)
|
||||
customerTypeRatio.value = res.data
|
||||
const cacheKey = getCacheKey('getCustomerTypeRatio', params);
|
||||
const result = await withCache(cacheKey, async () => {
|
||||
const res = await getCustomerTypeDistribution(params);
|
||||
return res.data;
|
||||
});
|
||||
customerTypeRatio.value = result;
|
||||
} catch (error) {
|
||||
console.error("获取客户类型占比失败:", error);
|
||||
}
|
||||
@@ -664,12 +608,17 @@ const problemRankingData = ref([]);
|
||||
|
||||
async function getCustomerUrgency() {
|
||||
try {
|
||||
const res = await getUrgentNeedToAddress()
|
||||
customerUrgency.value = res.data
|
||||
const cacheKey = getCacheKey('getCustomerUrgency');
|
||||
const result = await withCache(cacheKey, async () => {
|
||||
const res = await getUrgentNeedToAddress();
|
||||
return res.data;
|
||||
});
|
||||
|
||||
customerUrgency.value = result;
|
||||
|
||||
// 将API返回的数据转换为ProblemRanking组件需要的格式
|
||||
if (res.data && res.data.company_urgent_issue_ratio) {
|
||||
problemRankingData.value = Object.entries(res.data.company_urgent_issue_ratio).map(([name, value]) => ({
|
||||
if (result && result.company_urgent_issue_ratio) {
|
||||
problemRankingData.value = Object.entries(result.company_urgent_issue_ratio).map(([name, value]) => ({
|
||||
name,
|
||||
value
|
||||
}));
|
||||
@@ -682,8 +631,12 @@ async function getCustomerUrgency() {
|
||||
const levelTree = ref({});
|
||||
async function CusotomGetLevelTree() {
|
||||
try {
|
||||
const res = await getLevelTree()
|
||||
levelTree.value = res.data
|
||||
const cacheKey = getCacheKey('CusotomGetLevelTree');
|
||||
const result = await withCache(cacheKey, async () => {
|
||||
const res = await getLevelTree();
|
||||
return res.data;
|
||||
});
|
||||
levelTree.value = result;
|
||||
} catch (error) {
|
||||
console.error("获取级别树失败:", error);
|
||||
}
|
||||
@@ -691,22 +644,18 @@ async function CusotomGetLevelTree() {
|
||||
// 获取详细数据表格
|
||||
const detailData = ref({});
|
||||
async function getDetailData(params) {
|
||||
if(params?.center_leader){
|
||||
try {
|
||||
const res = await getDetailedDataTable(params)
|
||||
detailData.value = res.data
|
||||
const cacheKey = getCacheKey('getDetailData', params || {});
|
||||
const result = await withCache(cacheKey, async () => {
|
||||
const res = params?.center_leader
|
||||
? await getDetailedDataTable(params)
|
||||
: await getDetailedDataTable();
|
||||
return res.data;
|
||||
});
|
||||
detailData.value = result;
|
||||
} catch (error) {
|
||||
console.error("获取详细数据表格失败:", error);
|
||||
}
|
||||
}else{
|
||||
try {
|
||||
const res = await getDetailedDataTable()
|
||||
detailData.value = res.data
|
||||
} catch (error) {
|
||||
console.error("获取详细数据表格失败:", error);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// 处理筛选器变化
|
||||
@@ -714,19 +663,53 @@ const handleFilterChange = (filterParams) => {
|
||||
console.log('筛选器变化:', filterParams)
|
||||
getDetailData(filterParams)
|
||||
}
|
||||
|
||||
// 优秀录音
|
||||
const excellentRecord = ref([]);
|
||||
async function CenterExcellentRecord() {
|
||||
const params={
|
||||
user_level:userStore.userInfo.user_level.toString(),
|
||||
user_name:userStore.userInfo.username
|
||||
}
|
||||
try {
|
||||
const cacheKey = getCacheKey('CenterExcellentRecord', params);
|
||||
const result = await withCache(cacheKey, async () => {
|
||||
const res = await getExcellentRecordFile(params);
|
||||
return res.data;
|
||||
});
|
||||
excellentRecord.value = result;
|
||||
} catch (error) {
|
||||
console.error("获取优秀录音失败:", error);
|
||||
}
|
||||
}
|
||||
onMounted(async() => {
|
||||
// 页面初始化逻辑
|
||||
await getRealTimeProgress()
|
||||
await getTotalDeals()
|
||||
await getTaskList()
|
||||
await getConversionComparison('month')
|
||||
await getCompanySalesRank('red')
|
||||
await getCustomerTypeRatio('child_education')
|
||||
await getCustomerUrgency()
|
||||
await CusotomGetLevelTree()
|
||||
await getDetailData()
|
||||
await name() // 获取下属人员列表
|
||||
console.log('页面初始化,开始加载数据...');
|
||||
|
||||
getRealTimeProgress()
|
||||
getTotalDeals()
|
||||
getConversionComparison('month')
|
||||
getCompanySalesRank('red')
|
||||
getCustomerTypeRatio('child_education')
|
||||
getCustomerUrgency()
|
||||
CusotomGetLevelTree()
|
||||
getDetailData()
|
||||
CenterExcellentRecord()
|
||||
|
||||
// 输出缓存状态信息
|
||||
const cacheInfo = getCacheInfo();
|
||||
console.log('数据加载完成,缓存状态:', cacheInfo);
|
||||
|
||||
// 在开发环境下暴露缓存管理函数到全局,方便调试
|
||||
if (import.meta.env.DEV) {
|
||||
window.dashboardCache = {
|
||||
clearCache,
|
||||
clearSpecificCache,
|
||||
getCacheInfo,
|
||||
forceRefreshAllData,
|
||||
cache: cache
|
||||
};
|
||||
console.log('开发模式:缓存管理函数已暴露到 window.dashboardCache');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -2200,4 +2183,20 @@ button {
|
||||
-ms-user-select: text;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
/* 意见反馈按钮样式 */
|
||||
.feedback-btn {
|
||||
background-color: #4299e1;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.feedback-btn:hover {
|
||||
background-color: #3182ce;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user