| | |
| | | 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 RealtimeTable from '../dashboard/modules/RealtimeTable.vue'; |
| | | import { |
| | | getChinaMapOptions, |
| | | getWarehouseAreaLineOptions, |
| | |
| | | const charts = ref<BiDashboardApi.ChartItem[]>([]); |
| | | const loading = ref(true); |
| | | const lastUpdateTime = ref(''); |
| | | let fetching = false; |
| | | let disposed = false; |
| | | |
| | | async function loadData() { |
| | | async function loadData(background = false) { |
| | | if (fetching || disposed) return false; |
| | | fetching = true; |
| | | try { |
| | | loading.value = true; |
| | | if (!background) loading.value = true; |
| | | const data = await getDashboardData('warehouse'); |
| | | if (disposed) return false; |
| | | charts.value = data.length > 0 ? data : []; |
| | | lastUpdateTime.value = new Date().toLocaleTimeString('zh-CN', { hour12: false }); |
| | | return true; |
| | | } catch { |
| | | charts.value = []; |
| | | if (!background && !disposed) charts.value = []; |
| | | return false; |
| | | } finally { |
| | | loading.value = false; |
| | | await nextTick(); |
| | | resizeAllCharts(); |
| | | fetching = false; |
| | | if (!disposed) { |
| | | loading.value = false; |
| | | await nextTick(); |
| | | if (!disposed) 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 tableData = computed(() => tableChart.value?.data || []); |
| | | const tableColumns = computed(() => { |
| | | if (tableData.value.length === 0) return []; |
| | | return Object.keys(tableData.value[0] || {}); |
| | | }); |
| | | |
| | | |
| | | // ======== 库存预警 ======== |
| | | const warningList = computed(() => { |
| | |
| | | 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; |
| | | |
| | | function setupTimer() { |
| | | clearTimer(); |
| | | refreshTimer = setInterval(async () => { |
| | | await loadData(); |
| | | if (document.hidden || editMode.value || disposed) return; |
| | | if (!(await loadData(true))) return; |
| | | 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(); |
| | | if (disposed) return; |
| | | await loadData(); |
| | | if (disposed) return; |
| | | await nextTick(); |
| | | await renderAllCharts(); |
| | | setupTimer(); |
| | | }); |
| | | |
| | | onBeforeUnmount(() => { |
| | | disposed = true; |
| | | document.removeEventListener('fullscreenchange', onFullscreenChange); |
| | | resizeObserver?.disconnect(); |
| | | clearTimer(); |
| | |
| | | |
| | | <!-- ===== 中栏 - 地图 ===== --> |
| | | <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"> |
| | |
| | | 最近入库记录 |
| | | <span class="wh-table-badge">实时滚动</span> |
| | | </div> |
| | | <div v-if="tableData.length > 0" class="wh-table-wrap"> |
| | | <table class="wh-table"> |
| | | <thead> |
| | | <tr> |
| | | <th v-for="col in tableColumns" :key="col">{{ col }}</th> |
| | | </tr> |
| | | </thead> |
| | | <tbody> |
| | | <tr |
| | | v-for="(row, ri) in tableData" |
| | | :key="ri" |
| | | :style="{ animationDelay: `${ri * 50}ms` }" |
| | | > |
| | | <td v-for="col in tableColumns" :key="col"> |
| | | {{ getCellValue(row, col) }} |
| | | </td> |
| | | </tr> |
| | | </tbody> |
| | | </table> |
| | | </div> |
| | | <RealtimeTable |
| | | v-if="tableData.length > 0" |
| | | :rows="tableData" |
| | | :format-cell="getCellValue" |
| | | /> |
| | | <Empty v-else description="暂无数据" /> |
| | | </div> |
| | | </template> |
| | |
| | | --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 { |
| | |
| | | padding: 2px 8px; border-radius: 10px; |
| | | background: rgba(0,255,136,0.08); color: var(--green); |
| | | border: 1px solid rgba(0,255,136,0.15); |
| | | } |
| | | |
| | | .wh-table-wrap { |
| | | overflow-x: auto; border-radius: 8px; |
| | | background: rgba(4, 14, 36, 0.55); |
| | | backdrop-filter: blur(6px); |
| | | -webkit-backdrop-filter: blur(6px); |
| | | border: 1px solid rgba(0, 229, 255, 0.08); |
| | | } |
| | | |
| | | .wh-table { |
| | | width: 100%; border-collapse: collapse; font-size: 12px; |
| | | } |
| | | |
| | | .wh-table thead th { |
| | | background: rgba(6, 18, 42, 0.8); |
| | | color: var(--text-secondary); |
| | | font-weight: 600; font-size: 10px; |
| | | text-transform: uppercase; letter-spacing: 0.8px; |
| | | padding: 10px 14px; text-align: left; |
| | | border-bottom: 1px solid rgba(0,229,255,0.12); |
| | | white-space: nowrap; |
| | | } |
| | | |
| | | .wh-table tbody td { |
| | | padding: 9px 14px; color: var(--text-primary); |
| | | border-bottom: 1px solid rgba(0, 229, 255, 0.04); |
| | | white-space: nowrap; |
| | | } |
| | | |
| | | .wh-table tbody tr { |
| | | transition: all 0.25s; |
| | | opacity: 0; |
| | | animation: row-fade-in 0.45s ease forwards; |
| | | } |
| | | |
| | | .wh-table tbody tr:hover { |
| | | background: rgba(0, 229, 255, 0.06); |
| | | box-shadow: inset 0 0 20px rgba(0, 229, 255, 0.03); |
| | | } |
| | | |
| | | @keyframes row-fade-in { |
| | | from { opacity: 0; transform: translateY(6px); } |
| | | to { opacity: 1; transform: translateY(0); } |
| | | } |
| | | |
| | | /* ======== 全屏 ======== */ |