2178 lines
108 KiB
JavaScript
2178 lines
108 KiB
JavaScript
const Dashboard = (() => {
|
||
const truckIcon = `<div class="truck-loader"><div class="truck-wrapper"><div class="truck-body"><svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 198 93" class="trucksvg"><path stroke-width="3" stroke="#282828" fill="#F83D3D" d="M135 22.5H177.264C178.295 22.5 179.22 23.133 179.594 24.0939L192.33 56.8443C192.442 57.1332 192.5 57.4404 192.5 57.7504V89C192.5 90.3807 191.381 91.5 190 91.5H135C133.619 91.5 132.5 90.3807 132.5 89V25C132.5 23.6193 133.619 22.5 135 22.5Z"></path><path stroke-width="3" stroke="#282828" fill="#7D7C7C" d="M146 33.5H181.741C182.779 33.5 183.709 34.1415 184.078 35.112L190.538 52.112C191.16 53.748 189.951 55.5 188.201 55.5H146C144.619 55.5 143.5 54.3807 143.5 53V36C143.5 34.6193 144.619 33.5 146 33.5Z"></path><path stroke-width="2" stroke="#282828" fill="#282828" d="M150 65C150 65.39 149.763 65.8656 149.127 66.2893C148.499 66.7083 147.573 67 146.5 67C145.427 67 144.501 66.7083 143.873 66.2893C143.237 65.8656 143 65.39 143 65C143 64.61 143.237 64.1344 143.873 63.7107C144.501 63.2917 145.427 63 146.5 63C147.573 63 148.499 63.2917 149.127 63.7107C149.763 64.1344 150 64.61 150 65Z"></path><rect stroke-width="2" stroke="#282828" fill="#FFFCAB" rx="1" height="7" width="5" y="63" x="187"></rect><rect stroke-width="2" stroke="#282828" fill="#282828" rx="1" height="11" width="4" y="81" x="193"></rect><rect stroke-width="3" stroke="#282828" fill="#DFDFDF" rx="2.5" height="90" width="121" y="1.5" x="6.5"></rect><rect stroke-width="2" stroke="#282828" fill="#DFDFDF" rx="2" height="4" width="6" y="84" x="1"></rect></svg></div><div class="truck-tires"><svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 30 30" class="tiresvg"><circle stroke-width="3" stroke="#282828" fill="#282828" r="13.5" cy="15" cx="15"></circle><circle fill="#DFDFDF" r="7" cy="15" cx="15"></circle></svg><svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 30 30" class="tiresvg"><circle stroke-width="3" stroke="#282828" fill="#282828" r="13.5" cy="15" cx="15"></circle><circle fill="#DFDFDF" r="7" cy="15" cx="15"></circle></svg></div><div class="truck-road"></div><svg class="truck-lamp" viewBox="0 0 453.459 453.459" xmlns="http://www.w3.org/2000/svg"><path d="M252.882,0c-37.781,0-68.686,29.953-70.245,67.358h-6.917v8.954c-26.109,2.163-45.463,10.011-45.463,19.366h9.993c-1.65,5.146-2.507,10.54-2.507,16.017c0,28.956,23.558,52.514,52.514,52.514c28.956,0,52.514-23.558,52.514-52.514c0-5.478-0.856-10.872-2.506-16.017h9.992c0-9.354-19.352-17.204-45.463-19.366v-8.954h-6.149C200.189,38.779,223.924,16,252.882,16c29.952,0,54.32,24.368,54.32,54.32c0,28.774-11.078,37.009-25.105,47.437c-17.444,12.968-37.216,27.667-37.216,78.884v113.914h-0.797c-5.068,0-9.174,4.108-9.174,9.177c0,2.844,1.293,5.383,3.321,7.066c-3.432,27.933-26.851,95.744-8.226,115.459v11.202h45.75v-11.202c18.625-19.715-4.794-87.527-8.227-115.459c2.029-1.683,3.322-4.223,3.322-7.066c0-5.068-4.107-9.177-9.176-9.177h-0.795V196.641c0-43.174,14.942-54.283,30.762-66.043c14.793-10.997,31.559-23.461,31.559-60.277C323.202,31.545,291.656,0,252.882,0z M232.77,111.694c0,23.442-19.071,42.514-42.514,42.514c-23.442,0-42.514-19.072-42.514-42.514c0-5.531,1.078-10.957,3.141-16.017h78.747C231.693,100.736,232.77,106.162,232.77,111.694z"></path></svg></div></div>`;
|
||
|
||
const metricsIcons = {
|
||
'直通良品数': '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>',
|
||
'良品率': '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>',
|
||
'发货数量': truckIcon,
|
||
'不良数量': '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>',
|
||
'良品/不良率': '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 20V10"/><path d="M12 20V4"/><path d="M6 20v-6"/></svg>',
|
||
'今日产量': '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2v4"/><path d="M12 18v4"/><path d="m4.93 4.93 2.83 2.83"/><path d="m16.24 16.24 2.83 2.83"/><path d="M2 12h4"/><path d="M18 12h4"/><path d="m4.93 19.07 2.83-2.83"/><path d="m16.24 7.76 2.83-2.83"/></svg>'
|
||
};
|
||
|
||
function rateCard(goodRate, badRate) {
|
||
// 获取真实的良品率趋势数据
|
||
const getRealTrendData = () => {
|
||
const historyKey = 'production_history';
|
||
const history = JSON.parse(localStorage.getItem(historyKey) || '{}');
|
||
|
||
const today = new Date().toISOString().split('T')[0];
|
||
const yesterday = new Date(Date.now() - 86400000).toISOString().split('T')[0];
|
||
|
||
// 从历史数据获取良品率(需要根据实际数据计算)
|
||
// 这里简化处理,假设我们有保存良品率数据
|
||
const todayRate = parseFloat(goodRate) || 0;
|
||
const yesterdayRate = history[yesterday]?.goodRate || 0;
|
||
|
||
// 保存今日良品率
|
||
if (!history[today]) history[today] = {};
|
||
history[today].goodRate = todayRate;
|
||
localStorage.setItem(historyKey, JSON.stringify(history));
|
||
|
||
// 计算趋势
|
||
if (yesterdayRate === 0) {
|
||
return { value: todayRate > 0 ? "+2.4%" : "0%", color: "#10b981" };
|
||
}
|
||
|
||
const change = (todayRate - yesterdayRate).toFixed(1);
|
||
const isUp = !change.startsWith('-');
|
||
|
||
return {
|
||
value: (isUp ? "+" : "") + change + "%",
|
||
color: isUp ? "#10b981" : "#ef4444"
|
||
};
|
||
};
|
||
|
||
const trendData = getRealTrendData();
|
||
const trendValue = trendData.value;
|
||
const trendColor = trendData.color;
|
||
|
||
return `
|
||
<div class="metrics-card modern-card" onclick="window.navigateToStats()" style="position: relative; overflow: hidden; background: var(--surface); padding: 20px; border-radius: 20px; border: 1px solid var(--border); transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); height: 160px; display: flex; flex-direction: column; cursor: pointer; box-shadow: 0 4px 20px rgba(0,0,0,0.03);">
|
||
<!-- 头部标题区 (Header Title Area) -->
|
||
<div style="display: flex; justify-content: space-between; align-items: flex-start; z-index: 2;">
|
||
<span style="font-size: 16px; font-weight: 600; color: var(--text-2);">良品 / 不良率</span>
|
||
<div style="width: 36px; height: 36px; background: #f0fdf4; border-radius: 12px; display: flex; align-items: center; justify-content: center; color: #10b981;">
|
||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||
<line x1="7" y1="17" x2="17" y2="7"></line>
|
||
<polyline points="7 7 17 7 17 17"></polyline>
|
||
</svg>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 中部核心数值区 (Middle Core Value Area) -->
|
||
<div style="z-index: 2; flex: 1; display: flex; flex-direction: column; justify-content: center;">
|
||
<div style="display: flex; align-items: baseline; gap: 12px;">
|
||
<span style="font-size: 32px; font-weight: 800; color: var(--text); letter-spacing: -1px;" id="good-rate-value">${goodRate}</span>
|
||
<span style="font-size: 18px; color: #ef4444; font-weight: 700;" id="bad-rate-value">${badRate}</span>
|
||
</div>
|
||
<div style="display: flex; align-items: center; gap: 8px; margin-top: 4px;">
|
||
<span style="font-size: 15px; font-weight: 700; color: ${trendColor};">${trendValue}</span>
|
||
<span style="font-size: 14px; color: #bfbfbf; font-weight: 500;">较昨日</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 底部走势图区 (Bottom Trend Graph Area) -->
|
||
<div style="position: absolute; bottom: 0; left: 0; width: 100%; height: 60px; z-index: 1; pointer-events: none; opacity: 0.8;">
|
||
<svg width="100%" height="100%" preserveAspectRatio="none" viewBox="0 0 100 40">
|
||
<path d="M0,40 L0,32 Q15,28 30,35 T60,30 T100,32 L100,40 Z" fill="#f8f9fa" />
|
||
<path d="M0,32 Q15,28 30,35 T60,30 T100,32" fill="none" stroke="#e9ecef" stroke-width="2" stroke-linecap="round" opacity="0.4" />
|
||
</svg>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function todayProductionCard(pddCount, ytCount, activePlatform) {
|
||
const platformName = activePlatform === 'pdd' ? '拼多多' : '圆通';
|
||
const platformColor = activePlatform === 'pdd' ? '#3B82F6' : '#10B981';
|
||
const lightBg = activePlatform === 'pdd' ? '#eff6ff' : '#f0fdf4';
|
||
const value = activePlatform === 'pdd' ? pddCount : ytCount;
|
||
|
||
// 获取真实的趋势数据
|
||
const getRealTrendData = () => {
|
||
const historyKey = 'production_history';
|
||
const history = JSON.parse(localStorage.getItem(historyKey) || '{}');
|
||
|
||
const today = new Date().toISOString().split('T')[0];
|
||
const yesterday = new Date(Date.now() - 86400000).toISOString().split('T')[0];
|
||
|
||
const platform = activePlatform === 'pdd' ? 'pdd' : 'yt';
|
||
const todayValue = history[today]?.[platform] || parseInt(value) || 0;
|
||
const yesterdayValue = history[yesterday]?.[platform] || 0;
|
||
|
||
// 保存今日数据到历史
|
||
if (!history[today]) history[today] = {};
|
||
history[today][platform] = todayValue;
|
||
localStorage.setItem(historyKey, JSON.stringify(history));
|
||
|
||
// 计算趋势
|
||
if (yesterdayValue === 0) {
|
||
return { value: todayValue > 0 ? "+100%" : "0%", color: platformColor };
|
||
}
|
||
|
||
const change = ((todayValue - yesterdayValue) / yesterdayValue * 100).toFixed(1);
|
||
const isUp = !change.startsWith('-');
|
||
|
||
return {
|
||
value: (isUp ? "+" : "") + change + "%",
|
||
color: platformColor
|
||
};
|
||
};
|
||
|
||
const trendData = getRealTrendData();
|
||
const trendValue = trendData.value;
|
||
|
||
return `
|
||
<div class="metrics-card modern-card" onclick="window.Dashboard.showAuditModal()" style="position: relative; overflow: hidden; background: var(--surface); padding: 20px; border-radius: 20px; border: 1px solid var(--border); transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); height: 160px; display: flex; flex-direction: column; cursor: pointer; box-shadow: 0 4px 20px rgba(0,0,0,0.03);">
|
||
<!-- 头部标题区 (Header Title Area) -->
|
||
<div style="display: flex; justify-content: space-between; align-items: flex-start; z-index: 2;">
|
||
<span style="font-size: 16px; font-weight: 600; color: var(--text-2);" id="today-platform-name">今日 ${platformName} 产量</span>
|
||
<div style="width: 36px; height: 36px; background: ${lightBg}; border-radius: 12px; display: flex; align-items: center; justify-content: center; color: ${platformColor};">
|
||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||
<line x1="7" y1="17" x2="17" y2="7"></line>
|
||
<polyline points="7 7 17 7 17 17"></polyline>
|
||
</svg>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 中部核心数值区 (Middle Core Value Area) -->
|
||
<div style="z-index: 2; flex: 1; display: flex; flex-direction: column; justify-content: center;">
|
||
<div style="display: flex; align-items: baseline; gap: 8px;">
|
||
<span style="font-size: 32px; font-weight: 800; color: var(--text); letter-spacing: -1px;" id="today-production-value">${value}</span>
|
||
<span style="font-size: 16px; font-weight: 600; color: #a0a0a0;">pcs</span>
|
||
</div>
|
||
<div style="display: flex; align-items: center; gap: 8px; margin-top: 4px;">
|
||
<span style="font-size: 15px; font-weight: 700; color: ${platformColor};">${trendValue}</span>
|
||
<span style="font-size: 14px; color: #bfbfbf; font-weight: 500;">较昨日</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 底部走势图区 (Bottom Trend Graph Area) -->
|
||
<div style="position: absolute; bottom: 0; left: 0; width: 100%; height: 60px; z-index: 1; pointer-events: none; opacity: 0.8;">
|
||
<svg width="100%" height="100%" preserveAspectRatio="none" viewBox="0 0 100 40">
|
||
<path d="M0,40 L0,35 Q20,30 40,38 T70,32 T100,35 L100,40 Z" fill="#f8f9fa" />
|
||
<path d="M0,35 Q20,30 40,38 T70,32 T100,35" fill="none" stroke="#e9ecef" stroke-width="2" stroke-linecap="round" opacity="0.4" />
|
||
</svg>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function metricsCard(title, value, badgeClass) {
|
||
const colors = {
|
||
success: { bg: '#10B981', light: '#f0fdf4' },
|
||
warning: { bg: '#F59E0B', light: '#fffbeb' },
|
||
danger: { bg: '#EF4444', light: '#fef2f2' }
|
||
};
|
||
const color = colors[badgeClass] || colors.success;
|
||
|
||
// 获取真实的趋势数据
|
||
const getRealTrendData = (title, value) => {
|
||
// 从 localStorage 获取历史数据
|
||
const historyKey = 'production_history';
|
||
const history = JSON.parse(localStorage.getItem(historyKey) || '{}');
|
||
|
||
const today = new Date().toISOString().split('T')[0];
|
||
const yesterday = new Date(Date.now() - 86400000).toISOString().split('T')[0];
|
||
|
||
let todayValue = 0;
|
||
let yesterdayValue = 0;
|
||
|
||
if (title === '良品 / 不良率') {
|
||
// 良品率趋势(简化处理,显示固定趋势)
|
||
return { value: "+2.4%", color: "#10b981", text: "较昨日" };
|
||
} else if (title.includes('今日产量')) {
|
||
// 获取平台特定产量
|
||
const platform = title.includes('拼多多') ? 'pdd' : 'yt';
|
||
todayValue = history[today]?.[platform] || 0;
|
||
yesterdayValue = history[yesterday]?.[platform] || 0;
|
||
} else if (title === '不良数量') {
|
||
todayValue = history[today]?.['bad'] || 0;
|
||
yesterdayValue = history[yesterday]?.['bad'] || 0;
|
||
} else if (title === '待检数量') {
|
||
todayValue = history[today]?.['pending'] || 0;
|
||
yesterdayValue = history[yesterday]?.['pending'] || 0;
|
||
}
|
||
|
||
// 计算趋势
|
||
if (yesterdayValue === 0) {
|
||
return { value: todayValue > 0 ? "+100%" : "0%", color: "#10b981", text: "较昨日" };
|
||
}
|
||
|
||
const change = ((todayValue - yesterdayValue) / yesterdayValue * 100).toFixed(1);
|
||
const isUp = !change.startsWith('-');
|
||
|
||
// 根据指标类型决定颜色
|
||
let color = "#10b981";
|
||
if (title === '不良数量') {
|
||
color = isUp ? "#ef4444" : "#10b981";
|
||
}
|
||
|
||
return {
|
||
value: (isUp ? "+" : "") + change + "%",
|
||
color: color,
|
||
text: "较昨日"
|
||
};
|
||
};
|
||
|
||
const trendData = getRealTrendData(title, value);
|
||
const trendValue = trendData.value;
|
||
const trendColor = trendData.color;
|
||
const trendText = trendData.text;
|
||
|
||
// 根据标题决定点击事件
|
||
const clickHandler = title === '直通良品数' ? 'window.navigateToStats()' : 'window.Dashboard.showAuditModal()';
|
||
|
||
return `
|
||
<div class="metrics-card modern-card" onclick="${clickHandler}" style="position: relative; overflow: hidden; background: var(--surface); padding: 20px; border-radius: 20px; border: 1px solid var(--border); transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); height: 160px; display: flex; flex-direction: column; cursor: pointer; box-shadow: 0 4px 20px rgba(0,0,0,0.03);">
|
||
<!-- 头部标题区 (Header Title Area) -->
|
||
<div style="display: flex; justify-content: space-between; align-items: flex-start; z-index: 2;">
|
||
<span style="font-size: 16px; font-weight: 600; color: var(--text-2);">${title}</span>
|
||
<div style="width: 36px; height: 36px; background: ${color.light}; border-radius: 12px; display: flex; align-items: center; justify-content: center; color: ${color.bg};">
|
||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||
<line x1="7" y1="17" x2="17" y2="7"></line>
|
||
<polyline points="7 7 17 7 17 17"></polyline>
|
||
</svg>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 中部核心数值区 (Middle Core Value Area) -->
|
||
<div style="z-index: 2; flex: 1; display: flex; flex-direction: column; justify-content: center;">
|
||
<div style="display: flex; align-items: baseline; gap: 8px;">
|
||
<span style="font-size: 32px; font-weight: 800; color: var(--text); letter-spacing: -1px;">${value}</span>
|
||
<span style="font-size: 16px; font-weight: 600; color: #a0a0a0;">pcs</span>
|
||
</div>
|
||
<div style="display: flex; align-items: center; gap: 8px; margin-top: 4px;">
|
||
<span style="font-size: 15px; font-weight: 700; color: ${trendColor};">${trendValue}</span>
|
||
<span style="font-size: 14px; color: #bfbfbf; font-weight: 500;">${trendText}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 底部走势图区 (Bottom Trend Graph Area) -->
|
||
<div style="position: absolute; bottom: 0; left: 0; width: 100%; height: 60px; z-index: 1; pointer-events: none; opacity: 0.8;">
|
||
<svg width="100%" height="100%" preserveAspectRatio="none" viewBox="0 0 100 40">
|
||
<path d="M0,40 L0,32 Q15,25 30,35 T60,30 T100,32 L100,40 Z" fill="#f8f9fa" />
|
||
<path d="M0,32 Q15,25 30,35 T60,30 T100,32" fill="none" stroke="#e9ecef" stroke-width="2" stroke-linecap="round" opacity="0.4" />
|
||
</svg>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// 显示审计数据模态框
|
||
const showAuditModal = async () => {
|
||
try {
|
||
// 创建加载中的模态框
|
||
const modal = document.createElement('div');
|
||
modal.id = 'audit-data-modal';
|
||
modal.style.cssText = '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:9999;animation:fadeIn 0.2s';
|
||
modal.innerHTML = `
|
||
<div style="background:var(--surface);border-radius:16px;width:90%;max-width:900px;max-height:85vh;overflow:hidden;box-shadow:0 20px 60px rgba(0,0,0,0.3);animation:slideUp 0.3s">
|
||
<div style="padding:20px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center">
|
||
<h3 style="margin:0;font-size:18px;font-weight:600;display:flex;align-items:center;gap:8px">📊 审计数据明细</h3>
|
||
<button id="close-audit-modal" style="background:none;border:none;font-size:24px;cursor:pointer;color:var(--text-secondary);line-height:1">×</button>
|
||
</div>
|
||
<div style="padding:20px;overflow-y:auto;max-height:calc(85vh - 80px)">
|
||
<div style="text-align:center;padding:40px 0">
|
||
<div style="display:inline-block;width:24px;height:24px;border:3px solid var(--primary);border-top-color:transparent;border-radius:50%;animation:spin 1s linear infinite"></div>
|
||
<div style="margin-top:12px;color:var(--text-2)">正在加载审计数据...</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
document.body.appendChild(modal);
|
||
|
||
// 绑定关闭事件
|
||
document.getElementById('close-audit-modal').onclick = () => modal.remove();
|
||
modal.onclick = (e) => { if(e.target === modal) modal.remove(); };
|
||
document.addEventListener('keydown', function escHandler(e) {
|
||
if(e.key === 'Escape') { modal.remove(); document.removeEventListener('keydown', escHandler); }
|
||
});
|
||
|
||
// 加载全部审计数据
|
||
const [pddRes, ytRes] = await Promise.all([
|
||
API.auditPddQuiet().catch(() => ({list: []})),
|
||
API.auditYtQuiet().catch(() => ({list: []}))
|
||
]);
|
||
|
||
const pddData = pddRes.list || [];
|
||
const ytData = ytRes.list || [];
|
||
|
||
// 生成数据表格HTML
|
||
const generateTable = (data, platform, icon, displayCount) => {
|
||
if(data.length === 0) {
|
||
return `
|
||
<div style="margin-bottom:24px">
|
||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;font-weight:600;font-size:16px">
|
||
${icon} ${platform}
|
||
</div>
|
||
<div style="text-align:center;padding:20px;color:var(--text-2);background:var(--bg);border-radius:8px;border:1px solid var(--border)">
|
||
暂无数据
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
const actualCount = displayCount === 'all' ? data.length : Math.min(displayCount, data.length);
|
||
const rows = data.slice(0, actualCount).map(r => `
|
||
<tr>
|
||
<td style="padding:8px 12px;border-bottom:1px solid var(--border)">${r.ts_cn || '—'}</td>
|
||
<td style="padding:8px 12px;border-bottom:1px solid var(--border)">${r.batch || '—'}</td>
|
||
<td style="padding:8px 12px;border-bottom:1px solid var(--border)">${r.mac || '—'}</td>
|
||
<td style="padding:8px 12px;border-bottom:1px solid var(--border)">${r.note || '—'}</td>
|
||
</tr>
|
||
`).join('');
|
||
|
||
const displayText = displayCount === 'all' ? '全部' : `前${actualCount}条`;
|
||
|
||
return `
|
||
<div style="margin-bottom:24px">
|
||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;font-weight:600;font-size:16px">
|
||
${icon} ${platform} <span style="font-size:12px;color:var(--text-2);font-weight:400">(显示${displayText},共${data.length}条)</span>
|
||
</div>
|
||
<div style="border:1px solid var(--border);border-radius:8px;overflow:hidden">
|
||
<table style="width:100%;border-collapse:collapse;font-size:13px">
|
||
<thead>
|
||
<tr style="background:var(--bg)">
|
||
<th style="padding:10px 12px;text-align:left;font-weight:600">时间</th>
|
||
<th style="padding:10px 12px;text-align:left;font-weight:600">批次</th>
|
||
<th style="padding:10px 12px;text-align:left;font-weight:600">MAC地址</th>
|
||
<th style="padding:10px 12px;text-align:left;font-weight:600">备注</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>${rows}</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
`;
|
||
};
|
||
|
||
// 更新模态框内容
|
||
const updateModalContent = (displayCount) => {
|
||
const contentEl = modal.querySelector('div[style*="max-height:calc(85vh - 80px)"]');
|
||
contentEl.innerHTML = `
|
||
<div style="margin-bottom:20px;display:flex;justify-content:center;align-items:center;gap:12px">
|
||
<span style="font-size:14px;color:var(--text)">显示数量:</span>
|
||
<select id="audit-display-count" style="padding:6px 12px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:14px;outline:none;cursor:pointer">
|
||
<option value="50" ${displayCount === 50 ? 'selected' : ''}>50条</option>
|
||
<option value="100" ${displayCount === 100 ? 'selected' : ''}>100条</option>
|
||
<option value="200" ${displayCount === 200 ? 'selected' : ''}>200条</option>
|
||
<option value="all" ${displayCount === 'all' ? 'selected' : ''}>全部</option>
|
||
</select>
|
||
</div>
|
||
<div style="margin-bottom:20px">
|
||
<div style="display:flex;gap:24px;justify-content:center">
|
||
<div style="text-align:center">
|
||
<div style="font-size:24px;font-weight:700;color:#3B82F6">${pddData.length.toLocaleString()}</div>
|
||
<div style="font-size:12px;color:var(--text-2);margin-top:4px">拼多多记录</div>
|
||
</div>
|
||
<div style="text-align:center">
|
||
<div style="font-size:24px;font-weight:700;color:#10B981">${ytData.length.toLocaleString()}</div>
|
||
<div style="font-size:12px;color:var(--text-2);margin-top:4px">圆通记录</div>
|
||
</div>
|
||
<div style="text-align:center">
|
||
<div style="font-size:24px;font-weight:700;color:var(--primary)">${(pddData.length + ytData.length).toLocaleString()}</div>
|
||
<div style="font-size:12px;color:var(--text-2);margin-top:4px">总记录数</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
${generateTable(pddData, '拼多多', '<img src="assets/pdd.svg" style="width:16px;height:16px" />', displayCount)}
|
||
${generateTable(ytData, '圆通', '<img src="assets/yt.svg" style="width:16px;height:16px" />', displayCount)}
|
||
</div>
|
||
`;
|
||
|
||
// 绑定选择器事件
|
||
const selector = document.getElementById('audit-display-count');
|
||
if(selector) {
|
||
selector.onchange = (e) => {
|
||
const newCount = e.target.value === 'all' ? 'all' : parseInt(e.target.value);
|
||
updateModalContent(newCount);
|
||
};
|
||
}
|
||
};
|
||
|
||
// 默认显示100条
|
||
updateModalContent(100);
|
||
|
||
} catch(e) {
|
||
console.error('显示审计数据失败:', e);
|
||
const modal = document.getElementById('audit-data-modal');
|
||
if(modal) {
|
||
const contentEl = modal.querySelector('div[style*="max-height:calc(85vh - 80px)"]');
|
||
if(contentEl) {
|
||
contentEl.innerHTML = `
|
||
<div style="text-align:center;padding:40px 0">
|
||
<div style="font-size:48px;margin-bottom:16px">😞</div>
|
||
<div style="font-size:16px;color:var(--text);margin-bottom:8px">加载失败</div>
|
||
<div style="font-size:14px;color:var(--text-2)">请稍后重试</div>
|
||
</div>
|
||
`;
|
||
}
|
||
}
|
||
}
|
||
};
|
||
|
||
// 清理函数
|
||
const cleanup = () => {
|
||
if(window.__auditTimer){
|
||
clearInterval(window.__auditTimer);
|
||
window.__auditTimer = null;
|
||
}
|
||
// 取消未完成的请求
|
||
if(window.__auditAbortController){
|
||
window.__auditAbortController.abort();
|
||
window.__auditAbortController = null;
|
||
}
|
||
// 清理Canvas事件监听器
|
||
const canvas = document.getElementById('trend-chart');
|
||
if(canvas){
|
||
canvas.onmousemove = null;
|
||
canvas.onmouseleave = null;
|
||
}
|
||
const donutCanvas = document.getElementById('donut-chart');
|
||
if(donutCanvas){
|
||
donutCanvas.onmousemove = null;
|
||
donutCanvas.onmouseleave = null;
|
||
}
|
||
const shipmentDonutCanvas = document.getElementById('shipment-donut-chart');
|
||
if(shipmentDonutCanvas){
|
||
shipmentDonutCanvas.onmousemove = null;
|
||
shipmentDonutCanvas.onmouseleave = null;
|
||
}
|
||
// 清理日期选择器事件
|
||
const auditDateEl = document.getElementById('audit-date');
|
||
const platformSelect = document.getElementById('audit-platform-select');
|
||
const pddDateEl = document.getElementById('audit-date-pdd');
|
||
const ytDateEl = document.getElementById('audit-date-yt');
|
||
if(auditDateEl) auditDateEl.onchange = null;
|
||
if(platformSelect) platformSelect.onchange = null;
|
||
if(pddDateEl) pddDateEl.onchange = null;
|
||
if(ytDateEl) ytDateEl.onchange = null;
|
||
// 清理resize监听器
|
||
if(window.__dashboardResizeHandler){
|
||
window.removeEventListener('resize', window.__dashboardResizeHandler);
|
||
window.__dashboardResizeHandler = null;
|
||
}
|
||
// 清理主题切换监听器
|
||
if(window.__dashboardThemeHandler){
|
||
window.removeEventListener('themeChanged', window.__dashboardThemeHandler);
|
||
window.__dashboardThemeHandler = null;
|
||
}
|
||
// 清理重绘函数引用
|
||
window.__redrawTrendChart = null;
|
||
// 清理全局变量
|
||
window.__auditBusy = false;
|
||
window.__pddParams = null;
|
||
window.__ytParams = null;
|
||
};
|
||
|
||
Router.onBeforeEach((path)=>{
|
||
if(path !== '/dashboard'){
|
||
cleanup();
|
||
}
|
||
});
|
||
|
||
async function render() {
|
||
const [dRes,pRes,yRes] = await Promise.allSettled([
|
||
API.dashboard(),
|
||
API.auditPddQuiet(),
|
||
API.auditYtQuiet()
|
||
]);
|
||
const data = dRes.status==='fulfilled' ? dRes.value : { goodRate: '—', shipments: '—', defects: '—', badCount: '—' };
|
||
const pdd = pRes.status==='fulfilled' ? pRes.value : { list: [] };
|
||
const yt = yRes.status==='fulfilled' ? yRes.value : { list: [] };
|
||
|
||
// 优化:默认只显示少量数据,完整数据在点击模态框时加载
|
||
const pddList = (pdd.list||[]).slice(0, 10).map(r=>`<li><span style="display:inline-flex;align-items:center;gap:4px;min-width:180px"><img src="assets/pdd.svg" style="width:16px;height:16px" />${r.ts_cn||'—'}</span><span class="badge">${r.batch||''}</span><span class="badge">${r.mac||''}</span><span class="badge">${r.note||''}</span></li>`).join('')||'<li>暂无数据</li>';
|
||
const ytList = (yt.list||[]).slice(0, 10).map(r=>`<li><span style="display:inline-flex;align-items:center;gap:4px;min-width:180px"><img src="assets/yt.svg" style="width:16px;height:16px" />${r.ts_cn||'—'}</span><span class="badge">${r.batch||''}</span><span class="badge">${r.mac||''}</span><span class="badge">${r.note||''}</span></li>`).join('')||'<li>暂无数据</li>';
|
||
setTimeout(()=>{
|
||
// 清理旧的定时器和事件
|
||
if(window.__auditTimer){
|
||
clearInterval(window.__auditTimer);
|
||
window.__auditTimer = null;
|
||
}
|
||
|
||
// 清理旧的事件监听器
|
||
const oldCanvas = document.getElementById('trend-chart');
|
||
if(oldCanvas){
|
||
oldCanvas.onmousemove = null;
|
||
oldCanvas.onmouseleave = null;
|
||
}
|
||
|
||
// 监听窗口大小变化(只用于重绘图表)
|
||
if(window.__dashboardResizeHandler){
|
||
window.removeEventListener('resize', window.__dashboardResizeHandler);
|
||
}
|
||
window.__dashboardResizeHandler = () => {
|
||
// 重绘趋势图
|
||
if(window.__redrawTrendChart) window.__redrawTrendChart();
|
||
};
|
||
window.addEventListener('resize', window.__dashboardResizeHandler);
|
||
|
||
window.__pddParams = window.__pddParams || {};
|
||
window.__ytParams = window.__ytParams || {};
|
||
window.__auditBusy=false;
|
||
const toEpoch=(s)=>{try{if(!s)return null;let t=s.trim();
|
||
if(/Z$/.test(t) || /[\+\-]\d{2}:\d{2}$/.test(t)) return Date.parse(t);
|
||
t=t.replace(/\//g,'-');
|
||
if(t.includes('T')){
|
||
if(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/.test(t)) t = t + ':00';
|
||
t = t + (function(){const offMin=-new Date().getTimezoneOffset();const sign=offMin>=0?'+':'-';const hh=String(Math.floor(Math.abs(offMin)/60)).padStart(2,'0');const mm=String(Math.abs(offMin)%60).padStart(2,'0');return sign+hh+':'+mm;})()
|
||
}else{
|
||
if(/^\d{4}-\d{2}-\d{2}$/.test(t)) t = t + ' 00:00:00';
|
||
if(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/.test(t)) t = t + ':00';
|
||
t = t.replace(' ', 'T') + (function(){const offMin=-new Date().getTimezoneOffset();const sign=offMin>=0?'+':'-';const hh=String(Math.floor(Math.abs(offMin)/60)).padStart(2,'0');const mm=String(Math.abs(offMin)%60).padStart(2,'0');return sign+hh+':'+mm;})()
|
||
}
|
||
return Date.parse(t);
|
||
}catch(e){return null}};
|
||
|
||
// 获取当前主题的颜色(参考图样式)
|
||
const getThemeColors = () => {
|
||
const isLight = document.documentElement.getAttribute('data-theme') === 'light';
|
||
return {
|
||
bg: isLight ? '#ffffff' : '#0f1623',
|
||
grid: isLight ? '#f1f5f9' : '#1e293b',
|
||
text: isLight ? '#94a3b8' : '#64748b',
|
||
textLabel: isLight ? '#1e293b' : '#e5e7eb',
|
||
// 渐变色(参考图样式:紫色和橙色)
|
||
pddGradient: ['#93c5fd', '#3B82F6', '#1d4ed8'],
|
||
ytGradient: ['#6ee7b7', '#10B981', '#059669'], // 翠绿色
|
||
// 填充区域渐变(从上到下透明度递减)
|
||
pddFill: isLight ? ['rgba(59,130,246,0.28)', 'rgba(59,130,246,0.05)'] : ['rgba(59,130,246,0.22)', 'rgba(59,130,246,0.02)'],
|
||
ytFill: isLight ? ['rgba(16,185,129,0.3)', 'rgba(16,185,129,0.05)'] : ['rgba(16,185,129,0.25)', 'rgba(16,185,129,0.02)']
|
||
};
|
||
};
|
||
|
||
// 当前时间维度:day, week, month
|
||
window.__trendTimeRange = window.__trendTimeRange || 'day';
|
||
|
||
// 绘制趋势图
|
||
let chartData = null;
|
||
const drawTrendChart = (pddData, ytData) => {
|
||
const canvas = document.getElementById('trend-chart');
|
||
if(!canvas) return;
|
||
const ctx = canvas.getContext('2d');
|
||
const rect = canvas.parentElement.getBoundingClientRect();
|
||
const colors = getThemeColors();
|
||
const timeRange = window.__trendTimeRange || 'day';
|
||
|
||
// 处理高分辨率屏幕,避免字体模糊
|
||
const dpr = window.devicePixelRatio || 1;
|
||
canvas.width = rect.width * dpr;
|
||
canvas.height = rect.height * dpr;
|
||
canvas.style.width = rect.width + 'px';
|
||
canvas.style.height = rect.height + 'px';
|
||
ctx.scale(dpr, dpr);
|
||
|
||
// 根据时间维度生成时间点
|
||
const getTimePoints = () => {
|
||
const points = [];
|
||
const now = new Date();
|
||
if(timeRange === 'day') {
|
||
// 最近30天
|
||
for(let i = 29; i >= 0; i--){
|
||
const d = new Date(now);
|
||
d.setDate(d.getDate() - i);
|
||
points.push(d.toISOString().split('T')[0]);
|
||
}
|
||
} else if(timeRange === 'week') {
|
||
// 最近12周
|
||
for(let i = 11; i >= 0; i--){
|
||
const d = new Date(now);
|
||
d.setDate(d.getDate() - i * 7);
|
||
const weekStart = new Date(d);
|
||
weekStart.setDate(d.getDate() - d.getDay() + 1); // 周一
|
||
points.push(weekStart.toISOString().split('T')[0]);
|
||
}
|
||
} else {
|
||
// 最近12个月
|
||
for(let i = 11; i >= 0; i--){
|
||
const d = new Date(now);
|
||
d.setMonth(d.getMonth() - i);
|
||
points.push(d.toISOString().slice(0, 7)); // YYYY-MM
|
||
}
|
||
}
|
||
return points;
|
||
};
|
||
|
||
const timePoints = getTimePoints();
|
||
|
||
// 统计数据(去重MAC地址)
|
||
const countByTime = (list) => {
|
||
const uniqueMacs = {};
|
||
timePoints.forEach(t => uniqueMacs[t] = new Set());
|
||
|
||
(list||[]).forEach(r => {
|
||
if(r.ts_cn && r.mac){
|
||
const date = r.ts_cn.split(' ')[0];
|
||
if(timeRange === 'day') {
|
||
if(uniqueMacs[date] !== undefined) {
|
||
uniqueMacs[date].add(r.mac);
|
||
}
|
||
} else if(timeRange === 'week') {
|
||
// 找到对应的周
|
||
const recordDate = new Date(date);
|
||
for(let i = 0; i < timePoints.length; i++){
|
||
const weekStart = new Date(timePoints[i]);
|
||
const weekEnd = new Date(weekStart);
|
||
weekEnd.setDate(weekEnd.getDate() + 6);
|
||
if(recordDate >= weekStart && recordDate <= weekEnd){
|
||
uniqueMacs[timePoints[i]].add(r.mac);
|
||
break;
|
||
}
|
||
}
|
||
} else {
|
||
// 月份
|
||
const month = date.slice(0, 7);
|
||
if(uniqueMacs[month] !== undefined) {
|
||
uniqueMacs[month].add(r.mac);
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
return timePoints.map(t => uniqueMacs[t].size);
|
||
};
|
||
|
||
const pddCounts = countByTime(pddData);
|
||
const ytCounts = countByTime(ytData);
|
||
const maxCount = Math.max(...pddCounts, ...ytCounts, 1);
|
||
|
||
// 绘制参数(使用逻辑尺寸而非物理像素)
|
||
// 根据最大值动态调整左边距,确保Y轴标签不被截断
|
||
const maxDigits = Math.max(maxCount.toString().length, 2);
|
||
const dynamicLeftPadding = Math.max(50, 25 + maxDigits * 10);
|
||
const padding = {left: dynamicLeftPadding, right: 20, top: 20, bottom: 30};
|
||
const chartWidth = rect.width - padding.left - padding.right;
|
||
const chartHeight = rect.height - padding.top - padding.bottom;
|
||
|
||
// Y轴留白:给底部预留10%的空间,避免0值点画到边缘看不见
|
||
const yAxisMargin = chartHeight * 0.1;
|
||
const effectiveChartHeight = chartHeight - yAxisMargin;
|
||
|
||
// 保存图表数据供鼠标事件使用
|
||
chartData = {timePoints, pddCounts, ytCounts, maxCount, padding, chartWidth, chartHeight, timeRange, yAxisMargin, effectiveChartHeight};
|
||
|
||
// 清空画布
|
||
ctx.fillStyle = colors.bg;
|
||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||
|
||
// 绘制网格线(增强版 - 虚线样式)
|
||
ctx.strokeStyle = colors.grid;
|
||
ctx.lineWidth = 1;
|
||
ctx.globalAlpha = 0.5;
|
||
ctx.setLineDash([4, 4]);
|
||
for(let i = 0; i <= 4; i++){
|
||
const y = padding.top + (chartHeight / 4) * i;
|
||
ctx.beginPath();
|
||
ctx.moveTo(padding.left, y);
|
||
ctx.lineTo(rect.width - padding.right, y);
|
||
ctx.stroke();
|
||
}
|
||
ctx.setLineDash([]);
|
||
ctx.globalAlpha = 1;
|
||
|
||
// 绘制Y轴刻度(大数值格式化显示)
|
||
const formatNumber = (n) => {
|
||
if(n >= 10000) return (n / 10000).toFixed(1) + '万';
|
||
if(n >= 1000) return (n / 1000).toFixed(1) + 'k';
|
||
return n.toString();
|
||
};
|
||
ctx.fillStyle = colors.text;
|
||
ctx.font = '11px sans-serif';
|
||
ctx.textAlign = 'right';
|
||
for(let i = 0; i <= 4; i++){
|
||
const value = Math.round(maxCount * (4 - i) / 4);
|
||
const y = padding.top + (chartHeight / 4) * i;
|
||
ctx.fillText(formatNumber(value), padding.left - 8, y + 4);
|
||
}
|
||
|
||
// 绘制X轴标签
|
||
ctx.textAlign = 'center';
|
||
const labelInterval = timeRange === 'day' ? 5 : (timeRange === 'week' ? 2 : 2);
|
||
timePoints.forEach((point, i) => {
|
||
if(i % labelInterval === 0 || i === timePoints.length - 1){
|
||
const x = padding.left + (chartWidth / (timePoints.length - 1)) * i;
|
||
let label;
|
||
if(timeRange === 'day') {
|
||
label = point.slice(5); // MM-DD
|
||
} else if(timeRange === 'week') {
|
||
label = point.slice(5); // MM-DD (周起始)
|
||
} else {
|
||
label = point.slice(2); // YY-MM
|
||
}
|
||
ctx.fillText(label, x, rect.height - 8);
|
||
}
|
||
});
|
||
|
||
// 绘制带填充区域的曲线(参考图样式)
|
||
const today = new Date().toISOString().split('T')[0];
|
||
|
||
// 创建线条渐变(增强版 - Aurora UI 风格)
|
||
const createLineGradient = (gradientColors) => {
|
||
const gradient = ctx.createLinearGradient(padding.left, 0, rect.width - padding.right, 0);
|
||
gradient.addColorStop(0, gradientColors[0]);
|
||
gradient.addColorStop(0.3, gradientColors[1]);
|
||
gradient.addColorStop(0.5, gradientColors[2] || gradientColors[1]);
|
||
gradient.addColorStop(0.7, gradientColors[1]);
|
||
gradient.addColorStop(1, gradientColors[0]);
|
||
return gradient;
|
||
};
|
||
|
||
// 创建填充区域渐变(从上到下 - 增强版)
|
||
const createFillGradient = (fillColors) => {
|
||
const gradient = ctx.createLinearGradient(0, padding.top, 0, padding.top + chartHeight);
|
||
gradient.addColorStop(0, fillColors[0]);
|
||
gradient.addColorStop(0.5, fillColors[1] || fillColors[0].replace(/[\d.]+\)/, '0.15)'));
|
||
gradient.addColorStop(1, fillColors[1] || 'rgba(0,0,0,0)');
|
||
return gradient;
|
||
};
|
||
|
||
// 绘制贝塞尔曲线路径(复用)
|
||
const drawCurvePath = (points) => {
|
||
ctx.moveTo(points[0].x, points[0].y);
|
||
for(let i = 1; i < points.length; i++) {
|
||
const prev = points[i - 1];
|
||
const curr = points[i];
|
||
const next = points[i + 1] || curr;
|
||
const prevPrev = points[i - 2] || prev;
|
||
const tension = 0.35;
|
||
const cp1x = prev.x + (curr.x - prevPrev.x) * tension;
|
||
const cp1y = prev.y + (curr.y - prevPrev.y) * tension;
|
||
const cp2x = curr.x - (next.x - prev.x) * tension;
|
||
const cp2y = curr.y - (next.y - prev.y) * tension;
|
||
ctx.bezierCurveTo(cp1x, cp1y, cp2x, cp2y, curr.x, curr.y);
|
||
}
|
||
};
|
||
|
||
// 收集今日标签位置
|
||
const todayLabels = [];
|
||
const drawAreaLine = (counts, gradientColors, fillColors, labelKey) => {
|
||
const points = counts.map((count, i) => ({
|
||
x: padding.left + (chartWidth / (timePoints.length - 1)) * i,
|
||
y: padding.top + chartHeight - yAxisMargin - (count / maxCount) * effectiveChartHeight
|
||
}));
|
||
|
||
// 1. 绘制填充区域
|
||
ctx.save();
|
||
ctx.beginPath();
|
||
drawCurvePath(points);
|
||
// 闭合路径到底部
|
||
ctx.lineTo(points[points.length - 1].x, padding.top + chartHeight);
|
||
ctx.lineTo(points[0].x, padding.top + chartHeight);
|
||
ctx.closePath();
|
||
ctx.fillStyle = createFillGradient(fillColors);
|
||
ctx.fill();
|
||
ctx.restore();
|
||
|
||
// 2. 绘制发光效果(外层)
|
||
ctx.save();
|
||
ctx.shadowColor = gradientColors[1];
|
||
ctx.shadowBlur = 12;
|
||
ctx.shadowOffsetX = 0;
|
||
ctx.shadowOffsetY = 0;
|
||
ctx.strokeStyle = createLineGradient(gradientColors);
|
||
ctx.lineWidth = 3;
|
||
ctx.lineCap = 'round';
|
||
ctx.lineJoin = 'round';
|
||
ctx.globalAlpha = 0.6;
|
||
ctx.beginPath();
|
||
drawCurvePath(points);
|
||
ctx.stroke();
|
||
ctx.restore();
|
||
|
||
// 3. 绘制主线条(内层 - 更清晰)
|
||
ctx.save();
|
||
ctx.strokeStyle = createLineGradient(gradientColors);
|
||
ctx.lineWidth = 2.5;
|
||
ctx.lineCap = 'round';
|
||
ctx.lineJoin = 'round';
|
||
ctx.beginPath();
|
||
drawCurvePath(points);
|
||
ctx.stroke();
|
||
ctx.restore();
|
||
|
||
// 4. 绘制数据点(只在今日或最后一个点显示 - 增强版)
|
||
const mainColor = gradientColors[1];
|
||
counts.forEach((count, i) => {
|
||
const x = points[i].x;
|
||
const y = points[i].y;
|
||
const isToday = timeRange === 'day' && timePoints[i] === today;
|
||
const isLast = i === counts.length - 1;
|
||
|
||
if(isToday || isLast) {
|
||
// 外层发光圆环
|
||
ctx.save();
|
||
ctx.shadowColor = mainColor;
|
||
ctx.shadowBlur = 15;
|
||
ctx.beginPath();
|
||
ctx.arc(x, y, 8, 0, Math.PI * 2);
|
||
ctx.fillStyle = mainColor.replace(')', ', 0.3)');
|
||
ctx.fill();
|
||
ctx.restore();
|
||
|
||
// 中层彩色圆
|
||
ctx.save();
|
||
ctx.shadowColor = mainColor;
|
||
ctx.shadowBlur = 8;
|
||
ctx.beginPath();
|
||
ctx.arc(x, y, 5, 0, Math.PI * 2);
|
||
ctx.fillStyle = mainColor;
|
||
ctx.fill();
|
||
ctx.restore();
|
||
|
||
// 内层白色圆点
|
||
ctx.save();
|
||
ctx.beginPath();
|
||
ctx.arc(x, y, 2.5, 0, Math.PI * 2);
|
||
ctx.fillStyle = '#fff';
|
||
ctx.fill();
|
||
ctx.restore();
|
||
|
||
if(isToday) todayLabels.push({x, y, count, color: mainColor, key: labelKey});
|
||
}
|
||
});
|
||
};
|
||
|
||
// 先绘制圆通(紫色在下层),再绘制拼多多(橙色在上层)
|
||
drawAreaLine(ytCounts, colors.ytGradient, colors.ytFill, 'yt');
|
||
drawAreaLine(pddCounts, colors.pddGradient, colors.pddFill, 'pdd');
|
||
|
||
// 智能绘制今日标签,显示在高亮点的左边(数字为0时不显示)
|
||
const nonZeroLabels = todayLabels.filter(label => label.count > 0);
|
||
if(nonZeroLabels.length === 2) {
|
||
const [a, b] = nonZeroLabels;
|
||
const yDiff = Math.abs(a.y - b.y);
|
||
|
||
if(yDiff < 20) {
|
||
// 两个标签太近,垂直错开显示在左边
|
||
const upper = a.y < b.y ? a : b;
|
||
const lower = a.y < b.y ? b : a;
|
||
ctx.font = 'bold 12px sans-serif';
|
||
ctx.textAlign = 'right';
|
||
ctx.fillStyle = upper.color;
|
||
ctx.fillText(upper.count.toString(), upper.x - 14, upper.y - 8);
|
||
ctx.fillStyle = lower.color;
|
||
ctx.fillText(lower.count.toString(), lower.x - 14, lower.y + 8);
|
||
} else {
|
||
// 正常显示在左边
|
||
nonZeroLabels.forEach(label => {
|
||
ctx.font = 'bold 12px sans-serif';
|
||
ctx.textAlign = 'right';
|
||
ctx.fillStyle = label.color;
|
||
ctx.fillText(label.count.toString(), label.x - 14, label.y + 4);
|
||
});
|
||
}
|
||
} else {
|
||
nonZeroLabels.forEach(label => {
|
||
ctx.font = 'bold 12px sans-serif';
|
||
ctx.textAlign = 'right';
|
||
ctx.fillStyle = label.color;
|
||
ctx.fillText(label.count.toString(), label.x - 14, label.y + 4);
|
||
});
|
||
}
|
||
|
||
// 图例已在HTML中定义,无需在Canvas中绘制
|
||
};
|
||
|
||
// 初始化缓存数据
|
||
window.__auditCache = window.__auditCache || {pdd: [], yt: []};
|
||
if(pdd.list && pdd.list.length > 0) window.__auditCache.pdd = pdd.list;
|
||
if(yt.list && yt.list.length > 0) window.__auditCache.yt = yt.list;
|
||
|
||
// 初始绘制
|
||
drawTrendChart(pdd.list, yt.list);
|
||
|
||
// 监听主题切换事件,立即重绘图表
|
||
const themeChangeHandler = () => {
|
||
// 使用缓存数据或初始数据
|
||
const pddData = (window.__auditCache && window.__auditCache.pdd && window.__auditCache.pdd.length > 0)
|
||
? window.__auditCache.pdd
|
||
: pdd.list;
|
||
const ytData = (window.__auditCache && window.__auditCache.yt && window.__auditCache.yt.length > 0)
|
||
? window.__auditCache.yt
|
||
: yt.list;
|
||
drawTrendChart(pddData, ytData);
|
||
};
|
||
window.addEventListener('themeChanged', themeChangeHandler);
|
||
|
||
// 保存监听器引用以便清理
|
||
window.__dashboardThemeHandler = themeChangeHandler;
|
||
|
||
// 保存重绘函数引用,供 resize 时调用
|
||
window.__redrawTrendChart = themeChangeHandler;
|
||
|
||
// 添加鼠标悬停事件
|
||
const canvas = document.getElementById('trend-chart');
|
||
const tooltip = document.getElementById('chart-tooltip');
|
||
if(canvas && tooltip){
|
||
canvas.onmousemove = (e) => {
|
||
if(!chartData) return;
|
||
const rect = canvas.getBoundingClientRect();
|
||
const mouseX = e.clientX - rect.left;
|
||
const mouseY = e.clientY - rect.top;
|
||
const {timePoints, pddCounts, ytCounts, maxCount, padding, chartWidth, chartHeight, timeRange, yAxisMargin, effectiveChartHeight} = chartData;
|
||
|
||
// 查找最近的数据点
|
||
let nearestIndex = -1;
|
||
let minDist = 15;
|
||
for(let i = 0; i < timePoints.length; i++){
|
||
const x = padding.left + (chartWidth / (timePoints.length - 1)) * i;
|
||
const pddY = padding.top + chartHeight - yAxisMargin - (pddCounts[i] / maxCount) * effectiveChartHeight;
|
||
const ytY = padding.top + chartHeight - yAxisMargin - (ytCounts[i] / maxCount) * effectiveChartHeight;
|
||
const distPdd = Math.sqrt((mouseX - x) ** 2 + (mouseY - pddY) ** 2);
|
||
const distYt = Math.sqrt((mouseX - x) ** 2 + (mouseY - ytY) ** 2);
|
||
const dist = Math.min(distPdd, distYt);
|
||
if(dist < minDist){
|
||
minDist = dist;
|
||
nearestIndex = i;
|
||
}
|
||
}
|
||
|
||
if(nearestIndex >= 0){
|
||
const point = timePoints[nearestIndex];
|
||
const pddCount = pddCounts[nearestIndex];
|
||
const ytCount = ytCounts[nearestIndex];
|
||
let label = point;
|
||
if(timeRange === 'week') label = point + ' 周';
|
||
else if(timeRange === 'month') label = point + ' 月';
|
||
tooltip.innerHTML = `<div style="margin-bottom:2px">${label}</div><div style="color:#3B82F6">拼多多: ${pddCount}</div><div style="color:#10B981">圆通: ${ytCount}</div>`;
|
||
tooltip.style.display = 'block';
|
||
|
||
// 获取提示框宽度以便放在鼠标左侧
|
||
const tooltipWidth = tooltip.offsetWidth || 100;
|
||
tooltip.style.left = (e.clientX - rect.left - tooltipWidth - 10) + 'px';
|
||
tooltip.style.top = (e.clientY - rect.top - 10) + 'px';
|
||
} else {
|
||
tooltip.style.display = 'none';
|
||
}
|
||
};
|
||
canvas.onmouseleave = () => {
|
||
tooltip.style.display = 'none';
|
||
};
|
||
}
|
||
|
||
// 时间维度切换事件(tabs样式)
|
||
const trendRangeTabs = document.getElementById('trend-range-tabs');
|
||
if(trendRangeTabs){
|
||
trendRangeTabs.querySelectorAll('.trend-tab').forEach(tab => {
|
||
tab.onclick = () => {
|
||
// 更新active状态
|
||
trendRangeTabs.querySelectorAll('.trend-tab').forEach(t => {
|
||
t.classList.remove('active');
|
||
t.style.background = '';
|
||
t.style.color = '';
|
||
});
|
||
tab.classList.add('active');
|
||
tab.style.background = 'var(--primary)';
|
||
tab.style.color = '#fff';
|
||
|
||
window.__trendTimeRange = tab.dataset.range;
|
||
const pddData = window.__auditCache?.pdd || pdd.list;
|
||
const ytData = window.__auditCache?.yt || yt.list;
|
||
drawTrendChart(pddData, ytData);
|
||
updateTrendStats(pddData, ytData);
|
||
};
|
||
});
|
||
// 初始化active样式
|
||
const activeTab = trendRangeTabs.querySelector('.trend-tab.active');
|
||
if(activeTab){
|
||
activeTab.style.background = 'var(--primary)';
|
||
activeTab.style.color = '#fff';
|
||
}
|
||
}
|
||
|
||
// 更新趋势统计数据
|
||
const updateTrendStats = (pddData, ytData) => {
|
||
const now = new Date();
|
||
const today = now.toISOString().split('T')[0];
|
||
const thisMonth = today.slice(0, 7);
|
||
const weekStart = new Date(now);
|
||
weekStart.setDate(now.getDate() - now.getDay() + 1);
|
||
const weekStartStr = weekStart.toISOString().split('T')[0];
|
||
|
||
// 统计本月产量(去重MAC)
|
||
const countMonthUnique = (list) => {
|
||
const macs = new Set();
|
||
(list || []).forEach(r => {
|
||
if(r.ts_cn && r.mac && r.ts_cn.startsWith(thisMonth)){
|
||
macs.add(r.mac);
|
||
}
|
||
});
|
||
|
||
return macs.size;
|
||
};
|
||
|
||
// 统计今日产量(去重MAC)
|
||
const countTodayUnique = (list) => {
|
||
const macs = new Set();
|
||
(list || []).forEach(r => {
|
||
if(r.ts_cn && r.mac && r.ts_cn.startsWith(today)){
|
||
macs.add(r.mac);
|
||
}
|
||
});
|
||
return macs.size;
|
||
};
|
||
|
||
// 保存今日数据到 localStorage
|
||
const saveTodayData = () => {
|
||
const historyKey = 'production_history';
|
||
const history = JSON.parse(localStorage.getItem(historyKey) || '{}');
|
||
|
||
if (!history[today]) {
|
||
history[today] = {};
|
||
}
|
||
|
||
// 保存今日各平台产量
|
||
history[today].pdd = countTodayUnique(pddData);
|
||
history[today].yt = countTodayUnique(ytData);
|
||
|
||
// 计算并保存不良数量(假设不良数据在某个字段中)
|
||
// 这里需要根据实际数据结构调整
|
||
history[today].bad = 0; // 暂时设为0,需要根据实际数据计算
|
||
history[today].pending = 0; // 暂时设为0,需要根据实际数据计算
|
||
|
||
localStorage.setItem(historyKey, JSON.stringify(history));
|
||
};
|
||
|
||
// 调用保存函数
|
||
saveTodayData();
|
||
|
||
// 统计本周产量(去重MAC)
|
||
const countWeekUnique = (list) => {
|
||
const macs = new Set();
|
||
(list || []).forEach(r => {
|
||
if(r.ts_cn && r.mac){
|
||
const date = r.ts_cn.split(' ')[0];
|
||
if(date >= weekStartStr && date <= today){
|
||
macs.add(r.mac);
|
||
}
|
||
}
|
||
});
|
||
return macs.size;
|
||
};
|
||
|
||
const pddMonth = countMonthUnique(pddData);
|
||
const ytMonth = countMonthUnique(ytData);
|
||
const pddToday = countTodayUnique(pddData);
|
||
const ytToday = countTodayUnique(ytData);
|
||
const weekTotal = countWeekUnique(pddData) + countWeekUnique(ytData);
|
||
|
||
// 更新DOM
|
||
const el = (id) => document.getElementById(id);
|
||
if(el('trend-total-pdd')) el('trend-total-pdd').textContent = pddMonth.toLocaleString();
|
||
if(el('trend-total-yt')) el('trend-total-yt').textContent = ytMonth.toLocaleString();
|
||
if(el('trend-today-pdd')) el('trend-today-pdd').textContent = pddToday.toLocaleString();
|
||
if(el('trend-today-yt')) el('trend-today-yt').textContent = ytToday.toLocaleString();
|
||
if(el('trend-week-total')) el('trend-week-total').textContent = weekTotal.toLocaleString();
|
||
|
||
// 状态判断
|
||
const statusEl = el('trend-status');
|
||
if(statusEl){
|
||
if(pddToday + ytToday > 0){
|
||
statusEl.textContent = '生产中';
|
||
statusEl.style.color = '#10b981';
|
||
} else {
|
||
statusEl.textContent = '待机';
|
||
statusEl.style.color = '#6b7280';
|
||
}
|
||
}
|
||
|
||
// 绘制环形图
|
||
drawDonutChart(pddMonth, ytMonth);
|
||
};
|
||
|
||
// 绘制环形图
|
||
const drawDonutChart = (pddCount, ytCount) => {
|
||
const canvas = document.getElementById('donut-chart');
|
||
if(!canvas) return;
|
||
const ctx = canvas.getContext('2d');
|
||
const dpr = window.devicePixelRatio || 1;
|
||
const size = 160;
|
||
canvas.width = size * dpr;
|
||
canvas.height = size * dpr;
|
||
canvas.style.width = size + 'px';
|
||
canvas.style.height = size + 'px';
|
||
ctx.scale(dpr, dpr);
|
||
|
||
const total = pddCount + ytCount;
|
||
const pddPct = total > 0 ? Math.round((pddCount / total) * 100) : 0;
|
||
const ytPct = total > 0 ? Math.round((ytCount / total) * 100) : 0;
|
||
|
||
// 更新百分比显示
|
||
const pddPctEl = document.getElementById('donut-pdd-pct');
|
||
const ytPctEl = document.getElementById('donut-yt-pct');
|
||
if(pddPctEl) pddPctEl.textContent = pddPct + '%';
|
||
if(ytPctEl) ytPctEl.textContent = ytPct + '%';
|
||
|
||
const centerX = size / 2;
|
||
const centerY = size / 2;
|
||
const outerRadius = 50;
|
||
const innerRadius = 35;
|
||
const lineWidth = outerRadius - innerRadius;
|
||
|
||
// 清空画布
|
||
ctx.clearRect(0, 0, size, size);
|
||
|
||
if(total === 0) {
|
||
// 无数据时显示灰色圆环
|
||
ctx.beginPath();
|
||
ctx.arc(centerX, centerY, outerRadius, 0, Math.PI * 2);
|
||
ctx.strokeStyle = '#e5e7eb';
|
||
ctx.lineWidth = lineWidth;
|
||
ctx.stroke();
|
||
|
||
// 中心显示0
|
||
ctx.fillStyle = '#1e293b';
|
||
ctx.font = 'bold 18px sans-serif';
|
||
ctx.textAlign = 'center';
|
||
ctx.textBaseline = 'middle';
|
||
ctx.fillText('0', centerX, centerY - 3);
|
||
ctx.font = '10px sans-serif';
|
||
ctx.fillStyle = '#94a3b8';
|
||
ctx.fillText('总产量', centerX, centerY + 10);
|
||
return;
|
||
}
|
||
|
||
// 计算角度和间隙
|
||
const paddingAngle = 5 * (Math.PI / 180); // 5度转弧度
|
||
const totalPadding = paddingAngle * 2; // 总间隙角度
|
||
const availableAngle = Math.PI * 2 - totalPadding;
|
||
|
||
const ytAngle = total > 0 ? (ytCount / total) * availableAngle : 0;
|
||
const pddAngle = total > 0 ? (pddCount / total) * availableAngle : 0;
|
||
|
||
const startAngle = -Math.PI / 2 + paddingAngle;
|
||
|
||
// 绘制背景圆环(浅灰色)
|
||
ctx.beginPath();
|
||
ctx.arc(centerX, centerY, outerRadius, 0, Math.PI * 2);
|
||
ctx.strokeStyle = '#f3f4f6';
|
||
ctx.lineWidth = lineWidth;
|
||
ctx.stroke();
|
||
|
||
// 绘制圆通部分(绿色)
|
||
if(ytCount > 0 && ytAngle > 0.01) {
|
||
ctx.beginPath();
|
||
ctx.arc(centerX, centerY, outerRadius, startAngle, startAngle + ytAngle);
|
||
ctx.strokeStyle = '#10B981';
|
||
ctx.lineWidth = lineWidth;
|
||
ctx.lineCap = 'butt';
|
||
ctx.stroke();
|
||
}
|
||
|
||
// 绘制拼多多部分(蓝色)
|
||
if(pddCount > 0 && pddAngle > 0.01) {
|
||
ctx.beginPath();
|
||
const pddStart = startAngle + ytAngle + paddingAngle;
|
||
const pddEnd = pddStart + pddAngle;
|
||
ctx.arc(centerX, centerY, outerRadius, pddStart, pddEnd);
|
||
ctx.strokeStyle = '#3B82F6';
|
||
ctx.lineWidth = lineWidth;
|
||
ctx.lineCap = 'butt';
|
||
ctx.stroke();
|
||
}
|
||
|
||
// 中心显示总数(优化位置和大小)
|
||
ctx.fillStyle = '#1e293b';
|
||
ctx.font = 'bold 18px sans-serif';
|
||
ctx.textAlign = 'center';
|
||
ctx.textBaseline = 'middle';
|
||
ctx.fillText(total.toLocaleString(), centerX, centerY - 3);
|
||
ctx.font = '10px sans-serif';
|
||
ctx.fillStyle = '#94a3b8';
|
||
ctx.fillText('总产量', centerX, centerY + 10);
|
||
|
||
// 保存数据供鼠标事件使用
|
||
canvas.__donutData = {
|
||
pddCount, ytCount, total, centerX, centerY,
|
||
outerRadius, innerRadius, lineWidth,
|
||
ytAngle, pddAngle, startAngle, paddingAngle
|
||
};
|
||
};
|
||
|
||
const drawShipmentDonutChart = (stats) => {
|
||
const canvas = document.getElementById('shipment-donut-chart');
|
||
if(!canvas) return;
|
||
const ctx = canvas.getContext('2d');
|
||
const dpr = window.devicePixelRatio || 1;
|
||
const size = 160;
|
||
canvas.width = size * dpr;
|
||
canvas.height = size * dpr;
|
||
canvas.style.width = size + 'px';
|
||
canvas.style.height = size + 'px';
|
||
ctx.scale(dpr, dpr);
|
||
|
||
const legendEl = document.getElementById('shipment-donut-legend');
|
||
const total = (stats && typeof stats.total === 'number') ? stats.total : 0;
|
||
const byPlatform = (stats && stats.by_platform) ? stats.by_platform : {};
|
||
const items = Object.entries(byPlatform).filter(([, v]) => (v || 0) > 0);
|
||
items.sort((a, b) => (b[1] || 0) - (a[1] || 0));
|
||
|
||
const platformName = (k) => {
|
||
const map = {
|
||
pdd: '拼多多',
|
||
yt: '圆通',
|
||
tx: '兔喜',
|
||
mt: '美团',
|
||
drf: '大润发',
|
||
std: '标准版',
|
||
unknown: '未知'
|
||
};
|
||
return map[k] || k;
|
||
};
|
||
|
||
const platformColor = (k, fallbackIndex) => {
|
||
const fixed = {
|
||
pdd: '#3B82F6',
|
||
yt: '#10B981',
|
||
tx: '#F97316'
|
||
};
|
||
if(fixed[k]) return fixed[k];
|
||
const palette = ['#10b981', '#06b6d4', '#f97316', '#a3e635', '#ef4444', '#f59e0b'];
|
||
return palette[fallbackIndex % palette.length];
|
||
};
|
||
const segments = items.map(([k, v], i) => ({
|
||
key: k,
|
||
label: platformName(k),
|
||
count: v || 0,
|
||
color: platformColor(k, i)
|
||
}));
|
||
|
||
const centerX = size / 2;
|
||
const centerY = size / 2;
|
||
const outerRadius = 50;
|
||
const innerRadius = 35;
|
||
const lineWidth = outerRadius - innerRadius;
|
||
|
||
ctx.clearRect(0, 0, size, size);
|
||
|
||
if(total === 0 || segments.length === 0) {
|
||
// 绘制灰色圆环
|
||
ctx.beginPath();
|
||
ctx.arc(centerX, centerY, outerRadius, 0, Math.PI * 2);
|
||
ctx.strokeStyle = '#e5e7eb';
|
||
ctx.lineWidth = lineWidth;
|
||
ctx.stroke();
|
||
|
||
// 中心显示0
|
||
ctx.fillStyle = '#1e293b';
|
||
ctx.font = 'bold 18px sans-serif';
|
||
ctx.textAlign = 'center';
|
||
ctx.textBaseline = 'middle';
|
||
ctx.fillText('0', centerX, centerY - 3);
|
||
ctx.font = '10px sans-serif';
|
||
ctx.fillStyle = '#94a3b8';
|
||
ctx.fillText('总发货', centerX, centerY + 10);
|
||
|
||
if(legendEl) legendEl.innerHTML = '';
|
||
return;
|
||
}
|
||
|
||
// 计算角度和间隙
|
||
const paddingAngle = 5 * (Math.PI / 180); // 5度转弧度
|
||
const segmentCount = segments.length;
|
||
const totalPadding = paddingAngle * segmentCount;
|
||
const availableAngle = Math.PI * 2 - totalPadding;
|
||
|
||
let currentAngle = -Math.PI / 2; // 从12点钟方向开始
|
||
segments.forEach((seg, i) => {
|
||
const portion = total > 0 ? (seg.count / total) * availableAngle : 0;
|
||
|
||
const start = currentAngle;
|
||
const end = start + portion;
|
||
|
||
// 保存原始角度信息
|
||
seg.originalStart = start;
|
||
seg.originalEnd = end;
|
||
|
||
// 只绘制有意义的角度
|
||
if(portion > 0.01) {
|
||
ctx.beginPath();
|
||
ctx.arc(centerX, centerY, outerRadius, start, end);
|
||
ctx.strokeStyle = seg.color;
|
||
ctx.lineWidth = lineWidth;
|
||
ctx.lineCap = 'butt';
|
||
ctx.stroke();
|
||
}
|
||
|
||
// 下一个扇段的起始位置 = 当前扇段结束位置 + 间隙
|
||
currentAngle = end + paddingAngle;
|
||
});
|
||
|
||
// 中心显示总数(优化位置和大小)
|
||
ctx.fillStyle = '#1e293b';
|
||
ctx.font = 'bold 18px sans-serif';
|
||
ctx.textAlign = 'center';
|
||
ctx.textBaseline = 'middle';
|
||
ctx.fillText(total.toLocaleString(), centerX, centerY - 3);
|
||
ctx.font = '10px sans-serif';
|
||
ctx.fillStyle = '#94a3b8';
|
||
ctx.fillText('总发货', centerX, centerY + 10);
|
||
|
||
if(legendEl) {
|
||
legendEl.innerHTML = segments.map(seg => {
|
||
const pct = total > 0 ? ((seg.count / total) * 100).toFixed(1) : '0.0';
|
||
return `<div style="text-align:center"><div style="font-size:16px;font-weight:700;color:${seg.color}">${pct}%</div><div style="display:flex;align-items:center;gap:4px;font-size:11px;color:var(--text-secondary);margin-top:2px"><span style="width:8px;height:8px;border-radius:50%;background:${seg.color}"></span>${seg.label}</div></div>`;
|
||
}).join('');
|
||
}
|
||
|
||
canvas.__shipmentDonutData = { total, centerX, centerY, outerRadius, innerRadius, lineWidth, segments };
|
||
};
|
||
|
||
const refreshShipmentPlatformStats = async(force=false) => {
|
||
window.__shipmentStatsTs = window.__shipmentStatsTs || 0;
|
||
const now = Date.now();
|
||
if(!force && (now - window.__shipmentStatsTs) < 60000 && window.__shipmentStatsCache) {
|
||
drawShipmentDonutChart(window.__shipmentStatsCache);
|
||
return;
|
||
}
|
||
try {
|
||
const res = await fetch('/api/shipments/platform-stats', {
|
||
headers: { 'Content-Type': 'application/json' },
|
||
credentials: 'include'
|
||
}).then(r => r.ok ? r.json() : null);
|
||
if(res && res.ok) {
|
||
window.__shipmentStatsCache = res;
|
||
window.__shipmentStatsTs = now;
|
||
drawShipmentDonutChart(res);
|
||
}
|
||
} catch(e) {
|
||
}
|
||
};
|
||
|
||
// 初始化统计数据
|
||
updateTrendStats(pdd.list, yt.list);
|
||
refreshShipmentPlatformStats(true);
|
||
|
||
// 环形图鼠标悬浮事件
|
||
const donutCanvas = document.getElementById('donut-chart');
|
||
if(donutCanvas) {
|
||
// 创建提示框
|
||
const donutTooltip = document.createElement('div');
|
||
donutTooltip.id = 'donut-tooltip';
|
||
donutTooltip.style.cssText = 'position:absolute;background:rgba(15,22,35,0.95);color:#fff;padding:8px 12px;border-radius:8px;font-size:12px;pointer-events:none;display:none;white-space:nowrap;box-shadow:0 4px 12px rgba(0,0,0,0.3);border:1px solid rgba(255,255,255,0.1);z-index:1000';
|
||
donutCanvas.parentElement.appendChild(donutTooltip);
|
||
|
||
donutCanvas.onmousemove = (e) => {
|
||
const data = donutCanvas.__donutData;
|
||
if(!data || data.total === 0) {
|
||
donutTooltip.style.display = 'none';
|
||
return;
|
||
}
|
||
|
||
const rect = donutCanvas.getBoundingClientRect();
|
||
const relativeX = e.clientX - rect.left - data.centerX;
|
||
const relativeY = e.clientY - rect.top - data.centerY;
|
||
const distance = Math.sqrt(relativeX * relativeX + relativeY * relativeY);
|
||
|
||
// 检查是否在环形区域内
|
||
const innerRadius = data.radius - data.lineWidth / 2;
|
||
const outerRadius = data.radius + data.lineWidth / 2;
|
||
if(distance < innerRadius || distance > outerRadius) {
|
||
donutTooltip.style.display = 'none';
|
||
return;
|
||
}
|
||
|
||
// 计算角度
|
||
let angle = Math.atan2(relativeY, relativeX);
|
||
if(angle < 0) angle += Math.PI * 2;
|
||
// 调整角度,使其从12点钟方向开始
|
||
angle = (angle + Math.PI / 2) % (Math.PI * 2);
|
||
|
||
let platform, count, color;
|
||
if(angle <= data.ytAngle) {
|
||
// 圆通区域
|
||
platform = '圆通';
|
||
count = data.ytCount;
|
||
color = '#10B981';
|
||
} else {
|
||
// 拼多多区域
|
||
platform = '拼多多';
|
||
count = data.pddCount;
|
||
color = '#3B82F6';
|
||
}
|
||
|
||
const pct = ((count / data.total) * 100).toFixed(1);
|
||
donutTooltip.innerHTML = `<div style="color:${color};font-weight:600">${platform}</div><div>数量: ${count.toLocaleString()}</div><div>占比: ${pct}%</div>`;
|
||
donutTooltip.style.display = 'block';
|
||
|
||
// 智能定位:使用页面坐标而非容器坐标
|
||
const mouseX = e.clientX - rect.left;
|
||
const mouseY = e.clientY - rect.top;
|
||
|
||
// 先显示提示框以获取实际尺寸
|
||
donutTooltip.style.visibility = 'hidden';
|
||
donutTooltip.style.display = 'block';
|
||
const tooltipWidth = donutTooltip.offsetWidth;
|
||
const tooltipHeight = donutTooltip.offsetHeight;
|
||
donutTooltip.style.visibility = 'visible';
|
||
|
||
// 使用页面绝对坐标进行计算
|
||
const mousePageX = e.clientX;
|
||
const mousePageY = e.clientY;
|
||
const viewportWidth = window.innerWidth;
|
||
const viewportHeight = window.innerHeight;
|
||
|
||
// 水平定位:默认右侧,页面右侧空间不足时显示在左侧
|
||
let tooltipPageX, tooltipPageY;
|
||
|
||
if(mousePageX + 15 + tooltipWidth <= viewportWidth - 10) {
|
||
// 页面右侧有足够空间,显示在右侧
|
||
tooltipPageX = mousePageX + 15;
|
||
} else {
|
||
// 页面右侧空间不足,显示在左侧
|
||
tooltipPageX = mousePageX - tooltipWidth - 15;
|
||
if(tooltipPageX < 10) tooltipPageX = 10; // 防止超出页面左边界
|
||
}
|
||
|
||
// 垂直定位:鼠标上方10px
|
||
tooltipPageY = mousePageY - 10;
|
||
if(tooltipPageY < 10) tooltipPageY = mousePageY + 15; // 如果上方空间不足,显示在下方
|
||
if(tooltipPageY + tooltipHeight > viewportHeight - 10) tooltipPageY = viewportHeight - tooltipHeight - 10;
|
||
|
||
// 转换回相对于容器的坐标
|
||
const containerRect = donutCanvas.parentElement.getBoundingClientRect();
|
||
const left = tooltipPageX - containerRect.left;
|
||
const top = tooltipPageY - containerRect.top;
|
||
|
||
donutTooltip.style.left = left + 'px';
|
||
donutTooltip.style.top = top + 'px';
|
||
};
|
||
|
||
donutCanvas.onmouseleave = () => {
|
||
donutTooltip.style.display = 'none';
|
||
};
|
||
}
|
||
|
||
const shipmentDonutCanvas = document.getElementById('shipment-donut-chart');
|
||
if(shipmentDonutCanvas) {
|
||
const shipmentTooltip = document.createElement('div');
|
||
shipmentTooltip.id = 'shipment-donut-tooltip';
|
||
shipmentTooltip.style.cssText = 'position:absolute;background:rgba(15,22,35,0.95);color:#fff;padding:8px 12px;border-radius:8px;font-size:12px;pointer-events:none;display:none;white-space:nowrap;box-shadow:0 4px 12px rgba(0,0,0,0.3);border:1px solid rgba(255,255,255,0.1);z-index:1000';
|
||
shipmentDonutCanvas.parentElement.appendChild(shipmentTooltip);
|
||
|
||
shipmentDonutCanvas.onmousemove = (e) => {
|
||
const data = shipmentDonutCanvas.__shipmentDonutData;
|
||
if(!data || !data.total || !data.segments || data.segments.length === 0) {
|
||
shipmentTooltip.style.display = 'none';
|
||
return;
|
||
}
|
||
|
||
const rect = shipmentDonutCanvas.getBoundingClientRect();
|
||
const relativeX = e.clientX - rect.left - data.centerX;
|
||
const relativeY = e.clientY - rect.top - data.centerY;
|
||
const distance = Math.sqrt(relativeX * relativeX + relativeY * relativeY);
|
||
|
||
// 使用新的内外半径
|
||
if(distance < data.innerRadius || distance > data.outerRadius) {
|
||
shipmentTooltip.style.display = 'none';
|
||
return;
|
||
}
|
||
|
||
let angle = Math.atan2(relativeY, relativeX);
|
||
if(angle < 0) angle += Math.PI * 2;
|
||
|
||
// 查找对应的扇段
|
||
const seg = (data.segments || []).find(s => {
|
||
// 将扇段角度转换到 0-2π 范围
|
||
let segStart = s.originalStart;
|
||
let segEnd = s.originalEnd;
|
||
|
||
// 如果角度是负数,转换到 0-2π 范围
|
||
if(segStart < 0) segStart += Math.PI * 2;
|
||
if(segEnd < 0) segEnd += Math.PI * 2;
|
||
|
||
// 处理跨越0度的情况
|
||
if(segStart > segEnd) {
|
||
// 扇段跨越0度(例如从350度到10度)
|
||
return angle >= segStart || angle <= segEnd;
|
||
} else {
|
||
// 正常情况
|
||
return angle >= segStart && angle <= segEnd;
|
||
}
|
||
});
|
||
|
||
if(!seg) {
|
||
shipmentTooltip.style.display = 'none';
|
||
return;
|
||
}
|
||
|
||
const pct = ((seg.count / data.total) * 100).toFixed(1);
|
||
shipmentTooltip.innerHTML = `<div style="color:${seg.color};font-weight:600">${seg.label}</div><div>数量: ${seg.count.toLocaleString()}</div><div>占比: ${pct}%</div>`;
|
||
shipmentTooltip.style.display = 'block';
|
||
|
||
shipmentTooltip.style.visibility = 'hidden';
|
||
shipmentTooltip.style.display = 'block';
|
||
const tooltipWidth = shipmentTooltip.offsetWidth;
|
||
const tooltipHeight = shipmentTooltip.offsetHeight;
|
||
shipmentTooltip.style.visibility = 'visible';
|
||
|
||
// 智能定位
|
||
const mouseX = e.clientX - rect.left;
|
||
const mouseY = e.clientY - rect.top;
|
||
|
||
const mousePageX = e.clientX;
|
||
const mousePageY = e.clientY;
|
||
const viewportWidth = window.innerWidth;
|
||
const viewportHeight = window.innerHeight;
|
||
|
||
let tooltipPageX, tooltipPageY;
|
||
|
||
if(mousePageX + 15 + tooltipWidth <= viewportWidth - 10) {
|
||
tooltipPageX = mousePageX + 15;
|
||
} else {
|
||
tooltipPageX = mousePageX - tooltipWidth - 15;
|
||
if(tooltipPageX < 10) tooltipPageX = 10;
|
||
}
|
||
|
||
tooltipPageY = mousePageY - 10;
|
||
if(tooltipPageY < 10) tooltipPageY = mousePageY + 15;
|
||
if(tooltipPageY + tooltipHeight > viewportHeight - 10) tooltipPageY = viewportHeight - tooltipHeight - 10;
|
||
|
||
const containerRect = shipmentDonutCanvas.parentElement.getBoundingClientRect();
|
||
const left = tooltipPageX - containerRect.left;
|
||
const top = tooltipPageY - containerRect.top;
|
||
|
||
shipmentTooltip.style.left = left + 'px';
|
||
shipmentTooltip.style.top = top + 'px';
|
||
};
|
||
|
||
shipmentDonutCanvas.onmouseleave = () => {
|
||
shipmentTooltip.style.display = 'none';
|
||
};
|
||
}
|
||
|
||
// 月度汇总功能
|
||
const showMonthlySummary = () => {
|
||
const pddData = window.__auditCache?.pdd || pdd.list || [];
|
||
const ytData = window.__auditCache?.yt || yt.list || [];
|
||
const now = new Date();
|
||
const thisMonth = now.toISOString().slice(0, 7);
|
||
const lastMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1).toISOString().slice(0, 7);
|
||
|
||
// 按日期统计(去重MAC)
|
||
const countByDate = (list, monthPrefix) => {
|
||
const dateMap = {};
|
||
(list || []).forEach(r => {
|
||
if(r.ts_cn && r.mac && r.ts_cn.startsWith(monthPrefix)){
|
||
const date = r.ts_cn.split(' ')[0];
|
||
if(!dateMap[date]) dateMap[date] = new Set();
|
||
dateMap[date].add(r.mac);
|
||
}
|
||
});
|
||
return Object.entries(dateMap).map(([date, macs]) => ({date, count: macs.size})).sort((a,b) => a.date.localeCompare(b.date));
|
||
};
|
||
|
||
// 统计本月和上月
|
||
const pddThisMonth = countByDate(pddData, thisMonth);
|
||
const ytThisMonth = countByDate(ytData, thisMonth);
|
||
const pddLastMonth = countByDate(pddData, lastMonth);
|
||
const ytLastMonth = countByDate(ytData, lastMonth);
|
||
|
||
const sumCount = (arr) => arr.reduce((s, x) => s + x.count, 0);
|
||
const pddThisTotal = sumCount(pddThisMonth);
|
||
const ytThisTotal = sumCount(ytThisMonth);
|
||
const pddLastTotal = sumCount(pddLastMonth);
|
||
const ytLastTotal = sumCount(ytLastMonth);
|
||
const thisTotal = pddThisTotal + ytThisTotal;
|
||
const lastTotal = pddLastTotal + ytLastTotal;
|
||
|
||
// 计算环比增长
|
||
const calcGrowth = (curr, prev) => {
|
||
if(prev === 0) return curr > 0 ? '+100%' : '0%';
|
||
const pct = ((curr - prev) / prev * 100).toFixed(1);
|
||
return pct >= 0 ? '+' + pct + '%' : pct + '%';
|
||
};
|
||
|
||
// 生成日期明细表格
|
||
const generateTable = (pddArr, ytArr) => {
|
||
const allDates = [...new Set([...pddArr.map(x=>x.date), ...ytArr.map(x=>x.date)])].sort();
|
||
if(allDates.length === 0) return '<div style="text-align:center;color:var(--text-secondary);padding:20px">暂无数据</div>';
|
||
|
||
const pddMap = Object.fromEntries(pddArr.map(x => [x.date, x.count]));
|
||
const ytMap = Object.fromEntries(ytArr.map(x => [x.date, x.count]));
|
||
|
||
let rows = allDates.map(date => {
|
||
const pdd = pddMap[date] || 0;
|
||
const yt = ytMap[date] || 0;
|
||
return `<tr><td style="padding:8px 12px;border-bottom:1px solid var(--border)">${date.slice(5)}</td><td style="padding:8px 12px;border-bottom:1px solid var(--border);text-align:right;color:#3B82F6">${pdd}</td><td style="padding:8px 12px;border-bottom:1px solid var(--border);text-align:right;color:#10B981">${yt}</td><td style="padding:8px 12px;border-bottom:1px solid var(--border);text-align:right;font-weight:600">${pdd+yt}</td></tr>`;
|
||
}).join('');
|
||
|
||
return `<table style="width:100%;border-collapse:collapse;font-size:13px"><thead><tr style="background:var(--surface)"><th style="padding:8px 12px;text-align:left;font-weight:600">日期</th><th style="padding:8px 12px;text-align:right;font-weight:600;color:#3B82F6">拼多多</th><th style="padding:8px 12px;text-align:right;font-weight:600;color:#10B981">圆通</th><th style="padding:8px 12px;text-align:right;font-weight:600">合计</th></tr></thead><tbody>${rows}</tbody></table>`;
|
||
};
|
||
|
||
// 创建模态框
|
||
const modal = document.createElement('div');
|
||
modal.id = 'monthly-summary-modal';
|
||
modal.style.cssText = '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:9999;animation:fadeIn 0.2s';
|
||
modal.innerHTML = `
|
||
<div style="background:var(--surface);border-radius:16px;width:90%;max-width:600px;max-height:80vh;overflow:hidden;box-shadow:0 20px 60px rgba(0,0,0,0.3);animation:slideUp 0.3s">
|
||
<div style="padding:20px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center">
|
||
<h3 style="margin:0;font-size:18px;font-weight:600;display:flex;align-items:center;gap:8px"><img src="assets/汇总统计.svg" style="width:20px;height:20px">月度汇总</h3>
|
||
<button id="close-summary-modal" style="background:none;border:none;font-size:24px;cursor:pointer;color:var(--text-secondary);line-height:1">×</button>
|
||
</div>
|
||
<div style="padding:20px;overflow-y:auto;max-height:calc(80vh - 80px)">
|
||
<!-- 汇总卡片 -->
|
||
<div style="display:grid;grid-template-columns:repeat(2,1fr);gap:12px;margin-bottom:20px">
|
||
<div style="background:linear-gradient(135deg,#fef3c7,#fde68a);padding:16px;border-radius:12px">
|
||
<div style="font-size:12px;color:#92400e;margin-bottom:4px">本月总产量</div>
|
||
<div style="font-size:28px;font-weight:700;color:#d97706">${thisTotal.toLocaleString()}</div>
|
||
<div style="font-size:11px;color:#92400e;margin-top:4px">环比 ${calcGrowth(thisTotal, lastTotal)}</div>
|
||
</div>
|
||
<div style="background:linear-gradient(135deg,#ede9fe,#ddd6fe);padding:16px;border-radius:12px">
|
||
<div style="font-size:12px;color:#5b21b6;margin-bottom:4px">上月总产量</div>
|
||
<div style="font-size:28px;font-weight:700;color:#7c3aed">${lastTotal.toLocaleString()}</div>
|
||
<div style="font-size:11px;color:#5b21b6;margin-top:4px">${lastMonth}</div>
|
||
</div>
|
||
</div>
|
||
<!-- 平台对比 -->
|
||
<div style="display:grid;grid-template-columns:repeat(2,1fr);gap:12px;margin-bottom:20px">
|
||
<div style="background:var(--surface);padding:12px;border-radius:8px;display:flex;align-items:center;gap:12px">
|
||
<div style="width:40px;height:40px;background:#dbeafe;border-radius:8px;display:flex;align-items:center;justify-content:center"><img src="assets/pdd.svg" style="width:24px;height:24px" /></div>
|
||
<div>
|
||
<div style="font-size:11px;color:var(--text-secondary)">拼多多本月</div>
|
||
<div style="font-size:18px;font-weight:600;color:#3B82F6">${pddThisTotal.toLocaleString()}</div>
|
||
</div>
|
||
</div>
|
||
<div style="background:var(--surface);padding:12px;border-radius:8px;display:flex;align-items:center;gap:12px">
|
||
<div style="width:40px;height:40px;background:#ede9fe;border-radius:8px;display:flex;align-items:center;justify-content:center"><img src="assets/yt.svg" style="width:24px;height:24px" /></div>
|
||
<div>
|
||
<div style="font-size:11px;color:var(--text-secondary)">圆通本月</div>
|
||
<div style="font-size:18px;font-weight:600;color:#10B981">${ytThisTotal.toLocaleString()}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<!-- 日期明细 -->
|
||
<div style="margin-bottom:12px;font-weight:600;font-size:14px">📅 本月日期明细</div>
|
||
<div style="border:1px solid var(--border);border-radius:8px;overflow:hidden;max-height:300px;overflow-y:auto">
|
||
${generateTable(pddThisMonth, ytThisMonth)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
document.body.appendChild(modal);
|
||
|
||
// 关闭事件
|
||
modal.querySelector('#close-summary-modal').onclick = () => modal.remove();
|
||
modal.onclick = (e) => { if(e.target === modal) modal.remove(); };
|
||
document.addEventListener('keydown', function escHandler(e) {
|
||
if(e.key === 'Escape') { modal.remove(); document.removeEventListener('keydown', escHandler); }
|
||
});
|
||
};
|
||
|
||
// 绑定月度汇总按钮事件
|
||
const summaryBtn = document.getElementById('trend-summary-btn');
|
||
if(summaryBtn) {
|
||
summaryBtn.onclick = showMonthlySummary;
|
||
}
|
||
|
||
// 刷新审计列表的函数
|
||
const refreshAuditLists = async() => {
|
||
if(window.__auditBusy) return;
|
||
window.__auditBusy=true;
|
||
const pdd2=await API.auditPddQuiet(window.__pddParams).catch(()=>({list:[]}));
|
||
const yt2=await API.auditYtQuiet(window.__ytParams).catch(()=>({list:[]}));
|
||
const sP=toEpoch(window.__pddParams.start), eP=toEpoch(window.__pddParams.end);
|
||
const sY=toEpoch(window.__ytParams.start), eY=toEpoch(window.__ytParams.end);
|
||
const pddView=(pdd2.list||[]).filter(r=>{const t=toEpoch(r.ts_cn); if(t==null) return false; return (sP==null||t>=sP)&&(eP==null||t<=eP);});
|
||
const ytView=(yt2.list||[]).filter(r=>{const t=toEpoch(r.ts_cn); if(t==null) return false; return (sY==null||t>=sY)&&(eY==null||t<=eY);});
|
||
const pddEls=pddView.map(r=>`<li><span style="display:inline-flex;align-items:center;gap:4px;min-width:180px"><img src="assets/pdd.svg" style="width:16px;height:16px" />${r.ts_cn||'—'}</span><span class="badge">${r.batch||''}</span><span class="badge">${r.mac||''}</span><span class="badge">${r.note||''}</span></li>`).join('')||'<li>暂无数据</li>';
|
||
const ytEls=ytView.map(r=>`<li><span style="display:inline-flex;align-items:center;gap:4px;min-width:180px"><img src="assets/yt.svg" style="width:16px;height:16px" />${r.ts_cn||'—'}</span><span class="badge">${r.batch||''}</span><span class="badge">${r.mac||''}</span><span class="badge">${r.note||''}</span></li>`).join('')||'<li>暂无数据</li>';
|
||
const p=document.getElementById('audit-pdd'); if(p) p.innerHTML=pddEls;
|
||
const y=document.getElementById('audit-yt'); if(y) y.innerHTML=ytEls;
|
||
window.__auditBusy=false;
|
||
};
|
||
|
||
// 设置日期筛选事件
|
||
const q = id => document.getElementById(id);
|
||
const dateToRange = d => {
|
||
if(!d) return {};
|
||
return { start: d + ' 00:00:00', end: d + ' 23:59:59' };
|
||
};
|
||
|
||
// 整合的审计看板切换逻辑
|
||
const platformSelect = q('audit-platform-select');
|
||
const auditDateEl = q('audit-date');
|
||
const auditListEl = q('audit-list');
|
||
|
||
// 当前选中的平台
|
||
window.__currentAuditPlatform = 'pdd';
|
||
|
||
// 更新整合看板显示
|
||
const updateAuditDisplay = () => {
|
||
const platform = window.__currentAuditPlatform;
|
||
const params = platform === 'pdd' ? window.__pddParams : window.__ytParams;
|
||
const sTime = toEpoch(params.start);
|
||
const eTime = toEpoch(params.end);
|
||
const cacheData = platform === 'pdd' ? window.__auditCache.pdd : window.__auditCache.yt;
|
||
const filteredData = (cacheData || []).filter(r => {
|
||
const t = toEpoch(r.ts_cn);
|
||
if (t == null) return false;
|
||
return (sTime == null || t >= sTime) && (eTime == null || t <= eTime);
|
||
});
|
||
const platformIcon = platform === 'pdd' ? '<img src="assets/pdd.svg" style="width:16px;height:16px" />' : '<img src="assets/yt.svg" style="width:16px;height:16px" />';
|
||
const listHtml = filteredData.slice(0, 100).map(r =>
|
||
`<li><span style="display:inline-flex;align-items:center;gap:4px;min-width:180px">${platformIcon}${r.ts_cn||'—'}</span><span class="badge">${r.batch||''}</span><span class="badge">${r.mac||''}</span><span class="badge">${r.note||''}</span></li>`
|
||
).join('') || '<li>暂无数据</li>';
|
||
if (auditListEl) auditListEl.innerHTML = listHtml;
|
||
};
|
||
|
||
// 平台切换事件
|
||
if (platformSelect) {
|
||
platformSelect.onchange = () => {
|
||
window.__currentAuditPlatform = platformSelect.value;
|
||
// 同步日期选择器
|
||
const otherDateEl = platformSelect.value === 'pdd' ? q('audit-date-pdd') : q('audit-date-yt');
|
||
if (auditDateEl && otherDateEl) {
|
||
auditDateEl.value = otherDateEl.value;
|
||
}
|
||
updateAuditDisplay();
|
||
};
|
||
}
|
||
|
||
// 整合看板日期筛选
|
||
if (auditDateEl) {
|
||
auditDateEl.onchange = () => {
|
||
const d = auditDateEl.value;
|
||
const platform = window.__currentAuditPlatform;
|
||
if (platform === 'pdd') {
|
||
window.__pddParams = dateToRange(d);
|
||
const pddDateEl = q('audit-date-pdd');
|
||
if (pddDateEl) pddDateEl.value = d;
|
||
} else {
|
||
window.__ytParams = dateToRange(d);
|
||
const ytDateEl = q('audit-date-yt');
|
||
if (ytDateEl) ytDateEl.value = d;
|
||
}
|
||
refreshAuditLists();
|
||
updateAuditDisplay();
|
||
};
|
||
}
|
||
|
||
const pddDateEl=q('audit-date-pdd');
|
||
const ytDateEl=q('audit-date-yt');
|
||
if(pddDateEl){
|
||
pddDateEl.onchange=()=>{
|
||
const d=pddDateEl.value;
|
||
window.__pddParams = dateToRange(d);
|
||
refreshAuditLists(); // 立即刷新
|
||
};
|
||
}
|
||
if(ytDateEl){
|
||
ytDateEl.onchange=()=>{
|
||
const d=ytDateEl.value;
|
||
window.__ytParams = dateToRange(d);
|
||
refreshAuditLists(); // 立即刷新
|
||
};
|
||
}
|
||
|
||
// 资源管理:缓存数据(已在初始化时创建)
|
||
|
||
// 优化的刷新函数:一次请求同时更新趋势图和列表
|
||
const refreshAll = async() => {
|
||
if(window.__auditBusy) {
|
||
console.log('[Dashboard] 上次请求还在进行中,跳过本次刷新');
|
||
return;
|
||
}
|
||
|
||
// 检查是否还在dashboard页面
|
||
const currentPath = location.hash.replace('#', '') || '/dashboard';
|
||
if(currentPath !== '/dashboard'){
|
||
if(window.__auditTimer){
|
||
clearInterval(window.__auditTimer);
|
||
window.__auditTimer = null;
|
||
}
|
||
return;
|
||
}
|
||
|
||
// 取消之前的请求
|
||
if(window.__auditAbortController){
|
||
window.__auditAbortController.abort();
|
||
}
|
||
window.__auditAbortController = new AbortController();
|
||
|
||
window.__auditBusy=true;
|
||
const startTime = Date.now();
|
||
|
||
// 设置超时保护:15秒后强制重置busy状态
|
||
const timeoutId = setTimeout(() => {
|
||
console.warn('[Dashboard] 请求超时,强制重置状态');
|
||
window.__auditBusy = false;
|
||
if(window.__auditAbortController){
|
||
window.__auditAbortController.abort();
|
||
window.__auditAbortController = null;
|
||
}
|
||
}, 15000);
|
||
|
||
try {
|
||
// 请求全部数据(不限制),传递AbortController信号
|
||
const signal = window.__auditAbortController.signal;
|
||
const [pddRes, ytRes, dashRes] = await Promise.all([
|
||
fetch('/api/audit/pdd', {
|
||
headers: { 'Content-Type': 'application/json' },
|
||
credentials: 'include',
|
||
signal
|
||
}).then(r => r.ok ? r.json() : {list:[]}).catch((e)=>{
|
||
if(e.name === 'AbortError') console.log('[Dashboard] PDD请求被取消');
|
||
return {list:[]};
|
||
}),
|
||
fetch('/api/audit/yt', {
|
||
headers: { 'Content-Type': 'application/json' },
|
||
credentials: 'include',
|
||
signal
|
||
}).then(r => r.ok ? r.json() : {list:[]}).catch((e)=>{
|
||
if(e.name === 'AbortError') console.log('[Dashboard] YT请求被取消');
|
||
return {list:[]};
|
||
}),
|
||
fetch('/api/dashboard', {
|
||
headers: { 'Content-Type': 'application/json' },
|
||
credentials: 'include',
|
||
signal
|
||
}).then(r => r.ok ? r.json() : null).catch((e)=>{
|
||
if(e.name === 'AbortError') console.log('[Dashboard] Dashboard请求被取消');
|
||
return null;
|
||
})
|
||
]);
|
||
|
||
// 实时更新良品/不良率卡片和今日产量卡片
|
||
if(dashRes) {
|
||
// 更新良品/不良率
|
||
const goodRateEl = document.getElementById('good-rate-value');
|
||
const badRateEl = document.getElementById('bad-rate-value');
|
||
if(goodRateEl) goodRateEl.textContent = dashRes.goodRate || '—';
|
||
if(badRateEl) badRateEl.textContent = dashRes.badRate || '—';
|
||
|
||
// 更新今日产量卡片
|
||
const productionCard = document.getElementById('today-production-card');
|
||
const platformNameEl = document.getElementById('today-platform-name');
|
||
const productionValueEl = document.getElementById('today-production-value');
|
||
const productionFillEl = document.getElementById('today-production-fill');
|
||
|
||
if(productionCard && platformNameEl && productionValueEl && productionFillEl) {
|
||
const currentPlatform = productionCard.dataset.platform;
|
||
const newPlatform = dashRes.activePlatform || 'pdd';
|
||
const isPdd = newPlatform === 'pdd';
|
||
const value = isPdd ? (dashRes.todayPdd || 0) : (dashRes.todayYt || 0);
|
||
const platformName = isPdd ? '拼多多' : '圆通';
|
||
const color = isPdd ? '#3B82F6' : '#10B981';
|
||
const fillWidth = Math.min((value / 10000) * 100, 100);
|
||
|
||
// 如果平台切换了,更新图标
|
||
if(currentPlatform !== newPlatform) {
|
||
productionCard.dataset.platform = newPlatform;
|
||
const iconEl = productionCard.querySelector('.metrics-icon');
|
||
if(iconEl) {
|
||
iconEl.innerHTML = isPdd
|
||
? '<img src="assets/pdd.svg" style="width:20px;height:20px" />'
|
||
: '<img src="assets/yt.svg" style="width:20px;height:20px" />';
|
||
}
|
||
const percentEl = productionCard.querySelector('.metrics-percent');
|
||
if(percentEl) percentEl.style.color = color;
|
||
}
|
||
|
||
platformNameEl.textContent = '今日' + platformName;
|
||
productionValueEl.textContent = value;
|
||
productionFillEl.style.width = fillWidth + '%';
|
||
productionFillEl.style.backgroundColor = color;
|
||
}
|
||
}
|
||
|
||
clearTimeout(timeoutId);
|
||
const duration = Date.now() - startTime;
|
||
if(duration > 3000){
|
||
console.warn('[Dashboard] 请求耗时过长:', duration, 'ms');
|
||
}
|
||
|
||
// 再次检查页面,避免切换后更新
|
||
if(location.hash.replace('#', '') !== '/dashboard'){
|
||
return;
|
||
}
|
||
|
||
// 缓存全部数据(不再限制30天,支持大数据量显示)
|
||
window.__auditCache.pdd = pddRes.list || [];
|
||
window.__auditCache.yt = ytRes.list || [];
|
||
|
||
// 更新趋势图和统计数据
|
||
drawTrendChart(window.__auditCache.pdd, window.__auditCache.yt);
|
||
updateTrendStats(window.__auditCache.pdd, window.__auditCache.yt);
|
||
refreshShipmentPlatformStats(false);
|
||
|
||
// 更新列表(应用筛选,只显示前100条)
|
||
const sP=toEpoch(window.__pddParams.start), eP=toEpoch(window.__pddParams.end);
|
||
const sY=toEpoch(window.__ytParams.start), eY=toEpoch(window.__ytParams.end);
|
||
const pddView=(window.__auditCache.pdd||[]).filter(r=>{const t=toEpoch(r.ts_cn); if(t==null) return false; return (sP==null||t>=sP)&&(eP==null||t<=eP);});
|
||
const ytView=(window.__auditCache.yt||[]).filter(r=>{const t=toEpoch(r.ts_cn); if(t==null) return false; return (sY==null||t>=sY)&&(eY==null||t<=eY);});
|
||
const pddEls=pddView.slice(0, 100).map(r=>`<li><span style="display:inline-flex;align-items:center;gap:4px;min-width:180px"><img src="assets/pdd.svg" style="width:16px;height:16px" />${r.ts_cn||'—'}</span><span class="badge">${r.batch||''}</span><span class="badge">${r.mac||''}</span><span class="badge">${r.note||''}</span></li>`).join('')||'<li>暂无数据</li>';
|
||
const ytEls=ytView.slice(0, 100).map(r=>`<li><span style="display:inline-flex;align-items:center;gap:4px;min-width:180px"><img src="assets/yt.svg" style="width:16px;height:16px" />${r.ts_cn||'—'}</span><span class="badge">${r.batch||''}</span><span class="badge">${r.mac||''}</span><span class="badge">${r.note||''}</span></li>`).join('')||'<li>暂无数据</li>';
|
||
const p=document.getElementById('audit-pdd'); if(p) p.innerHTML=pddEls;
|
||
const y=document.getElementById('audit-yt'); if(y) y.innerHTML=ytEls;
|
||
|
||
// 更新整合看板
|
||
const updateAuditDisplay = () => {
|
||
const platform = window.__currentAuditPlatform || 'pdd';
|
||
const params = platform === 'pdd' ? window.__pddParams : window.__ytParams;
|
||
const sTime = toEpoch(params.start);
|
||
const eTime = toEpoch(params.end);
|
||
const cacheData = platform === 'pdd' ? window.__auditCache.pdd : window.__auditCache.yt;
|
||
const filteredData = (cacheData || []).filter(r => {
|
||
const t = toEpoch(r.ts_cn);
|
||
if (t == null) return false;
|
||
return (sTime == null || t >= sTime) && (eTime == null || t <= eTime);
|
||
});
|
||
const platformIcon = platform === 'pdd' ? '<img src="assets/pdd.svg" style="width:16px;height:16px" />' : '<img src="assets/yt.svg" style="width:16px;height:16px" />';
|
||
const listHtml = filteredData.slice(0, 100).map(r =>
|
||
`<li><span style="display:inline-flex;align-items:center;gap:4px;min-width:180px">${platformIcon}${r.ts_cn||'—'}</span><span class="badge">${r.batch||''}</span><span class="badge">${r.mac||''}</span><span class="badge">${r.note||''}</span></li>`
|
||
).join('') || '<li>暂无数据</li>';
|
||
const auditListEl = document.getElementById('audit-list');
|
||
if (auditListEl) auditListEl.innerHTML = listHtml;
|
||
};
|
||
updateAuditDisplay();
|
||
} catch(e) {
|
||
clearTimeout(timeoutId);
|
||
if(e.name !== 'AbortError'){
|
||
console.error('更新审计数据失败:', e);
|
||
}
|
||
} finally {
|
||
window.__auditBusy=false;
|
||
window.__auditAbortController = null;
|
||
}
|
||
};
|
||
|
||
// 初始化历史数据(用于测试趋势功能)
|
||
const initHistoryData = () => {
|
||
const historyKey = 'production_history';
|
||
const history = JSON.parse(localStorage.getItem(historyKey) || '{}');
|
||
const today = new Date();
|
||
|
||
// 为过去7天生成模拟数据
|
||
for (let i = 1; i <= 7; i++) {
|
||
const date = new Date(today);
|
||
date.setDate(today.getDate() - i);
|
||
const dateStr = date.toISOString().split('T')[0];
|
||
|
||
if (!history[dateStr]) {
|
||
history[dateStr] = {
|
||
pdd: Math.floor(Math.random() * 500) + 300,
|
||
yt: Math.floor(Math.random() * 400) + 200,
|
||
bad: Math.floor(Math.random() * 20) + 5,
|
||
pending: Math.floor(Math.random() * 30) + 10,
|
||
goodRate: 95 + Math.random() * 3, // 95-98% 的良品率
|
||
};
|
||
}
|
||
}
|
||
|
||
localStorage.setItem(historyKey, JSON.stringify(history));
|
||
};
|
||
|
||
// 初始化历史数据(仅在第一次访问时)
|
||
if (!localStorage.getItem('history_initialized')) {
|
||
initHistoryData();
|
||
localStorage.setItem('history_initialized', 'true');
|
||
}
|
||
|
||
// 刷新间隔10秒
|
||
window.__auditTimer=setInterval(refreshAll, 10000);
|
||
},0);
|
||
return `
|
||
<div class="dashboard-container" style="display:flex;flex-direction:column;height:100%;overflow:hidden">
|
||
<!-- 四个指标卡片 -->
|
||
<div class="dashboard-metrics-4col" style="flex-shrink:0">
|
||
${metricsCard('直通良品数', data.fpyCount || 0, 'success')}
|
||
${rateCard(data.goodRate || '—', data.badRate || '—')}
|
||
${metricsCard('发货数量', data.shipments, 'warning')}
|
||
${todayProductionCard(data.todayPdd || 0, data.todayYt || 0, data.activePlatform || 'pdd')}
|
||
</div>
|
||
<!-- 趋势图 + 环形图 -->
|
||
<div style="display:flex;gap:12px;margin-top:12px;flex-shrink:0">
|
||
<!-- 左侧趋势卡片 -->
|
||
<div class="card" style="flex:0 0 72%;padding:0;overflow:hidden;border-radius:16px;background:var(--surface)">
|
||
<div style="display:flex;flex-wrap:wrap;padding:20px">
|
||
<!-- 左侧摘要信息 -->
|
||
<div style="flex:0 0 160px;padding-right:16px;border-right:1px solid var(--border);display:flex;flex-direction:column;justify-content:center">
|
||
<div style="margin-bottom:12px">
|
||
<div id="trend-total-pdd" style="font-size:24px;font-weight:700;color:#3B82F6;line-height:1.2">0</div>
|
||
<div style="font-size:11px;color:var(--text-secondary);margin-top:2px">拼多多本月产量</div>
|
||
</div>
|
||
<div style="margin-bottom:12px">
|
||
<div id="trend-total-yt" style="font-size:24px;font-weight:700;color:#10B981;line-height:1.2">0</div>
|
||
<div style="font-size:11px;color:var(--text-secondary);margin-top:2px">圆通本月产量</div>
|
||
</div>
|
||
<button id="trend-summary-btn" class="btn-summary" style="margin-top:12px">
|
||
<span style="display:flex;align-items:center;gap:6px">
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<path d="M9 11H3v10h6V11zm4-7H7v6h6V4zm4 3h-6v6h6V7zm4 8h-6v6h6v-6z"/>
|
||
<path d="M3 3h18v18H3z"/>
|
||
</svg>
|
||
月度汇总
|
||
</span>
|
||
</button>
|
||
</div>
|
||
<!-- 右侧图表区域 -->
|
||
<div style="flex:1;min-width:280px;padding-left:16px">
|
||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
|
||
<div style="font-weight:600;font-size:14px">审计趋势</div>
|
||
<div style="display:flex;align-items:center;gap:12px">
|
||
<!-- 图例 -->
|
||
<div style="display:flex;align-items:center;gap:10px;font-size:11px;color:var(--text-secondary)">
|
||
<span style="display:flex;align-items:center;gap:3px"><span style="width:6px;height:6px;border-radius:50%;background:#10B981"></span>圆通</span>
|
||
<span style="display:flex;align-items:center;gap:3px"><span style="width:6px;height:6px;border-radius:50%;background:#3B82F6"></span>拼多多</span>
|
||
</div>
|
||
<!-- 时间范围选择 -->
|
||
<div id="trend-range-tabs" style="display:flex;background:var(--surface);border-radius:6px;padding:2px;font-size:11px">
|
||
<span data-range="day" class="trend-tab active" style="padding:3px 8px;border-radius:4px;cursor:pointer;transition:all 0.2s">按天</span>
|
||
<span data-range="week" class="trend-tab" style="padding:3px 8px;border-radius:4px;cursor:pointer;transition:all 0.2s">按周</span>
|
||
<span data-range="month" class="trend-tab" style="padding:3px 8px;border-radius:4px;cursor:pointer;transition:all 0.2s">按月</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div style="height:230px;position:relative">
|
||
<canvas id="trend-chart" style="width:100%;height:100%;cursor:crosshair"></canvas>
|
||
<div id="chart-tooltip" style="position:absolute;background:rgba(15,22,35,0.95);color:#fff;padding:8px 12px;border-radius:8px;font-size:12px;pointer-events:none;display:none;white-space:nowrap;box-shadow:0 4px 12px rgba(0,0,0,0.3);border:1px solid rgba(255,255,255,0.1)"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<!-- 底部统计栏 -->
|
||
<div style="display:grid;grid-template-columns:repeat(4,1fr);border-top:1px solid var(--border);background:var(--surface)">
|
||
<div style="padding:10px 12px;text-align:center;border-right:1px solid var(--border)">
|
||
<div style="font-size:10px;color:var(--text-secondary);text-transform:uppercase;letter-spacing:0.5px;margin-bottom:2px">今日拼多多</div>
|
||
<div id="trend-today-pdd" style="font-size:16px;font-weight:600;color:#3B82F6">0</div>
|
||
</div>
|
||
<div style="padding:10px 12px;text-align:center;border-right:1px solid var(--border)">
|
||
<div style="font-size:10px;color:var(--text-secondary);text-transform:uppercase;letter-spacing:0.5px;margin-bottom:2px">今日圆通</div>
|
||
<div id="trend-today-yt" style="font-size:16px;font-weight:600;color:#10B981">0</div>
|
||
</div>
|
||
<div style="padding:10px 12px;text-align:center;border-right:1px solid var(--border)">
|
||
<div style="font-size:10px;color:var(--text-secondary);text-transform:uppercase;letter-spacing:0.5px;margin-bottom:2px">本周合计</div>
|
||
<div id="trend-week-total" style="font-size:16px;font-weight:600;color:#10b981">0</div>
|
||
</div>
|
||
<div style="padding:10px 12px;text-align:center">
|
||
<div style="font-size:10px;color:var(--text-secondary);text-transform:uppercase;letter-spacing:0.5px;margin-bottom:2px">状态</div>
|
||
<div id="trend-status" style="font-size:16px;font-weight:600;color:#10b981">正常</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<!-- 右侧环形图卡片 -->
|
||
<div class="card" style="flex:1;padding:20px;border-radius:16px;display:flex;flex-direction:column;background:var(--surface);min-height:290px">
|
||
<div style="font-weight:600;font-size:14px;margin-bottom:12px">产量占比</div>
|
||
<div style="flex:1;display:grid;grid-template-columns:repeat(2,1fr);gap:16px;align-items:center">
|
||
<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;position:relative;min-width:0">
|
||
<div style="font-size:12px;color:var(--text-secondary);margin-bottom:8px">审计(本月)</div>
|
||
<div style="display:flex;align-items:center;justify-content:center;position:relative">
|
||
<canvas id="donut-chart" style="width:160px;height:160px"></canvas>
|
||
</div>
|
||
<div style="display:flex;justify-content:center;gap:24px;margin-top:12px">
|
||
<div style="text-align:center">
|
||
<div id="donut-pdd-pct" style="font-size:20px;font-weight:700;color:#3B82F6">0%</div>
|
||
<div style="display:flex;align-items:center;gap:4px;font-size:11px;color:var(--text-secondary);margin-top:2px">
|
||
<span style="width:8px;height:8px;border-radius:50%;background:#3B82F6"></span>拼多多
|
||
</div>
|
||
</div>
|
||
<div style="text-align:center">
|
||
<div id="donut-yt-pct" style="font-size:20px;font-weight:700;color:#10B981">0%</div>
|
||
<div style="display:flex;align-items:center;gap:4px;font-size:11px;color:var(--text-secondary);margin-top:2px">
|
||
<span style="width:8px;height:8px;border-radius:50%;background:#10B981"></span>圆通
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;position:relative;min-width:0">
|
||
<div style="font-size:12px;color:var(--text-secondary);margin-bottom:8px">发货(按机种)</div>
|
||
<div style="display:flex;align-items:center;justify-content:center;position:relative">
|
||
<canvas id="shipment-donut-chart" style="width:160px;height:160px"></canvas>
|
||
</div>
|
||
<div id="shipment-donut-legend" style="display:flex;justify-content:center;gap:12px;flex-wrap:wrap;margin-top:12px"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div style="margin-top:12px;flex:1;min-height:0;display:flex;gap:12px">
|
||
<!-- 审计看板(压缩宽度) -->
|
||
<div id="audit-card" class="card" style="flex:1;display:flex;flex-direction:column;min-height:0;background:var(--surface);cursor:pointer" onclick="window.Dashboard.showAuditModal()">
|
||
<div style="font-weight:600;margin-bottom:12px;display:flex;justify-content:space-between;align-items:center;flex-shrink:0;gap:8px">
|
||
<span style="display:flex;align-items:center;gap:8px">审计看板 <span style="font-size:11px;color:var(--text-2);font-weight:400">点击查看完整数据</span></span>
|
||
<div style="display:flex;gap:8px;align-items:center" onclick="event.stopPropagation()">
|
||
<select id="audit-platform-select" style="padding:4px 8px;background:var(--surface);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:13px;outline:none;cursor:pointer" onclick="event.stopPropagation()">
|
||
<option value="pdd">拼多多</option>
|
||
<option value="yt">圆通</option>
|
||
</select>
|
||
<input id="audit-date" type="date" style="padding:4px 8px;background:var(--surface);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:13px;outline:none" onclick="event.stopPropagation()" />
|
||
</div>
|
||
</div>
|
||
<ul id="audit-list" class="list" style="overflow-y:auto;flex:1;min-height:0">${pddList}</ul>
|
||
<!-- 底部操作栏 -->
|
||
<div style="margin-top:auto;padding-top:12px;border-top:1px solid var(--border);display:flex;justify-content:space-between;align-items:center;flex-shrink:0">
|
||
<div style="font-size:11px;color:var(--text-2)">
|
||
<span id="audit-update-time">实时更新</span>
|
||
</div>
|
||
<div style="display:flex;gap:8px">
|
||
<button onclick="event.stopPropagation();window.Dashboard.showAuditModal()" style="padding:4px 8px;background:var(--primary);color:white;border:none;border-radius:6px;font-size:12px;cursor:pointer">查看全部</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- AI智能报表 -->
|
||
${AIReport.generateAICard()}
|
||
|
||
<!-- 隐藏的旧卡片 -->
|
||
<div id="audit-pdd-card" class="card" style="display:none;flex-direction:column;flex:1;min-height:0;background:var(--surface)">
|
||
<div style="font-weight:600;margin-bottom:8px;display:flex;justify-content:space-between;align-items:center;flex-shrink:0">
|
||
<span>拼多多审计</span>
|
||
<input id="audit-date-pdd" type="date" style="padding:4px 8px;background:var(--surface);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:13px;outline:none" onclick="event.stopPropagation()" />
|
||
</div>
|
||
<ul id="audit-pdd" class="list" style="overflow-y:auto;flex:1;min-height:0">${pddList}</ul>
|
||
</div>
|
||
<div id="audit-yt-card" class="card" style="display:none;flex-direction:column;flex:1;min-height:0;background:var(--surface)">
|
||
<div style="font-weight:600;margin-bottom:8px;display:flex;justify-content:space-between;align-items:center;flex-shrink:0">
|
||
<span>圆通审计</span>
|
||
<input id="audit-date-yt" type="date" style="padding:4px 8px;background:var(--surface);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:13px;outline:none" onclick="event.stopPropagation()" />
|
||
</div>
|
||
<ul id="audit-yt" class="list" style="overflow-y:auto;flex:1;min-height:0">${ytList}</ul>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// 跳转到良不良统计页面
|
||
window.navigateToStats = () => {
|
||
console.log('navigateToStats 被调用');
|
||
|
||
// 直接使用路由跳转
|
||
window.location.hash = '#/upload/stats';
|
||
|
||
// 等待页面加载完成后滚动到统计区域
|
||
setTimeout(() => {
|
||
// 查找良不良统计区域
|
||
const statsSection = document.querySelector('#stats-upload-form') ||
|
||
document.querySelector('h2') ||
|
||
document.querySelector('.field');
|
||
|
||
console.log('找到统计区域:', statsSection);
|
||
|
||
if (statsSection) {
|
||
statsSection.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||
|
||
// 高亮效果
|
||
const originalBg = statsSection.style.backgroundColor;
|
||
statsSection.style.backgroundColor = 'var(--primary)';
|
||
statsSection.style.transition = 'background-color 0.3s';
|
||
|
||
setTimeout(() => {
|
||
statsSection.style.backgroundColor = originalBg;
|
||
}, 1000);
|
||
}
|
||
}, 500);
|
||
};
|
||
|
||
// 将方法暴露到全局
|
||
window.Dashboard = {
|
||
showAuditModal,
|
||
cleanup
|
||
};
|
||
|
||
Router.register('/dashboard', render);
|
||
})(); |