| | |
| | | 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 { |