diff --git a/frontend/js/components/dashboard.js b/frontend/js/components/dashboard.js index a79b24a..482fdf9 100755 --- a/frontend/js/components/dashboard.js +++ b/frontend/js/components/dashboard.js @@ -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 `
${pct}%
${seg.label}
`; + }).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 `
${pct}%
${seg.label}
`; - }).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 = `
${seg.label}
数量: ${seg.count.toLocaleString()}
占比: ${pct}%
`; - 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 = (() => {
产量占比
-
-
-
审计(本月)
-
- -
-
-
-
0%
-
- - 拼多多 -
-
-
-
0%
-
- - 圆通 -
-
-
-
-
-
发货(按机种)
-
- -
-
+
+
+
+