691 lines
22 KiB
JavaScript
691 lines
22 KiB
JavaScript
// 常量定义
|
||
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);
|
||
});
|