gaoluyang
6 天以前 cd8b52bdf89a4d1101f72e0ece9f4df22a3d3f7a
src/views/bi/warehouse/index.vue
@@ -7,11 +7,17 @@
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,
@@ -25,19 +31,63 @@
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());
    });
  }
}
@@ -96,7 +146,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 +182,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 };
  });
});
@@ -199,10 +244,7 @@
// ======== 表格数据 ========
const tableData = computed(() => tableChart.value?.data || []);
const tableColumns = computed(() => {
  if (tableData.value.length === 0) return [];
  return Object.keys(tableData.value[0] || {});
});
// ======== 库存预警 ========
const warningList = computed(() => {
@@ -241,11 +283,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,15 +394,53 @@
  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);
}
@@ -337,13 +505,17 @@
  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();
@@ -463,10 +635,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">
@@ -535,26 +732,11 @@
          最近入库记录
          <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>
@@ -574,8 +756,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 +877,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 +1059,7 @@
  width: 6px; height: 6px; border-radius: 50%;
  background: var(--cyan);
  box-shadow: 0 0 6px var(--cyan);
  flex-shrink: 0;
}
.wh-map-panel {
@@ -942,50 +1184,6 @@
  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); }
}
/* ======== 全屏 ======== */