<script lang="ts" setup>
|
import type { BiDashboardApi } from '#/api/bi/dashboard';
|
import type { EchartsUIType } from '@vben/plugins/echarts';
|
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
|
import { IconifyIcon } from '@vben/icons';
|
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
|
|
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 {
|
getChinaMapOptions,
|
getWarehouseAreaLineOptions,
|
getWarehouseBarOptions,
|
getWarehouseDonutOptions,
|
} from './chart-options';
|
|
defineOptions({ name: 'BiWarehouseDashboard' });
|
|
// ======== 数据加载 ========
|
const charts = ref<BiDashboardApi.ChartItem[]>([]);
|
const loading = ref(true);
|
const lastUpdateTime = ref('');
|
|
async function loadData() {
|
try {
|
loading.value = true;
|
const data = await getDashboardData('warehouse');
|
charts.value = data.length > 0 ? data : [];
|
lastUpdateTime.value = new Date().toLocaleTimeString('zh-CN', { hour12: false });
|
} catch {
|
charts.value = [];
|
} finally {
|
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 numberCharts = computed(() => charts.value.filter((c) => c.chartType === 'number'));
|
const barChart = computed(() => charts.value.find((c) => c.chartType === 'bar'));
|
const lineChart = computed(() => charts.value.find((c) => c.chartType === 'line'));
|
const tableChart = computed(() => charts.value.find((c) => c.chartType === 'table'));
|
|
function getCellValue(row: unknown, col: string): unknown {
|
return (row as Record<string, unknown>)[col];
|
}
|
|
function extractNum(d: Record<string, unknown> | undefined): number {
|
if (!d) return 0;
|
return (Object.values(d).find((v): v is number => typeof v === 'number') || 0) as number;
|
}
|
|
function extractNums(data: Array<Record<string, unknown>> | undefined, key?: string): number[] {
|
if (!data) return [];
|
return data.map((d) => {
|
if (key) return Number(d[key]) || 0;
|
return extractNum(d);
|
});
|
}
|
|
function extractLabels(data: Array<Record<string, unknown>> | undefined): string[] {
|
if (!data || data.length === 0) return [];
|
const keys = Object.keys(data[0] || {});
|
return data.map((d) => String(d[keys[0]] || ''));
|
}
|
|
// ======== KPI 数据 ========
|
interface KpiItem {
|
id: number;
|
name: string;
|
value: number;
|
trend: number[];
|
icon: string;
|
color: string;
|
}
|
|
const turnoverRate = computed(() => {
|
const lineData = lineChart.value?.data;
|
if (!lineData || lineData.length === 0) return 0;
|
let totalOut = 0;
|
lineData.forEach((d: Record<string, unknown>) => {
|
const keys = Object.keys(d);
|
totalOut += Math.abs(Number(d[keys[2]])) || 0;
|
});
|
const avgOutPerMonth = totalOut / Math.max(1, lineData.length);
|
const totalStock = warehouseNodes.value.reduce((sum, n) => sum + Math.abs(n.stock), 0);
|
if (totalStock === 0) return 0;
|
return Number((avgOutPerMonth / totalStock).toFixed(2));
|
});
|
|
const kpiCards = computed<KpiItem[]>(() => {
|
const cards: KpiItem[] = [];
|
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({
|
id: c.id!,
|
name: c.name,
|
value: c.data?.[0] ? extractNum(c.data[0] as Record<string, unknown>) : 0,
|
trend: trendData.length > 0 ? trendData : [c.data?.[0] ? extractNum(c.data[0] as Record<string, unknown>) : 0],
|
icon: getKpiIcon(c.name),
|
color: getKpiColor(cards.length),
|
});
|
}
|
cards.push({
|
id: 0,
|
name: '库存周转率',
|
value: turnoverRate.value,
|
trend: trendData,
|
icon: 'lucide:repeat',
|
color: '#8B5CF6',
|
});
|
return cards;
|
});
|
|
function getKpiIcon(name: string): string {
|
if (name.includes('仓库')) return 'lucide:building-2';
|
if (name.includes('物料') || name.includes('库存') || name.includes('品种')) return 'lucide:package';
|
if (name.includes('到货')) return 'lucide:truck';
|
if (name.includes('出货')) return 'lucide:send';
|
return 'lucide:bar-chart-4';
|
}
|
|
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 [];
|
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;
|
const coord = getCoord(name);
|
return { name, value: coord, stock };
|
});
|
});
|
|
const mapFlyLines = computed(() => {
|
const nodes = warehouseNodes.value;
|
if (nodes.length < 2) return [];
|
const lines: Array<{ from: string; to: string }> = [];
|
for (let i = 0; i < nodes.length - 1; i++) {
|
lines.push({ from: nodes[i].name, to: nodes[i + 1].name });
|
}
|
return lines;
|
});
|
|
// ======== 库存结构 Donut ========
|
const structureDonutData = computed(() => {
|
const data = barChart.value?.data;
|
if (!data || data.length === 0) return [];
|
return data.map((d: Record<string, unknown>) => {
|
const keys = Object.keys(d);
|
return { name: String(d[keys[0]] || ''), value: Number(d[keys[1]]) || 0 };
|
});
|
});
|
|
// ======== 出入库趋势 ========
|
const inOutTrendLabels = computed(() => extractLabels(lineChart.value?.data));
|
const inOutTrendSeries = computed(() => {
|
const data = lineChart.value?.data;
|
if (!data || data.length === 0) return [];
|
const keys = Object.keys(data[0] || {});
|
return [
|
{ name: keys[1] || '入库', data: extractNums(data, keys[1]) },
|
{ name: keys[2] || '出库', data: extractNums(data, keys[2]) },
|
];
|
});
|
|
const mapStats = computed(() => {
|
const data = lineChart.value?.data;
|
if (!data || data.length === 0) return { todayIn: 0, todayOut: 0, totalStock: 0 };
|
const last = data[data.length - 1] as Record<string, unknown>;
|
const keys = Object.keys(last);
|
const lastIn = Math.abs(Number(last[keys[1]])) || 0;
|
const lastOut = Math.abs(Number(last[keys[2]])) || 0;
|
const totalStock = warehouseNodes.value.reduce((sum, n) => sum + Math.abs(n.stock), 0);
|
return {
|
todayIn: Math.round(lastIn / 30),
|
todayOut: Math.round(lastOut / 30),
|
totalStock,
|
};
|
});
|
|
// ======== 表格数据 ========
|
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 data = barChart.value?.data || [];
|
return data.slice(0, 5).map((d: Record<string, unknown>) => {
|
const keys = Object.keys(d);
|
const name = String(d[keys[0]] || '');
|
const qty = Number(d[keys[1]]) || 0;
|
return {
|
name: `${name}库存`,
|
type: qty < 1000 ? 'danger' as const : qty < 5000 ? 'warning' as const : 'normal' as const,
|
text: qty < 1000 ? '库存不足' : qty < 5000 ? '即将缺货' : '库存正常',
|
qty,
|
};
|
});
|
});
|
|
// ======== 实时动态 ========
|
const timelineEvents = computed(() => {
|
const data = tableChart.value?.data || [];
|
return data.slice(0, 6).map((d: Record<string, unknown>, i: number) => {
|
const keys = Object.keys(d);
|
const now = new Date();
|
now.setMinutes(now.getMinutes() - i * 15);
|
return {
|
time: now.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }),
|
type: i % 2 === 0 ? '到货' : '出货',
|
title: String(d[keys[1]] || d[keys[0]] || ''),
|
};
|
});
|
});
|
|
// ======== 图表实例 ========
|
const mapRef = ref<EchartsUIType>();
|
const leftDonutRef = ref<EchartsUIType>();
|
const leftAreaRef = ref<EchartsUIType>();
|
const rightBarRef = ref<EchartsUIType>();
|
const rightDonutRef = ref<EchartsUIType>();
|
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>[] = [];
|
|
if (warehouseNodes.value.length > 0) {
|
promises.push(renderMap(getChinaMapOptions(warehouseNodes.value, mapFlyLines.value)));
|
}
|
if (structureDonutData.value.length > 0) {
|
promises.push(renderLeftDonut(getWarehouseDonutOptions(structureDonutData.value)));
|
promises.push(renderRightDonut(getWarehouseDonutOptions(structureDonutData.value)));
|
}
|
if (inOutTrendLabels.value.length > 0) {
|
promises.push(renderLeftArea(getWarehouseAreaLineOptions(inOutTrendLabels.value, inOutTrendSeries.value)));
|
promises.push(renderRightBar(getWarehouseBarOptions(inOutTrendLabels.value, inOutTrendSeries.value)));
|
}
|
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();
|
await nextTick();
|
await renderAllCharts();
|
if (editMode.value) await nextTick().then(() => attachMapListeners());
|
}, 60_000);
|
}
|
|
function clearTimer() {
|
if (refreshTimer !== null) { clearInterval(refreshTimer); refreshTimer = null; }
|
}
|
|
// ======== 自适应 ========
|
function resizeAllCharts() {
|
setTimeout(() => window.dispatchEvent(new Event('resize')), 100);
|
}
|
|
const dashboardRef = ref<HTMLElement>();
|
let resizeObserver: ResizeObserver | null = null;
|
|
// ======== 全屏 ========
|
const isFullscreen = ref(false);
|
async function toggleFullscreen() {
|
if (!document.fullscreenElement) {
|
await dashboardRef.value?.requestFullscreen();
|
isFullscreen.value = true;
|
} else {
|
await document.exitFullscreen();
|
isFullscreen.value = false;
|
}
|
setTimeout(resizeAllCharts, 400);
|
}
|
function onFullscreenChange() {
|
isFullscreen.value = !!document.fullscreenElement;
|
setTimeout(resizeAllCharts, 300);
|
}
|
|
// ======== KPI 动画 ========
|
const animatedIds = new Set<number>();
|
|
function animateValue(el: HTMLElement, end: number, decimals = 0) {
|
if (end === 0) { el.textContent = '0'; return; }
|
const duration = 1800;
|
const startTime = performance.now();
|
function update(currentTime: number) {
|
const progress = Math.min((currentTime - startTime) / duration, 1);
|
const eased = 1 - (1 - progress) ** 3;
|
el.textContent = (end * eased).toFixed(decimals);
|
if (progress < 1) requestAnimationFrame(update);
|
}
|
requestAnimationFrame(update);
|
}
|
|
function maybeAnimate(el: HTMLElement, cardId: number, value: number) {
|
if (animatedIds.has(cardId)) return;
|
animatedIds.add(cardId);
|
animateValue(el, value, Number.isInteger(value) ? 0 : 2);
|
}
|
|
watch(() => charts.value, () => {
|
animatedIds.clear();
|
nextTick(() => renderAllCharts());
|
});
|
|
// ======== 生命周期 ========
|
onMounted(async () => {
|
document.addEventListener('fullscreenchange', onFullscreenChange);
|
resizeObserver = new ResizeObserver(() => resizeAllCharts());
|
if (dashboardRef.value) resizeObserver.observe(dashboardRef.value);
|
await loadCoordinates();
|
await loadData();
|
await nextTick();
|
await renderAllCharts();
|
setupTimer();
|
});
|
|
onBeforeUnmount(() => {
|
document.removeEventListener('fullscreenchange', onFullscreenChange);
|
resizeObserver?.disconnect();
|
clearTimer();
|
});
|
</script>
|
|
<template>
|
<div ref="dashboardRef" class="warehouse-dashboard" :class="{ 'is-fullscreen': isFullscreen }">
|
<!-- ======== 头部 ======== -->
|
<header class="wh-header">
|
<div class="wh-header-left">
|
<div class="wh-logo">
|
<IconifyIcon icon="lucide:warehouse" class="text-xl" />
|
</div>
|
<div class="wh-title-group">
|
<h1>仓储物流运营中心</h1>
|
<p>库存周转 · 智能分析 · 物流协同</p>
|
</div>
|
</div>
|
<div class="wh-header-divider" />
|
<div class="wh-header-right">
|
<div class="wh-status-tags">
|
<span class="wh-status-tag">
|
<i class="wh-status-dot online" /> 系统正常
|
</span>
|
<span class="wh-status-tag">
|
<i class="wh-status-dot" /> 数据更新
|
</span>
|
</div>
|
<ClockWidget />
|
<div class="wh-live-badge">
|
<span class="wh-live-dot" />
|
<span>实时</span>
|
<span v-if="lastUpdateTime" class="wh-live-time">· {{ lastUpdateTime }}</span>
|
</div>
|
<button class="wh-fs-btn" :title="isFullscreen ? '退出全屏' : '全屏'" @click="toggleFullscreen">
|
<IconifyIcon :icon="isFullscreen ? 'lucide:minimize-2' : 'lucide:maximize-2'" />
|
</button>
|
</div>
|
</header>
|
|
<!-- ======== 加载态 ======== -->
|
<Spin v-if="loading && charts.length === 0" :spinning="true" tip="加载仪表盘中...">
|
<div style="height: 400px" />
|
</Spin>
|
|
<!-- ======== 主体内容 ======== -->
|
<template v-else>
|
<!-- KPI 卡片行 -->
|
<div class="wh-kpi-row">
|
<div
|
v-for="(card, i) in kpiCards"
|
:key="card.id"
|
class="wh-kpi-card"
|
:style="{ '--kpi-color': card.color, animationDelay: `${i * 0.06}s` }"
|
>
|
<div class="wh-kpi-icon">
|
<IconifyIcon :icon="card.icon" />
|
</div>
|
<div class="wh-kpi-body">
|
<span class="wh-kpi-label">{{ card.name }}</span>
|
<span
|
class="wh-kpi-value"
|
:ref="(el: unknown) => {
|
if (el && card.value) {
|
maybeAnimate(el as HTMLElement, card.id, card.value);
|
}
|
}"
|
>{{ card.name.includes('周转率') ? card.value : Math.round(card.value).toLocaleString() }}</span>
|
</div>
|
</div>
|
</div>
|
|
<!-- 三栏主布局 -->
|
<div class="wh-main-grid">
|
<!-- ===== 左栏 ===== -->
|
<div class="wh-left-col">
|
<div class="wh-panel">
|
<div class="wh-panel-header">
|
<span class="wh-panel-dot" />
|
库存结构分析
|
</div>
|
<EchartsUI ref="leftDonutRef" class="!h-full" :style="{ minHeight: '180px' }" />
|
</div>
|
<div class="wh-panel">
|
<div class="wh-panel-header">
|
<span class="wh-panel-dot" />
|
库存趋势
|
</div>
|
<EchartsUI ref="leftAreaRef" class="!h-full" :style="{ minHeight: '160px' }" />
|
</div>
|
<div class="wh-panel">
|
<div class="wh-panel-header">
|
<span class="wh-panel-dot" style="background: #FF3860;" />
|
库存预警
|
</div>
|
<div class="wh-warning-list">
|
<div
|
v-for="(w, wi) in warningList"
|
:key="wi"
|
class="wh-warn-item"
|
:class="w.type"
|
>
|
<div class="wh-warn-left">
|
<span class="wh-warn-dot" :class="w.type" />
|
<span class="wh-warn-name">{{ w.name }}</span>
|
</div>
|
<div class="wh-warn-right">
|
<span class="wh-warn-tag" :class="w.type">{{ w.text }}</span>
|
<span class="wh-warn-qty">{{ w.qty.toLocaleString() }}</span>
|
</div>
|
</div>
|
<Empty v-if="warningList.length === 0" description="暂无预警" />
|
</div>
|
</div>
|
</div>
|
|
<!-- ===== 中栏 - 地图 ===== -->
|
<div class="wh-center-col">
|
<div class="wh-panel wh-map-panel" :class="{ 'is-editing': editMode }">
|
<div class="wh-panel-header">
|
<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">
|
<div class="wh-map-stat">
|
<span class="wh-stat-label">今日入库(估)</span>
|
<span class="wh-stat-value in">{{ mapStats.todayIn.toLocaleString() }}</span>
|
</div>
|
<div class="wh-map-stat">
|
<span class="wh-stat-label">今日出库(估)</span>
|
<span class="wh-stat-value out">{{ mapStats.todayOut.toLocaleString() }}</span>
|
</div>
|
<div class="wh-map-stat">
|
<span class="wh-stat-label">总库存</span>
|
<span class="wh-stat-value total">{{ mapStats.totalStock.toLocaleString() }}</span>
|
</div>
|
</div>
|
</div>
|
</div>
|
|
<!-- ===== 右栏 ===== -->
|
<div class="wh-right-col">
|
<div class="wh-panel">
|
<div class="wh-panel-header">
|
<span class="wh-panel-dot" />
|
出入库趋势
|
</div>
|
<EchartsUI ref="rightBarRef" class="!h-full" :style="{ minHeight: '160px' }" />
|
</div>
|
<div class="wh-panel">
|
<div class="wh-panel-header">
|
<span class="wh-panel-dot" />
|
库存结构
|
</div>
|
<EchartsUI ref="rightDonutRef" class="!h-full" :style="{ minHeight: '180px' }" />
|
</div>
|
<div class="wh-panel">
|
<div class="wh-panel-header">
|
<span class="wh-panel-dot" style="background: #00FF88;" />
|
实时动态
|
</div>
|
<div class="wh-timeline">
|
<div
|
v-for="(evt, ei) in timelineEvents"
|
:key="ei"
|
class="wh-tl-item"
|
>
|
<div class="wh-tl-dot" :class="evt.type === '到货' ? 'in' : 'out'" />
|
<div class="wh-tl-content">
|
<span class="wh-tl-time">{{ evt.time }}</span>
|
<span class="wh-tl-title">{{ evt.title }}</span>
|
</div>
|
<span class="wh-tl-type" :class="evt.type === '到货' ? 'in' : 'out'">
|
{{ evt.type }}
|
</span>
|
</div>
|
<Empty v-if="timelineEvents.length === 0" description="暂无动态" />
|
</div>
|
</div>
|
</div>
|
</div>
|
|
<!-- ======== 底部数据表格 ======== -->
|
<div class="wh-panel wh-table-panel">
|
<div class="wh-panel-header">
|
<span class="wh-panel-dot" />
|
最近入库记录
|
<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>
|
<Empty v-else description="暂无数据" />
|
</div>
|
</template>
|
</div>
|
</template>
|
|
<style scoped>
|
/* ======== CSS 变量 ======== */
|
.warehouse-dashboard {
|
--bg-deep: #020817;
|
--bg-card: rgba(10, 24, 52, 0.55);
|
--bg-card-hover: rgba(16, 34, 68, 0.72);
|
--border: rgba(0, 229, 255, 0.2);
|
--border-hover: rgba(0, 229, 255, 0.4);
|
--cyan: #00E5FF;
|
--green: #00FF88;
|
--yellow: #FFC107;
|
--red: #FF3860;
|
--text-primary: rgba(235, 240, 252, 0.95);
|
--text-secondary: rgba(195, 205, 225, 0.82);
|
--text-muted: rgba(150, 160, 185, 0.5);
|
|
position: relative;
|
background:
|
radial-gradient(ellipse 70% 50% at 50% 0%, #0d1f42 0%, #060e24 35%, #020817 100%);
|
color: var(--text-primary);
|
padding: 16px 20px 24px;
|
font-family: 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
overflow-x: hidden;
|
overflow-y: auto;
|
max-height: calc(100vh - 104px);
|
}
|
|
/* 背景光晕 + 网格 */
|
.warehouse-dashboard::before {
|
content: '';
|
position: absolute; inset: 0; pointer-events: none; z-index: 0;
|
background:
|
radial-gradient(ellipse 50% 45% at 50% 5%, rgba(0, 229, 255, 0.12) 0%, transparent 50%),
|
radial-gradient(ellipse 35% 30% at 20% 75%, rgba(0, 255, 136, 0.07) 0%, transparent 55%),
|
radial-gradient(ellipse 30% 30% at 80% 65%, rgba(139, 92, 246, 0.08) 0%, transparent 55%),
|
radial-gradient(ellipse 25% 25% at 50% 90%, rgba(0, 229, 255, 0.05) 0%, transparent 60%);
|
animation: bg-breathe 10s ease-in-out infinite alternate;
|
}
|
|
@keyframes bg-breathe {
|
0% { opacity: 0.7; }
|
100% { opacity: 1; }
|
}
|
|
.warehouse-dashboard::after {
|
content: '';
|
position: absolute; inset: 0; pointer-events: none; z-index: 0;
|
background-image:
|
linear-gradient(rgba(0, 229, 255, 0.03) 1px, transparent 1px),
|
linear-gradient(90deg, rgba(0, 229, 255, 0.03) 1px, transparent 1px);
|
background-size: 64px 64px;
|
mask-image: radial-gradient(ellipse 60% 60% at 50% 35%, black 18%, transparent 78%);
|
-webkit-mask-image: radial-gradient(ellipse 60% 60% at 50% 35%, black 18%, transparent 78%);
|
}
|
|
/* ======== 头部 ======== */
|
.wh-header {
|
position: relative; z-index: 1;
|
display: flex; align-items: center; gap: 16px;
|
padding-bottom: 14px; margin-bottom: 16px;
|
border-bottom: 1px solid rgba(0,229,255,0.08);
|
}
|
|
.wh-header-left { display: flex; align-items: center; gap: 12px; flex-shrink: 0; }
|
|
.wh-logo {
|
display: flex; align-items: center; justify-content: center;
|
width: 42px; height: 42px;
|
background: linear-gradient(135deg, rgba(0,229,255,0.25), rgba(0,229,255,0.08));
|
border: 1px solid rgba(0,229,255,0.25);
|
border-radius: 10px; color: var(--cyan);
|
box-shadow: 0 0 24px rgba(0,229,255,0.18), inset 0 1px 0 rgba(255,255,255,0.05);
|
}
|
|
.wh-title-group h1 {
|
font-size: 18px; font-weight: 700; letter-spacing: 1px;
|
background: linear-gradient(90deg, #fff, var(--cyan));
|
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
|
background-clip: text;
|
}
|
|
.wh-title-group p {
|
font-size: 11px; color: var(--text-secondary); margin-top: 2px; letter-spacing: 0.5px;
|
}
|
|
.wh-header-divider {
|
flex: 1; height: 1px;
|
background: linear-gradient(90deg, transparent, rgba(0,229,255,0.15), transparent);
|
}
|
|
.wh-header-right { display: flex; align-items: center; gap: 14px; flex-shrink: 0; }
|
|
.wh-status-tags { display: flex; gap: 8px; }
|
|
.wh-status-tag {
|
display: flex; align-items: center; gap: 5px;
|
font-size: 11px; color: var(--text-secondary);
|
padding: 3px 10px; background: rgba(0,229,255,0.06);
|
border-radius: 12px; border: 1px solid rgba(0,229,255,0.1);
|
}
|
|
.wh-status-dot {
|
width: 5px; height: 5px; border-radius: 50%; background: var(--text-muted);
|
}
|
.wh-status-dot.online { background: var(--green); box-shadow: 0 0 6px var(--green); }
|
|
.wh-live-badge {
|
display: flex; align-items: center; gap: 5px;
|
font-size: 11px; color: var(--text-secondary);
|
padding: 3px 10px; background: rgba(0,229,255,0.06);
|
border-radius: 12px; border: 1px solid rgba(0,229,255,0.1);
|
}
|
|
.wh-live-dot {
|
width: 5px; height: 5px; border-radius: 50%;
|
background: #22c55e; box-shadow: 0 0 6px #22c55e;
|
animation: live-pulse 2s ease-in-out infinite;
|
}
|
@keyframes live-pulse {
|
0%, 100% { opacity: 1; }
|
50% { opacity: 0.3; }
|
}
|
|
.wh-live-time { color: var(--text-muted); }
|
|
.wh-fs-btn {
|
display: flex; align-items: center; justify-content: center;
|
width: 32px; height: 32px; border-radius: 6px;
|
border: 1px solid rgba(255,255,255,0.06);
|
background: rgba(255,255,255,0.02);
|
color: var(--text-secondary); cursor: pointer;
|
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 {
|
position: relative; z-index: 1;
|
display: grid;
|
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
|
gap: 12px; margin-bottom: 16px;
|
}
|
|
.wh-kpi-card {
|
display: flex; align-items: center; gap: 12px;
|
padding: 14px 16px;
|
background: var(--bg-card);
|
backdrop-filter: blur(10px);
|
-webkit-backdrop-filter: blur(10px);
|
border: 1px solid var(--border);
|
border-radius: 10px;
|
cursor: default;
|
transition: all 0.35s cubic-bezier(0.4, 0, 0.2, 1);
|
animation: kpi-fade-up 0.55s cubic-bezier(0.4, 0, 0.2, 1) both;
|
min-width: 0;
|
position: relative;
|
overflow: hidden;
|
}
|
.wh-kpi-card::after {
|
content: '';
|
position: absolute; top: 0; left: 0; right: 0; height: 1px;
|
background: linear-gradient(90deg, transparent, var(--kpi-color, var(--cyan)), transparent);
|
opacity: 0.35;
|
}
|
.wh-kpi-card:hover {
|
border-color: var(--kpi-color, var(--border-hover));
|
box-shadow:
|
0 0 20px color-mix(in srgb, var(--kpi-color, var(--cyan)) 20%, transparent),
|
0 0 40px color-mix(in srgb, var(--kpi-color, var(--cyan)) 8%, transparent),
|
0 4px 20px rgba(0, 0, 0, 0.35);
|
transform: translateY(-2px);
|
}
|
|
.wh-kpi-icon {
|
display: flex; align-items: center; justify-content: center;
|
width: 40px; height: 40px; flex-shrink: 0;
|
border-radius: 8px;
|
background: color-mix(in srgb, var(--kpi-color, var(--cyan)) 15%, transparent);
|
color: var(--kpi-color, var(--cyan));
|
font-size: 18px;
|
box-shadow: 0 0 12px color-mix(in srgb, var(--kpi-color, var(--cyan)) 20%, transparent);
|
}
|
|
.wh-kpi-body {
|
flex: 1; min-width: 0;
|
display: flex; flex-direction: column; gap: 4px;
|
}
|
|
.wh-kpi-label {
|
font-size: 11px; color: var(--text-secondary); letter-spacing: 0.3px;
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
}
|
|
.wh-kpi-value {
|
font-size: 24px; font-weight: 800; line-height: 1;
|
font-variant-numeric: tabular-nums;
|
color: var(--text-primary);
|
}
|
|
@keyframes kpi-fade-up {
|
from { opacity: 0; transform: translateY(16px); }
|
to { opacity: 1; transform: translateY(0); }
|
}
|
|
/* ======== 三栏网格 ======== */
|
.wh-main-grid {
|
position: relative; z-index: 1;
|
display: grid;
|
grid-template-columns: 1fr 2fr 1fr;
|
gap: 12px;
|
margin-bottom: 16px;
|
}
|
|
.wh-left-col,
|
.wh-right-col {
|
display: flex; flex-direction: column; gap: 12px;
|
min-width: 0;
|
}
|
|
.wh-center-col {
|
min-width: 0;
|
}
|
|
/* ======== 面板 ======== */
|
.wh-panel {
|
background: var(--bg-card);
|
backdrop-filter: blur(8px);
|
-webkit-backdrop-filter: blur(8px);
|
border: 1px solid var(--border);
|
border-radius: 10px;
|
padding: 14px;
|
transition: all 0.35s;
|
animation: kpi-fade-up 0.5s cubic-bezier(0.4, 0, 0.2, 1) both;
|
position: relative;
|
}
|
.wh-panel::before {
|
content: '';
|
position: absolute; inset: 0; border-radius: 10px; pointer-events: none;
|
background: linear-gradient(135deg, rgba(0, 229, 255, 0.04) 0%, transparent 50%, rgba(0, 255, 136, 0.03) 100%);
|
}
|
.wh-panel:hover {
|
border-color: var(--border-hover);
|
box-shadow: 0 0 24px rgba(0, 229, 255, 0.06), 0 0 48px rgba(0, 229, 255, 0.03);
|
}
|
|
.wh-panel-header {
|
display: flex; align-items: center; gap: 8px;
|
font-size: 13px; font-weight: 600;
|
color: var(--text-primary);
|
margin-bottom: 10px;
|
letter-spacing: 0.5px;
|
}
|
|
.wh-panel-dot {
|
width: 6px; height: 6px; border-radius: 50%;
|
background: var(--cyan);
|
box-shadow: 0 0 6px var(--cyan);
|
flex-shrink: 0;
|
}
|
|
.wh-map-panel {
|
display: flex; flex-direction: column;
|
height: 100%;
|
}
|
.wh-map-panel :deep(.echarts) {
|
flex: 1;
|
}
|
|
/* ======== 地图底部统计 ======== */
|
.wh-map-stats {
|
display: flex; gap: 12px; margin-top: 8px; padding-top: 10px;
|
border-top: 1px solid rgba(0,229,255,0.06);
|
}
|
|
.wh-map-stat {
|
flex: 1; text-align: center;
|
}
|
|
.wh-stat-label {
|
display: block; font-size: 10px; color: var(--text-secondary); margin-bottom: 4px;
|
}
|
|
.wh-stat-value {
|
font-size: 20px; font-weight: 800; font-variant-numeric: tabular-nums;
|
background: linear-gradient(90deg, var(--cyan), #fff);
|
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
|
background-clip: text;
|
}
|
.wh-stat-value.out {
|
background: linear-gradient(90deg, var(--green), #fff);
|
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
|
background-clip: text;
|
}
|
.wh-stat-value.total {
|
background: linear-gradient(90deg, var(--yellow), #fff);
|
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
|
background-clip: text;
|
}
|
|
/* ======== 预警列表 ======== */
|
.wh-warning-list {
|
display: flex; flex-direction: column; gap: 8px;
|
max-height: 180px; overflow-y: auto;
|
}
|
|
.wh-warn-item {
|
display: flex; align-items: center; justify-content: space-between;
|
padding: 8px 10px; border-radius: 6px;
|
background: rgba(255,255,255,0.02);
|
border: 1px solid rgba(255,255,255,0.03);
|
transition: all 0.25s;
|
}
|
.wh-warn-item:hover { background: rgba(255,255,255,0.04); }
|
|
.wh-warn-left { display: flex; align-items: center; gap: 8px; }
|
|
.wh-warn-dot {
|
width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0;
|
}
|
.wh-warn-dot.danger { background: var(--red); box-shadow: 0 0 6px var(--red); }
|
.wh-warn-dot.warning { background: var(--yellow); box-shadow: 0 0 6px var(--yellow); }
|
.wh-warn-dot.normal { background: var(--green); }
|
|
.wh-warn-name { font-size: 12px; color: var(--text-primary); }
|
|
.wh-warn-right { display: flex; align-items: center; gap: 10px; }
|
|
.wh-warn-tag {
|
font-size: 10px; padding: 2px 6px; border-radius: 4px;
|
}
|
.wh-warn-tag.danger { background: rgba(255,56,96,0.12); color: var(--red); }
|
.wh-warn-tag.warning { background: rgba(255,193,7,0.12); color: var(--yellow); }
|
.wh-warn-tag.normal { background: rgba(0,255,136,0.12); color: var(--green); }
|
|
.wh-warn-qty { font-size: 12px; color: var(--text-secondary); font-variant-numeric: tabular-nums; }
|
|
/* ======== 时间轴 ======== */
|
.wh-timeline {
|
display: flex; flex-direction: column; gap: 8px;
|
max-height: 180px; overflow-y: auto;
|
}
|
|
.wh-tl-item {
|
display: flex; align-items: center; gap: 10px;
|
padding: 6px 8px; border-radius: 6px;
|
border-left: 2px solid rgba(0,229,255,0.1);
|
transition: all 0.2s;
|
}
|
.wh-tl-item:hover { border-left-color: var(--cyan); background: rgba(0,229,255,0.03); }
|
|
.wh-tl-dot {
|
width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0;
|
}
|
.wh-tl-dot.in { background: var(--green); box-shadow: 0 0 5px var(--green); }
|
.wh-tl-dot.out { background: var(--cyan); box-shadow: 0 0 5px var(--cyan); }
|
|
.wh-tl-content {
|
flex: 1; min-width: 0;
|
display: flex; flex-direction: column; gap: 2px;
|
}
|
|
.wh-tl-time { font-size: 10px; color: var(--text-muted); }
|
|
.wh-tl-title {
|
font-size: 11px; color: var(--text-primary);
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
}
|
|
.wh-tl-type {
|
font-size: 10px; padding: 2px 8px; border-radius: 4px; flex-shrink: 0;
|
}
|
.wh-tl-type.in { background: rgba(0,255,136,0.1); color: var(--green); }
|
.wh-tl-type.out { background: rgba(0,229,255,0.1); color: var(--cyan); }
|
|
/* ======== 底部表格 ======== */
|
.wh-table-panel { position: relative; z-index: 1; }
|
|
.wh-table-badge {
|
margin-left: auto; font-size: 10px; font-weight: 400;
|
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); }
|
}
|
|
/* ======== 全屏 ======== */
|
.is-fullscreen {
|
max-height: none; min-height: 100vh; padding: 24px 28px; border-radius: 0; overflow-y: auto;
|
}
|
.is-fullscreen::before,
|
.is-fullscreen::after {
|
position: fixed;
|
}
|
.is-fullscreen .wh-kpi-row { gap: 16px; }
|
.is-fullscreen .wh-main-grid { gap: 16px; }
|
|
/* ======== 空状态 ======== */
|
:deep(.ant-empty) { color: var(--text-muted); }
|
:deep(.ant-empty-description) { color: var(--text-muted); }
|
|
/* ======== 加载态 ======== */
|
:deep(.ant-spin-text) { color: var(--text-secondary); }
|
|
/* ======== 响应式 ======== */
|
@media (max-width: 1400px) {
|
.wh-main-grid {
|
grid-template-columns: 1fr 1.5fr 1fr;
|
}
|
}
|
|
@media (max-width: 1100px) {
|
.wh-main-grid {
|
grid-template-columns: 1fr;
|
}
|
.wh-kpi-row {
|
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
}
|
}
|
|
@media (max-width: 640px) {
|
.warehouse-dashboard { padding: 10px; }
|
.wh-header { flex-wrap: wrap; }
|
.wh-header-divider { display: none; }
|
.wh-kpi-row { grid-template-columns: 1fr 1fr; }
|
.wh-kpi-value { font-size: 20px; }
|
}
|
</style>
|