2026-08-05 bd36da9f3daa0c326b9c954f920c97b4a61d5fce
feat(bi): 添加BI图表配置和可视化功能

- 集成echarts图表库,新增散点图、连线图和特效散点图支持
- 创建BI图表配置API接口,实现图表CRUD操作
- 开发仪表盘图表组件,支持多种图表类型的渲染
- 设计科技蓝暗色主题图表样式和配色方案
- 构建图表选项配置系统,支持自定义图表展示
- 实现图表数据获取和自动刷新机制
- 添加时钟组件和仪表盘布局管理功能
- 创建图表配置管理界面和数据源配置模块
已修改1个文件
已添加18个文件
3581 ■■■■■ 文件已修改
dist.zip 补丁 | 查看 | 原始文档 | blame | 历史
public/ref-dashboard.png 补丁 | 查看 | 原始文档 | blame | 历史
src/api/bi/chart-config.ts 52 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/bi/dashboard.ts 36 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/bi/data-source-config.ts 40 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/packages/effects/plugins/src/echarts/echarts.ts 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/bi/config/chart/data.ts 213 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/bi/config/chart/index.vue 190 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/bi/config/chart/modules/form.vue 104 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/bi/config/data-source/data.ts 139 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/bi/config/data-source/index.vue 168 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/bi/config/data-source/modules/form.vue 79 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/bi/dashboard/chart-options.ts 222 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/bi/dashboard/data.ts 69 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/bi/dashboard/index.vue 665 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/bi/dashboard/modules/ChartCard.vue 207 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/bi/dashboard/modules/ClockWidget.vue 54 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/bi/warehouse/chart-options.ts 305 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/bi/warehouse/index.vue 1032 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
dist.zip
Binary files differ
public/ref-dashboard.png
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 } });
}
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,
  );
}
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 } });
}
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,
src/views/bi/config/chart/data.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,213 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
/** çŠ¶æ€é€‰é¡¹ï¼šBI æ¨¡å— 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: '散点图 (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: '数据源ID',
      component: 'InputNumber',
      componentProps: {
        placeholder: '数据源配置ID',
      },
    },
    {
      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' },
    },
  ];
}
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: `正在删除「${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: '正在批量删除...',
    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>
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>
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' },
    },
  ];
}
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: `正在删除「${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: '正在批量删除...',
    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>
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>
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' },
  };
}
/** æ•£ç‚¹å›¾ */
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',
    }],
  };
}
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: '请配置图表数据',
    },
  ];
}
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" /> ç³»ç»Ÿæ­£å¸¸
          </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>
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>
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>
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' },
  };
}
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 ? '即将缺货' : '库存正常',
      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" /> ç³»ç»Ÿæ­£å¸¸
          </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>