788 lines
39 KiB
JavaScript
788 lines
39 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>'
|
||
};
|
||
|
||
function metricsCard(title, value, badgeClass) {
|
||
const colors = {
|
||
success: { bg: '#10B981', text: '#02972f' },
|
||
warning: { bg: '#F59E0B', text: '#B45309' },
|
||
danger: { bg: '#EF4444', text: '#DC2626' }
|
||
};
|
||
const color = colors[badgeClass] || colors.success;
|
||
const isPercent = String(value).includes('%');
|
||
const numValue = parseFloat(String(value).replace('%', '')) || 0;
|
||
const fillWidth = isPercent ? Math.min(numValue, 100) : Math.min((numValue / 100000) * 100, 100);
|
||
const icon = metricsIcons[title] || metricsIcons['良品率'];
|
||
const isTruck = title === '发货数量';
|
||
const iconBg = isTruck ? '#f5f5f5' : color.bg;
|
||
|
||
return `<div class="metrics-card">
|
||
<div class="metrics-title">
|
||
<span class="metrics-icon" style="background-color:${iconBg}">
|
||
${icon}
|
||
</span>
|
||
<p class="metrics-title-text">${title}</p>
|
||
<p class="metrics-percent" style="color:${color.text}">
|
||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1792 1792" fill="currentColor" height="16" width="16">
|
||
<path d="M1408 1216q0 26-19 45t-45 19h-896q-26 0-45-19t-19-45 19-45l448-448q19-19 45-19t45 19l448 448q19 19 19 45z"></path>
|
||
</svg>
|
||
</p>
|
||
</div>
|
||
<div class="metrics-data">
|
||
<p class="metrics-value">${value}</p>
|
||
<div class="metrics-range">
|
||
<div class="metrics-fill" style="width:${fillWidth}%;background-color:${color.bg}"></div>
|
||
</div>
|
||
</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 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||[]).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||[]).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;
|
||
}
|
||
|
||
// 动态计算审计框高度
|
||
const calculateAuditHeight = () => {
|
||
const viewEl = document.getElementById('view');
|
||
if(!viewEl) return 460;
|
||
|
||
const viewHeight = viewEl.clientHeight;
|
||
const viewPadding = 40; // view的padding (20px * 2)
|
||
|
||
// 获取其他元素的高度
|
||
const metricsEl = document.querySelector('.dashboard-metrics-4col');
|
||
const trendEl = document.querySelector('.card');
|
||
|
||
const metricsHeight = metricsEl ? metricsEl.offsetHeight : 60;
|
||
const trendHeight = trendEl ? trendEl.offsetHeight : 260;
|
||
const gaps = 24; // margin-top间距 (12px * 2)
|
||
|
||
// 计算剩余高度
|
||
const remainingHeight = viewHeight - viewPadding - metricsHeight - trendHeight - gaps;
|
||
|
||
// 最小高度300px,最大不超过剩余空间
|
||
return Math.max(300, Math.min(remainingHeight, 460));
|
||
};
|
||
|
||
// 设置审计框高度
|
||
const setAuditHeight = () => {
|
||
const height = calculateAuditHeight();
|
||
const auditCard = document.getElementById('audit-card');
|
||
const pddCard = document.getElementById('audit-pdd-card');
|
||
const ytCard = document.getElementById('audit-yt-card');
|
||
if(auditCard) auditCard.style.height = height + 'px';
|
||
if(pddCard) pddCard.style.height = height + 'px';
|
||
if(ytCard) ytCard.style.height = height + 'px';
|
||
};
|
||
|
||
// 初始设置高度
|
||
setAuditHeight();
|
||
|
||
// 监听窗口大小变化
|
||
if(window.__dashboardResizeHandler){
|
||
window.removeEventListener('resize', window.__dashboardResizeHandler);
|
||
}
|
||
window.__dashboardResizeHandler = () => {
|
||
setAuditHeight();
|
||
// 重绘趋势图
|
||
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 ? '#f8f9fb' : '#0c0f14',
|
||
grid: isLight ? '#e5e7eb' : '#1a1f28',
|
||
text: isLight ? '#6b7280' : '#6b7280',
|
||
textLabel: isLight ? '#1a1d23' : '#e5e7eb'
|
||
};
|
||
};
|
||
|
||
// 当前时间维度: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);
|
||
|
||
// 绘制参数(使用逻辑尺寸而非物理像素)
|
||
const padding = {left: 40, right: 20, top: 20, bottom: 30};
|
||
const chartWidth = rect.width - padding.left - padding.right;
|
||
const chartHeight = rect.height - padding.top - padding.bottom;
|
||
|
||
// 保存图表数据供鼠标事件使用
|
||
chartData = {timePoints, pddCounts, ytCounts, maxCount, padding, chartWidth, chartHeight, timeRange};
|
||
|
||
// 清空画布
|
||
ctx.fillStyle = colors.bg;
|
||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||
|
||
// 绘制网格线
|
||
ctx.strokeStyle = colors.grid;
|
||
ctx.lineWidth = 1;
|
||
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();
|
||
}
|
||
|
||
// 绘制Y轴刻度
|
||
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(value.toString(), 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];
|
||
const drawLine = (counts, color) => {
|
||
ctx.strokeStyle = color;
|
||
ctx.lineWidth = 2;
|
||
ctx.beginPath();
|
||
counts.forEach((count, i) => {
|
||
const x = padding.left + (chartWidth / (timePoints.length - 1)) * i;
|
||
const y = padding.top + chartHeight - (count / maxCount) * chartHeight;
|
||
if(i === 0) ctx.moveTo(x, y);
|
||
else ctx.lineTo(x, y);
|
||
});
|
||
ctx.stroke();
|
||
|
||
// 绘制数据点
|
||
counts.forEach((count, i) => {
|
||
const x = padding.left + (chartWidth / (timePoints.length - 1)) * i;
|
||
const y = padding.top + chartHeight - (count / maxCount) * chartHeight;
|
||
const isToday = timeRange === 'day' && timePoints[i] === today;
|
||
|
||
if(isToday) {
|
||
// 今日数据点:更大的圆圈 + 外圈光晕
|
||
ctx.beginPath();
|
||
ctx.arc(x, y, 10, 0, Math.PI * 2);
|
||
ctx.fillStyle = color.replace(')', ', 0.2)').replace('rgb', 'rgba').replace('#', '');
|
||
ctx.fillStyle = color + '33'; // 添加透明度
|
||
ctx.fill();
|
||
|
||
ctx.beginPath();
|
||
ctx.arc(x, y, 6, 0, Math.PI * 2);
|
||
ctx.fillStyle = color;
|
||
ctx.fill();
|
||
|
||
// 白色内圈
|
||
ctx.beginPath();
|
||
ctx.arc(x, y, 3, 0, Math.PI * 2);
|
||
ctx.fillStyle = '#fff';
|
||
ctx.fill();
|
||
|
||
// 显示今日数值标签
|
||
ctx.fillStyle = color;
|
||
ctx.font = 'bold 12px sans-serif';
|
||
ctx.textAlign = 'center';
|
||
ctx.fillText(count.toString(), x, y - 14);
|
||
} else {
|
||
// 普通数据点
|
||
ctx.beginPath();
|
||
ctx.arc(x, y, 3, 0, Math.PI * 2);
|
||
ctx.fillStyle = color;
|
||
ctx.fill();
|
||
}
|
||
});
|
||
};
|
||
|
||
drawLine(pddCounts, '#f59e0b');
|
||
drawLine(ytCounts, '#3b82f6');
|
||
|
||
// 绘制图例
|
||
ctx.font = '12px sans-serif';
|
||
ctx.textAlign = 'left';
|
||
ctx.fillStyle = '#f59e0b';
|
||
ctx.fillRect(padding.left, 5, 12, 12);
|
||
ctx.fillStyle = colors.textLabel;
|
||
ctx.fillText('拼多多', padding.left + 18, 15);
|
||
ctx.fillStyle = '#3b82f6';
|
||
ctx.fillRect(padding.left + 80, 5, 12, 12);
|
||
ctx.fillStyle = colors.textLabel;
|
||
ctx.fillText('圆通', padding.left + 98, 15);
|
||
};
|
||
|
||
// 初始化缓存数据
|
||
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} = 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 - (pddCounts[i] / maxCount) * chartHeight;
|
||
const ytY = padding.top + chartHeight - (ytCounts[i] / maxCount) * chartHeight;
|
||
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:#f59e0b">拼多多: ${pddCount}</div><div style="color:#3b82f6">圆通: ${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';
|
||
};
|
||
}
|
||
|
||
// 时间维度切换事件
|
||
const trendRangeSelect = document.getElementById('trend-range-select');
|
||
if(trendRangeSelect){
|
||
trendRangeSelect.onchange = () => {
|
||
window.__trendTimeRange = trendRangeSelect.value;
|
||
const pddData = window.__auditCache?.pdd || pdd.list;
|
||
const ytData = window.__auditCache?.yt || yt.list;
|
||
drawTrendChart(pddData, ytData);
|
||
};
|
||
}
|
||
|
||
// 刷新审计列表的函数
|
||
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] = 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:[]};
|
||
})
|
||
]);
|
||
|
||
clearTimeout(timeoutId);
|
||
const duration = Date.now() - startTime;
|
||
if(duration > 3000){
|
||
console.warn('[Dashboard] 请求耗时过长:', duration, 'ms');
|
||
}
|
||
|
||
// 再次检查页面,避免切换后更新
|
||
if(location.hash.replace('#', '') !== '/dashboard'){
|
||
return;
|
||
}
|
||
|
||
// 缓存数据(只保留最近30天的数据以节省内存)
|
||
const filterRecent30Days = (list) => {
|
||
const cutoff = Date.now() - 30 * 24 * 60 * 60 * 1000;
|
||
return (list || []).filter(r => {
|
||
if(!r.ts_cn) return false;
|
||
const t = toEpoch(r.ts_cn);
|
||
return t && t >= cutoff;
|
||
});
|
||
};
|
||
|
||
window.__auditCache.pdd = filterRecent30Days(pddRes.list);
|
||
window.__auditCache.yt = filterRecent30Days(ytRes.list);
|
||
|
||
// 更新趋势图
|
||
drawTrendChart(window.__auditCache.pdd, window.__auditCache.yt);
|
||
|
||
// 更新列表(应用筛选,只显示前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;
|
||
}
|
||
};
|
||
|
||
// 刷新间隔10秒
|
||
window.__auditTimer=setInterval(refreshAll, 10000);
|
||
},0);
|
||
return `
|
||
<div>
|
||
<div class="dashboard-metrics-4col">
|
||
${metricsCard('直通良品率', data.fpyRate || '—', 'success')}
|
||
${metricsCard('良品率', data.goodRate, 'success')}
|
||
${metricsCard('发货数量', data.shipments, 'warning')}
|
||
${metricsCard('不良数量', data.badCount ?? data.defects, 'danger')}
|
||
</div>
|
||
<div class="card" style="margin-top:12px">
|
||
<div style="font-weight:600;margin-bottom:8px;display:flex;justify-content:space-between;align-items:center">
|
||
<span>审计趋势</span>
|
||
<select id="trend-range-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">
|
||
<option value="day">按天</option>
|
||
<option value="week">按周</option>
|
||
<option value="month">按月</option>
|
||
</select>
|
||
</div>
|
||
<div style="height:220px;background:var(--bg);border:1px solid var(--border);border-radius:8px;position:relative">
|
||
<canvas id="trend-chart" style="width:100%;height:100%;cursor:crosshair"></canvas>
|
||
<div id="chart-tooltip" style="position:absolute;background:rgba(0,0,0,0.85);color:#fff;padding:6px 10px;border-radius:4px;font-size:12px;pointer-events:none;display:none;white-space:nowrap"></div>
|
||
</div>
|
||
</div>
|
||
<div style="margin-top:12px">
|
||
<div id="audit-card" class="card" style="display:flex;flex-direction:column;height:460px">
|
||
<div style="font-weight:600;margin-bottom:8px;display:flex;justify-content:space-between;align-items:center;flex-shrink:0;gap:8px">
|
||
<span>审计看板</span>
|
||
<div style="display:flex;gap:8px;align-items:center">
|
||
<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">
|
||
<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" />
|
||
</div>
|
||
</div>
|
||
<ul id="audit-list" class="list" style="overflow-y:auto;flex:1;min-height:0">${pddList}</ul>
|
||
</div>
|
||
<div id="audit-pdd-card" class="card" style="display:none;flex-direction:column;height:460px">
|
||
<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" />
|
||
</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;height:460px">
|
||
<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" />
|
||
</div>
|
||
<ul id="audit-yt" class="list" style="overflow-y:auto;flex:1;min-height:0">${ytList}</ul>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
Router.register('/dashboard', render);
|
||
})(); |