From bd36da9f3daa0c326b9c954f920c97b4a61d5fce Mon Sep 17 00:00:00 2001
From: 云 <2163098428@qq.com>
Date: 星期三, 05 八月 2026 17:57:48 +0800
Subject: [PATCH] feat(bi): 添加BI图表配置和可视化功能

---
 src/views/bi/dashboard/index.vue                    |  665 +++++++++++
 dist.zip                                            |    0 
 src/api/bi/dashboard.ts                             |   36 
 src/views/bi/config/data-source/modules/form.vue    |   79 +
 src/views/bi/dashboard/modules/ChartCard.vue        |  207 +++
 src/api/bi/data-source-config.ts                    |   40 
 src/views/bi/config/chart/modules/form.vue          |  104 +
 src/views/bi/config/data-source/index.vue           |  168 ++
 src/views/bi/config/chart/data.ts                   |  213 +++
 src/views/bi/warehouse/chart-options.ts             |  305 +++++
 src/views/bi/warehouse/index.vue                    | 1032 +++++++++++++++++
 public/ref-dashboard.png                            |    0 
 src/views/bi/config/data-source/data.ts             |  139 ++
 src/packages/effects/plugins/src/echarts/echarts.ts |    6 
 src/views/bi/dashboard/modules/ClockWidget.vue      |   54 
 src/api/bi/chart-config.ts                          |   52 
 src/views/bi/config/chart/index.vue                 |  190 +++
 src/views/bi/dashboard/data.ts                      |   69 +
 src/views/bi/dashboard/chart-options.ts             |  222 +++
 19 files changed, 3,581 insertions(+), 0 deletions(-)

diff --git a/dist.zip b/dist.zip
new file mode 100644
index 0000000..9cb3c7e
--- /dev/null
+++ b/dist.zip
Binary files differ
diff --git a/public/ref-dashboard.png b/public/ref-dashboard.png
new file mode 100644
index 0000000..d6c77b2
--- /dev/null
+++ b/public/ref-dashboard.png
Binary files differ
diff --git a/src/api/bi/chart-config.ts b/src/api/bi/chart-config.ts
new file mode 100644
index 0000000..2b207d7
--- /dev/null
+++ b/src/api/bi/chart-config.ts
@@ -0,0 +1,52 @@
+import { requestClient } from '#/api/request';
+
+export namespace BiChartConfigApi {
+  export interface ChartConfig {
+    id?: number;
+    dashboardId?: number;
+    name?: string;
+    chartType?: string;
+    dataSourceId?: number;
+    querySql?: string;
+    position?: string;
+    chartOptions?: string;
+    refreshInterval?: number;
+    sort?: number;
+    status?: number;
+    creator?: string;
+    createTime?: string;
+    updater?: string;
+    updateTime?: string;
+  }
+
+  export interface PageParams {
+    dashboardId?: number;
+    pageNo?: number;
+    pageSize?: number;
+  }
+
+  export interface PageResult {
+    list: ChartConfig[];
+    total: number;
+  }
+}
+
+export function getChartConfigPage(params?: BiChartConfigApi.PageParams) {
+  return requestClient.get<BiChartConfigApi.PageResult>('/bi/chart-config/page', { params });
+}
+
+export function getChartConfig(id: number) {
+  return requestClient.get<BiChartConfigApi.ChartConfig>('/bi/chart-config/get', { params: { id } });
+}
+
+export function createChartConfig(data: BiChartConfigApi.ChartConfig) {
+  return requestClient.post<number>('/bi/chart-config/create', data);
+}
+
+export function updateChartConfig(data: BiChartConfigApi.ChartConfig) {
+  return requestClient.put<boolean>('/bi/chart-config/update', data);
+}
+
+export function deleteChartConfig(id: number) {
+  return requestClient.delete<boolean>('/bi/chart-config/delete', { params: { id } });
+}
diff --git a/src/api/bi/dashboard.ts b/src/api/bi/dashboard.ts
new file mode 100644
index 0000000..1845e46
--- /dev/null
+++ b/src/api/bi/dashboard.ts
@@ -0,0 +1,36 @@
+import { requestClient } from '#/api/request';
+
+export namespace BiDashboardApi {
+  export interface ChartItem {
+    id: number;
+    name: string;
+    chartType: 'bar' | 'line' | 'pie' | 'scatter' | 'gauge' | 'table' | 'number';
+    refreshInterval: number;
+    position?: { x: number; y: number; w: number; h: number };
+    chartOptions?: Record<string, unknown>;
+    data?: Record<string, unknown>[];
+    error?: string;
+  }
+}
+
+/**
+ * 鑾峰彇浠〃鐩樺垪琛�
+ */
+export function getDashboardList() {
+  return requestClient.get<Array<{ id: number; name: string; code: string; groupCode: string; icon: string; sort: number; status: number; remark: string }>>('/bi/dashboard/list');
+}
+
+/**
+ * 鑾峰彇浠〃鐩樻暟鎹�
+ * @param dashboardCode 浠〃鐩樼紪鐮�
+ * @param params 鏌ヨ鍙傛暟
+ */
+export function getDashboardData(
+  dashboardCode: string,
+  params?: Record<string, unknown>,
+) {
+  return requestClient.post<BiDashboardApi.ChartItem[]>(
+    `/bi/dashboard/${dashboardCode}/data`,
+    params,
+  );
+}
diff --git a/src/api/bi/data-source-config.ts b/src/api/bi/data-source-config.ts
new file mode 100644
index 0000000..1e723d7
--- /dev/null
+++ b/src/api/bi/data-source-config.ts
@@ -0,0 +1,40 @@
+import { requestClient } from '#/api/request';
+
+export namespace BiDataSourceConfigApi {
+  export interface DataSourceConfig {
+    id?: number;
+    name?: string;
+    type?: string;
+    config?: string;
+    status?: number;
+    creator?: string;
+    createTime?: string;
+    updater?: string;
+    updateTime?: string;
+  }
+
+  export interface PageResult {
+    list: DataSourceConfig[];
+    total: number;
+  }
+}
+
+export function getDataSourceConfigPage(params?: { pageNo?: number; pageSize?: number }) {
+  return requestClient.get<BiDataSourceConfigApi.PageResult>('/bi/data-source-config/page', { params });
+}
+
+export function getDataSourceConfig(id: number) {
+  return requestClient.get<BiDataSourceConfigApi.DataSourceConfig>('/bi/data-source-config/get', { params: { id } });
+}
+
+export function createDataSourceConfig(data: BiDataSourceConfigApi.DataSourceConfig) {
+  return requestClient.post<number>('/bi/data-source-config/create', data);
+}
+
+export function updateDataSourceConfig(data: BiDataSourceConfigApi.DataSourceConfig) {
+  return requestClient.put<boolean>('/bi/data-source-config/update', data);
+}
+
+export function deleteDataSourceConfig(id: number) {
+  return requestClient.delete<boolean>('/bi/data-source-config/delete', { params: { id } });
+}
diff --git a/src/packages/effects/plugins/src/echarts/echarts.ts b/src/packages/effects/plugins/src/echarts/echarts.ts
index 4888685..bd9b828 100644
--- a/src/packages/effects/plugins/src/echarts/echarts.ts
+++ b/src/packages/effects/plugins/src/echarts/echarts.ts
@@ -1,11 +1,14 @@
 import {
   BarChart,
+  EffectScatterChart,
   FunnelChart,
   GaugeChart,
   LineChart,
+  LinesChart,
   MapChart,
   PieChart,
   RadarChart,
+  ScatterChart,
 } from 'echarts/charts';
 import {
   DatasetComponent,
@@ -45,6 +48,9 @@
   LineChart,
   FunnelChart,
   GaugeChart,
+  ScatterChart,
+  EffectScatterChart,
+  LinesChart,
   LabelLayout,
   LegacyGridContainLabel,
   UniversalTransition,
diff --git a/src/views/bi/config/chart/data.ts b/src/views/bi/config/chart/data.ts
new file mode 100644
index 0000000..44b770d
--- /dev/null
+++ b/src/views/bi/config/chart/data.ts
@@ -0,0 +1,213 @@
+import type { VbenFormSchema } from '#/adapter/form';
+import type { VxeTableGridOptions } from '#/adapter/vxe-table';
+
+/** 鐘舵�侀�夐」锛欱I 妯″潡 1=鍚敤 0=绂佺敤锛堜笌 common_status 瀛楀吀鍊肩浉鍙嶏級 */
+const STATUS_OPTIONS = [
+  { label: '鍚敤', value: 1 },
+  { label: '绂佺敤', value: 0 },
+];
+
+/** 鍥捐〃绫诲瀷閫夐」 */
+const CHART_TYPE_OPTIONS = [
+  { label: '鏌辩姸鍥� (bar)', value: 'bar' },
+  { label: '鎶樼嚎鍥� (line)', value: 'line' },
+  { label: '楗煎浘 (pie)', value: 'pie' },
+  { label: '鏁g偣鍥� (scatter)', value: 'scatter' },
+  { label: '浠〃鐩� (gauge)', value: 'gauge' },
+  { label: '琛ㄦ牸 (table)', value: 'table' },
+  { label: '鏁板瓧鍗� (number)', value: 'number' },
+];
+
+/** 鏂板/淇敼琛ㄥ崟 */
+export function useFormSchema(
+  dashboardOptions: { label: string; value: number }[],
+): VbenFormSchema[] {
+  return [
+    {
+      component: 'Input',
+      fieldName: 'id',
+      dependencies: {
+        triggerFields: [''],
+        show: () => false,
+      },
+    },
+    {
+      fieldName: 'dashboardId',
+      label: '鎵�灞炰华琛ㄧ洏',
+      component: 'Select',
+      componentProps: {
+        options: dashboardOptions,
+        placeholder: '璇烽�夋嫨浠〃鐩�',
+      },
+      rules: 'required',
+    },
+    {
+      fieldName: 'name',
+      label: '鍥捐〃鍚嶇О',
+      component: 'Input',
+      componentProps: {
+        placeholder: '璇疯緭鍏ュ浘琛ㄥ悕绉�',
+      },
+      rules: 'required',
+    },
+    {
+      fieldName: 'chartType',
+      label: '鍥捐〃绫诲瀷',
+      component: 'Select',
+      componentProps: {
+        options: CHART_TYPE_OPTIONS,
+        placeholder: '璇烽�夋嫨鍥捐〃绫诲瀷',
+      },
+      defaultValue: 'bar',
+      rules: 'required',
+    },
+    {
+      fieldName: 'dataSourceId',
+      label: '鏁版嵁婧怚D',
+      component: 'InputNumber',
+      componentProps: {
+        placeholder: '鏁版嵁婧愰厤缃甀D',
+      },
+    },
+    {
+      fieldName: 'querySql',
+      label: '鏌ヨSQL',
+      component: 'Textarea',
+      componentProps: {
+        placeholder: 'SELECT ...',
+        rows: 3,
+      },
+    },
+    {
+      fieldName: 'position',
+      label: '甯冨眬浣嶇疆',
+      component: 'Input',
+      componentProps: {
+        placeholder: '{"x":0,"y":0,"w":12,"h":6}',
+      },
+    },
+    {
+      fieldName: 'chartOptions',
+      label: '鍥捐〃閰嶇疆',
+      component: 'Textarea',
+      componentProps: {
+        placeholder: 'JSON 閰嶇疆锛堝彲閫夛級',
+        rows: 3,
+      },
+    },
+    {
+      fieldName: 'refreshInterval',
+      label: '鍒锋柊闂撮殧(绉�)',
+      component: 'InputNumber',
+      componentProps: {
+        placeholder: '0 琛ㄧず涓嶈嚜鍔ㄥ埛鏂�',
+        min: 0,
+      },
+      defaultValue: 0,
+    },
+    {
+      fieldName: 'sort',
+      label: '鎺掑簭',
+      component: 'InputNumber',
+      componentProps: {
+        min: 0,
+      },
+      defaultValue: 0,
+    },
+    {
+      fieldName: 'status',
+      label: '鐘舵��',
+      component: 'RadioGroup',
+      componentProps: {
+        options: STATUS_OPTIONS,
+        buttonStyle: 'solid',
+        optionType: 'button',
+      },
+      defaultValue: 1,
+      rules: 'required',
+    },
+  ];
+}
+
+/** 鍒楄〃鎼滅储琛ㄥ崟 */
+export function useGridFormSchema(
+  dashboardOptions: { label: string; value: number }[],
+): VbenFormSchema[] {
+  return [
+    {
+      fieldName: 'dashboardId',
+      label: '鎵�灞炰华琛ㄧ洏',
+      component: 'Select',
+      componentProps: {
+        options: dashboardOptions,
+        placeholder: '璇烽�夋嫨浠〃鐩�',
+        allowClear: true,
+      },
+    },
+    {
+      fieldName: 'name',
+      label: '鍥捐〃鍚嶇О',
+      component: 'Input',
+      componentProps: {
+        placeholder: '璇疯緭鍏ュ浘琛ㄥ悕绉�',
+        allowClear: true,
+      },
+    },
+  ];
+}
+
+/** 鍒楄〃瀛楁 */
+export function useGridColumns(): VxeTableGridOptions['columns'] {
+  return [
+    { type: 'checkbox', width: 40 },
+    {
+      field: 'id',
+      title: '缂栧彿',
+      width: 80,
+    },
+    {
+      field: 'name',
+      title: '鍥捐〃鍚嶇О',
+      minWidth: 150,
+    },
+    {
+      field: 'chartType',
+      title: '鍥捐〃绫诲瀷',
+      width: 120,
+      cellRender: {
+        name: 'CellSelect',
+        props: {
+          options: CHART_TYPE_OPTIONS,
+        },
+      },
+    },
+    {
+      field: 'refreshInterval',
+      title: '鍒锋柊闂撮殧(s)',
+      width: 100,
+    },
+    {
+      field: 'sort',
+      title: '鎺掑簭',
+      width: 70,
+    },
+    {
+      field: 'status',
+      title: '鐘舵��',
+      width: 80,
+      slots: { default: 'status' },
+    },
+    {
+      field: 'createTime',
+      title: '鍒涘缓鏃堕棿',
+      width: 170,
+      formatter: 'formatDateTime',
+    },
+    {
+      title: '鎿嶄綔',
+      width: 160,
+      fixed: 'right',
+      slots: { default: 'actions' },
+    },
+  ];
+}
diff --git a/src/views/bi/config/chart/index.vue b/src/views/bi/config/chart/index.vue
new file mode 100644
index 0000000..b2ae546
--- /dev/null
+++ b/src/views/bi/config/chart/index.vue
@@ -0,0 +1,190 @@
+<script lang="ts" setup>
+import type { VxeTableGridOptions } from '#/adapter/vxe-table';
+import type { BiChartConfigApi } from '#/api/bi/chart-config';
+
+import { onMounted, ref } from 'vue';
+
+import { confirm, Page, useVbenModal } from '@vben/common-ui';
+import { isEmpty } from '@vben/utils';
+
+import { message } from 'ant-design-vue';
+
+import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
+import {
+  deleteChartConfig,
+  getChartConfigPage,
+} from '#/api/bi/chart-config';
+import { getDashboardList } from '#/api/bi/dashboard';
+
+import { useGridColumns, useGridFormSchema } from './data';
+import Form from './modules/form.vue';
+
+defineOptions({ name: 'BiChartConfig' });
+
+const [FormModal, formModalApi] = useVbenModal({
+  connectedComponent: Form,
+  destroyOnClose: true,
+});
+
+function handleRefresh() {
+  gridApi.query();
+}
+
+function handleCreate() {
+  formModalApi.setData(null).open();
+}
+
+function handleEdit(row: BiChartConfigApi.ChartConfig) {
+  formModalApi.setData(row).open();
+}
+
+async function handleDelete(row: BiChartConfigApi.ChartConfig) {
+  const hideLoading = message.loading({
+    content: `姝e湪鍒犻櫎銆�${row.name}銆�...`,
+    duration: 0,
+  });
+  try {
+    await deleteChartConfig(row.id!);
+    message.success(`鍒犻櫎銆�${row.name}銆嶆垚鍔焋);
+    handleRefresh();
+  } finally {
+    hideLoading();
+  }
+}
+
+async function handleDeleteBatch() {
+  await confirm('纭鎵归噺鍒犻櫎鎵�閫夊浘琛ㄩ厤缃紵');
+  const hideLoading = message.loading({
+    content: '姝e湪鎵归噺鍒犻櫎...',
+    duration: 0,
+  });
+  try {
+    for (const id of checkedIds.value) {
+      await deleteChartConfig(id);
+    }
+    checkedIds.value = [];
+    message.success('鎵归噺鍒犻櫎鎴愬姛');
+    handleRefresh();
+  } finally {
+    hideLoading();
+  }
+}
+
+const checkedIds = ref<number[]>([]);
+function handleRowCheckboxChange({
+  records,
+}: {
+  records: BiChartConfigApi.ChartConfig[];
+}) {
+  checkedIds.value = records.map((item) => item.id!);
+}
+
+const [Grid, gridApi] = useVbenVxeGrid({
+  formOptions: {
+    schema: useGridFormSchema(),
+  },
+  gridOptions: {
+    columns: useGridColumns(),
+    height: 'auto',
+    keepSource: true,
+    proxyConfig: {
+      ajax: {
+        query: async ({ page }, formValues) => {
+          return await getChartConfigPage({
+            pageNo: page.currentPage,
+            pageSize: page.pageSize,
+            ...formValues,
+          });
+        },
+      },
+    },
+    rowConfig: {
+      keyField: 'id',
+      isHover: true,
+    },
+    toolbarConfig: {
+      refresh: true,
+      search: true,
+    },
+  } as VxeTableGridOptions<BiChartConfigApi.ChartConfig>,
+  gridEvents: {
+    checkboxAll: handleRowCheckboxChange,
+    checkboxChange: handleRowCheckboxChange,
+  },
+});
+
+onMounted(async () => {
+  try {
+    const list = await getDashboardList();
+    const options = (list ?? []).map((d) => ({
+      label: d.name,
+      value: d.id,
+    }));
+    gridApi.formApi.setState((prev) => ({
+      ...prev,
+      schema: useGridFormSchema(options),
+    }));
+  } catch {
+    // ignore
+  }
+});
+</script>
+
+<template>
+  <Page auto-content-height>
+    <FormModal @success="handleRefresh" />
+    <Grid table-title="鍥捐〃閰嶇疆">
+      <template #toolbar-tools>
+        <TableAction
+          :actions="[
+            {
+              label: '鏂板鍥捐〃閰嶇疆',
+              type: 'primary',
+              icon: ACTION_ICON.ADD,
+              auth: ['bi:chart-config:create'],
+              onClick: handleCreate,
+            },
+            {
+              label: '鎵归噺鍒犻櫎',
+              type: 'primary',
+              danger: true,
+              icon: ACTION_ICON.DELETE,
+              disabled: isEmpty(checkedIds),
+              auth: ['bi:chart-config:delete'],
+              onClick: handleDeleteBatch,
+            },
+          ]"
+        />
+      </template>
+      <template #status="{ row }">
+        <Tag :color="row.status === 1 ? 'green' : 'default'">
+          {{ row.status === 1 ? '鍚敤' : '绂佺敤' }}
+        </Tag>
+      </template>
+      <template #actions="{ row }">
+        <TableAction
+          :actions="[
+            {
+              label: '缂栬緫',
+              type: 'link',
+              icon: ACTION_ICON.EDIT,
+              auth: ['bi:chart-config:update'],
+              onClick: handleEdit.bind(null, row),
+            },
+            {
+              label: '鍒犻櫎',
+              type: 'link',
+              danger: true,
+              icon: ACTION_ICON.DELETE,
+              auth: ['bi:chart-config:delete'],
+              popConfirm: {
+                title: `纭鍒犻櫎銆�${row.name}銆嶏紵`,
+                confirm: handleDelete.bind(null, row),
+              },
+            },
+          ]"
+        />
+      </template>
+    </Grid>
+  </Page>
+</template>
diff --git a/src/views/bi/config/chart/modules/form.vue b/src/views/bi/config/chart/modules/form.vue
new file mode 100644
index 0000000..503c742
--- /dev/null
+++ b/src/views/bi/config/chart/modules/form.vue
@@ -0,0 +1,104 @@
+<script lang="ts" setup>
+import type { BiChartConfigApi } from '#/api/bi/chart-config';
+
+import { computed, onMounted, ref } from 'vue';
+
+import { useVbenModal } from '@vben/common-ui';
+
+import { message } from 'ant-design-vue';
+
+import { useVbenForm } from '#/adapter/form';
+import {
+  createChartConfig,
+  getChartConfig,
+  updateChartConfig,
+} from '#/api/bi/chart-config';
+import { getDashboardList } from '#/api/bi/dashboard';
+
+import { useFormSchema } from '../data';
+
+const emit = defineEmits(['success']);
+const formData = ref<BiChartConfigApi.ChartConfig>();
+const dashboardOptions = ref<{ label: string; value: number }[]>([]);
+
+const getTitle = computed(() => {
+  return formData.value?.id ? '缂栬緫鍥捐〃閰嶇疆' : '鏂板鍥捐〃閰嶇疆';
+});
+
+const [Form, formApi] = useVbenForm({
+  commonConfig: {
+    componentProps: {
+      class: 'w-full',
+    },
+    formItemClass: 'col-span-2',
+    labelWidth: 120,
+  },
+  layout: 'horizontal',
+  schema: useFormSchema([]),
+  showDefaultActions: false,
+});
+
+async function refreshSchema() {
+  try {
+    const list = await getDashboardList();
+    dashboardOptions.value = (list ?? []).map((d) => ({
+      label: d.name,
+      value: d.id,
+    }));
+  } catch {
+    // ignore
+  }
+  formApi.setState((prev) => ({
+    ...prev,
+    schema: useFormSchema(dashboardOptions.value),
+  }));
+}
+
+const [Modal, modalApi] = useVbenModal({
+  async onConfirm() {
+    const { valid } = await formApi.validate();
+    if (!valid) {
+      return;
+    }
+    modalApi.lock();
+    const data = (await formApi.getValues()) as BiChartConfigApi.ChartConfig;
+    try {
+      await (formData.value?.id
+        ? updateChartConfig(data)
+        : createChartConfig(data));
+      await modalApi.close();
+      emit('success');
+      message.success('鎿嶄綔鎴愬姛');
+    } finally {
+      modalApi.unlock();
+    }
+  },
+  async onOpenChange(isOpen: boolean) {
+    if (!isOpen) {
+      formData.value = undefined;
+      return;
+    }
+    const data = modalApi.getData<BiChartConfigApi.ChartConfig>();
+    if (!data || !data.id) {
+      return;
+    }
+    modalApi.lock();
+    try {
+      formData.value = await getChartConfig(data.id);
+      await formApi.setValues(formData.value);
+    } finally {
+      modalApi.unlock();
+    }
+  },
+});
+
+onMounted(() => {
+  refreshSchema();
+});
+</script>
+
+<template>
+  <Modal :title="getTitle">
+    <Form class="mx-4" />
+  </Modal>
+</template>
diff --git a/src/views/bi/config/data-source/data.ts b/src/views/bi/config/data-source/data.ts
new file mode 100644
index 0000000..7375b4e
--- /dev/null
+++ b/src/views/bi/config/data-source/data.ts
@@ -0,0 +1,139 @@
+import type { VbenFormSchema } from '#/adapter/form';
+import type { VxeTableGridOptions } from '#/adapter/vxe-table';
+
+import { DICT_TYPE } from '@vben/constants';
+import { getDictOptions } from '@vben/hooks';
+
+/** 鏁版嵁婧愮被鍨嬮�夐」 */
+const DATA_SOURCE_TYPE_OPTIONS = [
+  { label: 'MySQL', value: 'mysql' },
+  { label: 'API', value: 'api' },
+];
+
+/** 鏂板/淇敼琛ㄥ崟 */
+export function useFormSchema(): VbenFormSchema[] {
+  return [
+    {
+      component: 'Input',
+      fieldName: 'id',
+      dependencies: {
+        triggerFields: [''],
+        show: () => false,
+      },
+    },
+    {
+      fieldName: 'name',
+      label: '鏁版嵁婧愬悕绉�',
+      component: 'Input',
+      componentProps: {
+        placeholder: '璇疯緭鍏ユ暟鎹簮鍚嶇О',
+      },
+      rules: 'required',
+    },
+    {
+      fieldName: 'type',
+      label: '鏁版嵁婧愮被鍨�',
+      component: 'Select',
+      componentProps: {
+        options: DATA_SOURCE_TYPE_OPTIONS,
+        placeholder: '璇烽�夋嫨鏁版嵁婧愮被鍨�',
+      },
+      rules: 'required',
+    },
+    {
+      fieldName: 'config',
+      label: '杩炴帴閰嶇疆',
+      component: 'Textarea',
+      componentProps: {
+        placeholder: 'JSON鏍煎紡鐨勮繛鎺ラ厤缃�',
+        rows: 4,
+      },
+    },
+    {
+      fieldName: 'status',
+      label: '鐘舵��',
+      component: 'RadioGroup',
+      componentProps: {
+        options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
+        buttonStyle: 'solid',
+        optionType: 'button',
+      },
+      defaultValue: 1,
+      rules: 'required',
+    },
+  ];
+}
+
+/** 鍒楄〃鎼滅储琛ㄥ崟 */
+export function useGridFormSchema(): VbenFormSchema[] {
+  return [
+    {
+      fieldName: 'name',
+      label: '鏁版嵁婧愬悕绉�',
+      component: 'Input',
+      componentProps: {
+        placeholder: '璇疯緭鍏ユ暟鎹簮鍚嶇О',
+        allowClear: true,
+      },
+    },
+    {
+      fieldName: 'type',
+      label: '鏁版嵁婧愮被鍨�',
+      component: 'Select',
+      componentProps: {
+        options: DATA_SOURCE_TYPE_OPTIONS,
+        placeholder: '璇烽�夋嫨鏁版嵁婧愮被鍨�',
+        allowClear: true,
+      },
+    },
+  ];
+}
+
+/** 鍒楄〃瀛楁 */
+export function useGridColumns(): VxeTableGridOptions['columns'] {
+  return [
+    { type: 'checkbox', width: 40 },
+    {
+      field: 'id',
+      title: '缂栧彿',
+      width: 80,
+    },
+    {
+      field: 'name',
+      title: '鏁版嵁婧愬悕绉�',
+      minWidth: 150,
+    },
+    {
+      field: 'type',
+      title: '绫诲瀷',
+      width: 100,
+      cellRender: {
+        name: 'CellSelect',
+        props: {
+          options: DATA_SOURCE_TYPE_OPTIONS,
+        },
+      },
+    },
+    {
+      field: 'status',
+      title: '鐘舵��',
+      width: 80,
+      cellRender: {
+        name: 'CellDict',
+        props: { type: DICT_TYPE.COMMON_STATUS },
+      },
+    },
+    {
+      field: 'createTime',
+      title: '鍒涘缓鏃堕棿',
+      width: 170,
+      formatter: 'formatDateTime',
+    },
+    {
+      title: '鎿嶄綔',
+      width: 160,
+      fixed: 'right',
+      slots: { default: 'actions' },
+    },
+  ];
+}
diff --git a/src/views/bi/config/data-source/index.vue b/src/views/bi/config/data-source/index.vue
new file mode 100644
index 0000000..3f87c40
--- /dev/null
+++ b/src/views/bi/config/data-source/index.vue
@@ -0,0 +1,168 @@
+<script lang="ts" setup>
+import type { VxeTableGridOptions } from '#/adapter/vxe-table';
+import type { BiDataSourceConfigApi } from '#/api/bi/data-source-config';
+
+import { ref } from 'vue';
+
+import { confirm, Page, useVbenModal } from '@vben/common-ui';
+import { isEmpty } from '@vben/utils';
+
+import { message } from 'ant-design-vue';
+
+import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
+import {
+  deleteDataSourceConfig,
+  getDataSourceConfigPage,
+} from '#/api/bi/data-source-config';
+
+import { useGridColumns, useGridFormSchema } from './data';
+import Form from './modules/form.vue';
+
+defineOptions({ name: 'BiDataSourceConfig' });
+
+const [FormModal, formModalApi] = useVbenModal({
+  connectedComponent: Form,
+  destroyOnClose: true,
+});
+
+function handleRefresh() {
+  gridApi.query();
+}
+
+function handleCreate() {
+  formModalApi.setData(null).open();
+}
+
+function handleEdit(row: BiDataSourceConfigApi.DataSourceConfig) {
+  formModalApi.setData(row).open();
+}
+
+async function handleDelete(row: BiDataSourceConfigApi.DataSourceConfig) {
+  const hideLoading = message.loading({
+    content: `姝e湪鍒犻櫎銆�${row.name}銆�...`,
+    duration: 0,
+  });
+  try {
+    await deleteDataSourceConfig(row.id!);
+    message.success(`鍒犻櫎銆�${row.name}銆嶆垚鍔焋);
+    handleRefresh();
+  } finally {
+    hideLoading();
+  }
+}
+
+async function handleDeleteBatch() {
+  await confirm('纭鎵归噺鍒犻櫎鎵�閫夋暟鎹簮閰嶇疆锛�');
+  const hideLoading = message.loading({
+    content: '姝e湪鎵归噺鍒犻櫎...',
+    duration: 0,
+  });
+  try {
+    for (const id of checkedIds.value) {
+      await deleteDataSourceConfig(id);
+    }
+    checkedIds.value = [];
+    message.success('鎵归噺鍒犻櫎鎴愬姛');
+    handleRefresh();
+  } finally {
+    hideLoading();
+  }
+}
+
+const checkedIds = ref<number[]>([]);
+function handleRowCheckboxChange({
+  records,
+}: {
+  records: BiDataSourceConfigApi.DataSourceConfig[];
+}) {
+  checkedIds.value = records.map((item) => item.id!);
+}
+
+const [Grid, gridApi] = useVbenVxeGrid({
+  formOptions: {
+    schema: useGridFormSchema(),
+  },
+  gridOptions: {
+    columns: useGridColumns(),
+    height: 'auto',
+    keepSource: true,
+    proxyConfig: {
+      ajax: {
+        query: async ({ page }, formValues) => {
+          return await getDataSourceConfigPage({
+            pageNo: page.currentPage,
+            pageSize: page.pageSize,
+            ...formValues,
+          });
+        },
+      },
+    },
+    rowConfig: {
+      keyField: 'id',
+      isHover: true,
+    },
+    toolbarConfig: {
+      refresh: true,
+      search: true,
+    },
+  } as VxeTableGridOptions<BiDataSourceConfigApi.DataSourceConfig>,
+  gridEvents: {
+    checkboxAll: handleRowCheckboxChange,
+    checkboxChange: handleRowCheckboxChange,
+  },
+});
+</script>
+
+<template>
+  <Page auto-content-height>
+    <FormModal @success="handleRefresh" />
+    <Grid table-title="鏁版嵁婧愰厤缃�">
+      <template #toolbar-tools>
+        <TableAction
+          :actions="[
+            {
+              label: '鏂板鏁版嵁婧愰厤缃�',
+              type: 'primary',
+              icon: ACTION_ICON.ADD,
+              auth: ['bi:data-source-config:create'],
+              onClick: handleCreate,
+            },
+            {
+              label: '鎵归噺鍒犻櫎',
+              type: 'primary',
+              danger: true,
+              icon: ACTION_ICON.DELETE,
+              disabled: isEmpty(checkedIds),
+              auth: ['bi:data-source-config:delete'],
+              onClick: handleDeleteBatch,
+            },
+          ]"
+        />
+      </template>
+      <template #actions="{ row }">
+        <TableAction
+          :actions="[
+            {
+              label: '缂栬緫',
+              type: 'link',
+              icon: ACTION_ICON.EDIT,
+              auth: ['bi:data-source-config:update'],
+              onClick: handleEdit.bind(null, row),
+            },
+            {
+              label: '鍒犻櫎',
+              type: 'link',
+              danger: true,
+              icon: ACTION_ICON.DELETE,
+              auth: ['bi:data-source-config:delete'],
+              popConfirm: {
+                title: `纭鍒犻櫎銆�${row.name}銆嶏紵`,
+                confirm: handleDelete.bind(null, row),
+              },
+            },
+          ]"
+        />
+      </template>
+    </Grid>
+  </Page>
+</template>
diff --git a/src/views/bi/config/data-source/modules/form.vue b/src/views/bi/config/data-source/modules/form.vue
new file mode 100644
index 0000000..1078f2b
--- /dev/null
+++ b/src/views/bi/config/data-source/modules/form.vue
@@ -0,0 +1,79 @@
+<script lang="ts" setup>
+import type { BiDataSourceConfigApi } from '#/api/bi/data-source-config';
+
+import { computed, ref } from 'vue';
+
+import { useVbenModal } from '@vben/common-ui';
+
+import { message } from 'ant-design-vue';
+
+import { useVbenForm } from '#/adapter/form';
+import {
+  createDataSourceConfig,
+  getDataSourceConfig,
+  updateDataSourceConfig,
+} from '#/api/bi/data-source-config';
+
+import { useFormSchema } from '../data';
+
+const emit = defineEmits(['success']);
+const formData = ref<BiDataSourceConfigApi.DataSourceConfig>();
+const getTitle = computed(() => {
+  return formData.value?.id ? '缂栬緫鏁版嵁婧愰厤缃�' : '鏂板鏁版嵁婧愰厤缃�';
+});
+
+const [Form, formApi] = useVbenForm({
+  commonConfig: {
+    componentProps: {
+      class: 'w-full',
+    },
+    formItemClass: 'col-span-2',
+    labelWidth: 120,
+  },
+  layout: 'horizontal',
+  schema: useFormSchema(),
+  showDefaultActions: false,
+});
+
+const [Modal, modalApi] = useVbenModal({
+  async onConfirm() {
+    const { valid } = await formApi.validate();
+    if (!valid) {
+      return;
+    }
+    modalApi.lock();
+    const data = (await formApi.getValues()) as BiDataSourceConfigApi.DataSourceConfig;
+    try {
+      await (formData.value?.id ? updateDataSourceConfig(data) : createDataSourceConfig(data));
+      await modalApi.close();
+      emit('success');
+      message.success('鎿嶄綔鎴愬姛');
+    } finally {
+      modalApi.unlock();
+    }
+  },
+  async onOpenChange(isOpen: boolean) {
+    if (!isOpen) {
+      formData.value = undefined;
+      return;
+    }
+    const data = modalApi.getData<BiDataSourceConfigApi.DataSourceConfig>();
+    if (!data || !data.id) {
+      return;
+    }
+    modalApi.lock();
+    try {
+      formData.value = await getDataSourceConfig(data.id);
+      await formApi.setValues(formData.value);
+    } finally {
+      modalApi.unlock();
+    }
+  },
+});
+</script>
+
+<template>
+  <Modal :title="getTitle">
+    <Form class="mx-4" />
+  </Modal>
+</template>
diff --git a/src/views/bi/dashboard/chart-options.ts b/src/views/bi/dashboard/chart-options.ts
new file mode 100644
index 0000000..f01bc66
--- /dev/null
+++ b/src/views/bi/dashboard/chart-options.ts
@@ -0,0 +1,222 @@
+import type { EChartsOption } from '@vben/plugins/echarts';
+
+/** 绉戞妧钃濇殫鑹蹭富棰樿壊鏉� */
+const C = {
+  cyan: '#00E5FF',
+  green: '#00FF88',
+  yellow: '#FFC107',
+  red: '#FF3860',
+  blue: '#3366FF',
+  purple: '#8B5CF6',
+  orange: '#FF6B35',
+  textPrimary: 'rgba(230, 236, 250, 0.95)',
+  textSecondary: 'rgba(180, 190, 215, 0.7)',
+  border: 'rgba(56, 128, 237, 0.12)',
+  splitLine: 'rgba(56, 128, 237, 0.08)',
+};
+
+const darkAxis = {
+  axisLabel: { color: C.textSecondary, fontSize: 10 },
+  axisLine: { lineStyle: { color: C.border } },
+  axisTick: { show: false },
+  splitLine: { lineStyle: { color: C.splitLine } },
+};
+
+const darkLegend = {
+  textStyle: { color: C.textSecondary, fontSize: 10 },
+  itemGap: 12,
+};
+
+const darkTooltip = {
+  backgroundColor: 'rgba(6, 18, 42, 0.94)',
+  borderColor: 'rgba(0, 229, 255, 0.28)',
+  borderWidth: 1,
+  padding: [10, 14],
+  textStyle: { color: C.textPrimary, fontSize: 11 },
+};
+
+/** 娓愬彉鏌辩姸鍥� */
+export function getBarChartOptions(
+  xData: string[],
+  series: Array<{ name: string; data: number[] }>,
+  colors: string[] = [C.cyan, C.blue, C.purple],
+): EChartsOption {
+  return {
+    grid: { bottom: 40, left: 48, right: 16, top: 16 },
+    legend: { ...darkLegend, bottom: 0 },
+    tooltip: { ...darkTooltip, axisPointer: { type: 'shadow' }, trigger: 'axis' },
+    xAxis: { ...darkAxis, data: xData, type: 'category' },
+    yAxis: { ...darkAxis, type: 'value' },
+    series: series.map((s, i) => ({
+      barGap: '30%',
+      barMaxWidth: 36,
+      data: s.data,
+      emphasis: {
+        itemStyle: { shadowBlur: 12, shadowColor: colors[i] || C.cyan },
+      },
+      itemStyle: {
+        borderRadius: [6, 6, 0, 0],
+        color: {
+          colorStops: [
+            { color: colors[i] || C.cyan, offset: 0 },
+            { color: `${colors[i] || C.cyan}22`, offset: 1 },
+          ],
+          type: 'linear',
+          x: 0, y: 0, x2: 0, y2: 1,
+        },
+      },
+      name: s.name,
+      type: 'bar',
+    })),
+  };
+}
+
+/** 娓愬彉闈㈢Н鎶樼嚎鍥� */
+export function getAreaLineChartOptions(
+  xData: string[],
+  series: Array<{ name: string; data: number[] }>,
+  colors: string[] = [C.cyan, C.purple, C.green],
+): EChartsOption {
+  return {
+    grid: { bottom: 40, left: 48, right: 16, top: 16 },
+    legend: { ...darkLegend, bottom: 0 },
+    tooltip: { ...darkTooltip, axisPointer: { type: 'cross' }, trigger: 'axis' },
+    xAxis: { ...darkAxis, boundaryGap: false, data: xData, type: 'category' },
+    yAxis: { ...darkAxis, type: 'value' },
+    series: series.map((s, i) => ({
+      areaStyle: {
+        color: {
+          colorStops: [
+            { color: `${colors[i] || C.cyan}22`, offset: 0 },
+            { color: `${colors[i] || C.cyan}02`, offset: 1 },
+          ],
+          type: 'linear',
+          x: 0, y: 0, x2: 0, y2: 1,
+        },
+      },
+      data: s.data,
+      itemStyle: { color: colors[i] || C.cyan },
+      lineStyle: { width: 2 },
+      name: s.name,
+      smooth: true,
+      symbol: 'circle',
+      symbolSize: 4,
+      type: 'line',
+    })),
+  };
+}
+
+/** 鐜舰楗煎浘 */
+export function getDonutPieChartOptions(
+  data: Array<{ name: string; value: number }>,
+  colors: string[] = [C.cyan, C.blue, C.purple, C.green, C.yellow, C.orange],
+): EChartsOption {
+  return {
+    legend: { ...darkLegend, bottom: 0, type: 'scroll' },
+    series: [{
+      avoidLabelOverlap: true,
+      data,
+      emphasis: {
+        itemStyle: { shadowBlur: 16, shadowColor: 'rgba(0,229,255,0.3)' },
+        label: { fontSize: 12, fontWeight: 'bold', show: true },
+        scaleSize: 8,
+      },
+      itemStyle: {
+        borderColor: '#060e24',
+        borderRadius: 3,
+        borderWidth: 3,
+        color: (params: { dataIndex: number }) => colors[params.dataIndex % colors.length],
+      },
+      label: { show: false },
+      radius: ['55%', '78%'],
+      type: 'pie',
+    }],
+    tooltip: { ...darkTooltip, formatter: '{b}: {c} ({d}%)', trigger: 'item' },
+  };
+}
+
+/** 浠〃鐩橈紙Gauge锛� */
+export function getGaugeChartOptions(
+  value: number,
+  name: string,
+  max = 100,
+  color = C.cyan,
+): EChartsOption {
+  return {
+    series: [{
+      anchor: { show: true, showAbove: true, size: 12 },
+      axisLine: {
+        lineStyle: {
+          color: [[0.3, C.red], [0.7, C.yellow], [1, C.green]],
+          width: 12,
+        },
+      },
+      axisTick: { distance: -16, length: 4, lineStyle: { width: 1, color: C.textSecondary } },
+      data: [{ name, value }],
+      detail: {
+        color,
+        fontSize: 22,
+        fontWeight: 'bold',
+        formatter: '{value}%',
+        offsetCenter: [0, '55%'],
+        valueAnimation: true,
+      },
+      pointer: { length: '65%', width: 4, itemStyle: { color: C.cyan } },
+      progress: { itemStyle: { color }, show: true, width: 12 },
+      radius: '100%',
+      splitLine: { distance: -18, length: 10, lineStyle: { width: 2, color: C.textSecondary } },
+      title: { fontSize: 11, offsetCenter: [0, '80%'], color: C.textSecondary },
+      type: 'gauge',
+      max,
+    }],
+    tooltip: { ...darkTooltip, formatter: '{b}: {c}%', trigger: 'item' },
+  };
+}
+
+/** 鏁g偣鍥� */
+export function getScatterChartOptions(
+  data: Array<[number, number]>,
+  xName = '',
+  yName = '',
+): EChartsOption {
+  return {
+    grid: { bottom: 40, left: 56, right: 16, top: 16 },
+    tooltip: {
+      ...darkTooltip,
+      formatter: (params: { value: [number, number] }) =>
+        `${xName}: ${params.value[0]}<br/>${yName}: ${params.value[1]}`,
+      trigger: 'item',
+    },
+    xAxis: {
+      ...darkAxis,
+      name: xName,
+      nameTextStyle: { color: C.textSecondary, fontSize: 10 },
+      type: 'value',
+    },
+    yAxis: {
+      ...darkAxis,
+      name: yName,
+      nameTextStyle: { color: C.textSecondary, fontSize: 10 },
+      type: 'value',
+    },
+    series: [{
+      data,
+      emphasis: {
+        itemStyle: { shadowBlur: 12, shadowColor: 'rgba(0,229,255,0.5)' },
+      },
+      itemStyle: {
+        color: {
+          colorStops: [
+            { color: C.cyan, offset: 0 },
+            { color: `${C.cyan}33`, offset: 1 },
+          ],
+          r: 1,
+          type: 'radial',
+          x: 0.4, y: 0.3,
+        },
+      },
+      symbolSize: 10,
+      type: 'scatter',
+    }],
+  };
+}
diff --git a/src/views/bi/dashboard/data.ts b/src/views/bi/dashboard/data.ts
new file mode 100644
index 0000000..49dc1f9
--- /dev/null
+++ b/src/views/bi/dashboard/data.ts
@@ -0,0 +1,69 @@
+import type { BiDashboardApi } from '#/api/bi/dashboard';
+
+export interface DashboardMeta {
+  code: string;
+  title: string;
+  subtitle: string;
+  gradient: string;
+  icon: string;
+}
+
+/** 浠〃鐩樺厓淇℃伅鏄犲皠 */
+export const DASHBOARD_META: Record<string, DashboardMeta> = {
+  'purchase-sales': {
+    code: 'purchase_sales',
+    title: '閲囪喘 路 閿�鍞� 路 鍞悗',
+    subtitle: '渚涘簲閾惧叏閾捐矾缁忚惀鍐崇瓥鐪嬫澘',
+    gradient: 'from-indigo-600 via-blue-600 to-cyan-500',
+    icon: 'lucide:shopping-cart',
+  },
+  warehouse: {
+    code: 'warehouse',
+    title: '浠撳偍鐗╂祦',
+    subtitle: '搴撳瓨鍛ㄨ浆涓庣墿娴佹晥鐜囩湅鏉�',
+    gradient: 'from-emerald-600 via-green-600 to-teal-500',
+    icon: 'lucide:warehouse',
+  },
+  'production-equipment': {
+    code: 'production_equipment',
+    title: '鐢熶骇 路 璁惧',
+    subtitle: '鐢熶骇鎵ц涓庤澶囪繍琛屾晥鐜囩湅鏉�',
+    gradient: 'from-orange-600 via-amber-600 to-yellow-500',
+    icon: 'lucide:factory',
+  },
+  quality: {
+    code: 'quality',
+    title: '璐ㄩ噺绠$悊',
+    subtitle: '璐ㄦ杈炬爣涓庤川閲忚秼鍔跨洃鎺х湅鏉�',
+    gradient: 'from-rose-600 via-pink-600 to-fuchsia-500',
+    icon: 'lucide:shield-check',
+  },
+  'hr-oa': {
+    code: 'hr_oa',
+    title: '浜哄姏璧勬簮 路 鍗忓悓鍔炲叕',
+    subtitle: '缁勭粐鏁堣兘涓庡崗鍚屾晥鐜囧垎鏋愮湅鏉�',
+    gradient: 'from-violet-600 via-purple-600 to-indigo-500',
+    icon: 'lucide:users',
+  },
+};
+
+/** 鏍规嵁璺敱璺緞鑾峰彇浠〃鐩樼紪鐮� */
+export function getDashboardCode(path: string): string {
+  const segments = path.split('/').filter(Boolean);
+  return segments[segments.length - 1] || '';
+}
+
+/** 榛樿绌轰华琛ㄧ洏鏁版嵁 */
+export function getDefaultCharts(): BiDashboardApi.ChartItem[] {
+  return [
+    {
+      id: 0,
+      name: '鏆傛棤鏁版嵁',
+      chartType: 'number',
+      refreshInterval: 0,
+      position: { x: 0, y: 0, w: 24, h: 6 },
+      data: [],
+      error: '璇烽厤缃浘琛ㄦ暟鎹�',
+    },
+  ];
+}
diff --git a/src/views/bi/dashboard/index.vue b/src/views/bi/dashboard/index.vue
new file mode 100644
index 0000000..cac84a6
--- /dev/null
+++ b/src/views/bi/dashboard/index.vue
@@ -0,0 +1,665 @@
+<script lang="ts" setup>
+import type { BiDashboardApi } from '#/api/bi/dashboard';
+
+import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
+import { useRoute } from 'vue-router';
+
+import { IconifyIcon } from '@vben/icons';
+
+import { Empty, Spin } from 'ant-design-vue';
+
+import { getDashboardData } from '#/api/bi/dashboard';
+
+import ChartCard from './modules/ChartCard.vue';
+import ClockWidget from './modules/ClockWidget.vue';
+import { DASHBOARD_META, getDashboardCode, getDefaultCharts } from './data';
+
+defineOptions({ name: 'BiDashboard' });
+
+const route = useRoute();
+const dashboardCode = computed(() => getDashboardCode(route.path));
+const meta = computed(() => DASHBOARD_META[dashboardCode.value]);
+
+const charts = ref<BiDashboardApi.ChartItem[]>([]);
+const loading = ref(true);
+const lastUpdateTime = ref('');
+
+async function loadData() {
+  if (!meta.value) return;
+  try {
+    loading.value = true;
+    const data = await getDashboardData(meta.value.code);
+    charts.value = data.length > 0 ? data : getDefaultCharts();
+    lastUpdateTime.value = new Date().toLocaleTimeString('zh-CN', { hour12: false });
+  } catch {
+    charts.value = getDefaultCharts();
+  } finally {
+    loading.value = false;
+    await nextTick();
+    resizeAllCharts();
+  }
+}
+
+// ======== 鍒嗙被 ========
+const numberCards = computed(() => charts.value.filter((c) => c.chartType === 'number'));
+const tableCharts = computed(() => charts.value.filter((c) => c.chartType === 'table'));
+const echartsCharts = computed(() => charts.value.filter((c) => !['number', 'table'].includes(c.chartType)));
+
+// ======== 鍥捐〃 Grid 鍒楄法搴� ========
+function isFullWidth(chart: BiDashboardApi.ChartItem): boolean {
+  const w = chart.position?.w;
+  if (w && w >= 24) return true;
+  return chart.chartType === 'table';
+}
+
+function getGridStyle(chart: BiDashboardApi.ChartItem): Record<string, string> {
+  if (isFullWidth(chart)) return { 'grid-column': '1 / -1' };
+  return {};
+}
+
+// ======== 鍥捐〃缂╂斁 ========
+const dashboardRef = ref<HTMLElement>();
+let resizeObserver: ResizeObserver | null = null;
+
+function resizeAllCharts() {
+  setTimeout(() => window.dispatchEvent(new Event('resize')), 100);
+}
+
+function setupResizeObserver() {
+  if (!dashboardRef.value) return;
+  resizeObserver = new ResizeObserver(() => resizeAllCharts());
+  resizeObserver.observe(dashboardRef.value);
+}
+
+// ======== 鍏ㄥ睆 ========
+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, 0);
+}
+
+watch(() => charts.value, () => {
+  animatedIds.clear();
+});
+
+function formatNumber(num: number): string {
+  if (num >= 1_0000_0000) return `${(num / 1_0000_0000).toFixed(1)}浜縛;
+  if (num >= 1_0000) return `${(num / 1_0000).toFixed(1)}涓嘸;
+  if (Number.isInteger(num)) return num.toLocaleString();
+  return num.toFixed(2);
+}
+
+function extractNumberValue(record: Record<string, unknown>): number {
+  return (Object.values(record).find((v): v is number => typeof v === 'number') || 0) as number;
+}
+
+function getCardDisplayValue(card: BiDashboardApi.ChartItem): string {
+  if (!card.data?.[0]) return '--';
+  const v = extractNumberValue(card.data[0] as Record<string, unknown>);
+  return formatNumber(v);
+}
+
+function getCellValue(row: unknown, col: string): unknown {
+  return (row as Record<string, unknown>)[col];
+}
+
+// ======== 鐪嬫澘涓婚鑹� ========
+const dashboardAccentColors: Record<string, string> = {
+  'purchase-sales': '#6366f1',
+  'production-equipment': '#f59e0b',
+  quality: '#f43f5e',
+  'hr-oa': '#8b5cf6',
+};
+const accentColor = computed(() => dashboardAccentColors[dashboardCode.value] || '#00E5FF');
+
+// ======== KPI 鍥炬爣/棰滆壊 ========
+function getKpiIcon(name: string): string {
+  if (name.includes('閲囪喘')) return 'lucide:shopping-cart';
+  if (name.includes('閿�鍞�')) return 'lucide:trending-up';
+  if (name.includes('鍞悗')) return 'lucide:headphones';
+  if (name.includes('鐢熶骇')) return 'lucide:factory';
+  if (name.includes('璁惧')) return 'lucide:cpu';
+  if (name.includes('璐ㄩ噺') || name.includes('妫�楠�') || name.includes('璐ㄦ')) return 'lucide:shield-check';
+  if (name.includes('浜哄姏') || name.includes('鍛樺伐') || name.includes('浜轰簨')) return 'lucide:users';
+  if (name.includes('鑰冨嫟') || name.includes('鎵撳崱')) return 'lucide:clock';
+  if (name.includes('鍚堝悓') || name.includes('鍗忓悓')) return 'lucide:file-text';
+  if (name.includes('璁㈠崟')) return 'lucide:clipboard-list';
+  if (name.includes('搴撳瓨') || name.includes('浠撳簱')) return 'lucide:package';
+  return 'lucide:bar-chart-4';
+}
+
+const kpiColors = ['#00E5FF', '#00FF88', '#FFC107', '#FF3860', '#8B5CF6', '#6366f1'];
+function getKpiColor(i: number) { return kpiColors[i % kpiColors.length]; }
+
+watch(dashboardCode, () => loadData());
+onMounted(() => {
+  document.addEventListener('fullscreenchange', onFullscreenChange);
+  setupResizeObserver();
+  loadData();
+});
+onBeforeUnmount(() => {
+  document.removeEventListener('fullscreenchange', onFullscreenChange);
+  resizeObserver?.disconnect();
+});
+</script>
+
+<template>
+  <div
+    ref="dashboardRef"
+    class="bi-dashboard"
+    :class="{ 'is-fullscreen': isFullscreen }"
+    :style="{ '--bi-accent': accentColor }"
+  >
+    <!-- ======== 澶撮儴 ======== -->
+    <header v-if="meta" class="dash-header">
+      <div class="dash-header-left">
+        <div class="dash-logo">
+          <IconifyIcon :icon="meta.icon" class="text-xl" />
+        </div>
+        <div class="dash-title-group">
+          <h1>{{ meta.title }}</h1>
+          <p>{{ meta.subtitle }}</p>
+        </div>
+      </div>
+      <div class="dash-header-divider" />
+      <div class="dash-header-right">
+        <div class="dash-status-tags">
+          <span class="dash-status-tag">
+            <i class="dash-status-dot online" /> 绯荤粺姝e父
+          </span>
+          <span class="dash-status-tag">
+            <i class="dash-status-dot" /> 鏁版嵁鏇存柊
+          </span>
+        </div>
+        <ClockWidget />
+        <div class="dash-live-badge">
+          <span class="dash-live-dot" />
+          <span>瀹炴椂</span>
+          <span v-if="lastUpdateTime" class="dash-live-time">路 {{ lastUpdateTime }}</span>
+        </div>
+        <button
+          class="dash-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 v-if="numberCards.length > 0" class="kpi-row">
+        <div
+          v-for="(card, i) in numberCards"
+          :key="card.id"
+          class="kpi-card"
+          :style="{ '--kpi-color': getKpiColor(i), animationDelay: `${i * 0.06}s` }"
+        >
+          <div class="kpi-icon">
+            <IconifyIcon :icon="getKpiIcon(card.name)" />
+          </div>
+          <div class="kpi-body">
+            <span class="kpi-label">{{ card.name }}</span>
+            <span
+              class="kpi-value"
+              :ref="(el: unknown) => {
+                if (el && card.data?.[0]) {
+                  const v = extractNumberValue(card.data[0] as Record<string, unknown>);
+                  maybeAnimate(el as HTMLElement, card.id!, v);
+                }
+              }"
+            >{{ getCardDisplayValue(card) }}</span>
+          </div>
+        </div>
+      </div>
+
+      <!-- 鍥捐〃 Grid -->
+      <div v-if="echartsCharts.length > 0" class="chart-row">
+        <div
+          v-for="(chart, i) in echartsCharts"
+          :key="chart.id"
+          :style="{ ...getGridStyle(chart), animationDelay: `${i * 0.08}s` }"
+        >
+          <ChartCard :chart="chart" :accent-color="accentColor" />
+        </div>
+      </div>
+
+      <!-- 鏁版嵁琛ㄦ牸 -->
+      <template v-if="tableCharts.length > 0">
+        <div
+          v-for="chart in tableCharts"
+          :key="chart.id"
+          class="table-card"
+        >
+          <div class="table-header">
+            <span class="table-header-dot" :style="{ background: accentColor, boxShadow: `0 0 6px ${accentColor}` }" />
+            <span class="table-header-title">{{ chart.name }}</span>
+            <span class="table-badge">瀹炴椂婊氬姩</span>
+          </div>
+          <div v-if="chart.data && chart.data.length > 0" class="table-wrap">
+            <table class="data-table">
+              <thead>
+                <tr>
+                  <th
+                    v-for="key in Object.keys(chart.data[0] || {})"
+                    :key="key"
+                  >{{ key }}</th>
+                </tr>
+              </thead>
+              <tbody>
+                <tr
+                  v-for="(row, ri) in chart.data"
+                  :key="ri"
+                  :style="{ animationDelay: `${ri * 50}ms` }"
+                >
+                  <td
+                    v-for="key in Object.keys(chart.data[0] || {})"
+                    :key="key"
+                  >{{ getCellValue(row, key) }}</td>
+                </tr>
+              </tbody>
+            </table>
+          </div>
+          <Empty v-else description="鏆傛棤鏁版嵁" />
+        </div>
+      </template>
+
+      <!-- 绌虹姸鎬� -->
+      <div v-if="charts.length === 0" class="empty-state">
+        <IconifyIcon class="size-16 text-white/15" icon="lucide:bar-chart-4" />
+        <p>鏆傛湭閰嶇疆鍥捐〃鏁版嵁</p>
+        <p class="empty-sub">璇峰湪閰嶇疆绠$悊涓负褰撳墠浠〃鐩樻坊鍔犲浘琛�</p>
+      </div>
+    </template>
+  </div>
+</template>
+
+<style scoped>
+/* ======== CSS 鍙橀噺 ======== */
+.bi-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(185, 196, 220, 0.72);
+  --text-muted: rgba(145, 158, 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);
+}
+
+/* 鑳屾櫙鍏夋檿 + 缃戞牸 */
+.bi-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; }
+}
+
+.bi-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%);
+}
+
+/* ======== 澶撮儴 ======== */
+.dash-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);
+}
+
+.dash-header-left { display: flex; align-items: center; gap: 12px; flex-shrink: 0; }
+
+.dash-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);
+}
+
+.dash-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;
+}
+
+.dash-title-group p {
+  font-size: 11px; color: var(--text-secondary); margin-top: 2px; letter-spacing: 0.5px;
+}
+
+.dash-header-divider {
+  flex: 1; height: 1px;
+  background: linear-gradient(90deg, transparent, rgba(0,229,255,0.15), transparent);
+}
+
+.dash-header-right { display: flex; align-items: center; gap: 14px; flex-shrink: 0; }
+
+.dash-status-tags { display: flex; gap: 8px; }
+
+.dash-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);
+}
+
+.dash-status-dot {
+  width: 5px; height: 5px; border-radius: 50%; background: var(--text-muted);
+}
+.dash-status-dot.online { background: var(--green); box-shadow: 0 0 6px var(--green); }
+
+.dash-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);
+}
+
+.dash-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; }
+}
+
+.dash-live-time { color: var(--text-muted); }
+
+.dash-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;
+}
+.dash-fs-btn:hover { border-color: rgba(255,255,255,0.2); color: #fff; background: rgba(255,255,255,0.04); }
+
+/* ======== KPI 琛� ======== */
+.kpi-row {
+  position: relative; z-index: 1;
+  display: grid;
+  grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
+  gap: 12px; margin-bottom: 16px;
+}
+
+.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;
+}
+.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;
+}
+.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);
+}
+
+.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);
+}
+
+.kpi-body {
+  flex: 1; min-width: 0;
+  display: flex; flex-direction: column; gap: 4px;
+}
+
+.kpi-label {
+  font-size: 11px; color: var(--text-secondary); letter-spacing: 0.3px;
+  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
+}
+
+.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); }
+}
+
+/* ======== 鍥捐〃琛� ======== */
+.chart-row {
+  position: relative; z-index: 1;
+  display: grid;
+  grid-template-columns: repeat(auto-fit, minmax(380px, 1fr));
+  gap: 12px;
+  margin-bottom: 16px;
+}
+
+.chart-row > div {
+  min-width: 0;
+  animation: kpi-fade-up 0.5s cubic-bezier(0.4, 0, 0.2, 1) both;
+}
+
+/* ======== 琛ㄦ牸鍗$墖 ======== */
+.table-card {
+  position: relative; z-index: 1;
+  background: var(--bg-card);
+  backdrop-filter: blur(8px);
+  -webkit-backdrop-filter: blur(8px);
+  border: 1px solid var(--border);
+  border-radius: 10px;
+  padding: 14px;
+  margin-bottom: 16px;
+  transition: all 0.35s;
+  animation: kpi-fade-up 0.5s cubic-bezier(0.4, 0, 0.2, 1) both;
+}
+.table-card::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%);
+}
+.table-card: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);
+}
+
+.table-header {
+  display: flex; align-items: center; gap: 8px;
+  margin-bottom: 10px;
+}
+
+.table-header-dot {
+  width: 6px; height: 6px; border-radius: 50%;
+  flex-shrink: 0;
+}
+
+.table-header-title {
+  font-size: 13px; font-weight: 600;
+  color: var(--text-primary);
+  letter-spacing: 0.5px;
+}
+
+.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);
+}
+
+.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);
+}
+
+.data-table {
+  width: 100%; border-collapse: collapse; font-size: 12px;
+}
+
+.data-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;
+}
+
+.data-table tbody td {
+  padding: 9px 14px; color: var(--text-primary);
+  border-bottom: 1px solid rgba(0, 229, 255, 0.04);
+  white-space: nowrap;
+}
+
+.data-table tbody tr {
+  transition: all 0.25s;
+  opacity: 0;
+  animation: row-fade-in 0.45s ease forwards;
+}
+
+.data-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); }
+}
+
+/* ======== 绌虹姸鎬� ======== */
+.empty-state {
+  position: relative; z-index: 1;
+  display: flex; flex-direction: column; align-items: center;
+  justify-content: center; padding: 60px 0;
+  color: var(--text-muted);
+}
+.empty-state p { margin-top: 12px; }
+.empty-sub { font-size: 12px; color: rgba(140, 152, 180, 0.35); }
+
+/* ======== 鍏ㄥ睆 ======== */
+.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 .kpi-row { gap: 16px; }
+.is-fullscreen .chart-row { gap: 16px; }
+.is-fullscreen .dash-header { margin-bottom: 24px; }
+
+/* ======== 鍔犺浇鎬�/绌虹姸鎬� ======== */
+:deep(.ant-spin-text) { color: var(--text-secondary); }
+:deep(.ant-empty) { color: var(--text-muted); }
+:deep(.ant-empty-description) { color: var(--text-muted); }
+
+/* ======== 鍝嶅簲寮� ======== */
+@media (max-width: 768px) {
+  .bi-dashboard { padding: 10px; }
+  .dash-header { flex-wrap: wrap; }
+  .dash-header-divider { display: none; }
+  .kpi-row { grid-template-columns: 1fr 1fr; }
+  .chart-row { grid-template-columns: 1fr; }
+  .chart-row > div { grid-column: 1 / -1 !important; }
+  .kpi-value { font-size: 20px; }
+}
+</style>
diff --git a/src/views/bi/dashboard/modules/ChartCard.vue b/src/views/bi/dashboard/modules/ChartCard.vue
new file mode 100644
index 0000000..88e4152
--- /dev/null
+++ b/src/views/bi/dashboard/modules/ChartCard.vue
@@ -0,0 +1,207 @@
+<script lang="ts" setup>
+import type { BiDashboardApi } from '#/api/bi/dashboard';
+import type { EchartsUIType } from '@vben/plugins/echarts';
+
+import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
+
+import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
+
+import { Empty } from 'ant-design-vue';
+
+import {
+  getAreaLineChartOptions,
+  getBarChartOptions,
+  getDonutPieChartOptions,
+  getGaugeChartOptions,
+  getScatterChartOptions,
+} from '../chart-options';
+
+defineOptions({ name: 'BiChartCard' });
+
+interface Props {
+  chart: BiDashboardApi.ChartItem;
+  accentColor?: string;
+}
+
+const props = withDefaults(defineProps<Props>(), {
+  accentColor: '#00E5FF',
+});
+
+const chartRef = ref<EchartsUIType>();
+const { renderEcharts } = useEcharts(chartRef);
+
+let refreshTimer: ReturnType<typeof setInterval> | null = null;
+let resizeObserver: ResizeObserver | null = null;
+
+function getEchartsOptions(chart: BiDashboardApi.ChartItem) {
+  if (!chart.data || chart.data.length === 0) return {};
+
+  const keys = Object.keys(chart.data[0] || {});
+  const labelKey = keys[0] || 'name';
+  const valueKey = keys[1] || 'value';
+  const labels = chart.data.map((d: Record<string, unknown>) => String(d[labelKey] || ''));
+  const values = chart.data.map((d: Record<string, unknown>) => Number(d[valueKey]) || 0);
+
+  switch (chart.chartType) {
+    case 'bar':
+      return getBarChartOptions(labels, [{ name: chart.name, data: values }]);
+    case 'line':
+      return getAreaLineChartOptions(labels, [{ name: chart.name, data: values }]);
+    case 'pie':
+      return getDonutPieChartOptions(labels.map((l, i) => ({ name: l, value: values[i] || 0 })));
+    case 'gauge': {
+      const gVal = values[0] || values.find((v) => v > 0) || 0;
+      return getGaugeChartOptions(Math.min(gVal, 100), chart.name, 100);
+    }
+    case 'scatter': {
+      const pts: Array<[number, number]> = chart.data.map((d: Record<string, unknown>) => [
+        Number(d[keys[0]]) || 0,
+        Number(d[keys[1]]) || 0,
+      ]);
+      return getScatterChartOptions(pts, keys[0], keys[1]);
+    }
+    default:
+      return {};
+  }
+}
+
+async function render() {
+  const type = props.chart.chartType;
+  if (!['bar', 'line', 'pie', 'gauge', 'scatter'].includes(type)) return;
+  const options = getEchartsOptions(props.chart);
+  if (props.chart.chartOptions) {
+    Object.assign(options, props.chart.chartOptions);
+  }
+  await renderEcharts(options);
+}
+
+function setupTimer() {
+  clearTimer();
+  if (props.chart.refreshInterval > 0) {
+    refreshTimer = setInterval(render, props.chart.refreshInterval * 1000);
+  }
+}
+
+function clearTimer() {
+  if (refreshTimer !== null) {
+    clearInterval(refreshTimer);
+    refreshTimer = null;
+  }
+}
+
+onMounted(() => {
+  render();
+  setupTimer();
+  const el = (chartRef.value as any)?.$el || (chartRef.value as any)?.root;
+  if (el) {
+    resizeObserver = new ResizeObserver(() => {
+      setTimeout(() => window.dispatchEvent(new Event('resize')), 50);
+    });
+    resizeObserver.observe(el);
+  }
+});
+
+watch(() => props.chart, () => {
+  render();
+  setupTimer();
+});
+
+onBeforeUnmount(() => {
+  clearTimer();
+  resizeObserver?.disconnect();
+});
+</script>
+
+<template>
+  <div class="chart-card">
+    <div class="chart-header">
+      <span class="chart-dot" :style="{ background: accentColor, boxShadow: `0 0 6px ${accentColor}` }" />
+      <span class="chart-title">{{ chart.name }}</span>
+      <span
+        v-if="chart.refreshInterval > 0"
+        class="chart-badge"
+      >姣� {{ chart.refreshInterval }}s</span>
+    </div>
+    <div v-if="chart.error" class="flex h-[240px] items-center justify-center">
+      <Empty :description="chart.error" />
+    </div>
+    <EchartsUI
+      v-else
+      ref="chartRef"
+      class="!h-full"
+      :style="{ minHeight: '220px' }"
+    />
+  </div>
+</template>
+
+<style scoped>
+.chart-card {
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+  background: rgba(10, 24, 52, 0.55);
+  backdrop-filter: blur(10px);
+  -webkit-backdrop-filter: blur(10px);
+  border: 1px solid rgba(0, 229, 255, 0.2);
+  border-radius: 10px;
+  padding: 14px;
+  transition: all 0.35s;
+  animation: chart-fade-up 0.5s cubic-bezier(0.4, 0, 0.2, 1) both;
+  position: relative;
+}
+.chart-card::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%);
+}
+
+.chart-card:hover {
+  border-color: rgba(0, 229, 255, 0.35);
+  box-shadow:
+    0 0 24px rgba(0, 229, 255, 0.08),
+    0 0 48px rgba(0, 229, 255, 0.03),
+    0 4px 20px rgba(0, 0, 0, 0.3);
+}
+
+.chart-card > :last-child {
+  flex: 1;
+  min-height: 0;
+}
+
+.chart-header {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  margin-bottom: 8px;
+}
+
+.chart-dot {
+  width: 6px;
+  height: 6px;
+  border-radius: 50%;
+  flex-shrink: 0;
+}
+
+.chart-title {
+  font-size: 13px;
+  font-weight: 600;
+  color: rgba(230, 236, 250, 0.95);
+  letter-spacing: 0.5px;
+}
+
+.chart-badge {
+  margin-left: auto;
+  font-size: 10px;
+  font-weight: 400;
+  padding: 2px 6px;
+  border-radius: 10px;
+  background: rgba(0, 229, 255, 0.06);
+  color: rgba(180, 190, 215, 0.6);
+  border: 1px solid rgba(0, 229, 255, 0.1);
+}
+
+@keyframes chart-fade-up {
+  from { opacity: 0; transform: translateY(16px); }
+  to { opacity: 1; transform: translateY(0); }
+}
+</style>
diff --git a/src/views/bi/dashboard/modules/ClockWidget.vue b/src/views/bi/dashboard/modules/ClockWidget.vue
new file mode 100644
index 0000000..94bd501
--- /dev/null
+++ b/src/views/bi/dashboard/modules/ClockWidget.vue
@@ -0,0 +1,54 @@
+<script lang="ts" setup>
+import { onBeforeUnmount, onMounted, ref } from 'vue';
+
+defineOptions({ name: 'BiClockWidget' });
+
+const clockTime = ref('');
+const clockDate = ref('');
+let timer: ReturnType<typeof setInterval>;
+
+function update() {
+  const now = new Date();
+  clockTime.value = now.toLocaleTimeString('zh-CN', { hour12: false });
+  clockDate.value = now.toLocaleDateString('zh-CN', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
+}
+
+onMounted(() => {
+  update();
+  timer = setInterval(update, 1000);
+});
+onBeforeUnmount(() => clearInterval(timer));
+</script>
+
+<template>
+  <div class="clock-widget">
+    <span class="clock-time">{{ clockTime }}</span>
+    <span class="clock-date">{{ clockDate }}</span>
+  </div>
+</template>
+
+<style scoped>
+.clock-widget {
+  display: none;
+  flex-direction: column;
+  align-items: flex-end;
+}
+
+@media (min-width: 640px) {
+  .clock-widget { display: flex; }
+}
+
+.clock-time {
+  font-family: 'JetBrains Mono', 'Fira Code', monospace;
+  font-size: 16px;
+  font-weight: 700;
+  font-variant-numeric: tabular-nums;
+  letter-spacing: 1px;
+  color: rgba(230, 236, 250, 0.95);
+}
+
+.clock-date {
+  font-size: 10px;
+  color: rgba(180, 190, 215, 0.7);
+}
+</style>
diff --git a/src/views/bi/warehouse/chart-options.ts b/src/views/bi/warehouse/chart-options.ts
new file mode 100644
index 0000000..90b9b41
--- /dev/null
+++ b/src/views/bi/warehouse/chart-options.ts
@@ -0,0 +1,305 @@
+import type { EChartsOption } from '@vben/plugins/echarts';
+
+/** 绉戞妧钃濇殫鑹蹭富棰樿壊鏉� */
+const C = {
+  cyan: '#00E5FF',
+  green: '#00FF88',
+  yellow: '#FFC107',
+  red: '#FF3860',
+  blue: '#3366FF',
+  purple: '#8B5CF6',
+  orange: '#FF6B35',
+  textPrimary: 'rgba(235, 240, 252, 0.95)',
+  textSecondary: 'rgba(180, 196, 220, 0.65)',
+  border: 'rgba(56, 128, 237, 0.12)',
+  splitLine: 'rgba(56, 128, 237, 0.08)',
+};
+
+const darkAxis = {
+  axisLabel: { color: C.textSecondary, fontSize: 10 },
+  axisLine: { lineStyle: { color: C.border } },
+  axisTick: { show: false },
+  splitLine: { lineStyle: { color: C.splitLine } },
+};
+
+const techTooltip = {
+  backgroundColor: 'rgba(6, 18, 42, 0.94)',
+  borderColor: 'rgba(0, 229, 255, 0.28)',
+  borderWidth: 1,
+  padding: [10, 14],
+  textStyle: { color: C.textPrimary, fontSize: 11 },
+};
+
+/** 娓愬彉鏌辩姸鍥撅紙鍑哄叆搴撹秼鍔匡級 */
+export function getWarehouseBarOptions(
+  xData: string[],
+  series: Array<{ name: string; data: number[] }>,
+): EChartsOption {
+  const colors = [C.cyan, C.green];
+  return {
+    grid: { bottom: 40, left: 48, right: 16, top: 16 },
+    legend: { bottom: 0, itemGap: 12, textStyle: { color: C.textSecondary, fontSize: 10 } },
+    tooltip: { ...techTooltip, axisPointer: { type: 'shadow' }, trigger: 'axis' },
+    xAxis: { ...darkAxis, data: xData, type: 'category' },
+    yAxis: { ...darkAxis, type: 'value' },
+    series: series.map((s, i) => ({
+      barGap: '30%',
+      barMaxWidth: 24,
+      data: s.data,
+      emphasis: { itemStyle: { shadowBlur: 10, shadowColor: colors[i] } },
+      itemStyle: {
+        borderRadius: [4, 4, 0, 0],
+        color: { colorStops: [{ color: colors[i], offset: 0 }, { color: `${colors[i]}22`, offset: 1 }], type: 'linear', x: 0, y: 0, x2: 0, y2: 1 },
+      },
+      name: s.name,
+      type: 'bar',
+    })),
+  };
+}
+
+/** 娓愬彉闈㈢Н鎶樼嚎鍥撅紙搴撳瓨瓒嬪娍锛� */
+export function getWarehouseAreaLineOptions(
+  xData: string[],
+  series: Array<{ name: string; data: number[] }>,
+): EChartsOption {
+  const colors = [C.cyan, C.purple, C.green];
+  return {
+    grid: { bottom: 40, left: 48, right: 16, top: 16 },
+    legend: { bottom: 0, itemGap: 12, textStyle: { color: C.textSecondary, fontSize: 10 } },
+    tooltip: { ...techTooltip, axisPointer: { type: 'cross' }, trigger: 'axis' },
+    xAxis: { ...darkAxis, boundaryGap: false, data: xData, type: 'category' },
+    yAxis: { ...darkAxis, type: 'value' },
+    series: series.map((s, i) => ({
+      areaStyle: { color: { colorStops: [{ color: `${colors[i]}22`, offset: 0 }, { color: `${colors[i]}02`, offset: 1 }], type: 'linear', x: 0, y: 0, x2: 0, y2: 1 } },
+      data: s.data,
+      itemStyle: { color: colors[i] },
+      lineStyle: { width: 2 },
+      name: s.name,
+      smooth: true,
+      symbol: 'circle',
+      symbolSize: 4,
+      type: 'line',
+    })),
+  };
+}
+
+/** 鐜舰楗煎浘锛堝簱瀛樼粨鏋勶級 */
+export function getWarehouseDonutOptions(
+  data: Array<{ name: string; value: number }>,
+  colors: string[] = [C.cyan, C.blue, C.purple, C.green, C.yellow, C.orange],
+): EChartsOption {
+  return {
+    legend: { bottom: 0, itemGap: 8, textStyle: { color: C.textSecondary, fontSize: 9 }, type: 'scroll' },
+    series: [{
+      avoidLabelOverlap: true,
+      data,
+      emphasis: {
+        itemStyle: { shadowBlur: 16, shadowColor: 'rgba(0,229,255,0.3)' },
+        label: { fontSize: 12, fontWeight: 'bold', show: true },
+        scaleSize: 8,
+      },
+      itemStyle: {
+        borderColor: '#06152F',
+        borderRadius: 3,
+        borderWidth: 3,
+        color: (params: { dataIndex: number }) => colors[params.dataIndex % colors.length],
+      },
+      label: { show: false },
+      radius: ['55%', '78%'],
+      type: 'pie',
+    }],
+    tooltip: { ...techTooltip, formatter: '{b}: {c} ({d}%)', trigger: 'item' },
+  };
+}
+
+/** 涓浗鍦板浘 + 浠撳簱鑺傜偣 */
+export function getChinaMapOptions(
+  warehouseNodes: Array<{ name: string; value: number[]; stock: number }>,
+  flyLines: Array<{ from: string; to: string }> = [],
+): EChartsOption {
+  return {
+    geo: {
+      map: 'china',
+      roam: false,
+      label: { show: false },
+      itemStyle: {
+        areaColor: {
+          type: 'linear',
+          x: 0, y: 0, x2: 0, y2: 1,
+          colorStops: [
+            { offset: 0, color: '#0d1f42' },
+            { offset: 0.5, color: '#0a1835' },
+            { offset: 1, color: '#081028' },
+          ],
+        },
+        borderColor: 'rgba(0, 229, 255, 0.22)',
+        borderWidth: 1.2,
+        shadowColor: 'rgba(0, 229, 255, 0.08)',
+        shadowOffsetX: 0,
+        shadowOffsetY: 3,
+      },
+      emphasis: {
+        itemStyle: {
+          areaColor: '#132848',
+          borderColor: 'rgba(0, 229, 255, 0.45)',
+          borderWidth: 1.5,
+        },
+      },
+    },
+    series: [
+      {
+        type: 'effectScatter',
+        coordinateSystem: 'geo',
+        data: warehouseNodes.map((n) => ({
+          name: n.name,
+          value: [...n.value, n.stock],
+        })),
+        symbolSize: (val: number[]) => Math.max(8, Math.min(22, (val[2] || 1) / 1500)),
+        showEffectOn: 'render',
+        rippleEffect: { brushType: 'stroke', scale: 4, period: 4.5 },
+        label: {
+          show: true,
+          position: 'bottom',
+          distance: 8,
+          formatter: '{b}',
+          fontSize: 10,
+          color: C.textPrimary,
+        },
+        itemStyle: {
+          color: {
+            type: 'radial',
+            x: 0.5, y: 0.5, r: 0.5,
+            colorStops: [
+              { offset: 0, color: '#fff' },
+              { offset: 0.3, color: C.cyan },
+              { offset: 1, color: 'rgba(0,229,255,0.3)' },
+            ],
+          },
+          shadowBlur: 20,
+          shadowColor: C.cyan,
+        },
+        zlevel: 1,
+      },
+      {
+        type: 'lines',
+        coordinateSystem: 'geo',
+        data: flyLines.map((f) => {
+          const from = warehouseNodes.find((n) => n.name === f.from);
+          const to = warehouseNodes.find((n) => n.name === f.to);
+          return { coords: [from?.value, to?.value].filter(Boolean) };
+        }),
+        lineStyle: {
+          color: {
+            type: 'linear',
+            x: 0, y: 0, x2: 1, y2: 1,
+            colorStops: [
+              { offset: 0, color: C.cyan },
+              { offset: 1, color: C.green },
+            ],
+          },
+          curveness: 0.25,
+          width: 1.2,
+          opacity: 0.5,
+        },
+        effect: {
+          show: true,
+          period: 3,
+          trailLength: 0.4,
+          symbol: 'arrow',
+          symbolSize: 6,
+          color: C.green,
+        },
+        zlevel: 1,
+      },
+    ],
+    tooltip: {
+      backgroundColor: 'rgba(6, 18, 42, 0.94)',
+      borderColor: 'rgba(0, 229, 255, 0.3)',
+      borderWidth: 1,
+      padding: [10, 14],
+      textStyle: { color: C.textPrimary, fontSize: 12, fontFamily: 'monospace' },
+      formatter: (params: { name?: string; value?: number[] }) => {
+        if (!params.value || params.value.length < 3) return '';
+        return `<div style="font-size:13px;font-weight:600;margin-bottom:6px;color:${C.cyan}">${params.name}</div>
+          <div style="display:flex;justify-content:space-between;gap:20px">
+            <span style="color:${C.textSecondary}">搴撳瓨閲�</span>
+            <span style="font-weight:700">${params.value[2].toLocaleString()}</span>
+          </div>`;
+      },
+      trigger: 'item',
+    },
+  };
+}
+
+/** 浠〃鐩橈紙搴撳瓨鍛ㄨ浆鐜囷級 */
+export function getWarehouseGaugeOptions(value: number, max = 2): EChartsOption {
+  return {
+    series: [{
+      anchor: { show: true, showAbove: true, size: 12 },
+      axisLine: {
+        lineStyle: {
+          color: [[0.3, C.red], [0.7, C.yellow], [1, C.green]],
+          width: 12,
+        },
+      },
+      axisTick: { distance: -16, length: 4, lineStyle: { width: 1, color: C.textSecondary } },
+      data: [{ name: '鍛ㄨ浆鐜�', value }],
+      detail: {
+        color: C.cyan,
+        fontSize: 22,
+        fontWeight: 'bold',
+        formatter: '{value}',
+        offsetCenter: [0, '55%'],
+        valueAnimation: true,
+      },
+      pointer: { length: '65%', width: 4, itemStyle: { color: C.cyan } },
+      progress: { itemStyle: { color: C.cyan }, show: true, width: 12 },
+      radius: '100%',
+      splitLine: { distance: -18, length: 10, lineStyle: { width: 2, color: C.textSecondary } },
+      title: { fontSize: 11, offsetCenter: [0, '80%'], color: C.textSecondary },
+      type: 'gauge',
+      max,
+    }],
+    tooltip: { ...techTooltip, formatter: '鍛ㄨ浆鐜�: {c}', trigger: 'item' },
+  };
+}
+
+/** 杩蜂綘瓒嬪娍绾匡紙KPI 鍗$墖鍐咃級 */
+export function getMiniTrendOptions(data: number[], color = C.green): EChartsOption {
+  return {
+    grid: { top: 4, bottom: 4, left: 0, right: 0 },
+    xAxis: { show: false, data: data.map((_, i) => i) },
+    yAxis: { show: false, min: Math.min(...data) * 0.9 },
+    series: [{
+      areaStyle: { color: { colorStops: [{ color: `${color}22`, offset: 0 }, { color: `${color}02`, offset: 1 }], type: 'linear', x: 0, y: 0, x2: 0, y2: 1 } },
+      data,
+      itemStyle: { color },
+      lineStyle: { width: 1.5 },
+      showSymbol: false,
+      smooth: true,
+      type: 'line',
+    }],
+  };
+}
+
+/** 鏃堕棿杞村姩鎬佸垪琛� */
+export function getTimelineOptions(events: Array<{ time: string; type: string; title: string }>): EChartsOption {
+  const typeColor: Record<string, string> = { '鍒拌揣': C.green, '鍑鸿揣': C.cyan, '棰勮': C.yellow, '璋冩嫧': C.purple };
+  return {
+    grid: { top: 8, bottom: 8, left: 16, right: 16 },
+    xAxis: { show: false, min: 0, max: 1 },
+    yAxis: { show: false, type: 'category', inverse: true, data: events.map((e) => e.title) },
+    series: [{
+      type: 'scatter',
+      symbol: 'roundRect',
+      symbolSize: [8, 8],
+      data: events.map((_, i) => [0, i]),
+      itemStyle: { color: (p: { dataIndex: number }) => typeColor[events[p.dataIndex]?.type] || C.cyan },
+    }],
+    tooltip: { ...techTooltip, formatter: (p: { dataIndex: number }) => {
+      const e = events[p.dataIndex];
+      return `<div style="font-size:12px;font-weight:600;margin-bottom:4px;color:${typeColor[e?.type] || C.cyan}">${e?.type}</div>
+        <span style="color:${C.textSecondary}">${e?.time}</span>&nbsp;&nbsp;${e?.title}`;
+    }, trigger: 'item' },
+  };
+}
diff --git a/src/views/bi/warehouse/index.vue b/src/views/bi/warehouse/index.vue
new file mode 100644
index 0000000..8ff8591
--- /dev/null
+++ b/src/views/bi/warehouse/index.vue
@@ -0,0 +1,1032 @@
+<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 } from 'ant-design-vue';
+
+import { getDashboardData } from '#/api/bi/dashboard';
+
+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 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 trendData = extractNums(lineChart.value?.data, Object.keys(lineChart.value?.data?.[0] || {})[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 [];
+  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) => {
+    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 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 ? '鍗冲皢缂鸿揣' : '搴撳瓨姝e父',
+      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 } = useEcharts(mapRef);
+const { renderEcharts: renderLeftDonut } = useEcharts(leftDonutRef);
+const { renderEcharts: renderLeftArea } = useEcharts(leftAreaRef);
+const { renderEcharts: renderRightBar } = useEcharts(rightBarRef);
+const { renderEcharts: renderRightDonut } = useEcharts(rightDonutRef);
+
+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);
+}
+
+// ======== 鍒锋柊瀹氭椂鍣� ========
+let refreshTimer: ReturnType<typeof setInterval> | null = null;
+
+function setupTimer() {
+  clearTimer();
+  refreshTimer = setInterval(async () => {
+    await loadData();
+    await nextTick();
+    await renderAllCharts();
+  }, 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 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" /> 绯荤粺姝e父
+          </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">
+            <div class="wh-panel-header">
+              <span class="wh-panel-dot" />
+              鍏ㄥ浗浠撳偍缃戠粶
+            </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(185, 196, 220, 0.72);
+  --text-muted: rgba(145, 158, 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); }
+
+/* ======== 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);
+}
+
+.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>

--
Gitblit v1.9.3