| | |
| | | import { IconifyIcon } from '@vben/icons'; |
| | | import { EchartsUI, useEcharts } from '@vben/plugins/echarts'; |
| | | |
| | | import { Empty, Spin } from 'ant-design-vue'; |
| | | import { Empty, Spin, message } from 'ant-design-vue'; |
| | | |
| | | import { getDashboardData } from '#/api/bi/dashboard'; |
| | | import { |
| | | getWarehouseCoordinates, |
| | | batchSaveWarehouseCoordinates, |
| | | type WarehouseCoordinateApi, |
| | | } from '#/api/bi/warehouseCoordinate'; |
| | | |
| | | import ClockWidget from '../dashboard/modules/ClockWidget.vue'; |
| | | import { |
| | |
| | | loading.value = false; |
| | | await nextTick(); |
| | | resizeAllCharts(); |
| | | } |
| | | } |
| | | |
| | | // ======== ä»åºåæ ï¼ä»å端å è½½ï¼ ======== |
| | | const coordinates = ref<WarehouseCoordinateApi.Coordinate[]>([]); |
| | | const dirtyCoords = ref(false); |
| | | |
| | | /** 硬ç¼ç åéåæ */ |
| | | const FALLBACK_COORDS: Record<string, number[]> = { |
| | | '䏿µ·ä»': [121.47, 31.23], |
| | | '广ä¸ä»': [113.26, 23.13], |
| | | '西åä»': [104.07, 30.67], |
| | | 'å京ä»': [116.41, 39.90], |
| | | 'æ¦æ±ä»': [114.30, 30.60], |
| | | }; |
| | | |
| | | function getCoord(name: string): number[] { |
| | | const found = coordinates.value.find((c) => c.name === name); |
| | | if (found) return [found.longitude, found.latitude]; |
| | | return FALLBACK_COORDS[name] || [116.41, 39.9]; |
| | | } |
| | | |
| | | // ======== ç¼è¾æ¨¡å¼ ======== |
| | | const editMode = ref(false); |
| | | |
| | | function toggleEditMode() { |
| | | editMode.value = !editMode.value; |
| | | if (!editMode.value) { |
| | | nextTick(() => renderAllCharts()); |
| | | } else { |
| | | nextTick(() => { |
| | | renderAllCharts(); |
| | | nextTick(() => attachMapListeners()); |
| | | }); |
| | | } |
| | | } |
| | | |
| | |
| | | |
| | | const kpiCards = computed<KpiItem[]>(() => { |
| | | const cards: KpiItem[] = []; |
| | | const trendData = extractNums(lineChart.value?.data, Object.keys(lineChart.value?.data?.[0] || {})[1]); |
| | | const lineData = lineChart.value?.data; |
| | | const keys = lineData?.[0] ? Object.keys(lineData[0] as Record<string, unknown>) : []; |
| | | const trendData = extractNums(lineData, keys[1]); |
| | | |
| | | for (const c of numberCharts.value) { |
| | | cards.push({ |
| | |
| | | const kpiColors = ['#00E5FF', '#00FF88', '#FFC107', '#FF3860', '#8B5CF6']; |
| | | function getKpiColor(i: number) { return kpiColors[i % kpiColors.length]; } |
| | | |
| | | // ======== å°å¾æ°æ® ======== |
| | | // ======== å°å¾èç¹ï¼ä½¿ç¨åç«¯åæ ï¼ ======== |
| | | const warehouseNodes = computed(() => { |
| | | const data = barChart.value?.data; |
| | | if (!data || data.length === 0) return []; |
| | | const coordMap: Record<string, number[]> = { |
| | | '䏿µ·ä»': [121.47, 31.23], |
| | | '广ä¸ä»': [113.26, 23.13], |
| | | '西åä»': [104.07, 30.67], |
| | | 'å京ä»': [116.41, 39.90], |
| | | 'æ¦æ±ä»': [114.30, 30.60], |
| | | }; |
| | | const defaultCoords = [[116.41, 39.90], [121.47, 31.23], [113.26, 23.13], [104.07, 30.67], [114.30, 30.60]]; |
| | | return data.map((d: Record<string, unknown>, i: number) => { |
| | | return data.map((d: Record<string, unknown>) => { |
| | | const keys = Object.keys(d); |
| | | const name = String(d[keys[0]] || ''); |
| | | const stock = Number(d[keys[1]]) || 0; |
| | | return { name, value: coordMap[name] || defaultCoords[i % defaultCoords.length], stock }; |
| | | const coord = getCoord(name); |
| | | return { name, value: coord, stock }; |
| | | }); |
| | | }); |
| | | |
| | |
| | | const leftAreaRef = ref<EchartsUIType>(); |
| | | const rightBarRef = ref<EchartsUIType>(); |
| | | const rightDonutRef = ref<EchartsUIType>(); |
| | | const { renderEcharts: renderMap } = useEcharts(mapRef); |
| | | const { renderEcharts: renderMap, getChartInstance: getMapInstance } = useEcharts(mapRef); |
| | | const { renderEcharts: renderLeftDonut } = useEcharts(leftDonutRef); |
| | | const { renderEcharts: renderLeftArea } = useEcharts(leftAreaRef); |
| | | const { renderEcharts: renderRightBar } = useEcharts(rightBarRef); |
| | | const { renderEcharts: renderRightDonut } = useEcharts(rightDonutRef); |
| | | |
| | | // ======== å°å¾ææ½ ======== |
| | | let draggingNodeName: string | null = null; |
| | | |
| | | function attachMapListeners() { |
| | | const chart = getMapInstance(); |
| | | if (!chart || !editMode.value) return; |
| | | |
| | | const zr = chart.getZr(); |
| | | // ç§»é¤æ§çå¬å¨é¿å
éå¤ |
| | | zr.off('mousedown'); |
| | | zr.off('mousemove'); |
| | | zr.off('mouseup'); |
| | | zr.off('mouseupoutside'); |
| | | |
| | | zr.on('mousedown', (e: any) => { |
| | | const nodes = warehouseNodes.value; |
| | | for (const node of nodes) { |
| | | const pixel = chart.convertToPixel({ geoIndex: 0 }, node.value); |
| | | if (!pixel) continue; |
| | | const dist = Math.hypot(e.offsetX - pixel[0], e.offsetY - pixel[1]); |
| | | if (dist < 22) { |
| | | draggingNodeName = node.name; |
| | | zr.setCursorStyle?.('grabbing'); |
| | | break; |
| | | } |
| | | } |
| | | }); |
| | | |
| | | zr.on('mousemove', (e: any) => { |
| | | if (!draggingNodeName) { |
| | | // hover æ£æµï¼æ¥è¿èç¹æ¶åæåå
æ |
| | | const nodes = warehouseNodes.value; |
| | | let near = false; |
| | | for (const node of nodes) { |
| | | const pixel = chart.convertToPixel({ geoIndex: 0 }, node.value); |
| | | if (!pixel) continue; |
| | | if (Math.hypot(e.offsetX - pixel[0], e.offsetY - pixel[1]) < 22) { |
| | | near = true; |
| | | break; |
| | | } |
| | | } |
| | | zr.setCursorStyle?.(near ? 'grab' : 'default'); |
| | | return; |
| | | } |
| | | |
| | | const geoCoord = chart.convertFromPixel({ geoIndex: 0 }, [e.offsetX, e.offsetY]); |
| | | if (!geoCoord || geoCoord.length < 2) return; |
| | | |
| | | // æ´æ° coordinates æ°ç» |
| | | const coord = coordinates.value.find((c) => c.name === draggingNodeName); |
| | | if (coord) { |
| | | coord.longitude = Number(Number(geoCoord[0]).toFixed(4)); |
| | | coord.latitude = Number(Number(geoCoord[1]).toFixed(4)); |
| | | } else { |
| | | coordinates.value.push({ |
| | | name: draggingNodeName, |
| | | longitude: Number(Number(geoCoord[0]).toFixed(4)), |
| | | latitude: Number(Number(geoCoord[1]).toFixed(4)), |
| | | sort: coordinates.value.length + 1, |
| | | }); |
| | | } |
| | | dirtyCoords.value = true; |
| | | |
| | | // å±é¨å·æ°å°å¾ scatter æ°æ®ï¼ä¸æ¸
é¤åºå¾ |
| | | const newData = warehouseNodes.value.map((n) => ({ |
| | | name: n.name, |
| | | value: [...n.value, n.stock], |
| | | })); |
| | | const newFlyData = mapFlyLines.value.map((f) => { |
| | | const from = warehouseNodes.value.find((n) => n.name === f.from); |
| | | const to = warehouseNodes.value.find((n) => n.name === f.to); |
| | | return { coords: [from?.value, to?.value].filter(Boolean) }; |
| | | }); |
| | | chart.setOption({ |
| | | series: [ |
| | | { name: 'outerGlow', data: newData }, |
| | | { name: 'innerGlow', data: newData }, |
| | | { name: 'warehouseNodes', data: newData }, |
| | | { name: 'nodeCores', data: newData }, |
| | | { name: 'flyLines', data: newFlyData }, |
| | | ], |
| | | }, { notMerge: false }); |
| | | }); |
| | | |
| | | zr.on('mouseup', () => { draggingNodeName = null; zr.setCursorStyle?.('grab'); }); |
| | | zr.on('mouseupoutside', () => { draggingNodeName = null; zr.setCursorStyle?.('default'); }); |
| | | } |
| | | |
| | | async function renderAllCharts() { |
| | | const promises: Promise<unknown>[] = []; |
| | |
| | | await Promise.all(promises); |
| | | } |
| | | |
| | | // ======== åæ æä¹
å ======== |
| | | const saving = ref(false); |
| | | |
| | | async function saveCoordinates() { |
| | | if (saving.value) return; |
| | | saving.value = true; |
| | | try { |
| | | await batchSaveWarehouseCoordinates(coordinates.value.map((c) => ({ |
| | | name: c.name, |
| | | longitude: c.longitude, |
| | | latitude: c.latitude, |
| | | sort: c.sort || 0, |
| | | }))); |
| | | dirtyCoords.value = false; |
| | | message.success('ä»åºåæ ä¿åæå'); |
| | | // æ´æ°èç¹æ°æ®ä»¥è§¦åå°å¾éç» |
| | | await nextTick(); |
| | | await renderAllCharts(); |
| | | } catch { |
| | | message.error('ä¿å失败ï¼è¯·éè¯'); |
| | | } finally { |
| | | saving.value = false; |
| | | } |
| | | } |
| | | |
| | | async function loadCoordinates() { |
| | | try { |
| | | const data = await getWarehouseCoordinates(); |
| | | if (data && data.length > 0) { |
| | | coordinates.value = data; |
| | | } |
| | | } catch { |
| | | // å 载失败使ç¨ç¡¬ç¼ç åé |
| | | } |
| | | } |
| | | |
| | | // ======== å·æ°å®æ¶å¨ ======== |
| | | let refreshTimer: ReturnType<typeof setInterval> | null = null; |
| | | |
| | |
| | | await loadData(); |
| | | await nextTick(); |
| | | await renderAllCharts(); |
| | | if (editMode.value) await nextTick().then(() => attachMapListeners()); |
| | | }, 60_000); |
| | | } |
| | | |
| | |
| | | document.addEventListener('fullscreenchange', onFullscreenChange); |
| | | resizeObserver = new ResizeObserver(() => resizeAllCharts()); |
| | | if (dashboardRef.value) resizeObserver.observe(dashboardRef.value); |
| | | await loadCoordinates(); |
| | | await loadData(); |
| | | await nextTick(); |
| | | await renderAllCharts(); |
| | |
| | | |
| | | <!-- ===== 䏿 - å°å¾ ===== --> |
| | | <div class="wh-center-col"> |
| | | <div class="wh-panel wh-map-panel"> |
| | | <div class="wh-panel wh-map-panel" :class="{ 'is-editing': editMode }"> |
| | | <div class="wh-panel-header"> |
| | | <span class="wh-panel-dot" /> |
| | | <span class="wh-panel-dot" :style="editMode ? { background: '#FFC107', boxShadow: '0 0 8px #FFC107' } : {}" /> |
| | | å
¨å½ä»å¨ç½ç» |
| | | <div class="wh-map-actions"> |
| | | <button |
| | | v-if="!editMode" |
| | | class="wh-edit-btn" |
| | | title="ç¼è¾ä»åºç¹ä½" |
| | | @click="toggleEditMode" |
| | | > |
| | | <IconifyIcon icon="lucide:move" class="text-xs" /> |
| | | ç¼è¾ç¹ä½ |
| | | </button> |
| | | <template v-else> |
| | | <span class="wh-editing-hint">ææ½èç¹è°æ´ä½ç½®</span> |
| | | <button |
| | | class="wh-save-btn" |
| | | :disabled="!dirtyCoords || saving" |
| | | @click="saveCoordinates" |
| | | > |
| | | <IconifyIcon icon="lucide:save" class="text-xs" /> |
| | | {{ saving ? 'ä¿åä¸...' : 'ä¿ååæ ' }} |
| | | </button> |
| | | <button class="wh-cancel-btn" @click="toggleEditMode"> |
| | | éåºç¼è¾ |
| | | </button> |
| | | </template> |
| | | </div> |
| | | </div> |
| | | <EchartsUI ref="mapRef" class="!h-full" :style="{ minHeight: '380px' }" /> |
| | | <div class="wh-map-stats"> |
| | |
| | | --yellow: #FFC107; |
| | | --red: #FF3860; |
| | | --text-primary: rgba(235, 240, 252, 0.95); |
| | | --text-secondary: rgba(185, 196, 220, 0.72); |
| | | --text-muted: rgba(145, 158, 185, 0.5); |
| | | --text-secondary: rgba(195, 205, 225, 0.82); |
| | | --text-muted: rgba(150, 160, 185, 0.5); |
| | | |
| | | position: relative; |
| | | background: |
| | |
| | | transition: all 0.2s; |
| | | } |
| | | .wh-fs-btn:hover { border-color: rgba(255,255,255,0.2); color: #fff; background: rgba(255,255,255,0.04); } |
| | | |
| | | /* ======== å°å¾æä½æé® ======== */ |
| | | .wh-map-actions { |
| | | margin-left: auto; |
| | | display: flex; align-items: center; gap: 8px; |
| | | } |
| | | |
| | | .wh-edit-btn { |
| | | display: flex; align-items: center; gap: 4px; |
| | | font-size: 11px; color: var(--text-secondary); |
| | | padding: 3px 10px; border-radius: 6px; |
| | | border: 1px solid rgba(255,255,255,0.08); |
| | | background: rgba(255,255,255,0.03); |
| | | cursor: pointer; transition: all 0.2s; |
| | | } |
| | | .wh-edit-btn:hover { |
| | | border-color: var(--yellow); |
| | | color: var(--yellow); |
| | | background: rgba(255,193,7,0.08); |
| | | } |
| | | |
| | | .wh-editing-hint { |
| | | font-size: 10px; color: var(--yellow); |
| | | animation: pulse-text 1.5s ease-in-out infinite; |
| | | } |
| | | @keyframes pulse-text { |
| | | 0%, 100% { opacity: 0.6; } |
| | | 50% { opacity: 1; } |
| | | } |
| | | |
| | | .wh-save-btn { |
| | | display: flex; align-items: center; gap: 4px; |
| | | font-size: 11px; padding: 3px 12px; border-radius: 6px; |
| | | border: 1px solid var(--green); |
| | | background: rgba(0,255,136,0.1); |
| | | color: var(--green); |
| | | cursor: pointer; transition: all 0.2s; |
| | | } |
| | | .wh-save-btn:hover:not(:disabled) { |
| | | background: rgba(0,255,136,0.2); |
| | | } |
| | | .wh-save-btn:disabled { |
| | | opacity: 0.4; cursor: not-allowed; |
| | | } |
| | | |
| | | .wh-cancel-btn { |
| | | font-size: 11px; color: var(--text-muted); |
| | | padding: 3px 10px; border-radius: 6px; |
| | | border: 1px solid rgba(255,255,255,0.06); |
| | | background: transparent; |
| | | cursor: pointer; transition: all 0.2s; |
| | | } |
| | | .wh-cancel-btn:hover { color: var(--text-secondary); border-color: rgba(255,255,255,0.15); } |
| | | |
| | | /* ç¼è¾æé¢æ¿ */ |
| | | .wh-map-panel.is-editing { |
| | | border-color: rgba(255,193,7,0.25); |
| | | box-shadow: 0 0 20px rgba(255,193,7,0.06); |
| | | } |
| | | |
| | | /* ======== KPI è¡ ======== */ |
| | | .wh-kpi-row { |
| | |
| | | width: 6px; height: 6px; border-radius: 50%; |
| | | background: var(--cyan); |
| | | box-shadow: 0 0 6px var(--cyan); |
| | | flex-shrink: 0; |
| | | } |
| | | |
| | | .wh-map-panel { |