初步完成可视化页面

master
songxiangjie 2025-12-12 16:17:42 +08:00
parent 5ea806182c
commit 919dcabafa
2 changed files with 843 additions and 1026 deletions

File diff suppressed because it is too large Load Diff

View File

@ -3,31 +3,109 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>客服数据可视化中心</title> <title>客服数据可视化中心</title>
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta content="width=device-width, initial-scale=1" name="viewport">
<!-- 依赖 --> <!-- 依赖 -->
<script src="./js/echarts.min.js"></script> <script src="./js/echarts.min.js"></script>
<script src="./js/dayjs.min.js"></script> <script src="./js/dayjs.min.js"></script>
<script src="./js/isoWeek.js"></script> <script src="./js/isoWeek.js"></script>
<script src="./node_modules/axios/dist/axios.min.js"></script> <script src="./node_modules/axios/dist/axios.min.js"></script>
<style> <style>
:root{--primary:#3388ff;} :root {
* {box-sizing:border-box;} --primary: #3388ff;
body{margin:0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Arial;font-size:14px;background:#f5f7fa;color:#333;} }
.header{position:sticky;top:0;z-index:9;background:#fff;padding:12px 16px;box-shadow:0 2px 4px rgba(0,0,0,.08);}
.header h2{margin:0 0 8px;font-size:18px;} * {
.ctrl{display:flex;gap:12px;flex-wrap:wrap;align-items:center;} box-sizing: border-box;
.ctrl select,.ctrl input{padding:6px 10px;border:1px solid #ddd;border-radius:4px;background:#fff;} }
.grid{display:grid;grid-template-columns:1fr;gap:12px;padding:12px;}
.card{background:#fff;border-radius:6px;box-shadow:0 1px 3px rgba(0,0,0,.06);overflow:hidden;} body {
.card-title{font-weight:700;padding:12px 16px 0;font-size:15px;margin:0;} margin: 0;
.chart{height:400px;width:100%;min-height:350px;} font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Arial;
.chart-container{position:relative;width:100%;height:100%;} font-size: 14px;
/* 移动端 */ background: #f5f7fa;
@media(max-width:600px){ color: #333;
.grid{grid-template-columns:1fr;} }
.ctrl{flex-direction:column;align-items:stretch;}
.header {
position: sticky;
top: 0;
z-index: 9;
background: #fff;
padding: 12px 16px;
box-shadow: 0 2px 4px rgba(0, 0, 0, .08);
}
.header h2 {
margin: 0 0 8px;
font-size: 18px;
}
.ctrl {
display: flex;
gap: 12px;
flex-wrap: wrap;
align-items: center;
}
.ctrl select, .ctrl input {
padding: 6px 10px;
border: 1px solid #ddd;
border-radius: 4px;
background: #fff;
}
.grid {
display: grid;
grid-template-columns:1fr;
gap: 12px;
padding: 12px;
}
.card {
background: #fff;
border-radius: 6px;
box-shadow: 0 1px 3px rgba(0, 0, 0, .06);
overflow: hidden;
}
.card-title {
font-weight: 700;
padding: 12px 16px 0;
font-size: 15px;
margin: 0;
}
.chart {
height: 400px;
width: 100%;
min-height: 350px;
}
.chart-container {
position: relative;
width: 100%;
height: 100%;
}
/* 移动端 */
@media (max-width: 600px) {
.grid {
grid-template-columns:1fr;
}
.ctrl {
flex-direction: column;
align-items: stretch;
}
}
.loading {
display: flex;
justify-content: center;
align-items: center;
height: 100%;
color: #999;
} }
.loading{display:flex;justify-content:center;align-items:center;height:100%;color:#999;}
</style> </style>
</head> </head>
<body> <body>
@ -36,10 +114,10 @@
<a href="index_company.html" target="_blank">公司数据可视化中心</a> <a href="index_company.html" target="_blank">公司数据可视化中心</a>
<div class="ctrl"> <div class="ctrl">
<label>开始月份 <label>开始月份
<input type="month" id="startMonth" value="2025-01"> <input id="startMonth" type="month" value="2025-01">
</label> </label>
<label>结束月份 <label>结束月份
<input type="month" id="endMonth" value="2025-11"> <input id="endMonth" type="month" value="2025-11">
</label> </label>
<button id="searchBtn">搜索</button> <button id="searchBtn">搜索</button>
<label>客服 <label>客服
@ -62,35 +140,51 @@
<div class="grid"> <div class="grid">
<div class="card"> <div class="card">
<div class="card-title">订单数量(多指标对比)</div> <div class="card-title">订单数量(多指标对比)</div>
<div class="chart-container"><div id="chart1" class="chart"></div></div> <div class="chart-container">
<div id="chart1" class="chart"></div>
</div>
</div> </div>
<div class="card"> <div class="card">
<div class="card-title">完成率</div> <div class="card-title">完成率</div>
<div class="chart-container"><div id="chart2" class="chart"></div></div> <div class="chart-container">
<div id="chart2" class="chart"></div>
</div>
</div> </div>
<div class="card"> <div class="card">
<div class="card-title">溢价金额</div> <div class="card-title">溢价金额</div>
<div class="chart-container"><div id="chart3" class="chart"></div></div> <div class="chart-container">
<div id="chart3" class="chart"></div>
</div>
</div> </div>
<div class="card"> <div class="card">
<div class="card-title">投诉率</div> <div class="card-title">投诉率</div>
<div class="chart-container"><div id="chart4" class="chart"></div></div> <div class="chart-container">
<div id="chart4" class="chart"></div>
</div>
</div> </div>
<div class="card"> <div class="card">
<div class="card-title">空驶率</div> <div class="card-title">空驶率</div>
<div class="chart-container"><div id="chart5" class="chart"></div></div> <div class="chart-container">
<div id="chart5" class="chart"></div>
</div>
</div> </div>
<div class="card"> <div class="card">
<div class="card-title">关闭率</div> <div class="card-title">关闭率</div>
<div class="chart-container"><div id="chart6" class="chart"></div></div> <div class="chart-container">
<div id="chart6" class="chart"></div>
</div>
</div> </div>
<div class="card"> <div class="card">
<div class="card-title">客服完成率分布</div> <div class="card-title">客服完成率分布</div>
<div class="chart-container"><div id="pie" class="chart"></div></div> <div class="chart-container">
<div id="pie" class="chart"></div>
</div>
</div> </div>
<div class="card"> <div class="card">
<div class="card-title">月度趋势</div> <div class="card-title">月度趋势</div>
<div class="chart-container"><div id="line" class="chart"></div></div> <div class="chart-container">
<div id="line" class="chart"></div>
</div>
</div> </div>
<div class="card"> <div class="card">
<div class="card-title" style="display:flex;justify-content:space-between;align-items:center;"> <div class="card-title" style="display:flex;justify-content:space-between;align-items:center;">
@ -104,11 +198,15 @@
<option value="关闭率">关闭率</option> <option value="关闭率">关闭率</option>
</select> </select>
</div> </div>
<div class="chart-container"><div id="bar" class="chart"></div></div> <div class="chart-container">
<div id="bar" class="chart"></div>
</div>
</div> </div>
<div class="card"> <div class="card">
<div class="card-title">单个客服历史表现</div> <div class="card-title">单个客服历史表现</div>
<div class="chart-container"><div id="hist" class="chart"></div></div> <div class="chart-container">
<div id="hist" class="chart"></div>
</div>
</div> </div>
</div> </div>
<script> <script>
@ -153,11 +251,11 @@
} }
const processedData = []; const processedData = [];
// 遍历每个客服 // 遍历每个客服
Object.keys(apiData.data).forEach(staffName => { Object.keys(apiData.data).forEach(staffName => {
const staffMonthData = apiData.data[staffName]; const staffMonthData = apiData.data[staffName];
// 遍历该客服的每个月份数据 // 遍历该客服的每个月份数据
staffMonthData.forEach(monthData => { staffMonthData.forEach(monthData => {
const row = { const row = {
@ -206,42 +304,42 @@
/***************************************************************** /*****************************************************************
* 1. 初始化空数据 * 1. 初始化空数据
*****************************************************************/ *****************************************************************/
// 初始化为空数组,等待接口数据 // 初始化为空数组,等待接口数据
let novData = []; let novData = [];
/* 生成趋势数据(基于真实月度数据模拟日度数据) */ /* 生成趋势数据(基于真实月度数据模拟日度数据) */
function genTrend(base){ function genTrend(base) {
const arr=[]; const arr = [];
// 如果没有数据,返回空数组 // 如果没有数据,返回空数组
if (!base || base.length === 0) return arr; if (!base || base.length === 0) return arr;
// 获取最新月份 // 获取最新月份
const months = [...new Set(base.map(b => b.月份))].sort(); const months = [...new Set(base.map(b => b.月份))].sort();
const latestMonth = months[months.length - 1]; const latestMonth = months[months.length - 1];
if (!latestMonth) return arr; if (!latestMonth) return arr;
// 使用最新月份的数据生成30天趋势 // 使用最新月份的数据生成30天趋势
const latestData = base.filter(b => b.月份 === latestMonth); const latestData = base.filter(b => b.月份 === latestMonth);
for(let d=1;d<=30;d++){ for (let d = 1; d <= 30; d++) {
const date=dayjs(latestMonth + '-01').add(d-1,'day'); const date = dayjs(latestMonth + '-01').add(d - 1, 'day');
latestData.forEach(b=>{ latestData.forEach(b => {
arr.push({ arr.push({
日期:date.format('YYYY-MM-DD'), 日期: date.format('YYYY-MM-DD'),
客服:b.调度, 客服: b.调度,
完成率:b.完成率+(Math.random()-0.5)*4, 完成率: b.完成率 + (Math.random() - 0.5) * 4,
订单总数:b.订单总数+Math.floor((Math.random()-0.5)*100), 订单总数: b.订单总数 + Math.floor((Math.random() - 0.5) * 100),
溢价金额:b.溢价金额+(Math.random()-0.5)*500, 溢价金额: b.溢价金额 + (Math.random() - 0.5) * 500,
投诉率:b.投诉率+(Math.random()-0.5)*0.5, 投诉率: b.投诉率 + (Math.random() - 0.5) * 0.5,
空驶率:b.空驶率+(Math.random()-0.5)*1, 空驶率: b.空驶率 + (Math.random() - 0.5) * 1,
关闭率:b.关闭率+(Math.random()-0.5)*1 关闭率: b.关闭率 + (Math.random() - 0.5) * 1
}); });
}); });
} }
return arr; return arr;
} }
// 初始化为空数组 // 初始化为空数组
let trendData = []; let trendData = [];
@ -263,7 +361,7 @@
let chart1, chart2, chart3, chart4, chart5, chart6; // 时间序列图表 let chart1, chart2, chart3, chart4, chart5, chart6; // 时间序列图表
/* 通用配色 */ /* 通用配色 */
const colors = ['#3388ff','#00c362','#ff9f00','#ff4d4f','#9a6bfd','#36cfc9','#ff7ec2','#ffda5d']; const colors = ['#3388ff', '#00c362', '#ff9f00', '#ff4d4f', '#9a6bfd', '#36cfc9', '#ff7ec2', '#ffda5d'];
/* 初始化图表 */ /* 初始化图表 */
function initCharts() { function initCharts() {
@ -331,7 +429,7 @@
monthData[month].关闭数 += d.关闭数 || 0; monthData[month].关闭数 += d.关闭数 || 0;
monthData[month].投诉数 += d.投诉数 || 0; monthData[month].投诉数 += d.投诉数 || 0;
monthData[month].溢价金额 += d.溢价金额 || 0; monthData[month].溢价金额 += d.溢价金额 || 0;
// 如果是单个客服,直接使用百分比;如果是多个客服,稍后计算 // 如果是单个客服,直接使用百分比;如果是多个客服,稍后计算
if (staffs.length === 1) { if (staffs.length === 1) {
monthData[month].完成率 = d.完成率 || 0; monthData[month].完成率 = d.完成率 || 0;
@ -339,7 +437,7 @@
monthData[month].空驶率 = d.空驶率 || 0; monthData[month].空驶率 = d.空驶率 || 0;
monthData[month].关闭率 = d.关闭率 || 0; monthData[month].关闭率 = d.关闭率 || 0;
} }
monthData[month].count += 1; monthData[month].count += 1;
}); });
@ -383,7 +481,7 @@
color: colors, color: colors,
tooltip: { tooltip: {
trigger: 'axis', trigger: 'axis',
axisPointer: { type: 'shadow' } axisPointer: {type: 'shadow'}
}, },
legend: { legend: {
data: ['订单总数', '完成率', '溢价金额', '投诉率', '空驶率', '关闭率'], data: ['订单总数', '完成率', '溢价金额', '投诉率', '空驶率', '关闭率'],
@ -399,7 +497,7 @@
xAxis: { xAxis: {
type: 'category', type: 'category',
data: months, data: months,
axisLabel: { rotate: 45 } axisLabel: {rotate: 45}
}, },
yAxis: [ yAxis: [
{ {
@ -467,7 +565,7 @@
color: colors[0], color: colors[0],
tooltip: { tooltip: {
trigger: 'axis', trigger: 'axis',
axisPointer: { type: 'shadow' } axisPointer: {type: 'shadow'}
}, },
grid: { grid: {
left: '3%', left: '3%',
@ -479,7 +577,7 @@
xAxis: { xAxis: {
type: 'category', type: 'category',
data: months, data: months,
axisLabel: { rotate: 45 } axisLabel: {rotate: 45}
}, },
yAxis: { yAxis: {
type: 'value', type: 'value',
@ -508,7 +606,7 @@
color: colors[2], color: colors[2],
tooltip: { tooltip: {
trigger: 'axis', trigger: 'axis',
axisPointer: { type: 'shadow' } axisPointer: {type: 'shadow'}
}, },
grid: { grid: {
left: '3%', left: '3%',
@ -520,7 +618,7 @@
xAxis: { xAxis: {
type: 'category', type: 'category',
data: months, data: months,
axisLabel: { rotate: 45 } axisLabel: {rotate: 45}
}, },
yAxis: { yAxis: {
type: 'value', type: 'value',
@ -549,7 +647,7 @@
color: colors[3], color: colors[3],
tooltip: { tooltip: {
trigger: 'axis', trigger: 'axis',
axisPointer: { type: 'shadow' } axisPointer: {type: 'shadow'}
}, },
grid: { grid: {
left: '3%', left: '3%',
@ -561,7 +659,7 @@
xAxis: { xAxis: {
type: 'category', type: 'category',
data: months, data: months,
axisLabel: { rotate: 45 } axisLabel: {rotate: 45}
}, },
yAxis: { yAxis: {
type: 'value', type: 'value',
@ -590,7 +688,7 @@
color: colors[4], color: colors[4],
tooltip: { tooltip: {
trigger: 'axis', trigger: 'axis',
axisPointer: { type: 'shadow' } axisPointer: {type: 'shadow'}
}, },
grid: { grid: {
left: '3%', left: '3%',
@ -602,7 +700,7 @@
xAxis: { xAxis: {
type: 'category', type: 'category',
data: months, data: months,
axisLabel: { rotate: 45 } axisLabel: {rotate: 45}
}, },
yAxis: { yAxis: {
type: 'value', type: 'value',
@ -631,7 +729,7 @@
color: colors[5], color: colors[5],
tooltip: { tooltip: {
trigger: 'axis', trigger: 'axis',
axisPointer: { type: 'shadow' } axisPointer: {type: 'shadow'}
}, },
grid: { grid: {
left: '3%', left: '3%',
@ -643,7 +741,7 @@
xAxis: { xAxis: {
type: 'category', type: 'category',
data: months, data: months,
axisLabel: { rotate: 45 } axisLabel: {rotate: 45}
}, },
yAxis: { yAxis: {
type: 'value', type: 'value',
@ -660,7 +758,7 @@
} }
/* 1. 饼图 - 客服完成率分布(支持切换指标) */ /* 1. 饼图 - 客服完成率分布(支持切换指标) */
function updatePie(){ function updatePie() {
if (!pie) { if (!pie) {
console.warn('饼图实例未初始化'); console.warn('饼图实例未初始化');
return; return;
@ -763,7 +861,7 @@
color: colors[0], color: colors[0],
tooltip: { tooltip: {
trigger: 'axis', trigger: 'axis',
axisPointer: { type: 'shadow' }, axisPointer: {type: 'shadow'},
formatter: '{b}: {c}' + (isRate ? '%' : '') formatter: '{b}: {c}' + (isRate ? '%' : '')
}, },
grid: { grid: {
@ -801,14 +899,14 @@
} }
/* 2. 折线 - 月度趋势(使用真实月度数据) */ /* 2. 折线 - 月度趋势(使用真实月度数据) */
function updateLine(){ function updateLine() {
if (!line) { if (!line) {
console.warn('折线图实例未初始化'); console.warn('折线图实例未初始化');
return; return;
} }
const {novData, staff, index} = state; const {novData, staff, index} = state;
// 过滤掉 '全部' 和 '测试' 账号 // 过滤掉 '全部' 和 '测试' 账号
const validData = novData.filter(d => !d.调度.includes('全部') && !d.调度.includes('测试')); const validData = novData.filter(d => !d.调度.includes('全部') && !d.调度.includes('测试'));
@ -828,7 +926,7 @@
smooth: true, smooth: true,
symbol: 'circle', symbol: 'circle',
symbolSize: 6, symbolSize: 6,
lineStyle: { width: 3 }, lineStyle: {width: 3},
data: months.map(month => { data: months.map(month => {
const item = validData.find(d => d.月份 === month && d.调度 === s); const item = validData.find(d => d.月份 === month && d.调度 === s);
return item ? Math.max(0, item[index]) : 0; return item ? Math.max(0, item[index]) : 0;
@ -871,7 +969,7 @@
} }
/* 3. 柱状 - 客服多指标对比 */ /* 3. 柱状 - 客服多指标对比 */
function updateBar(){ function updateBar() {
if (!bar) { if (!bar) {
console.warn('柱状图实例未初始化'); console.warn('柱状图实例未初始化');
return; return;
@ -965,7 +1063,7 @@
} }
/* 4. 历史 - 单个或多个客服趋势(使用真实月度数据) */ /* 4. 历史 - 单个或多个客服趋势(使用真实月度数据) */
function updateHist(){ function updateHist() {
if (!hist) { if (!hist) {
console.warn('历史图表实例未初始化'); console.warn('历史图表实例未初始化');
return; return;
@ -1012,7 +1110,7 @@
smooth: true, smooth: true,
symbol: 'circle', symbol: 'circle',
symbolSize: 6, symbolSize: 6,
lineStyle: { width: 3 }, lineStyle: {width: 3},
data: months.map(month => { data: months.map(month => {
const item = staffData.find(d => d.月份 === month); const item = staffData.find(d => d.月份 === month);
return item ? Math.max(0, item[state.index]) : null; return item ? Math.max(0, item[state.index]) : null;
@ -1032,7 +1130,7 @@
title: { title: {
text: titleText, text: titleText,
left: 'center', left: 'center',
textStyle: { fontSize: 14 } textStyle: {fontSize: 14}
}, },
color: colors, color: colors,
tooltip: { tooltip: {
@ -1073,7 +1171,7 @@
} }
/* 5. 控件事件 */ /* 5. 控件事件 */
function bindCtrl(){ function bindCtrl() {
const startMonthInp = document.getElementById('startMonth'); const startMonthInp = document.getElementById('startMonth');
const endMonthInp = document.getElementById('endMonth'); const endMonthInp = document.getElementById('endMonth');
const staffSel = document.getElementById('staff'); const staffSel = document.getElementById('staff');
@ -1090,24 +1188,24 @@
searchBtn.addEventListener('click', async () => { searchBtn.addEventListener('click', async () => {
state.startMonth = startMonthInp.value; state.startMonth = startMonthInp.value;
state.endMonth = endMonthInp.value; state.endMonth = endMonthInp.value;
// 确保月份范围有效 // 确保月份范围有效
if (dayjs(state.endMonth).isBefore(dayjs(state.startMonth))) { if (dayjs(state.endMonth).isBefore(dayjs(state.startMonth))) {
alert('结束月份不能早于开始月份'); alert('结束月份不能早于开始月份');
return; return;
} }
// 请求数据 // 请求数据
const apiData = await fetchData(state.startMonth, state.endMonth); const apiData = await fetchData(state.startMonth, state.endMonth);
if (apiData && apiData.code === 200 && apiData.data) { if (apiData && apiData.code === 200 && apiData.data) {
novData = processApiData(apiData); novData = processApiData(apiData);
state.novData = novData; state.novData = novData;
// 重新生成趋势数据 // 重新生成趋势数据
const trendBase = novData.filter(b => b.调度 !== '全部' && b.调度 !== '技术部测试'); const trendBase = novData.filter(b => b.调度 !== '全部' && b.调度 !== '技术部测试');
trendData = genTrend(trendBase); trendData = genTrend(trendBase);
state.trendData = trendData; state.trendData = trendData;
// 重新初始化客服列表 // 重新初始化客服列表
initCtrl(); initCtrl();
renderAll(); renderAll();
@ -1190,7 +1288,7 @@
} }
/* 6. 渲染全部 */ /* 6. 渲染全部 */
function renderAll(){ function renderAll() {
updateChart1(); updateChart1();
updateChart2(); updateChart2();
updateChart3(); updateChart3();
@ -1204,12 +1302,12 @@
} }
/* 7. 初始化控件选项 */ /* 7. 初始化控件选项 */
function initCtrl(){ function initCtrl() {
const staffSel = document.getElementById('staff'); const staffSel = document.getElementById('staff');
// 清空现有选项 // 清空现有选项
staffSel.innerHTML = ''; staffSel.innerHTML = '';
const staffs = [...new Set(novData.map(d => d.调度))].filter(s => s !== '全部' && s !== '技术部测试'); const staffs = [...new Set(novData.map(d => d.调度))].filter(s => s !== '全部' && s !== '技术部测试');
// 添加"全部"选项 // 添加"全部"选项
@ -1249,29 +1347,29 @@
async function init() { async function init() {
try { try {
console.log('开始初始化页面...'); console.log('开始初始化页面...');
// 先请求接口数据 // 先请求接口数据
const startMonthInp = document.getElementById('startMonth'); const startMonthInp = document.getElementById('startMonth');
const endMonthInp = document.getElementById('endMonth'); const endMonthInp = document.getElementById('endMonth');
const apiData = await fetchData(startMonthInp.value, endMonthInp.value); const apiData = await fetchData(startMonthInp.value, endMonthInp.value);
// 如果接口返回数据,替换 novData // 如果接口返回数据,替换 novData
if (apiData && apiData.code === 200 && apiData.data) { if (apiData && apiData.code === 200 && apiData.data) {
novData = processApiData(apiData); novData = processApiData(apiData);
state.novData = novData; state.novData = novData;
// 重新生成趋势数据 // 重新生成趋势数据
const trendBase = novData.filter(b => b.调度 !== '全部' && b.调度 !== '技术部测试'); const trendBase = novData.filter(b => b.调度 !== '全部' && b.调度 !== '技术部测试');
trendData = genTrend(trendBase); trendData = genTrend(trendBase);
state.trendData = trendData; state.trendData = trendData;
console.log('使用接口数据,共', novData.length, '条记录'); console.log('使用接口数据,共', novData.length, '条记录');
} else { } else {
console.warn('接口数据获取失败,请检查接口配置'); console.warn('接口数据获取失败,请检查接口配置');
// 显示友好提示 // 显示友好提示
alert('数据加载失败,请检查网络连接或联系管理员'); alert('数据加载失败,请检查网络连接或联系管理员');
} }
initCtrl(); initCtrl();
initCharts(); initCharts();
bindCtrl(); bindCtrl();