2026-08-06 db152182da61f2e040ceb7506e5e5e5e201091e6
feat(warehouse): 添加仓库坐标编辑功能

- 集成仓库坐标API,支持获取和批量保存坐标数据
- 实现地图节点拖拽功能,支持可视化调整仓库位置
- 添加编辑模式切换和坐标保存机制
- 优化地图数据渲染逻辑,使用后端坐标替代硬编码
- 修复lineChart数据提取的类型安全问题
- 添加地图操作按钮界面和编辑状态提示
- 引入message组件用于用户操作反馈
已修改1个文件
已添加1个文件
304 ■■■■■ 文件已修改
src/api/bi/warehouseCoordinate.ts 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/bi/warehouse/index.vue 281 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
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);
}
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 {