仪表盘界面优化

This commit is contained in:
zzh 2026-05-14 11:38:30 +08:00
parent bd27a69944
commit 819ac1cdaa

View File

@ -698,7 +698,7 @@ const Dashboard = (() => {
// 绘制网格线(增强版 - 虚线样式)
ctx.strokeStyle = colors.grid;
ctx.lineWidth = 1;
ctx.globalAlpha = 0.5;
ctx.globalAlpha = 0.8;
ctx.setLineDash([4, 4]);
for(let i = 0; i <= 4; i++){
const y = padding.top + (chartHeight / 4) * i;
@ -1001,12 +1001,32 @@ const Dashboard = (() => {
const tooltipWidth = tooltip.offsetWidth || 100;
tooltip.style.left = (e.clientX - rect.left - tooltipWidth - 10) + 'px';
tooltip.style.top = (e.clientY - rect.top - 10) + 'px';
// 绘制悬停竖虚线(先重绘图表再叠加)
if(window.__redrawTrendChart) window.__redrawTrendChart();
const hoverX = padding.left + (chartWidth / (timePoints.length - 1)) * nearestIndex;
const dpr = window.devicePixelRatio || 1;
const hoverCtx = canvas.getContext('2d');
hoverCtx.save();
hoverCtx.strokeStyle = 'rgba(148,163,184,0.7)';
hoverCtx.lineWidth = 1;
hoverCtx.setLineDash([4, 4]);
hoverCtx.globalAlpha = 1;
hoverCtx.beginPath();
hoverCtx.moveTo(hoverX, padding.top);
hoverCtx.lineTo(hoverX, padding.top + chartHeight);
hoverCtx.stroke();
hoverCtx.setLineDash([]);
hoverCtx.restore();
} else {
tooltip.style.display = 'none';
// 移除悬停线,重绘图表
if(window.__redrawTrendChart) window.__redrawTrendChart();
}
};
canvas.onmouseleave = () => {
tooltip.style.display = 'none';
if(window.__redrawTrendChart) window.__redrawTrendChart();
};
}
@ -1259,12 +1279,13 @@ const Dashboard = (() => {
};
};
const drawShipmentDonutChart = async (stats) => {
// 纯同步绘制函数,不做任何 fetch
const renderShipmentDonut = (segments, total, activeIdx) => {
const canvas = document.getElementById('shipment-donut-chart');
if(!canvas) return;
const ctx = canvas.getContext('2d');
const dpr = window.devicePixelRatio || 1;
const size = 160;
const size = 280; // 足够容纳引出线
canvas.width = size * dpr;
canvas.height = size * dpr;
canvas.style.width = size + 'px';
@ -1272,147 +1293,184 @@ const Dashboard = (() => {
ctx.scale(dpr, dpr);
const legendEl = document.getElementById('shipment-donut-legend');
const cx = size / 2;
const cy = size / 2;
const outerR = 82;
const innerR = 56;
const ringW = outerR - innerR;
const paddingAngle = 8 * Math.PI / 180;
const isDark = document.documentElement.getAttribute('data-theme') === 'dark'
|| document.body.classList.contains('dark');
const textColor = isDark ? '#f1f5f9' : '#1e293b';
const subColor = '#94a3b8';
ctx.clearRect(0, 0, size, size);
if(total === 0 || segments.length === 0) {
ctx.beginPath();
ctx.arc(cx, cy, outerR, 0, Math.PI * 2);
ctx.strokeStyle = isDark ? '#334155' : '#e2e8f0';
ctx.lineWidth = ringW;
ctx.stroke();
if(legendEl) legendEl.innerHTML = '';
return;
}
// 计算各段角度
const availableAngle = Math.PI * 2 - paddingAngle * segments.length;
let angle = -Math.PI / 2;
segments.forEach((seg) => {
const span = (seg.count / total) * availableAngle;
seg.startAngle = angle;
seg.endAngle = angle + span;
seg.midAngle = angle + span / 2;
angle = seg.endAngle + paddingAngle;
});
// 第一遍:绘制所有扇段主环
segments.forEach((seg, i) => {
const isActive = i === activeIdx;
const span = seg.endAngle - seg.startAngle;
if(span < 0.01) return;
const curOuterR = isActive ? outerR + 7 : outerR;
const curRingW = isActive ? ringW + 5 : ringW;
ctx.beginPath();
ctx.arc(cx, cy, curOuterR, seg.startAngle, seg.endAngle);
ctx.strokeStyle = seg.color;
ctx.lineWidth = curRingW;
ctx.lineCap = 'butt';
ctx.stroke();
});
// 第二遍:在所有主环上方绘制激活项外细圈,确保不被遮盖
if(activeIdx >= 0 && activeIdx < segments.length) {
const activeSeg = segments[activeIdx];
const span = activeSeg.endAngle - activeSeg.startAngle;
if(span >= 0.01) {
ctx.save();
ctx.beginPath();
ctx.arc(cx, cy, outerR + 32, activeSeg.startAngle, activeSeg.endAngle);
ctx.strokeStyle = activeSeg.color;
ctx.lineWidth = 3;
ctx.lineCap = 'butt';
ctx.globalAlpha = 1;
ctx.stroke();
ctx.restore();
}
}
// 激活项引出线
if(activeIdx >= 0 && activeIdx < segments.length) {
const seg = segments[activeIdx];
const mid = seg.midAngle;
const sinM = Math.sin(mid);
const cosM = Math.cos(mid);
const sx = cx + (outerR + 36) * cosM;
const sy = cy + (outerR + 36) * sinM;
const mx2 = cx + (outerR + 50) * cosM;
const my2 = cy + (outerR + 50) * sinM;
const lineLen = 24;
const ex = mx2 + (cosM >= 0 ? 1 : -1) * lineLen;
const ey = my2;
const textX = ex + (cosM >= 0 ? 7 : -7);
const anchor = cosM >= 0 ? 'left' : 'right';
const pct = ((seg.count / total) * 100).toFixed(1);
ctx.strokeStyle = seg.color;
ctx.lineWidth = 1.5;
ctx.setLineDash([]);
ctx.beginPath();
ctx.moveTo(sx, sy);
ctx.lineTo(mx2, my2);
ctx.lineTo(ex, ey);
ctx.stroke();
ctx.beginPath();
ctx.arc(ex, ey, 2.5, 0, Math.PI * 2);
ctx.fillStyle = seg.color;
ctx.fill();
ctx.textBaseline = 'middle';
ctx.textAlign = anchor;
ctx.font = 'bold 12px sans-serif';
ctx.fillStyle = textColor;
ctx.fillText(seg.count.toLocaleString(), textX, ey - 9);
ctx.font = '10px sans-serif';
ctx.fillStyle = subColor;
ctx.fillText(`(${pct}%)`, textX, ey + 6);
}
// 中心文字:默认空白,悬停时显示百分比+名称+数量
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
if(activeIdx >= 0 && activeIdx < segments.length) {
const seg = segments[activeIdx];
const pct = ((seg.count / total) * 100).toFixed(1);
ctx.font = 'bold 20px sans-serif';
ctx.fillStyle = textColor;
ctx.fillText(`${pct}%`, cx, cy - 14);
ctx.font = 'bold 13px sans-serif';
ctx.fillStyle = seg.color;
ctx.fillText(seg.label, cx, cy + 4);
ctx.font = '11px sans-serif';
ctx.fillStyle = subColor;
ctx.fillText(seg.count.toLocaleString(), cx, cy + 20);
}
// 更新图例
if(legendEl) {
legendEl.innerHTML = segments.map((seg, i) => {
const pct = total > 0 ? ((seg.count / total) * 100).toFixed(1) : '0.0';
const isActive = i === activeIdx;
return `<div data-seg-idx="${i}" style="text-align:center;cursor:pointer;padding:4px 6px;border-radius:8px;background:${isActive ? 'rgba(255,255,255,0.06)' : 'transparent'}"><div style="font-size:15px;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;justify-content:center"><span style="width:7px;height:7px;border-radius:50%;background:${seg.color};${isActive ? `box-shadow:0 0 8px ${seg.color}99` : ''}"></span>${seg.label}</div></div>`;
}).join('');
}
canvas.__shipmentDonutData = { total, cx, cy, outerR, innerR, segments };
};
// 异步初始化fetch 数据后缓存 segments之后重绘只调用 renderShipmentDonut
const drawShipmentDonutChart = async (stats) => {
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));
// 获取机种列表以映射自定义机种名称
let platformList = [];
try {
const platformRes = await fetch('/api/shipments/platforms', {
headers: { 'Content-Type': 'application/json' },
credentials: 'include'
}).then(r => r.ok ? r.json() : null);
if(platformRes && platformRes.list) {
platformList = platformRes.list;
}
} catch(e) {
// 忽略错误,使用默认映射
if(!window.__shipmentPlatformList) {
try {
const res = await fetch('/api/shipments/platforms', { credentials: 'include' }).then(r => r.ok ? r.json() : null);
window.__shipmentPlatformList = (res && res.list) ? res.list : [];
} catch(e) { window.__shipmentPlatformList = []; }
}
const platformList = window.__shipmentPlatformList;
const platformName = (k) => {
const map = {
pdd: '拼多多',
yt: '圆通',
tx: '兔喜',
mt: '美团',
drf: '大润发',
std: '标准版',
unknown: '未知'
};
// 先从固定映射查找
const map = { pdd:'拼多多', yt:'圆通', tx:'兔喜', mt:'美团', drf:'大润发', std:'标准版', unknown:'未知' };
if(map[k]) return map[k];
// 再从机种列表查找自定义机种名称
const customPlatform = platformList.find(p => p.value === k);
if(customPlatform) return customPlatform.label;
// 最后返回原始值
return k;
const cp = platformList.find(p => p.value === k);
return cp ? cp.label : k;
};
const platformColor = (k, i) => {
const fixed = { pdd:'#3B82F6', yt:'#10B981', tx:'#F97316', mt:'#F59E0B' };
if(fixed[k]) return fixed[k];
return ['#06b6d4','#a3e635','#ef4444','#8b5cf6'][i % 4];
};
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)
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;
// 缓存 segments 供后续同步重绘
window.__shipmentSegments = segments;
window.__shipmentTotal = total;
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 };
renderShipmentDonut(segments, total, -1);
};
const refreshShipmentPlatformStats = async(force=false) => {
window.__shipmentStatsTs = window.__shipmentStatsTs || 0;
const now = Date.now();
if(!force && (now - window.__shipmentStatsTs) < 60000 && window.__shipmentStatsCache) {
await drawShipmentDonutChart(window.__shipmentStatsCache);
return;
}
try {
@ -1535,99 +1593,47 @@ const Dashboard = (() => {
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);
let currentActiveIdx = -1;
shipmentDonutCanvas.onmousemove = (e) => {
const data = shipmentDonutCanvas.__shipmentDonutData;
if(!data || !data.total || !data.segments || data.segments.length === 0) {
shipmentTooltip.style.display = 'none';
return;
}
const segs = window.__shipmentSegments;
const tot = window.__shipmentTotal;
if(!segs || segs.length === 0 || !tot) 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);
const scaleX = shipmentDonutCanvas.offsetWidth > 0 ? (280 / shipmentDonutCanvas.offsetWidth) : 1;
const scaleY = shipmentDonutCanvas.offsetHeight > 0 ? (280 / shipmentDonutCanvas.offsetHeight) : 1;
const rx = (e.clientX - rect.left) * scaleX - 140;
const ry = (e.clientY - rect.top) * scaleY - 140;
const dist = Math.sqrt(rx * rx + ry * ry);
// 使用新的内外半径
if(distance < data.innerRadius || distance > data.outerRadius) {
shipmentTooltip.style.display = 'none';
if(dist < 56 || dist > 100) {
if(currentActiveIdx !== -1) { currentActiveIdx = -1; renderShipmentDonut(segs, tot, -1); }
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;
}
let angle = Math.atan2(ry, rx);
if(angle < -Math.PI / 2) angle += Math.PI * 2;
let foundIdx = -1;
segs.forEach((seg, i) => {
let s = seg.startAngle, en = seg.endAngle;
if(s < -Math.PI / 2) { s += Math.PI * 2; en += Math.PI * 2; }
let a = angle;
if(a < s) a += Math.PI * 2;
if(a >= s && a <= en) foundIdx = i;
});
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;
if(foundIdx !== currentActiveIdx) {
currentActiveIdx = foundIdx;
renderShipmentDonut(segs, tot, foundIdx);
}
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 segs = window.__shipmentSegments;
const tot = window.__shipmentTotal;
if(currentActiveIdx !== -1) { currentActiveIdx = -1; if(segs && tot) renderShipmentDonut(segs, tot, -1); }
};
}
@ -2237,36 +2243,11 @@ const Dashboard = (() => {
<!-- 右侧环形图卡片 -->
<div class="card" style="flex:1;padding:20px;border-radius:16px;display:flex;flex-direction:column;background:var(--surface);min-height:340px">
<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:start;align-content:center">
<div style="display:flex;flex-direction:column;align-items:center;justify-content:flex-start;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;height:160px;width:160px;flex-shrink:0">
<canvas id="donut-chart" style="width:160px;height:160px"></canvas>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:20px;margin-top:12px;width:100%">
<div style="text-align:center">
<div id="donut-pdd-pct" style="font-size:20px;font-weight:700;color:#3B82F6">0%</div>
<div style="display:inline-flex;align-items:center;gap:4px;font-size:11px;color:var(--text-secondary);margin-top:2px">
<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:#3B82F6;flex-shrink:0"></span>
<span>拼多多</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:inline-flex;align-items:center;gap:4px;font-size:11px;color:var(--text-secondary);margin-top:2px">
<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:#10B981;flex-shrink:0"></span>
<span>圆通</span>
</div>
</div>
</div>
</div>
<div style="display:flex;flex-direction:column;align-items:center;justify-content:flex-start;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;height:160px;width:160px;flex-shrink:0">
<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;width:100%"></div>
<div style="flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center">
<div style="display:flex;align-items:center;justify-content:center;position:relative;height:280px;width:280px;flex-shrink:0">
<canvas id="shipment-donut-chart" style="width:280px;height:280px"></canvas>
</div>
<div id="shipment-donut-legend" style="display:flex;justify-content:center;gap:12px;flex-wrap:wrap;margin-top:12px;width:100%"></div>
</div>
</div>
</div>