Files
2026-02-13 15:59:30 +08:00

691 lines
22 KiB
JavaScript
Raw Permalink 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();
// 动态调整周圈大小以适应容器宽度
function adjustWeekCirclesSize() {
const weekContainers = document.querySelectorAll('.weeks-container');
// 只在PC端(>768px)时执行动态调整,移动端清除内联样式
const isMobile = window.innerWidth <= 768;
weekContainers.forEach(container => {
// 获取所有周圈
const weekCircles = container.querySelectorAll('.week-circle');
if (weekCircles.length === 0) return;
// 移动端:清除所有内联样式,让 CSS 样式生效
if (isMobile) {
weekCircles.forEach(circle => {
circle.style.width = '';
circle.style.height = '';
circle.style.fontSize = '';
circle.style.borderWidth = '';
});
container.style.transform = '';
return;
}
// PC端:动态调整周圈大小
// 获取容器的实际宽度
const containerWidth = container.offsetWidth;
// 计算所有周圈的总宽度(包括间距)
const gap = 3; // 默认间距
const totalGap = gap * (weekCircles.length - 1);
const circleCount = weekCircles.length;
// 计算理想的周圈大小
const idealSize = (containerWidth - totalGap) / circleCount;
// 设置最小和最大尺寸限制
const minSize = 10;
const maxSize = 24;
// 计算最终尺寸
let finalSize = Math.max(minSize, Math.min(idealSize, maxSize));
// 如果内容仍然溢出,计算缩放比例
const totalContentWidth = (finalSize * circleCount) + totalGap;
let scale = 1;
if (totalContentWidth > containerWidth) {
scale = containerWidth / totalContentWidth;
// 限制最小缩放比例,避免过小
scale = Math.max(0.5, scale);
}
// 应用样式到所有周圈
weekCircles.forEach(circle => {
circle.style.width = finalSize + 'px';
circle.style.height = finalSize + 'px';
circle.style.fontSize = (finalSize * 0.4) + 'px'; // 字体大小随圆圈缩放
circle.style.borderWidth = Math.max(1, finalSize / 8) + 'px'; // 边框随圆圈缩放
});
// 如果需要缩放整个容器
if (scale < 1) {
container.style.transform = `scaleX(${scale})`;
container.style.transformOrigin = 'left';
} else {
container.style.transform = 'none';
}
});
}
// 初始调整
setTimeout(() => {
adjustWeekCirclesSize();
}, 100);
// 监听窗口大小变化并重新调整
window.addEventListener('resize', () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
adjustWeekCirclesSize();
}, RESIZE_DEBOUNCE_TIME);
});
// 延迟启动进度条动画,使用动态计算的完成率
setTimeout(() => {
startProgressAnimation(progressRate);
}, PROGRESS_ANIM_DELAY);
// 可选:定时刷新数据(每30秒)
// setInterval(async () => {
// yearData = await fetchYearData();
// updateTopData();
// }, 30000);
});