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