Files
sales_data_dashboard/js/main.js
T
2026-02-13 14:22:40 +08:00

607 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 常量定义
const CHART_INIT_DELAY = 500; // 图表初始化延迟(毫秒)
const PROGRESS_ANIM_DELAY = 800; // 进度条动画延迟(毫秒)
const RESIZE_DEBOUNCE_TIME = 300; // 防抖时间(毫秒)
const PROGRESS_STEP = 0.4; // 进度条每帧增量
const PROGRESS_INTERVAL = 20; // 进度条更新间隔(毫秒)
const MOBILE_BREAKPOINT = 768; // 移动端断点(px)
// 年度销售数据(可通过API动态获取)
let yearData = {
target: 300000000, // 年度目标(万元)
actual: 231154000 // 年度实际销售额(万元)
};
// 模拟数据 - 小组排名
const groupData = [
{ name: '华东一组', target: 50000000000, actual: 62000000000 },
{ name: '华南二组', target: 45000000000, actual: 51000000000 },
{ name: '华北三组', target: 40000000000, actual: 43500000000 },
{ name: '西南四组', target: 35000000000, actual: 28000000000 },
{ name: '东北五组', target: 30000000000, actual: 26500000000 }
];
// 模拟数据 - 个人排名
const personData = [
{ name: '张伟', target: 5000000000, actual: 7200000000 },
{ name: '李娜', target: 4500000000, actual: 5800000000 },
{ name: '王强', target: 4000000000, actual: 4900000000 },
{ name: '刘洋', target: 3800000000, actual: 4100000000 },
{ name: '陈静', target: 3500000000, actual: 3600000000 },
{ name: '杨明', target: 3200000000, actual: 2800000000 },
{ name: '赵丽', target: 3000000000, actual: 2500000000 }
];
// 格式化数字(万元)
function formatNumber(num) {
return (num / 10000).toFixed(0) + '万';
}
// 格式化大数字(万元)
function formatLargeNumber(num) {
return num.toLocaleString('zh-CN') + ' 万元';
}
// 获取年度销售数据(可替换为API调用)
async function fetchYearData() {
// TODO: 替换为实际的API调用
// 示例:
// try {
// const response = await fetch('/api/year-data');
// const data = await response.json();
// return data;
// } catch (error) {
// console.error('获取数据失败:', error);
// return null;
// }
// 当前使用模拟数据
console.log('正在获取年度数据...');
return {
target: 3500, // 年度目标(万元)- 可修改此值测试
actual: 2750 // 年度实际销售额(万元)- 可修改此值测试
};
}
// 更新顶部数据区域
function updateTopData() {
const targetEl = document.querySelector('.target-amount');
const actualEl = document.querySelector('.total-amount');
const progressTextEl = document.querySelector('.progress-text');
console.log('正在更新顶部数据:', yearData);
if (targetEl) {
targetEl.textContent = formatLargeNumber(yearData.target);
console.log('年度目标已更新:', formatLargeNumber(yearData.target));
} else {
console.error('找不到 .target-amount 元素');
}
if (actualEl) {
actualEl.textContent = formatLargeNumber(yearData.actual);
console.log('年度总销售额已更新:', formatLargeNumber(yearData.actual));
} else {
console.error('找不到 .total-amount 元素');
}
// 计算完成率
const progressRate = ((yearData.actual / yearData.target) * 100).toFixed(2);
if (progressTextEl) {
progressTextEl.textContent = progressRate + '%';
console.log('完成率已更新:', progressRate + '%');
} else {
console.error('找不到 .progress-text 元素');
}
return parseFloat(progressRate);
}
// 启动进度条动画
function startProgressAnimation(targetRate) {
const progressBar = document.querySelector('.progress-bar');
if (!progressBar) return;
let value = 0;
let lastTime = 0;
function animateProgress(currentTime) {
if (!lastTime) lastTime = currentTime;
const deltaTime = currentTime - lastTime;
if (deltaTime >= PROGRESS_INTERVAL) {
value += PROGRESS_STEP;
if (value >= targetRate) {
value = targetRate;
progressBar.style.width = value + '%';
return; // 动画完成
}
progressBar.style.width = value + '%';
lastTime = currentTime;
}
requestAnimationFrame(animateProgress);
}
requestAnimationFrame(animateProgress);
}
// 渲染小组排名列表
function renderGroupRankList() {
const container = document.getElementById('groupRankList');
container.innerHTML = groupData.map((item, index) => {
const rate = ((item.actual / item.target) * 100).toFixed(1);
const rankClass = index < 3 ? `top-${index + 1}` : 'other';
return `
<div class="ranking-item ${rankClass}">
<div class="rank-number">${index + 1}</div>
<div class="rank-name">${item.name}</div>
<div class="rank-sales">销售额: ${formatNumber(item.actual)} | 目标: ${formatNumber(item.target)}</div>
<div class="rank-rate">完成率: ${rate}%</div>
</div>
`;
}).join('');
}
// 渲染个人排名列表
function renderPersonRankList() {
const container = document.getElementById('personRankList');
container.innerHTML = personData.map((item, index) => {
const rate = ((item.actual / item.target) * 100).toFixed(1);
const rankClass = index < 3 ? `top-${index + 1}` : 'other';
return `
<div class="ranking-item ${rankClass}">
<div class="rank-number">${index + 1}</div>
<div class="rank-name">${item.name}</div>
<div class="rank-sales">销售额: ${formatNumber(item.actual)} | 目标: ${formatNumber(item.target)}</div>
<div class="rank-rate">完成率: ${rate}%</div>
</div>
`;
}).join('');
}
// 渲染小组图表
function renderGroupChart() {
const chartDom = document.getElementById('groupChart');
const chart = echarts.init(chartDom);
const names = groupData.map(item => item.name);
const actuals = groupData.map(item => item.actual / 100000000);
const targets = groupData.map(item => item.target / 100000000);
// 判断是否为移动端
const isMobile = window.innerWidth <= MOBILE_BREAKPOINT;
const option = {
backgroundColor: 'transparent',
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow'
},
formatter: function(params) {
let result = params[0].name + '<br/>';
params.forEach(param => {
result += param.marker + param.seriesName + ': ' + param.value + '万<br/>';
});
return result;
},
backgroundColor: 'rgba(0, 0, 0, 0.9)',
borderColor: '#ff6600',
borderWidth: 1,
textStyle: {
color: '#fff',
fontSize: 12
}
},
grid: isMobile ? {
left: '5%',
right: '5%',
bottom: '15%',
top: '20%',
containLabel: true
} : {
left: '15%',
right: '10%',
bottom: '10%',
top: '20%',
containLabel: true
},
legend: {
data: ['目标销售额', '实际销售额'],
textStyle: {
color: '#fff',
fontSize: 12
},
top: '2%'
},
xAxis: isMobile ? {
type: 'category',
data: names,
name: '小组',
nameTextStyle: {
color: '#fff'
},
axisLabel: {
color: '#fff',
fontSize: 10,
interval: 0,
rotate: 30
},
axisLine: {
lineStyle: {
color: 'rgba(255, 102, 0, 0.5)'
}
}
} : {
type: 'value',
name: '万元',
nameTextStyle: {
color: '#fff'
},
axisLabel: {
color: '#fff',
fontSize: 11,
formatter: function(value) {
return value * 100;
}
},
axisLine: {
lineStyle: {
color: 'rgba(255, 102, 0, 0.5)'
}
},
splitLine: {
lineStyle: {
color: 'rgba(255, 255, 255, 0.1)'
}
}
},
yAxis: isMobile ? {
type: 'value',
name: '万',
nameTextStyle: {
color: '#fff'
},
axisLabel: {
color: '#fff',
fontSize: 11
},
axisLine: {
lineStyle: {
color: 'rgba(255, 102, 0, 0.5)'
}
},
splitLine: {
lineStyle: {
color: 'rgba(255, 255, 255, 0.1)'
}
}
} : {
type: 'category',
data: names,
axisLabel: {
color: '#fff',
fontSize: 11
},
axisLine: {
lineStyle: {
color: 'rgba(255, 102, 0, 0.5)'
}
}
},
series: [
{
name: '目标销售额',
type: 'bar',
data: targets,
itemStyle: {
color: new echarts.graphic.LinearGradient(isMobile ? 0 : 0, isMobile ? 1 : 0, isMobile ? 0 : 1, isMobile ? 0 : 0, [
{ offset: 0, color: '#F77436' },
{ offset: 1, color: '#F77436' }
]),
borderRadius: isMobile ? [4, 4, 0, 0] : [0, 4, 4, 0]
},
barMaxWidth: 15,
emphasis: {
itemStyle: {
shadowBlur: 10,
shadowOffsetX: 0,
shadowColor: 'rgba(102, 102, 102, 0.8)'
}
}
},
{
name: '实际销售额',
type: 'bar',
data: actuals,
itemStyle: {
color: new echarts.graphic.LinearGradient(isMobile ? 0 : 0, isMobile ? 1 : 0, isMobile ? 0 : 1, isMobile ? 0 : 0, [
{ offset: 0, color: '#65491D' },
{ offset: 1, color: '#65491D' }
]),
borderRadius: isMobile ? [4, 4, 0, 0] : [0, 4, 4, 0]
},
barMaxWidth: 15,
emphasis: {
itemStyle: {
shadowBlur: 10,
shadowOffsetX: 0,
shadowColor: 'rgba(255, 102, 0, 0.8)'
}
}
}
]
};
chart.setOption(option);
return chart;
}
// 渲染个人图表
function renderPersonChart() {
const chartDom = document.getElementById('personChart');
const chart = echarts.init(chartDom);
const names = personData.map(item => item.name);
const actuals = personData.map(item => item.actual / 100000000);
const targets = personData.map(item => item.target / 100000000);
// 判断是否为移动端
const isMobile = window.innerWidth <= MOBILE_BREAKPOINT;
const option = {
backgroundColor: 'transparent',
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow'
},
formatter: function(params) {
let result = params[0].name + '<br/>';
params.forEach(param => {
result += param.marker + param.seriesName + ': ' + param.value + '万<br/>';
});
return result;
},
backgroundColor: 'rgba(0, 0, 0, 0.9)',
borderColor: '#ff6600',
borderWidth: 1,
textStyle: {
color: '#fff',
fontSize: 12
}
},
grid: isMobile ? {
left: '10%',
right: '5%',
bottom: '15%',
top: '20%',
containLabel: true
} : {
left: '15%',
right: '10%',
bottom: '10%',
top: '20%',
containLabel: true
},
legend: {
data: ['目标销售额', '实际销售额'],
textStyle: {
color: '#fff',
fontSize: 12
},
top: '2%'
},
xAxis: isMobile ? {
type: 'value',
name: '万',
nameLocation: 'end',
nameGap: 5,
nameTextStyle: {
color: '#fff',
fontSize: 10
},
axisLabel: {
color: '#fff',
fontSize: 10
},
axisLine: {
lineStyle: {
color: 'rgba(255, 102, 0, 0.5)'
}
},
splitLine: {
lineStyle: {
color: 'rgba(255, 255, 255, 0.1)'
}
}
} : {
type: 'value',
name: '万',
nameTextStyle: {
color: '#fff'
},
axisLabel: {
color: '#fff',
fontSize: 11,
formatter: function(value) {
return value * 100;
}
},
axisLine: {
lineStyle: {
color: 'rgba(255, 102, 0, 0.5)'
}
},
splitLine: {
lineStyle: {
color: 'rgba(255, 255, 255, 0.1)'
}
}
},
yAxis: isMobile ? {
type: 'category',
data: names,
axisLabel: {
color: '#fff',
fontSize: 10
},
axisLine: {
lineStyle: {
color: 'rgba(255, 102, 0, 0.5)'
}
}
} : {
type: 'category',
data: names,
axisLabel: {
color: '#fff',
fontSize: 11
},
axisLine: {
lineStyle: {
color: 'rgba(255, 102, 0, 0.5)'
}
}
},
series: [
{
name: '目标销售额',
type: 'bar',
data: targets,
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 1, 0, [
{ offset: 0, color: '#F77436' },
{ offset: 1, color: '#F77436' }
]),
borderRadius: [0, 4, 4, 0]
},
barMaxWidth: 15,
emphasis: {
itemStyle: {
shadowBlur: 10,
shadowOffsetX: 0,
shadowColor: 'rgba(102, 102, 102, 0.8)'
}
}
},
{
name: '实际销售额',
type: 'bar',
data: actuals,
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 1, 0, [
{ offset: 0, color: '#65491D' },
{ offset: 1, color: '#65491D' }
]),
borderRadius: [0, 4, 4, 0]
},
barMaxWidth: 15,
emphasis: {
itemStyle: {
shadowBlur: 10,
shadowOffsetX: 0,
shadowColor: 'rgba(255, 102, 0, 0.8)'
}
}
}
]
};
chart.setOption(option);
return chart;
}
// 初始化
document.addEventListener('DOMContentLoaded', async () => {
// 获取年度数据并更新顶部显示
yearData = await fetchYearData();
const progressRate = updateTopData();
// 渲染排名列表
renderGroupRankList();
renderPersonRankList();
// 初始化图表
let groupChart = null;
let personChart = null;
let resizeTimer = null;
function initCharts() {
// 销毁旧图表
if (groupChart) {
groupChart.dispose();
}
if (personChart) {
personChart.dispose();
}
// 重新渲染图表
groupChart = renderGroupChart();
personChart = renderPersonChart();
}
setTimeout(() => {
initCharts();
// 响应式调整 - 使用防抖优化性能
window.addEventListener('resize', () => {
// 图表重新渲染使用防抖
clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
initCharts();
}, RESIZE_DEBOUNCE_TIME);
});
}, CHART_INIT_DELAY);
// 初始化季度和周数显示
function initQuarters() {
// 获取当前周数(1-52)
const now = new Date();
const start = new Date(now.getFullYear(), 0, 1);
const diff = now - start;
const currentWeek = Math.floor(diff / (7 * 24 * 60 * 60 * 1000)) + 1;
// 定义每个季度的周数范围
const quarters = [
{ id: 'q1Weeks', start: 1, end: 13 },
{ id: 'q2Weeks', start: 14, end: 26 },
{ id: 'q3Weeks', start: 27, end: 39 },
{ id: 'q4Weeks', start: 40, end: 52 }
];
// 为每个季度生成周数圆圈
quarters.forEach(quarter => {
const container = document.getElementById(quarter.id);
for (let i = quarter.start; i <= quarter.end; i++) {
const weekCircle = document.createElement('div');
weekCircle.className = 'week-circle';
weekCircle.textContent = i;
// 添加样式类
if (i < currentWeek) {
weekCircle.classList.add('passed');
} else if (i === currentWeek) {
weekCircle.classList.add('current');
}
// 添加悬停提示
weekCircle.title = `第${i}周`;
container.appendChild(weekCircle);
}
});
}
initQuarters();
// 延迟启动进度条动画,使用动态计算的完成率
setTimeout(() => {
startProgressAnimation(progressRate);
}, PROGRESS_ANIM_DELAY);
// 可选:定时刷新数据(每30秒)
// setInterval(async () => {
// yearData = await fetchYearData();
// updateTopData();
// }, 30000);
});