From db152182da61f2e040ceb7506e5e5e5e201091e6 Mon Sep 17 00:00:00 2001
From: 云 <2163098428@qq.com>
Date: 星期四, 06 八月 2026 16:48:19 +0800
Subject: [PATCH] feat(warehouse): 添加仓库坐标编辑功能

---
 src/views/bi/warehouse/index.vue  |  281 +++++++++++++++++++++++++++++++++++++++++++---
 src/api/bi/warehouseCoordinate.ts |   23 +++
 2 files changed, 286 insertions(+), 18 deletions(-)

diff --git a/src/api/bi/warehouseCoordinate.ts b/src/api/bi/warehouseCoordinate.ts
new file mode 100644
index 0000000..66eee0f
--- /dev/null
+++ b/src/api/bi/warehouseCoordinate.ts
@@ -0,0 +1,23 @@
+import { requestClient } from '#/api/request';
+
+export namespace WarehouseCoordinateApi {
+  export interface Coordinate {
+    id?: number;
+    name: string;
+    longitude: number;
+    latitude: number;
+    sort: number;
+  }
+}
+
+const BASE = '/bi/warehouse-coordinate';
+
+/** 鑾峰彇鍏ㄩ儴浠撳簱鍧愭爣 */
+export async function getWarehouseCoordinates() {
+  return requestClient.get<WarehouseCoordinateApi.Coordinate[]>(`${BASE}/list`);
+}
+
+/** 鎵归噺淇濆瓨浠撳簱鍧愭爣 */
+export async function batchSaveWarehouseCoordinates(data: WarehouseCoordinateApi.Coordinate[]) {
+  return requestClient.post<boolean>(`${BASE}/batch-save`, data);
+}
diff --git a/src/views/bi/warehouse/index.vue b/src/views/bi/warehouse/index.vue
index 8ff8591..dca30fd 100644
--- a/src/views/bi/warehouse/index.vue
+++ b/src/views/bi/warehouse/index.vue
@@ -7,9 +7,14 @@
 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 {
@@ -38,6 +43,40 @@
     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());
+    });
   }
 }
 
@@ -96,7 +135,9 @@
 
 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({
@@ -130,23 +171,16 @@
 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 };
   });
 });
 
@@ -241,11 +275,99 @@
 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>[] = [];
@@ -264,6 +386,42 @@
   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;
 
@@ -273,6 +431,7 @@
     await loadData();
     await nextTick();
     await renderAllCharts();
+    if (editMode.value) await nextTick().then(() => attachMapListeners());
   }, 60_000);
 }
 
@@ -337,6 +496,7 @@
   document.addEventListener('fullscreenchange', onFullscreenChange);
   resizeObserver = new ResizeObserver(() => resizeAllCharts());
   if (dashboardRef.value) resizeObserver.observe(dashboardRef.value);
+  await loadCoordinates();
   await loadData();
   await nextTick();
   await renderAllCharts();
@@ -463,10 +623,35 @@
 
         <!-- ===== 涓爮 - 鍦板浘 ===== -->
         <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">
@@ -574,8 +759,8 @@
   --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:
@@ -695,6 +880,65 @@
   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 {
@@ -818,6 +1062,7 @@
   width: 6px; height: 6px; border-radius: 50%;
   background: var(--cyan);
   box-shadow: 0 0 6px var(--cyan);
+  flex-shrink: 0;
 }
 
 .wh-map-panel {

--
Gitblit v1.9.3