ERP/frontend/js/components/dashboard.js

511 lines
22 KiB
JavaScript
Raw Normal View History

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;
2025-11-22 08:27:41 +00:00
// 清理resize监听器
if(window.__dashboardResizeHandler){
window.removeEventListener('resize', window.__dashboardResizeHandler);
window.__dashboardResizeHandler = 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;
}
2025-11-22 08:27:41 +00:00
// 动态计算审计框高度
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 pddCard = document.getElementById('audit-pdd-card');
const ytCard = document.getElementById('audit-yt-card');
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();
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'
};
};
// 绘制趋势图
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 `
2025-11-22 08:27:41 +00:00
<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>
2025-11-22 08:27:41 +00:00
<div class="card" style="margin-top:12px">
<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>
2025-11-22 08:27:41 +00:00
<div class="grid cols-2" style="margin-top:12px">
<div id="audit-pdd-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">
<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>
2025-11-22 08:27:41 +00:00
<div id="audit-yt-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">
<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);
})();