ERP/frontend/js/components/dashboard.js

464 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const Dashboard = (() => {
function metricsCard(title, value, badgeClass) {
return `<div class="card" style="padding:10px">
<div style="display:flex;flex-direction:column;gap:6px;align-items:center;text-align:center">
<div style="font-weight:600;font-size:12px;color:var(--text-2);white-space:nowrap">${title}</div>
<div class="badge ${badgeClass}" style="font-size:16px;font-weight:700;padding:6px 12px">${value}</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 pddDateEl = document.getElementById('audit-date-pdd');
const ytDateEl = document.getElementById('audit-date-yt');
if(pddDateEl) pddDateEl.onchange = null;
if(ytDateEl) ytDateEl.onchange = 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>${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>${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;
}
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'
};
};
// 绘制趋势图
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 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);
// 获取最近30天的日期
const days = [];
for(let i = 29; i >= 0; i--){
const d = new Date();
d.setDate(d.getDate() - i);
days.push(d.toISOString().split('T')[0]);
}
// 统计每天的数据去重MAC地址
const countByDay = (list) => {
const uniqueMacsByDay = {};
days.forEach(d => uniqueMacsByDay[d] = new Set());
(list||[]).forEach(r => {
if(r.ts_cn && r.mac){
const date = r.ts_cn.split(' ')[0];
if(uniqueMacsByDay[date] !== undefined) {
uniqueMacsByDay[date].add(r.mac);
}
}
});
return days.map(d => uniqueMacsByDay[d].size);
};
const pddCounts = countByDay(pddData);
const ytCounts = countByDay(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 = {days, pddCounts, ytCounts, maxCount, padding, chartWidth, chartHeight};
// 清空画布
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轴标签每5天显示一个
ctx.textAlign = 'center';
days.forEach((day, i) => {
if(i % 5 === 0 || i === days.length - 1){
const x = padding.left + (chartWidth / (days.length - 1)) * i;
const label = day.slice(5);
ctx.fillText(label, x, rect.height - 8);
}
});
// 绘制折线
const drawLine = (counts, color) => {
ctx.strokeStyle = color;
ctx.lineWidth = 2;
ctx.beginPath();
counts.forEach((count, i) => {
const x = padding.left + (chartWidth / (days.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();
// 绘制数据点
ctx.fillStyle = color;
counts.forEach((count, i) => {
const x = padding.left + (chartWidth / (days.length - 1)) * i;
const y = padding.top + chartHeight - (count / maxCount) * chartHeight;
ctx.beginPath();
ctx.arc(x, y, 3, 0, Math.PI * 2);
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);
};
// 初始绘制
drawTrendChart(pdd.list, yt.list);
// 添加鼠标悬停事件
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 {days, pddCounts, ytCounts, maxCount, padding, chartWidth, chartHeight} = chartData;
// 查找最近的数据点
let nearestIndex = -1;
let minDist = 15;
for(let i = 0; i < days.length; i++){
const x = padding.left + (chartWidth / (days.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 date = days[nearestIndex];
const pddCount = pddCounts[nearestIndex];
const ytCount = ytCounts[nearestIndex];
tooltip.innerHTML = `<div style="margin-bottom:2px">${date}</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 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>${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>${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 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(); // 立即刷新
};
}
// 资源管理:缓存数据(已禁用自动清理)
window.__auditCache = window.__auditCache || {pdd: [], yt: []};
// 优化的刷新函数:一次请求同时更新趋势图和列表
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>${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>${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;
} 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 style="display:flex;flex-direction:column;height:100%">
<div class="dashboard-metrics-4col" style="flex-shrink:0">
${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;flex-shrink:0">
<div style="font-weight:600;margin-bottom:8px">审计趋势最近30天</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 class="grid cols-2" style="margin-top:12px;flex:1;min-height:0;align-items:stretch">
<div class="card" style="display:flex;flex-direction:column;min-height:0">
<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 class="card" style="display:flex;flex-direction:column;min-height:0">
<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);
})();