feat(bi): 添加BI图表配置和可视化功能
- 集成echarts图表库,新增散点图、连线图和特效散点图支持
- 创建BI图表配置API接口,实现图表CRUD操作
- 开发仪表盘图表组件,支持多种图表类型的渲染
- 设计科技蓝暗色主题图表样式和配色方案
- 构建图表选项配置系统,支持自定义图表展示
- 实现图表数据获取和自动刷新机制
- 添加时钟组件和仪表盘布局管理功能
- 创建图表配置管理界面和数据源配置模块
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | 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 } }); |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | 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, |
| | | ); |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | 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 } }); |
| | | } |
| | |
| | | import { |
| | | BarChart, |
| | | EffectScatterChart, |
| | | FunnelChart, |
| | | GaugeChart, |
| | | LineChart, |
| | | LinesChart, |
| | | MapChart, |
| | | PieChart, |
| | | RadarChart, |
| | | ScatterChart, |
| | | } from 'echarts/charts'; |
| | | import { |
| | | DatasetComponent, |
| | |
| | | LineChart, |
| | | FunnelChart, |
| | | GaugeChart, |
| | | ScatterChart, |
| | | EffectScatterChart, |
| | | LinesChart, |
| | | LabelLayout, |
| | | LegacyGridContainLabel, |
| | | UniversalTransition, |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | import type { VbenFormSchema } from '#/adapter/form'; |
| | | import type { VxeTableGridOptions } from '#/adapter/vxe-table'; |
| | | |
| | | /** ç¶æé项ï¼BI 模å 1=å¯ç¨ 0=ç¦ç¨ï¼ä¸ common_status åå
¸å¼ç¸åï¼ */ |
| | | const STATUS_OPTIONS = [ |
| | | { label: 'å¯ç¨', value: 1 }, |
| | | { label: 'ç¦ç¨', value: 0 }, |
| | | ]; |
| | | |
| | | /** å¾è¡¨ç±»åé项 */ |
| | | const CHART_TYPE_OPTIONS = [ |
| | | { label: 'æ±ç¶å¾ (bar)', value: 'bar' }, |
| | | { label: 'æçº¿å¾ (line)', value: 'line' }, |
| | | { label: 'é¥¼å¾ (pie)', value: 'pie' }, |
| | | { label: 'æ£ç¹å¾ (scatter)', value: 'scatter' }, |
| | | { label: '仪表ç (gauge)', value: 'gauge' }, |
| | | { label: 'è¡¨æ ¼ (table)', value: 'table' }, |
| | | { label: 'æ°åå¡ (number)', value: 'number' }, |
| | | ]; |
| | | |
| | | /** æ°å¢/ä¿®æ¹è¡¨å */ |
| | | export function useFormSchema( |
| | | dashboardOptions: { label: string; value: number }[], |
| | | ): VbenFormSchema[] { |
| | | return [ |
| | | { |
| | | component: 'Input', |
| | | fieldName: 'id', |
| | | dependencies: { |
| | | triggerFields: [''], |
| | | show: () => false, |
| | | }, |
| | | }, |
| | | { |
| | | fieldName: 'dashboardId', |
| | | label: 'æå±ä»ªè¡¨ç', |
| | | component: 'Select', |
| | | componentProps: { |
| | | options: dashboardOptions, |
| | | placeholder: 'è¯·éæ©ä»ªè¡¨ç', |
| | | }, |
| | | rules: 'required', |
| | | }, |
| | | { |
| | | fieldName: 'name', |
| | | label: 'å¾è¡¨åç§°', |
| | | component: 'Input', |
| | | componentProps: { |
| | | placeholder: '请è¾å
¥å¾è¡¨åç§°', |
| | | }, |
| | | rules: 'required', |
| | | }, |
| | | { |
| | | fieldName: 'chartType', |
| | | label: 'å¾è¡¨ç±»å', |
| | | component: 'Select', |
| | | componentProps: { |
| | | options: CHART_TYPE_OPTIONS, |
| | | placeholder: 'è¯·éæ©å¾è¡¨ç±»å', |
| | | }, |
| | | defaultValue: 'bar', |
| | | rules: 'required', |
| | | }, |
| | | { |
| | | fieldName: 'dataSourceId', |
| | | label: 'æ°æ®æºID', |
| | | component: 'InputNumber', |
| | | componentProps: { |
| | | placeholder: 'æ°æ®æºé
ç½®ID', |
| | | }, |
| | | }, |
| | | { |
| | | fieldName: 'querySql', |
| | | label: 'æ¥è¯¢SQL', |
| | | component: 'Textarea', |
| | | componentProps: { |
| | | placeholder: 'SELECT ...', |
| | | rows: 3, |
| | | }, |
| | | }, |
| | | { |
| | | fieldName: 'position', |
| | | label: 'å¸å±ä½ç½®', |
| | | component: 'Input', |
| | | componentProps: { |
| | | placeholder: '{"x":0,"y":0,"w":12,"h":6}', |
| | | }, |
| | | }, |
| | | { |
| | | fieldName: 'chartOptions', |
| | | label: 'å¾è¡¨é
ç½®', |
| | | component: 'Textarea', |
| | | componentProps: { |
| | | placeholder: 'JSON é
ç½®ï¼å¯éï¼', |
| | | rows: 3, |
| | | }, |
| | | }, |
| | | { |
| | | fieldName: 'refreshInterval', |
| | | label: 'å·æ°é´é(ç§)', |
| | | component: 'InputNumber', |
| | | componentProps: { |
| | | placeholder: '0 表示ä¸èªå¨å·æ°', |
| | | min: 0, |
| | | }, |
| | | defaultValue: 0, |
| | | }, |
| | | { |
| | | fieldName: 'sort', |
| | | label: 'æåº', |
| | | component: 'InputNumber', |
| | | componentProps: { |
| | | min: 0, |
| | | }, |
| | | defaultValue: 0, |
| | | }, |
| | | { |
| | | fieldName: 'status', |
| | | label: 'ç¶æ', |
| | | component: 'RadioGroup', |
| | | componentProps: { |
| | | options: STATUS_OPTIONS, |
| | | buttonStyle: 'solid', |
| | | optionType: 'button', |
| | | }, |
| | | defaultValue: 1, |
| | | rules: 'required', |
| | | }, |
| | | ]; |
| | | } |
| | | |
| | | /** å表æç´¢è¡¨å */ |
| | | export function useGridFormSchema( |
| | | dashboardOptions: { label: string; value: number }[], |
| | | ): VbenFormSchema[] { |
| | | return [ |
| | | { |
| | | fieldName: 'dashboardId', |
| | | label: 'æå±ä»ªè¡¨ç', |
| | | component: 'Select', |
| | | componentProps: { |
| | | options: dashboardOptions, |
| | | placeholder: 'è¯·éæ©ä»ªè¡¨ç', |
| | | allowClear: true, |
| | | }, |
| | | }, |
| | | { |
| | | fieldName: 'name', |
| | | label: 'å¾è¡¨åç§°', |
| | | component: 'Input', |
| | | componentProps: { |
| | | placeholder: '请è¾å
¥å¾è¡¨åç§°', |
| | | allowClear: true, |
| | | }, |
| | | }, |
| | | ]; |
| | | } |
| | | |
| | | /** åè¡¨åæ®µ */ |
| | | export function useGridColumns(): VxeTableGridOptions['columns'] { |
| | | return [ |
| | | { type: 'checkbox', width: 40 }, |
| | | { |
| | | field: 'id', |
| | | title: 'ç¼å·', |
| | | width: 80, |
| | | }, |
| | | { |
| | | field: 'name', |
| | | title: 'å¾è¡¨åç§°', |
| | | minWidth: 150, |
| | | }, |
| | | { |
| | | field: 'chartType', |
| | | title: 'å¾è¡¨ç±»å', |
| | | width: 120, |
| | | cellRender: { |
| | | name: 'CellSelect', |
| | | props: { |
| | | options: CHART_TYPE_OPTIONS, |
| | | }, |
| | | }, |
| | | }, |
| | | { |
| | | field: 'refreshInterval', |
| | | title: 'å·æ°é´é(s)', |
| | | width: 100, |
| | | }, |
| | | { |
| | | field: 'sort', |
| | | title: 'æåº', |
| | | width: 70, |
| | | }, |
| | | { |
| | | field: 'status', |
| | | title: 'ç¶æ', |
| | | width: 80, |
| | | slots: { default: 'status' }, |
| | | }, |
| | | { |
| | | field: 'createTime', |
| | | title: 'å建æ¶é´', |
| | | width: 170, |
| | | formatter: 'formatDateTime', |
| | | }, |
| | | { |
| | | title: 'æä½', |
| | | width: 160, |
| | | fixed: 'right', |
| | | slots: { default: 'actions' }, |
| | | }, |
| | | ]; |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <script lang="ts" setup> |
| | | import type { VxeTableGridOptions } from '#/adapter/vxe-table'; |
| | | import type { BiChartConfigApi } from '#/api/bi/chart-config'; |
| | | |
| | | import { onMounted, ref } from 'vue'; |
| | | |
| | | import { confirm, Page, useVbenModal } from '@vben/common-ui'; |
| | | import { isEmpty } from '@vben/utils'; |
| | | |
| | | import { message } from 'ant-design-vue'; |
| | | |
| | | import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table'; |
| | | import { |
| | | deleteChartConfig, |
| | | getChartConfigPage, |
| | | } from '#/api/bi/chart-config'; |
| | | import { getDashboardList } from '#/api/bi/dashboard'; |
| | | |
| | | import { useGridColumns, useGridFormSchema } from './data'; |
| | | import Form from './modules/form.vue'; |
| | | |
| | | defineOptions({ name: 'BiChartConfig' }); |
| | | |
| | | const [FormModal, formModalApi] = useVbenModal({ |
| | | connectedComponent: Form, |
| | | destroyOnClose: true, |
| | | }); |
| | | |
| | | function handleRefresh() { |
| | | gridApi.query(); |
| | | } |
| | | |
| | | function handleCreate() { |
| | | formModalApi.setData(null).open(); |
| | | } |
| | | |
| | | function handleEdit(row: BiChartConfigApi.ChartConfig) { |
| | | formModalApi.setData(row).open(); |
| | | } |
| | | |
| | | async function handleDelete(row: BiChartConfigApi.ChartConfig) { |
| | | const hideLoading = message.loading({ |
| | | content: `æ£å¨å é¤ã${row.name}ã...`, |
| | | duration: 0, |
| | | }); |
| | | try { |
| | | await deleteChartConfig(row.id!); |
| | | message.success(`å é¤ã${row.name}ãæå`); |
| | | handleRefresh(); |
| | | } finally { |
| | | hideLoading(); |
| | | } |
| | | } |
| | | |
| | | async function handleDeleteBatch() { |
| | | await confirm('确认æ¹éå 餿éå¾è¡¨é
ç½®ï¼'); |
| | | const hideLoading = message.loading({ |
| | | content: 'æ£å¨æ¹éå é¤...', |
| | | duration: 0, |
| | | }); |
| | | try { |
| | | for (const id of checkedIds.value) { |
| | | await deleteChartConfig(id); |
| | | } |
| | | checkedIds.value = []; |
| | | message.success('æ¹éå 餿å'); |
| | | handleRefresh(); |
| | | } finally { |
| | | hideLoading(); |
| | | } |
| | | } |
| | | |
| | | const checkedIds = ref<number[]>([]); |
| | | function handleRowCheckboxChange({ |
| | | records, |
| | | }: { |
| | | records: BiChartConfigApi.ChartConfig[]; |
| | | }) { |
| | | checkedIds.value = records.map((item) => item.id!); |
| | | } |
| | | |
| | | const [Grid, gridApi] = useVbenVxeGrid({ |
| | | formOptions: { |
| | | schema: useGridFormSchema(), |
| | | }, |
| | | gridOptions: { |
| | | columns: useGridColumns(), |
| | | height: 'auto', |
| | | keepSource: true, |
| | | proxyConfig: { |
| | | ajax: { |
| | | query: async ({ page }, formValues) => { |
| | | return await getChartConfigPage({ |
| | | pageNo: page.currentPage, |
| | | pageSize: page.pageSize, |
| | | ...formValues, |
| | | }); |
| | | }, |
| | | }, |
| | | }, |
| | | rowConfig: { |
| | | keyField: 'id', |
| | | isHover: true, |
| | | }, |
| | | toolbarConfig: { |
| | | refresh: true, |
| | | search: true, |
| | | }, |
| | | } as VxeTableGridOptions<BiChartConfigApi.ChartConfig>, |
| | | gridEvents: { |
| | | checkboxAll: handleRowCheckboxChange, |
| | | checkboxChange: handleRowCheckboxChange, |
| | | }, |
| | | }); |
| | | |
| | | onMounted(async () => { |
| | | try { |
| | | const list = await getDashboardList(); |
| | | const options = (list ?? []).map((d) => ({ |
| | | label: d.name, |
| | | value: d.id, |
| | | })); |
| | | gridApi.formApi.setState((prev) => ({ |
| | | ...prev, |
| | | schema: useGridFormSchema(options), |
| | | })); |
| | | } catch { |
| | | // ignore |
| | | } |
| | | }); |
| | | </script> |
| | | |
| | | <template> |
| | | <Page auto-content-height> |
| | | <FormModal @success="handleRefresh" /> |
| | | <Grid table-title="å¾è¡¨é
ç½®"> |
| | | <template #toolbar-tools> |
| | | <TableAction |
| | | :actions="[ |
| | | { |
| | | label: 'æ°å¢å¾è¡¨é
ç½®', |
| | | type: 'primary', |
| | | icon: ACTION_ICON.ADD, |
| | | auth: ['bi:chart-config:create'], |
| | | onClick: handleCreate, |
| | | }, |
| | | { |
| | | label: 'æ¹éå é¤', |
| | | type: 'primary', |
| | | danger: true, |
| | | icon: ACTION_ICON.DELETE, |
| | | disabled: isEmpty(checkedIds), |
| | | auth: ['bi:chart-config:delete'], |
| | | onClick: handleDeleteBatch, |
| | | }, |
| | | ]" |
| | | /> |
| | | </template> |
| | | <template #status="{ row }"> |
| | | <Tag :color="row.status === 1 ? 'green' : 'default'"> |
| | | {{ row.status === 1 ? 'å¯ç¨' : 'ç¦ç¨' }} |
| | | </Tag> |
| | | </template> |
| | | <template #actions="{ row }"> |
| | | <TableAction |
| | | :actions="[ |
| | | { |
| | | label: 'ç¼è¾', |
| | | type: 'link', |
| | | icon: ACTION_ICON.EDIT, |
| | | auth: ['bi:chart-config:update'], |
| | | onClick: handleEdit.bind(null, row), |
| | | }, |
| | | { |
| | | label: 'å é¤', |
| | | type: 'link', |
| | | danger: true, |
| | | icon: ACTION_ICON.DELETE, |
| | | auth: ['bi:chart-config:delete'], |
| | | popConfirm: { |
| | | title: `确认å é¤ã${row.name}ãï¼`, |
| | | confirm: handleDelete.bind(null, row), |
| | | }, |
| | | }, |
| | | ]" |
| | | /> |
| | | </template> |
| | | </Grid> |
| | | </Page> |
| | | </template> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <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> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | 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' }, |
| | | }, |
| | | ]; |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <script lang="ts" setup> |
| | | import type { VxeTableGridOptions } from '#/adapter/vxe-table'; |
| | | import type { BiDataSourceConfigApi } from '#/api/bi/data-source-config'; |
| | | |
| | | import { ref } from 'vue'; |
| | | |
| | | import { confirm, Page, useVbenModal } from '@vben/common-ui'; |
| | | import { isEmpty } from '@vben/utils'; |
| | | |
| | | import { message } from 'ant-design-vue'; |
| | | |
| | | import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table'; |
| | | import { |
| | | deleteDataSourceConfig, |
| | | getDataSourceConfigPage, |
| | | } from '#/api/bi/data-source-config'; |
| | | |
| | | import { useGridColumns, useGridFormSchema } from './data'; |
| | | import Form from './modules/form.vue'; |
| | | |
| | | defineOptions({ name: 'BiDataSourceConfig' }); |
| | | |
| | | const [FormModal, formModalApi] = useVbenModal({ |
| | | connectedComponent: Form, |
| | | destroyOnClose: true, |
| | | }); |
| | | |
| | | function handleRefresh() { |
| | | gridApi.query(); |
| | | } |
| | | |
| | | function handleCreate() { |
| | | formModalApi.setData(null).open(); |
| | | } |
| | | |
| | | function handleEdit(row: BiDataSourceConfigApi.DataSourceConfig) { |
| | | formModalApi.setData(row).open(); |
| | | } |
| | | |
| | | async function handleDelete(row: BiDataSourceConfigApi.DataSourceConfig) { |
| | | const hideLoading = message.loading({ |
| | | content: `æ£å¨å é¤ã${row.name}ã...`, |
| | | duration: 0, |
| | | }); |
| | | try { |
| | | await deleteDataSourceConfig(row.id!); |
| | | message.success(`å é¤ã${row.name}ãæå`); |
| | | handleRefresh(); |
| | | } finally { |
| | | hideLoading(); |
| | | } |
| | | } |
| | | |
| | | async function handleDeleteBatch() { |
| | | await confirm('确认æ¹éå é¤æéæ°æ®æºé
ç½®ï¼'); |
| | | const hideLoading = message.loading({ |
| | | content: 'æ£å¨æ¹éå é¤...', |
| | | duration: 0, |
| | | }); |
| | | try { |
| | | for (const id of checkedIds.value) { |
| | | await deleteDataSourceConfig(id); |
| | | } |
| | | checkedIds.value = []; |
| | | message.success('æ¹éå 餿å'); |
| | | handleRefresh(); |
| | | } finally { |
| | | hideLoading(); |
| | | } |
| | | } |
| | | |
| | | const checkedIds = ref<number[]>([]); |
| | | function handleRowCheckboxChange({ |
| | | records, |
| | | }: { |
| | | records: BiDataSourceConfigApi.DataSourceConfig[]; |
| | | }) { |
| | | checkedIds.value = records.map((item) => item.id!); |
| | | } |
| | | |
| | | const [Grid, gridApi] = useVbenVxeGrid({ |
| | | formOptions: { |
| | | schema: useGridFormSchema(), |
| | | }, |
| | | gridOptions: { |
| | | columns: useGridColumns(), |
| | | height: 'auto', |
| | | keepSource: true, |
| | | proxyConfig: { |
| | | ajax: { |
| | | query: async ({ page }, formValues) => { |
| | | return await getDataSourceConfigPage({ |
| | | pageNo: page.currentPage, |
| | | pageSize: page.pageSize, |
| | | ...formValues, |
| | | }); |
| | | }, |
| | | }, |
| | | }, |
| | | rowConfig: { |
| | | keyField: 'id', |
| | | isHover: true, |
| | | }, |
| | | toolbarConfig: { |
| | | refresh: true, |
| | | search: true, |
| | | }, |
| | | } as VxeTableGridOptions<BiDataSourceConfigApi.DataSourceConfig>, |
| | | gridEvents: { |
| | | checkboxAll: handleRowCheckboxChange, |
| | | checkboxChange: handleRowCheckboxChange, |
| | | }, |
| | | }); |
| | | </script> |
| | | |
| | | <template> |
| | | <Page auto-content-height> |
| | | <FormModal @success="handleRefresh" /> |
| | | <Grid table-title="æ°æ®æºé
ç½®"> |
| | | <template #toolbar-tools> |
| | | <TableAction |
| | | :actions="[ |
| | | { |
| | | label: 'æ°å¢æ°æ®æºé
ç½®', |
| | | type: 'primary', |
| | | icon: ACTION_ICON.ADD, |
| | | auth: ['bi:data-source-config:create'], |
| | | onClick: handleCreate, |
| | | }, |
| | | { |
| | | label: 'æ¹éå é¤', |
| | | type: 'primary', |
| | | danger: true, |
| | | icon: ACTION_ICON.DELETE, |
| | | disabled: isEmpty(checkedIds), |
| | | auth: ['bi:data-source-config:delete'], |
| | | onClick: handleDeleteBatch, |
| | | }, |
| | | ]" |
| | | /> |
| | | </template> |
| | | <template #actions="{ row }"> |
| | | <TableAction |
| | | :actions="[ |
| | | { |
| | | label: 'ç¼è¾', |
| | | type: 'link', |
| | | icon: ACTION_ICON.EDIT, |
| | | auth: ['bi:data-source-config:update'], |
| | | onClick: handleEdit.bind(null, row), |
| | | }, |
| | | { |
| | | label: 'å é¤', |
| | | type: 'link', |
| | | danger: true, |
| | | icon: ACTION_ICON.DELETE, |
| | | auth: ['bi:data-source-config:delete'], |
| | | popConfirm: { |
| | | title: `确认å é¤ã${row.name}ãï¼`, |
| | | confirm: handleDelete.bind(null, row), |
| | | }, |
| | | }, |
| | | ]" |
| | | /> |
| | | </template> |
| | | </Grid> |
| | | </Page> |
| | | </template> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <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> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | import type { EChartsOption } from '@vben/plugins/echarts'; |
| | | |
| | | /** ç§æèæè²ä¸»é¢è²æ¿ */ |
| | | const C = { |
| | | cyan: '#00E5FF', |
| | | green: '#00FF88', |
| | | yellow: '#FFC107', |
| | | red: '#FF3860', |
| | | blue: '#3366FF', |
| | | purple: '#8B5CF6', |
| | | orange: '#FF6B35', |
| | | textPrimary: 'rgba(230, 236, 250, 0.95)', |
| | | textSecondary: 'rgba(180, 190, 215, 0.7)', |
| | | border: 'rgba(56, 128, 237, 0.12)', |
| | | splitLine: 'rgba(56, 128, 237, 0.08)', |
| | | }; |
| | | |
| | | const darkAxis = { |
| | | axisLabel: { color: C.textSecondary, fontSize: 10 }, |
| | | axisLine: { lineStyle: { color: C.border } }, |
| | | axisTick: { show: false }, |
| | | splitLine: { lineStyle: { color: C.splitLine } }, |
| | | }; |
| | | |
| | | const darkLegend = { |
| | | textStyle: { color: C.textSecondary, fontSize: 10 }, |
| | | itemGap: 12, |
| | | }; |
| | | |
| | | const darkTooltip = { |
| | | backgroundColor: 'rgba(6, 18, 42, 0.94)', |
| | | borderColor: 'rgba(0, 229, 255, 0.28)', |
| | | borderWidth: 1, |
| | | padding: [10, 14], |
| | | textStyle: { color: C.textPrimary, fontSize: 11 }, |
| | | }; |
| | | |
| | | /** æ¸åæ±ç¶å¾ */ |
| | | export function getBarChartOptions( |
| | | xData: string[], |
| | | series: Array<{ name: string; data: number[] }>, |
| | | colors: string[] = [C.cyan, C.blue, C.purple], |
| | | ): EChartsOption { |
| | | return { |
| | | grid: { bottom: 40, left: 48, right: 16, top: 16 }, |
| | | legend: { ...darkLegend, bottom: 0 }, |
| | | tooltip: { ...darkTooltip, axisPointer: { type: 'shadow' }, trigger: 'axis' }, |
| | | xAxis: { ...darkAxis, data: xData, type: 'category' }, |
| | | yAxis: { ...darkAxis, type: 'value' }, |
| | | series: series.map((s, i) => ({ |
| | | barGap: '30%', |
| | | barMaxWidth: 36, |
| | | data: s.data, |
| | | emphasis: { |
| | | itemStyle: { shadowBlur: 12, shadowColor: colors[i] || C.cyan }, |
| | | }, |
| | | itemStyle: { |
| | | borderRadius: [6, 6, 0, 0], |
| | | color: { |
| | | colorStops: [ |
| | | { color: colors[i] || C.cyan, offset: 0 }, |
| | | { color: `${colors[i] || C.cyan}22`, offset: 1 }, |
| | | ], |
| | | type: 'linear', |
| | | x: 0, y: 0, x2: 0, y2: 1, |
| | | }, |
| | | }, |
| | | name: s.name, |
| | | type: 'bar', |
| | | })), |
| | | }; |
| | | } |
| | | |
| | | /** æ¸åé¢ç§¯æçº¿å¾ */ |
| | | export function getAreaLineChartOptions( |
| | | xData: string[], |
| | | series: Array<{ name: string; data: number[] }>, |
| | | colors: string[] = [C.cyan, C.purple, C.green], |
| | | ): EChartsOption { |
| | | return { |
| | | grid: { bottom: 40, left: 48, right: 16, top: 16 }, |
| | | legend: { ...darkLegend, bottom: 0 }, |
| | | tooltip: { ...darkTooltip, axisPointer: { type: 'cross' }, trigger: 'axis' }, |
| | | xAxis: { ...darkAxis, boundaryGap: false, data: xData, type: 'category' }, |
| | | yAxis: { ...darkAxis, type: 'value' }, |
| | | series: series.map((s, i) => ({ |
| | | areaStyle: { |
| | | color: { |
| | | colorStops: [ |
| | | { color: `${colors[i] || C.cyan}22`, offset: 0 }, |
| | | { color: `${colors[i] || C.cyan}02`, offset: 1 }, |
| | | ], |
| | | type: 'linear', |
| | | x: 0, y: 0, x2: 0, y2: 1, |
| | | }, |
| | | }, |
| | | data: s.data, |
| | | itemStyle: { color: colors[i] || C.cyan }, |
| | | lineStyle: { width: 2 }, |
| | | name: s.name, |
| | | smooth: true, |
| | | symbol: 'circle', |
| | | symbolSize: 4, |
| | | type: 'line', |
| | | })), |
| | | }; |
| | | } |
| | | |
| | | /** ç¯å½¢é¥¼å¾ */ |
| | | export function getDonutPieChartOptions( |
| | | data: Array<{ name: string; value: number }>, |
| | | colors: string[] = [C.cyan, C.blue, C.purple, C.green, C.yellow, C.orange], |
| | | ): EChartsOption { |
| | | return { |
| | | legend: { ...darkLegend, bottom: 0, type: 'scroll' }, |
| | | series: [{ |
| | | avoidLabelOverlap: true, |
| | | data, |
| | | emphasis: { |
| | | itemStyle: { shadowBlur: 16, shadowColor: 'rgba(0,229,255,0.3)' }, |
| | | label: { fontSize: 12, fontWeight: 'bold', show: true }, |
| | | scaleSize: 8, |
| | | }, |
| | | itemStyle: { |
| | | borderColor: '#060e24', |
| | | borderRadius: 3, |
| | | borderWidth: 3, |
| | | color: (params: { dataIndex: number }) => colors[params.dataIndex % colors.length], |
| | | }, |
| | | label: { show: false }, |
| | | radius: ['55%', '78%'], |
| | | type: 'pie', |
| | | }], |
| | | tooltip: { ...darkTooltip, formatter: '{b}: {c} ({d}%)', trigger: 'item' }, |
| | | }; |
| | | } |
| | | |
| | | /** 仪表çï¼Gaugeï¼ */ |
| | | export function getGaugeChartOptions( |
| | | value: number, |
| | | name: string, |
| | | max = 100, |
| | | color = C.cyan, |
| | | ): EChartsOption { |
| | | return { |
| | | series: [{ |
| | | anchor: { show: true, showAbove: true, size: 12 }, |
| | | axisLine: { |
| | | lineStyle: { |
| | | color: [[0.3, C.red], [0.7, C.yellow], [1, C.green]], |
| | | width: 12, |
| | | }, |
| | | }, |
| | | axisTick: { distance: -16, length: 4, lineStyle: { width: 1, color: C.textSecondary } }, |
| | | data: [{ name, value }], |
| | | detail: { |
| | | color, |
| | | fontSize: 22, |
| | | fontWeight: 'bold', |
| | | formatter: '{value}%', |
| | | offsetCenter: [0, '55%'], |
| | | valueAnimation: true, |
| | | }, |
| | | pointer: { length: '65%', width: 4, itemStyle: { color: C.cyan } }, |
| | | progress: { itemStyle: { color }, show: true, width: 12 }, |
| | | radius: '100%', |
| | | splitLine: { distance: -18, length: 10, lineStyle: { width: 2, color: C.textSecondary } }, |
| | | title: { fontSize: 11, offsetCenter: [0, '80%'], color: C.textSecondary }, |
| | | type: 'gauge', |
| | | max, |
| | | }], |
| | | tooltip: { ...darkTooltip, formatter: '{b}: {c}%', trigger: 'item' }, |
| | | }; |
| | | } |
| | | |
| | | /** æ£ç¹å¾ */ |
| | | export function getScatterChartOptions( |
| | | data: Array<[number, number]>, |
| | | xName = '', |
| | | yName = '', |
| | | ): EChartsOption { |
| | | return { |
| | | grid: { bottom: 40, left: 56, right: 16, top: 16 }, |
| | | tooltip: { |
| | | ...darkTooltip, |
| | | formatter: (params: { value: [number, number] }) => |
| | | `${xName}: ${params.value[0]}<br/>${yName}: ${params.value[1]}`, |
| | | trigger: 'item', |
| | | }, |
| | | xAxis: { |
| | | ...darkAxis, |
| | | name: xName, |
| | | nameTextStyle: { color: C.textSecondary, fontSize: 10 }, |
| | | type: 'value', |
| | | }, |
| | | yAxis: { |
| | | ...darkAxis, |
| | | name: yName, |
| | | nameTextStyle: { color: C.textSecondary, fontSize: 10 }, |
| | | type: 'value', |
| | | }, |
| | | series: [{ |
| | | data, |
| | | emphasis: { |
| | | itemStyle: { shadowBlur: 12, shadowColor: 'rgba(0,229,255,0.5)' }, |
| | | }, |
| | | itemStyle: { |
| | | color: { |
| | | colorStops: [ |
| | | { color: C.cyan, offset: 0 }, |
| | | { color: `${C.cyan}33`, offset: 1 }, |
| | | ], |
| | | r: 1, |
| | | type: 'radial', |
| | | x: 0.4, y: 0.3, |
| | | }, |
| | | }, |
| | | symbolSize: 10, |
| | | type: 'scatter', |
| | | }], |
| | | }; |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | 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: '请é
ç½®å¾è¡¨æ°æ®', |
| | | }, |
| | | ]; |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <script lang="ts" setup> |
| | | import type { BiDashboardApi } from '#/api/bi/dashboard'; |
| | | |
| | | import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'; |
| | | import { useRoute } from 'vue-router'; |
| | | |
| | | import { IconifyIcon } from '@vben/icons'; |
| | | |
| | | import { Empty, Spin } from 'ant-design-vue'; |
| | | |
| | | import { getDashboardData } from '#/api/bi/dashboard'; |
| | | |
| | | import ChartCard from './modules/ChartCard.vue'; |
| | | import ClockWidget from './modules/ClockWidget.vue'; |
| | | import { DASHBOARD_META, getDashboardCode, getDefaultCharts } from './data'; |
| | | |
| | | defineOptions({ name: 'BiDashboard' }); |
| | | |
| | | const route = useRoute(); |
| | | const dashboardCode = computed(() => getDashboardCode(route.path)); |
| | | const meta = computed(() => DASHBOARD_META[dashboardCode.value]); |
| | | |
| | | const charts = ref<BiDashboardApi.ChartItem[]>([]); |
| | | const loading = ref(true); |
| | | const lastUpdateTime = ref(''); |
| | | |
| | | async function loadData() { |
| | | if (!meta.value) return; |
| | | try { |
| | | loading.value = true; |
| | | const data = await getDashboardData(meta.value.code); |
| | | charts.value = data.length > 0 ? data : getDefaultCharts(); |
| | | lastUpdateTime.value = new Date().toLocaleTimeString('zh-CN', { hour12: false }); |
| | | } catch { |
| | | charts.value = getDefaultCharts(); |
| | | } finally { |
| | | loading.value = false; |
| | | await nextTick(); |
| | | resizeAllCharts(); |
| | | } |
| | | } |
| | | |
| | | // ======== åç±» ======== |
| | | const numberCards = computed(() => charts.value.filter((c) => c.chartType === 'number')); |
| | | const tableCharts = computed(() => charts.value.filter((c) => c.chartType === 'table')); |
| | | const echartsCharts = computed(() => charts.value.filter((c) => !['number', 'table'].includes(c.chartType))); |
| | | |
| | | // ======== å¾è¡¨ Grid å跨度 ======== |
| | | function isFullWidth(chart: BiDashboardApi.ChartItem): boolean { |
| | | const w = chart.position?.w; |
| | | if (w && w >= 24) return true; |
| | | return chart.chartType === 'table'; |
| | | } |
| | | |
| | | function getGridStyle(chart: BiDashboardApi.ChartItem): Record<string, string> { |
| | | if (isFullWidth(chart)) return { 'grid-column': '1 / -1' }; |
| | | return {}; |
| | | } |
| | | |
| | | // ======== å¾è¡¨ç¼©æ¾ ======== |
| | | const dashboardRef = ref<HTMLElement>(); |
| | | let resizeObserver: ResizeObserver | null = null; |
| | | |
| | | function resizeAllCharts() { |
| | | setTimeout(() => window.dispatchEvent(new Event('resize')), 100); |
| | | } |
| | | |
| | | function setupResizeObserver() { |
| | | if (!dashboardRef.value) return; |
| | | resizeObserver = new ResizeObserver(() => resizeAllCharts()); |
| | | resizeObserver.observe(dashboardRef.value); |
| | | } |
| | | |
| | | // ======== å
¨å± ======== |
| | | const isFullscreen = ref(false); |
| | | async function toggleFullscreen() { |
| | | if (!document.fullscreenElement) { |
| | | await dashboardRef.value?.requestFullscreen(); |
| | | isFullscreen.value = true; |
| | | } else { |
| | | await document.exitFullscreen(); |
| | | isFullscreen.value = false; |
| | | } |
| | | setTimeout(resizeAllCharts, 400); |
| | | } |
| | | function onFullscreenChange() { |
| | | isFullscreen.value = !!document.fullscreenElement; |
| | | setTimeout(resizeAllCharts, 300); |
| | | } |
| | | |
| | | // ======== KPI æ°åå¨ç» ======== |
| | | const animatedIds = new Set<number>(); |
| | | |
| | | function animateValue(el: HTMLElement, end: number, decimals = 0) { |
| | | if (end === 0) { |
| | | el.textContent = '0'; |
| | | return; |
| | | } |
| | | const duration = 1800; |
| | | const startTime = performance.now(); |
| | | function update(currentTime: number) { |
| | | const progress = Math.min((currentTime - startTime) / duration, 1); |
| | | const eased = 1 - (1 - progress) ** 3; |
| | | el.textContent = (end * eased).toFixed(decimals); |
| | | if (progress < 1) requestAnimationFrame(update); |
| | | } |
| | | requestAnimationFrame(update); |
| | | } |
| | | |
| | | function maybeAnimate(el: HTMLElement, cardId: number, value: number) { |
| | | if (animatedIds.has(cardId)) return; |
| | | animatedIds.add(cardId); |
| | | animateValue(el, value, 0); |
| | | } |
| | | |
| | | watch(() => charts.value, () => { |
| | | animatedIds.clear(); |
| | | }); |
| | | |
| | | function formatNumber(num: number): string { |
| | | if (num >= 1_0000_0000) return `${(num / 1_0000_0000).toFixed(1)}亿`; |
| | | if (num >= 1_0000) return `${(num / 1_0000).toFixed(1)}ä¸`; |
| | | if (Number.isInteger(num)) return num.toLocaleString(); |
| | | return num.toFixed(2); |
| | | } |
| | | |
| | | function extractNumberValue(record: Record<string, unknown>): number { |
| | | return (Object.values(record).find((v): v is number => typeof v === 'number') || 0) as number; |
| | | } |
| | | |
| | | function getCardDisplayValue(card: BiDashboardApi.ChartItem): string { |
| | | if (!card.data?.[0]) return '--'; |
| | | const v = extractNumberValue(card.data[0] as Record<string, unknown>); |
| | | return formatNumber(v); |
| | | } |
| | | |
| | | function getCellValue(row: unknown, col: string): unknown { |
| | | return (row as Record<string, unknown>)[col]; |
| | | } |
| | | |
| | | // ======== çæ¿ä¸»é¢è² ======== |
| | | const dashboardAccentColors: Record<string, string> = { |
| | | 'purchase-sales': '#6366f1', |
| | | 'production-equipment': '#f59e0b', |
| | | quality: '#f43f5e', |
| | | 'hr-oa': '#8b5cf6', |
| | | }; |
| | | const accentColor = computed(() => dashboardAccentColors[dashboardCode.value] || '#00E5FF'); |
| | | |
| | | // ======== KPI 徿 /é¢è² ======== |
| | | function getKpiIcon(name: string): string { |
| | | if (name.includes('éè´')) return 'lucide:shopping-cart'; |
| | | if (name.includes('éå®')) return 'lucide:trending-up'; |
| | | if (name.includes('å®å')) return 'lucide:headphones'; |
| | | if (name.includes('ç产')) return 'lucide:factory'; |
| | | if (name.includes('设å¤')) return 'lucide:cpu'; |
| | | if (name.includes('è´¨é') || name.includes('æ£éª') || name.includes('è´¨æ£')) return 'lucide:shield-check'; |
| | | if (name.includes('人å') || name.includes('åå·¥') || name.includes('人äº')) return 'lucide:users'; |
| | | if (name.includes('èå¤') || name.includes('æå¡')) return 'lucide:clock'; |
| | | if (name.includes('åå') || name.includes('åå')) return 'lucide:file-text'; |
| | | if (name.includes('订å')) return 'lucide:clipboard-list'; |
| | | if (name.includes('åºå') || name.includes('ä»åº')) return 'lucide:package'; |
| | | return 'lucide:bar-chart-4'; |
| | | } |
| | | |
| | | const kpiColors = ['#00E5FF', '#00FF88', '#FFC107', '#FF3860', '#8B5CF6', '#6366f1']; |
| | | function getKpiColor(i: number) { return kpiColors[i % kpiColors.length]; } |
| | | |
| | | watch(dashboardCode, () => loadData()); |
| | | onMounted(() => { |
| | | document.addEventListener('fullscreenchange', onFullscreenChange); |
| | | setupResizeObserver(); |
| | | loadData(); |
| | | }); |
| | | onBeforeUnmount(() => { |
| | | document.removeEventListener('fullscreenchange', onFullscreenChange); |
| | | resizeObserver?.disconnect(); |
| | | }); |
| | | </script> |
| | | |
| | | <template> |
| | | <div |
| | | ref="dashboardRef" |
| | | class="bi-dashboard" |
| | | :class="{ 'is-fullscreen': isFullscreen }" |
| | | :style="{ '--bi-accent': accentColor }" |
| | | > |
| | | <!-- ======== å¤´é¨ ======== --> |
| | | <header v-if="meta" class="dash-header"> |
| | | <div class="dash-header-left"> |
| | | <div class="dash-logo"> |
| | | <IconifyIcon :icon="meta.icon" class="text-xl" /> |
| | | </div> |
| | | <div class="dash-title-group"> |
| | | <h1>{{ meta.title }}</h1> |
| | | <p>{{ meta.subtitle }}</p> |
| | | </div> |
| | | </div> |
| | | <div class="dash-header-divider" /> |
| | | <div class="dash-header-right"> |
| | | <div class="dash-status-tags"> |
| | | <span class="dash-status-tag"> |
| | | <i class="dash-status-dot online" /> ç³»ç»æ£å¸¸ |
| | | </span> |
| | | <span class="dash-status-tag"> |
| | | <i class="dash-status-dot" /> æ°æ®æ´æ° |
| | | </span> |
| | | </div> |
| | | <ClockWidget /> |
| | | <div class="dash-live-badge"> |
| | | <span class="dash-live-dot" /> |
| | | <span>宿¶</span> |
| | | <span v-if="lastUpdateTime" class="dash-live-time">· {{ lastUpdateTime }}</span> |
| | | </div> |
| | | <button |
| | | class="dash-fs-btn" |
| | | :title="isFullscreen ? 'éåºå
¨å±' : 'å
¨å±'" |
| | | @click="toggleFullscreen" |
| | | > |
| | | <IconifyIcon :icon="isFullscreen ? 'lucide:minimize-2' : 'lucide:maximize-2'" /> |
| | | </button> |
| | | </div> |
| | | </header> |
| | | |
| | | <!-- ======== å è½½ ======== --> |
| | | <Spin v-if="loading && charts.length === 0" :spinning="true" tip="å 载仪表çä¸..."> |
| | | <div style="height: 400px" /> |
| | | </Spin> |
| | | |
| | | <!-- ======== 仪表çå
容 ======== --> |
| | | <template v-else> |
| | | <!-- KPI å¡çè¡ --> |
| | | <div v-if="numberCards.length > 0" class="kpi-row"> |
| | | <div |
| | | v-for="(card, i) in numberCards" |
| | | :key="card.id" |
| | | class="kpi-card" |
| | | :style="{ '--kpi-color': getKpiColor(i), animationDelay: `${i * 0.06}s` }" |
| | | > |
| | | <div class="kpi-icon"> |
| | | <IconifyIcon :icon="getKpiIcon(card.name)" /> |
| | | </div> |
| | | <div class="kpi-body"> |
| | | <span class="kpi-label">{{ card.name }}</span> |
| | | <span |
| | | class="kpi-value" |
| | | :ref="(el: unknown) => { |
| | | if (el && card.data?.[0]) { |
| | | const v = extractNumberValue(card.data[0] as Record<string, unknown>); |
| | | maybeAnimate(el as HTMLElement, card.id!, v); |
| | | } |
| | | }" |
| | | >{{ getCardDisplayValue(card) }}</span> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | |
| | | <!-- å¾è¡¨ Grid --> |
| | | <div v-if="echartsCharts.length > 0" class="chart-row"> |
| | | <div |
| | | v-for="(chart, i) in echartsCharts" |
| | | :key="chart.id" |
| | | :style="{ ...getGridStyle(chart), animationDelay: `${i * 0.08}s` }" |
| | | > |
| | | <ChartCard :chart="chart" :accent-color="accentColor" /> |
| | | </div> |
| | | </div> |
| | | |
| | | <!-- æ°æ®è¡¨æ ¼ --> |
| | | <template v-if="tableCharts.length > 0"> |
| | | <div |
| | | v-for="chart in tableCharts" |
| | | :key="chart.id" |
| | | class="table-card" |
| | | > |
| | | <div class="table-header"> |
| | | <span class="table-header-dot" :style="{ background: accentColor, boxShadow: `0 0 6px ${accentColor}` }" /> |
| | | <span class="table-header-title">{{ chart.name }}</span> |
| | | <span class="table-badge">宿¶æ»å¨</span> |
| | | </div> |
| | | <div v-if="chart.data && chart.data.length > 0" class="table-wrap"> |
| | | <table class="data-table"> |
| | | <thead> |
| | | <tr> |
| | | <th |
| | | v-for="key in Object.keys(chart.data[0] || {})" |
| | | :key="key" |
| | | >{{ key }}</th> |
| | | </tr> |
| | | </thead> |
| | | <tbody> |
| | | <tr |
| | | v-for="(row, ri) in chart.data" |
| | | :key="ri" |
| | | :style="{ animationDelay: `${ri * 50}ms` }" |
| | | > |
| | | <td |
| | | v-for="key in Object.keys(chart.data[0] || {})" |
| | | :key="key" |
| | | >{{ getCellValue(row, key) }}</td> |
| | | </tr> |
| | | </tbody> |
| | | </table> |
| | | </div> |
| | | <Empty v-else description="ææ æ°æ®" /> |
| | | </div> |
| | | </template> |
| | | |
| | | <!-- ç©ºç¶æ --> |
| | | <div v-if="charts.length === 0" class="empty-state"> |
| | | <IconifyIcon class="size-16 text-white/15" icon="lucide:bar-chart-4" /> |
| | | <p>ææªé
ç½®å¾è¡¨æ°æ®</p> |
| | | <p class="empty-sub">请å¨é
置管çä¸ä¸ºå½åä»ªè¡¨çæ·»å å¾è¡¨</p> |
| | | </div> |
| | | </template> |
| | | </div> |
| | | </template> |
| | | |
| | | <style scoped> |
| | | /* ======== CSS åé ======== */ |
| | | .bi-dashboard { |
| | | --bg-deep: #020817; |
| | | --bg-card: rgba(10, 24, 52, 0.55); |
| | | --bg-card-hover: rgba(16, 34, 68, 0.72); |
| | | --border: rgba(0, 229, 255, 0.2); |
| | | --border-hover: rgba(0, 229, 255, 0.4); |
| | | --cyan: #00E5FF; |
| | | --green: #00FF88; |
| | | --yellow: #FFC107; |
| | | --red: #FF3860; |
| | | --text-primary: rgba(235, 240, 252, 0.95); |
| | | --text-secondary: rgba(185, 196, 220, 0.72); |
| | | --text-muted: rgba(145, 158, 185, 0.5); |
| | | |
| | | position: relative; |
| | | background: |
| | | radial-gradient(ellipse 70% 50% at 50% 0%, #0d1f42 0%, #060e24 35%, #020817 100%); |
| | | color: var(--text-primary); |
| | | padding: 16px 20px 24px; |
| | | font-family: 'PingFang SC', 'Microsoft YaHei', sans-serif; |
| | | overflow-x: hidden; |
| | | overflow-y: auto; |
| | | max-height: calc(100vh - 104px); |
| | | } |
| | | |
| | | /* èæ¯å
æ + ç½æ ¼ */ |
| | | .bi-dashboard::before { |
| | | content: ''; |
| | | position: absolute; inset: 0; pointer-events: none; z-index: 0; |
| | | background: |
| | | radial-gradient(ellipse 50% 45% at 50% 5%, rgba(0, 229, 255, 0.12) 0%, transparent 50%), |
| | | radial-gradient(ellipse 35% 30% at 20% 75%, rgba(0, 255, 136, 0.07) 0%, transparent 55%), |
| | | radial-gradient(ellipse 30% 30% at 80% 65%, rgba(139, 92, 246, 0.08) 0%, transparent 55%), |
| | | radial-gradient(ellipse 25% 25% at 50% 90%, rgba(0, 229, 255, 0.05) 0%, transparent 60%); |
| | | animation: bg-breathe 10s ease-in-out infinite alternate; |
| | | } |
| | | |
| | | @keyframes bg-breathe { |
| | | 0% { opacity: 0.7; } |
| | | 100% { opacity: 1; } |
| | | } |
| | | |
| | | .bi-dashboard::after { |
| | | content: ''; |
| | | position: absolute; inset: 0; pointer-events: none; z-index: 0; |
| | | background-image: |
| | | linear-gradient(rgba(0, 229, 255, 0.03) 1px, transparent 1px), |
| | | linear-gradient(90deg, rgba(0, 229, 255, 0.03) 1px, transparent 1px); |
| | | background-size: 64px 64px; |
| | | mask-image: radial-gradient(ellipse 60% 60% at 50% 35%, black 18%, transparent 78%); |
| | | -webkit-mask-image: radial-gradient(ellipse 60% 60% at 50% 35%, black 18%, transparent 78%); |
| | | } |
| | | |
| | | /* ======== å¤´é¨ ======== */ |
| | | .dash-header { |
| | | position: relative; z-index: 1; |
| | | display: flex; align-items: center; gap: 16px; |
| | | padding-bottom: 14px; margin-bottom: 16px; |
| | | border-bottom: 1px solid rgba(0,229,255,0.08); |
| | | } |
| | | |
| | | .dash-header-left { display: flex; align-items: center; gap: 12px; flex-shrink: 0; } |
| | | |
| | | .dash-logo { |
| | | display: flex; align-items: center; justify-content: center; |
| | | width: 42px; height: 42px; |
| | | background: linear-gradient(135deg, rgba(0,229,255,0.25), rgba(0,229,255,0.08)); |
| | | border: 1px solid rgba(0,229,255,0.25); |
| | | border-radius: 10px; color: var(--cyan); |
| | | box-shadow: 0 0 24px rgba(0,229,255,0.18), inset 0 1px 0 rgba(255,255,255,0.05); |
| | | } |
| | | |
| | | .dash-title-group h1 { |
| | | font-size: 18px; font-weight: 700; letter-spacing: 1px; |
| | | background: linear-gradient(90deg, #fff, var(--cyan)); |
| | | -webkit-background-clip: text; -webkit-text-fill-color: transparent; |
| | | background-clip: text; |
| | | } |
| | | |
| | | .dash-title-group p { |
| | | font-size: 11px; color: var(--text-secondary); margin-top: 2px; letter-spacing: 0.5px; |
| | | } |
| | | |
| | | .dash-header-divider { |
| | | flex: 1; height: 1px; |
| | | background: linear-gradient(90deg, transparent, rgba(0,229,255,0.15), transparent); |
| | | } |
| | | |
| | | .dash-header-right { display: flex; align-items: center; gap: 14px; flex-shrink: 0; } |
| | | |
| | | .dash-status-tags { display: flex; gap: 8px; } |
| | | |
| | | .dash-status-tag { |
| | | display: flex; align-items: center; gap: 5px; |
| | | font-size: 11px; color: var(--text-secondary); |
| | | padding: 3px 10px; background: rgba(0,229,255,0.06); |
| | | border-radius: 12px; border: 1px solid rgba(0,229,255,0.1); |
| | | } |
| | | |
| | | .dash-status-dot { |
| | | width: 5px; height: 5px; border-radius: 50%; background: var(--text-muted); |
| | | } |
| | | .dash-status-dot.online { background: var(--green); box-shadow: 0 0 6px var(--green); } |
| | | |
| | | .dash-live-badge { |
| | | display: flex; align-items: center; gap: 5px; |
| | | font-size: 11px; color: var(--text-secondary); |
| | | padding: 3px 10px; background: rgba(0,229,255,0.06); |
| | | border-radius: 12px; border: 1px solid rgba(0,229,255,0.1); |
| | | } |
| | | |
| | | .dash-live-dot { |
| | | width: 5px; height: 5px; border-radius: 50%; |
| | | background: #22c55e; box-shadow: 0 0 6px #22c55e; |
| | | animation: live-pulse 2s ease-in-out infinite; |
| | | } |
| | | @keyframes live-pulse { |
| | | 0%, 100% { opacity: 1; } |
| | | 50% { opacity: 0.3; } |
| | | } |
| | | |
| | | .dash-live-time { color: var(--text-muted); } |
| | | |
| | | .dash-fs-btn { |
| | | display: flex; align-items: center; justify-content: center; |
| | | width: 32px; height: 32px; border-radius: 6px; |
| | | border: 1px solid rgba(255,255,255,0.06); |
| | | background: rgba(255,255,255,0.02); |
| | | color: var(--text-secondary); cursor: pointer; |
| | | transition: all 0.2s; |
| | | } |
| | | .dash-fs-btn:hover { border-color: rgba(255,255,255,0.2); color: #fff; background: rgba(255,255,255,0.04); } |
| | | |
| | | /* ======== KPI è¡ ======== */ |
| | | .kpi-row { |
| | | position: relative; z-index: 1; |
| | | display: grid; |
| | | grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); |
| | | gap: 12px; margin-bottom: 16px; |
| | | } |
| | | |
| | | .kpi-card { |
| | | display: flex; align-items: center; gap: 12px; |
| | | padding: 14px 16px; |
| | | background: var(--bg-card); |
| | | backdrop-filter: blur(10px); |
| | | -webkit-backdrop-filter: blur(10px); |
| | | border: 1px solid var(--border); |
| | | border-radius: 10px; |
| | | cursor: default; |
| | | transition: all 0.35s cubic-bezier(0.4, 0, 0.2, 1); |
| | | animation: kpi-fade-up 0.55s cubic-bezier(0.4, 0, 0.2, 1) both; |
| | | min-width: 0; |
| | | position: relative; |
| | | overflow: hidden; |
| | | } |
| | | .kpi-card::after { |
| | | content: ''; |
| | | position: absolute; top: 0; left: 0; right: 0; height: 1px; |
| | | background: linear-gradient(90deg, transparent, var(--kpi-color, var(--cyan)), transparent); |
| | | opacity: 0.35; |
| | | } |
| | | .kpi-card:hover { |
| | | border-color: var(--kpi-color, var(--border-hover)); |
| | | box-shadow: |
| | | 0 0 20px color-mix(in srgb, var(--kpi-color, var(--cyan)) 20%, transparent), |
| | | 0 0 40px color-mix(in srgb, var(--kpi-color, var(--cyan)) 8%, transparent), |
| | | 0 4px 20px rgba(0, 0, 0, 0.35); |
| | | transform: translateY(-2px); |
| | | } |
| | | |
| | | .kpi-icon { |
| | | display: flex; align-items: center; justify-content: center; |
| | | width: 40px; height: 40px; flex-shrink: 0; |
| | | border-radius: 8px; |
| | | background: color-mix(in srgb, var(--kpi-color, var(--cyan)) 15%, transparent); |
| | | color: var(--kpi-color, var(--cyan)); |
| | | font-size: 18px; |
| | | box-shadow: 0 0 12px color-mix(in srgb, var(--kpi-color, var(--cyan)) 20%, transparent); |
| | | } |
| | | |
| | | .kpi-body { |
| | | flex: 1; min-width: 0; |
| | | display: flex; flex-direction: column; gap: 4px; |
| | | } |
| | | |
| | | .kpi-label { |
| | | font-size: 11px; color: var(--text-secondary); letter-spacing: 0.3px; |
| | | white-space: nowrap; overflow: hidden; text-overflow: ellipsis; |
| | | } |
| | | |
| | | .kpi-value { |
| | | font-size: 24px; font-weight: 800; line-height: 1; |
| | | font-variant-numeric: tabular-nums; |
| | | color: var(--text-primary); |
| | | } |
| | | |
| | | @keyframes kpi-fade-up { |
| | | from { opacity: 0; transform: translateY(16px); } |
| | | to { opacity: 1; transform: translateY(0); } |
| | | } |
| | | |
| | | /* ======== å¾è¡¨è¡ ======== */ |
| | | .chart-row { |
| | | position: relative; z-index: 1; |
| | | display: grid; |
| | | grid-template-columns: repeat(auto-fit, minmax(380px, 1fr)); |
| | | gap: 12px; |
| | | margin-bottom: 16px; |
| | | } |
| | | |
| | | .chart-row > div { |
| | | min-width: 0; |
| | | animation: kpi-fade-up 0.5s cubic-bezier(0.4, 0, 0.2, 1) both; |
| | | } |
| | | |
| | | /* ======== è¡¨æ ¼å¡ç ======== */ |
| | | .table-card { |
| | | position: relative; z-index: 1; |
| | | background: var(--bg-card); |
| | | backdrop-filter: blur(8px); |
| | | -webkit-backdrop-filter: blur(8px); |
| | | border: 1px solid var(--border); |
| | | border-radius: 10px; |
| | | padding: 14px; |
| | | margin-bottom: 16px; |
| | | transition: all 0.35s; |
| | | animation: kpi-fade-up 0.5s cubic-bezier(0.4, 0, 0.2, 1) both; |
| | | } |
| | | .table-card::before { |
| | | content: ''; |
| | | position: absolute; inset: 0; border-radius: 10px; pointer-events: none; |
| | | background: linear-gradient(135deg, rgba(0, 229, 255, 0.04) 0%, transparent 50%, rgba(0, 255, 136, 0.03) 100%); |
| | | } |
| | | .table-card:hover { |
| | | border-color: var(--border-hover); |
| | | box-shadow: 0 0 24px rgba(0, 229, 255, 0.06), 0 0 48px rgba(0, 229, 255, 0.03); |
| | | } |
| | | |
| | | .table-header { |
| | | display: flex; align-items: center; gap: 8px; |
| | | margin-bottom: 10px; |
| | | } |
| | | |
| | | .table-header-dot { |
| | | width: 6px; height: 6px; border-radius: 50%; |
| | | flex-shrink: 0; |
| | | } |
| | | |
| | | .table-header-title { |
| | | font-size: 13px; font-weight: 600; |
| | | color: var(--text-primary); |
| | | letter-spacing: 0.5px; |
| | | } |
| | | |
| | | .table-badge { |
| | | margin-left: auto; |
| | | font-size: 10px; font-weight: 400; |
| | | padding: 2px 8px; border-radius: 10px; |
| | | background: rgba(0,255,136,0.08); color: var(--green); |
| | | border: 1px solid rgba(0,255,136,0.15); |
| | | } |
| | | |
| | | .table-wrap { |
| | | overflow-x: auto; border-radius: 8px; |
| | | background: rgba(4, 14, 36, 0.55); |
| | | backdrop-filter: blur(6px); |
| | | -webkit-backdrop-filter: blur(6px); |
| | | border: 1px solid rgba(0, 229, 255, 0.08); |
| | | } |
| | | |
| | | .data-table { |
| | | width: 100%; border-collapse: collapse; font-size: 12px; |
| | | } |
| | | |
| | | .data-table thead th { |
| | | background: rgba(6, 18, 42, 0.8); |
| | | color: var(--text-secondary); |
| | | font-weight: 600; font-size: 10px; |
| | | text-transform: uppercase; letter-spacing: 0.8px; |
| | | padding: 10px 14px; text-align: left; |
| | | border-bottom: 1px solid rgba(0, 229, 255, 0.12); |
| | | white-space: nowrap; |
| | | } |
| | | |
| | | .data-table tbody td { |
| | | padding: 9px 14px; color: var(--text-primary); |
| | | border-bottom: 1px solid rgba(0, 229, 255, 0.04); |
| | | white-space: nowrap; |
| | | } |
| | | |
| | | .data-table tbody tr { |
| | | transition: all 0.25s; |
| | | opacity: 0; |
| | | animation: row-fade-in 0.45s ease forwards; |
| | | } |
| | | |
| | | .data-table tbody tr:hover { |
| | | background: rgba(0, 229, 255, 0.06); |
| | | box-shadow: inset 0 0 20px rgba(0, 229, 255, 0.03); |
| | | } |
| | | |
| | | @keyframes row-fade-in { |
| | | from { opacity: 0; transform: translateY(6px); } |
| | | to { opacity: 1; transform: translateY(0); } |
| | | } |
| | | |
| | | /* ======== ç©ºç¶æ ======== */ |
| | | .empty-state { |
| | | position: relative; z-index: 1; |
| | | display: flex; flex-direction: column; align-items: center; |
| | | justify-content: center; padding: 60px 0; |
| | | color: var(--text-muted); |
| | | } |
| | | .empty-state p { margin-top: 12px; } |
| | | .empty-sub { font-size: 12px; color: rgba(140, 152, 180, 0.35); } |
| | | |
| | | /* ======== å
¨å± ======== */ |
| | | .is-fullscreen { |
| | | max-height: none; min-height: 100vh; padding: 24px 28px; border-radius: 0; overflow-y: auto; |
| | | } |
| | | .is-fullscreen::before, |
| | | .is-fullscreen::after { |
| | | position: fixed; |
| | | } |
| | | .is-fullscreen .kpi-row { gap: 16px; } |
| | | .is-fullscreen .chart-row { gap: 16px; } |
| | | .is-fullscreen .dash-header { margin-bottom: 24px; } |
| | | |
| | | /* ======== å è½½æ/ç©ºç¶æ ======== */ |
| | | :deep(.ant-spin-text) { color: var(--text-secondary); } |
| | | :deep(.ant-empty) { color: var(--text-muted); } |
| | | :deep(.ant-empty-description) { color: var(--text-muted); } |
| | | |
| | | /* ======== ååºå¼ ======== */ |
| | | @media (max-width: 768px) { |
| | | .bi-dashboard { padding: 10px; } |
| | | .dash-header { flex-wrap: wrap; } |
| | | .dash-header-divider { display: none; } |
| | | .kpi-row { grid-template-columns: 1fr 1fr; } |
| | | .chart-row { grid-template-columns: 1fr; } |
| | | .chart-row > div { grid-column: 1 / -1 !important; } |
| | | .kpi-value { font-size: 20px; } |
| | | } |
| | | </style> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <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> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <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> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | 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' }, |
| | | }; |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <script lang="ts" setup> |
| | | import type { BiDashboardApi } from '#/api/bi/dashboard'; |
| | | import type { EchartsUIType } from '@vben/plugins/echarts'; |
| | | |
| | | import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'; |
| | | |
| | | import { IconifyIcon } from '@vben/icons'; |
| | | import { EchartsUI, useEcharts } from '@vben/plugins/echarts'; |
| | | |
| | | import { Empty, Spin } from 'ant-design-vue'; |
| | | |
| | | import { getDashboardData } from '#/api/bi/dashboard'; |
| | | |
| | | import ClockWidget from '../dashboard/modules/ClockWidget.vue'; |
| | | import { |
| | | getChinaMapOptions, |
| | | getWarehouseAreaLineOptions, |
| | | getWarehouseBarOptions, |
| | | getWarehouseDonutOptions, |
| | | } from './chart-options'; |
| | | |
| | | defineOptions({ name: 'BiWarehouseDashboard' }); |
| | | |
| | | // ======== æ°æ®å è½½ ======== |
| | | const charts = ref<BiDashboardApi.ChartItem[]>([]); |
| | | const loading = ref(true); |
| | | const lastUpdateTime = ref(''); |
| | | |
| | | async function loadData() { |
| | | try { |
| | | loading.value = true; |
| | | const data = await getDashboardData('warehouse'); |
| | | charts.value = data.length > 0 ? data : []; |
| | | lastUpdateTime.value = new Date().toLocaleTimeString('zh-CN', { hour12: false }); |
| | | } catch { |
| | | charts.value = []; |
| | | } finally { |
| | | loading.value = false; |
| | | await nextTick(); |
| | | resizeAllCharts(); |
| | | } |
| | | } |
| | | |
| | | // ======== æ°æ®åç±» ======== |
| | | const numberCharts = computed(() => charts.value.filter((c) => c.chartType === 'number')); |
| | | const barChart = computed(() => charts.value.find((c) => c.chartType === 'bar')); |
| | | const lineChart = computed(() => charts.value.find((c) => c.chartType === 'line')); |
| | | const tableChart = computed(() => charts.value.find((c) => c.chartType === 'table')); |
| | | |
| | | function getCellValue(row: unknown, col: string): unknown { |
| | | return (row as Record<string, unknown>)[col]; |
| | | } |
| | | |
| | | function extractNum(d: Record<string, unknown> | undefined): number { |
| | | if (!d) return 0; |
| | | return (Object.values(d).find((v): v is number => typeof v === 'number') || 0) as number; |
| | | } |
| | | |
| | | function extractNums(data: Array<Record<string, unknown>> | undefined, key?: string): number[] { |
| | | if (!data) return []; |
| | | return data.map((d) => { |
| | | if (key) return Number(d[key]) || 0; |
| | | return extractNum(d); |
| | | }); |
| | | } |
| | | |
| | | function extractLabels(data: Array<Record<string, unknown>> | undefined): string[] { |
| | | if (!data || data.length === 0) return []; |
| | | const keys = Object.keys(data[0] || {}); |
| | | return data.map((d) => String(d[keys[0]] || '')); |
| | | } |
| | | |
| | | // ======== KPI æ°æ® ======== |
| | | interface KpiItem { |
| | | id: number; |
| | | name: string; |
| | | value: number; |
| | | trend: number[]; |
| | | icon: string; |
| | | color: string; |
| | | } |
| | | |
| | | const turnoverRate = computed(() => { |
| | | const lineData = lineChart.value?.data; |
| | | if (!lineData || lineData.length === 0) return 0; |
| | | let totalOut = 0; |
| | | lineData.forEach((d: Record<string, unknown>) => { |
| | | const keys = Object.keys(d); |
| | | totalOut += Math.abs(Number(d[keys[2]])) || 0; |
| | | }); |
| | | const avgOutPerMonth = totalOut / Math.max(1, lineData.length); |
| | | const totalStock = warehouseNodes.value.reduce((sum, n) => sum + Math.abs(n.stock), 0); |
| | | if (totalStock === 0) return 0; |
| | | return Number((avgOutPerMonth / totalStock).toFixed(2)); |
| | | }); |
| | | |
| | | const kpiCards = computed<KpiItem[]>(() => { |
| | | const cards: KpiItem[] = []; |
| | | const trendData = extractNums(lineChart.value?.data, Object.keys(lineChart.value?.data?.[0] || {})[1]); |
| | | |
| | | for (const c of numberCharts.value) { |
| | | cards.push({ |
| | | id: c.id!, |
| | | name: c.name, |
| | | value: c.data?.[0] ? extractNum(c.data[0] as Record<string, unknown>) : 0, |
| | | trend: trendData.length > 0 ? trendData : [c.data?.[0] ? extractNum(c.data[0] as Record<string, unknown>) : 0], |
| | | icon: getKpiIcon(c.name), |
| | | color: getKpiColor(cards.length), |
| | | }); |
| | | } |
| | | cards.push({ |
| | | id: 0, |
| | | name: 'åºåå¨è½¬ç', |
| | | value: turnoverRate.value, |
| | | trend: trendData, |
| | | icon: 'lucide:repeat', |
| | | color: '#8B5CF6', |
| | | }); |
| | | return cards; |
| | | }); |
| | | |
| | | function getKpiIcon(name: string): string { |
| | | if (name.includes('ä»åº')) return 'lucide:building-2'; |
| | | if (name.includes('ç©æ') || name.includes('åºå') || name.includes('åç§')) return 'lucide:package'; |
| | | if (name.includes('å°è´§')) return 'lucide:truck'; |
| | | if (name.includes('åºè´§')) return 'lucide:send'; |
| | | return 'lucide:bar-chart-4'; |
| | | } |
| | | |
| | | const kpiColors = ['#00E5FF', '#00FF88', '#FFC107', '#FF3860', '#8B5CF6']; |
| | | function getKpiColor(i: number) { return kpiColors[i % kpiColors.length]; } |
| | | |
| | | // ======== å°å¾æ°æ® ======== |
| | | const warehouseNodes = computed(() => { |
| | | const data = barChart.value?.data; |
| | | if (!data || data.length === 0) return []; |
| | | const coordMap: Record<string, number[]> = { |
| | | '䏿µ·ä»': [121.47, 31.23], |
| | | '广ä¸ä»': [113.26, 23.13], |
| | | '西åä»': [104.07, 30.67], |
| | | 'å京ä»': [116.41, 39.90], |
| | | 'æ¦æ±ä»': [114.30, 30.60], |
| | | }; |
| | | const defaultCoords = [[116.41, 39.90], [121.47, 31.23], [113.26, 23.13], [104.07, 30.67], [114.30, 30.60]]; |
| | | return data.map((d: Record<string, unknown>, i: number) => { |
| | | const keys = Object.keys(d); |
| | | const name = String(d[keys[0]] || ''); |
| | | const stock = Number(d[keys[1]]) || 0; |
| | | return { name, value: coordMap[name] || defaultCoords[i % defaultCoords.length], stock }; |
| | | }); |
| | | }); |
| | | |
| | | const mapFlyLines = computed(() => { |
| | | const nodes = warehouseNodes.value; |
| | | if (nodes.length < 2) return []; |
| | | const lines: Array<{ from: string; to: string }> = []; |
| | | for (let i = 0; i < nodes.length - 1; i++) { |
| | | lines.push({ from: nodes[i].name, to: nodes[i + 1].name }); |
| | | } |
| | | return lines; |
| | | }); |
| | | |
| | | // ======== åºåç»æ Donut ======== |
| | | const structureDonutData = computed(() => { |
| | | const data = barChart.value?.data; |
| | | if (!data || data.length === 0) return []; |
| | | return data.map((d: Record<string, unknown>) => { |
| | | const keys = Object.keys(d); |
| | | return { name: String(d[keys[0]] || ''), value: Number(d[keys[1]]) || 0 }; |
| | | }); |
| | | }); |
| | | |
| | | // ======== åºå
¥åºè¶å¿ ======== |
| | | const inOutTrendLabels = computed(() => extractLabels(lineChart.value?.data)); |
| | | const inOutTrendSeries = computed(() => { |
| | | const data = lineChart.value?.data; |
| | | if (!data || data.length === 0) return []; |
| | | const keys = Object.keys(data[0] || {}); |
| | | return [ |
| | | { name: keys[1] || 'å
¥åº', data: extractNums(data, keys[1]) }, |
| | | { name: keys[2] || 'åºåº', data: extractNums(data, keys[2]) }, |
| | | ]; |
| | | }); |
| | | |
| | | const mapStats = computed(() => { |
| | | const data = lineChart.value?.data; |
| | | if (!data || data.length === 0) return { todayIn: 0, todayOut: 0, totalStock: 0 }; |
| | | const last = data[data.length - 1] as Record<string, unknown>; |
| | | const keys = Object.keys(last); |
| | | const lastIn = Math.abs(Number(last[keys[1]])) || 0; |
| | | const lastOut = Math.abs(Number(last[keys[2]])) || 0; |
| | | const totalStock = warehouseNodes.value.reduce((sum, n) => sum + Math.abs(n.stock), 0); |
| | | return { |
| | | todayIn: Math.round(lastIn / 30), |
| | | todayOut: Math.round(lastOut / 30), |
| | | totalStock, |
| | | }; |
| | | }); |
| | | |
| | | // ======== è¡¨æ ¼æ°æ® ======== |
| | | const tableData = computed(() => tableChart.value?.data || []); |
| | | const tableColumns = computed(() => { |
| | | if (tableData.value.length === 0) return []; |
| | | return Object.keys(tableData.value[0] || {}); |
| | | }); |
| | | |
| | | // ======== åºåé¢è¦ ======== |
| | | const warningList = computed(() => { |
| | | const data = barChart.value?.data || []; |
| | | return data.slice(0, 5).map((d: Record<string, unknown>) => { |
| | | const keys = Object.keys(d); |
| | | const name = String(d[keys[0]] || ''); |
| | | const qty = Number(d[keys[1]]) || 0; |
| | | return { |
| | | name: `${name}åºå`, |
| | | type: qty < 1000 ? 'danger' as const : qty < 5000 ? 'warning' as const : 'normal' as const, |
| | | text: qty < 1000 ? 'åºåä¸è¶³' : qty < 5000 ? 'å³å°ç¼ºè´§' : 'åºåæ£å¸¸', |
| | | qty, |
| | | }; |
| | | }); |
| | | }); |
| | | |
| | | // ======== 宿¶å¨æ ======== |
| | | const timelineEvents = computed(() => { |
| | | const data = tableChart.value?.data || []; |
| | | return data.slice(0, 6).map((d: Record<string, unknown>, i: number) => { |
| | | const keys = Object.keys(d); |
| | | const now = new Date(); |
| | | now.setMinutes(now.getMinutes() - i * 15); |
| | | return { |
| | | time: now.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }), |
| | | type: i % 2 === 0 ? 'å°è´§' : 'åºè´§', |
| | | title: String(d[keys[1]] || d[keys[0]] || ''), |
| | | }; |
| | | }); |
| | | }); |
| | | |
| | | // ======== å¾è¡¨å®ä¾ ======== |
| | | const mapRef = ref<EchartsUIType>(); |
| | | const leftDonutRef = ref<EchartsUIType>(); |
| | | const leftAreaRef = ref<EchartsUIType>(); |
| | | const rightBarRef = ref<EchartsUIType>(); |
| | | const rightDonutRef = ref<EchartsUIType>(); |
| | | const { renderEcharts: renderMap } = useEcharts(mapRef); |
| | | const { renderEcharts: renderLeftDonut } = useEcharts(leftDonutRef); |
| | | const { renderEcharts: renderLeftArea } = useEcharts(leftAreaRef); |
| | | const { renderEcharts: renderRightBar } = useEcharts(rightBarRef); |
| | | const { renderEcharts: renderRightDonut } = useEcharts(rightDonutRef); |
| | | |
| | | async function renderAllCharts() { |
| | | const promises: Promise<unknown>[] = []; |
| | | |
| | | if (warehouseNodes.value.length > 0) { |
| | | promises.push(renderMap(getChinaMapOptions(warehouseNodes.value, mapFlyLines.value))); |
| | | } |
| | | if (structureDonutData.value.length > 0) { |
| | | promises.push(renderLeftDonut(getWarehouseDonutOptions(structureDonutData.value))); |
| | | promises.push(renderRightDonut(getWarehouseDonutOptions(structureDonutData.value))); |
| | | } |
| | | if (inOutTrendLabels.value.length > 0) { |
| | | promises.push(renderLeftArea(getWarehouseAreaLineOptions(inOutTrendLabels.value, inOutTrendSeries.value))); |
| | | promises.push(renderRightBar(getWarehouseBarOptions(inOutTrendLabels.value, inOutTrendSeries.value))); |
| | | } |
| | | await Promise.all(promises); |
| | | } |
| | | |
| | | // ======== å·æ°å®æ¶å¨ ======== |
| | | let refreshTimer: ReturnType<typeof setInterval> | null = null; |
| | | |
| | | function setupTimer() { |
| | | clearTimer(); |
| | | refreshTimer = setInterval(async () => { |
| | | await loadData(); |
| | | await nextTick(); |
| | | await renderAllCharts(); |
| | | }, 60_000); |
| | | } |
| | | |
| | | function clearTimer() { |
| | | if (refreshTimer !== null) { clearInterval(refreshTimer); refreshTimer = null; } |
| | | } |
| | | |
| | | // ======== èªéåº ======== |
| | | function resizeAllCharts() { |
| | | setTimeout(() => window.dispatchEvent(new Event('resize')), 100); |
| | | } |
| | | |
| | | const dashboardRef = ref<HTMLElement>(); |
| | | let resizeObserver: ResizeObserver | null = null; |
| | | |
| | | // ======== å
¨å± ======== |
| | | const isFullscreen = ref(false); |
| | | async function toggleFullscreen() { |
| | | if (!document.fullscreenElement) { |
| | | await dashboardRef.value?.requestFullscreen(); |
| | | isFullscreen.value = true; |
| | | } else { |
| | | await document.exitFullscreen(); |
| | | isFullscreen.value = false; |
| | | } |
| | | setTimeout(resizeAllCharts, 400); |
| | | } |
| | | function onFullscreenChange() { |
| | | isFullscreen.value = !!document.fullscreenElement; |
| | | setTimeout(resizeAllCharts, 300); |
| | | } |
| | | |
| | | // ======== KPI å¨ç» ======== |
| | | const animatedIds = new Set<number>(); |
| | | |
| | | function animateValue(el: HTMLElement, end: number, decimals = 0) { |
| | | if (end === 0) { el.textContent = '0'; return; } |
| | | const duration = 1800; |
| | | const startTime = performance.now(); |
| | | function update(currentTime: number) { |
| | | const progress = Math.min((currentTime - startTime) / duration, 1); |
| | | const eased = 1 - (1 - progress) ** 3; |
| | | el.textContent = (end * eased).toFixed(decimals); |
| | | if (progress < 1) requestAnimationFrame(update); |
| | | } |
| | | requestAnimationFrame(update); |
| | | } |
| | | |
| | | function maybeAnimate(el: HTMLElement, cardId: number, value: number) { |
| | | if (animatedIds.has(cardId)) return; |
| | | animatedIds.add(cardId); |
| | | animateValue(el, value, Number.isInteger(value) ? 0 : 2); |
| | | } |
| | | |
| | | watch(() => charts.value, () => { |
| | | animatedIds.clear(); |
| | | nextTick(() => renderAllCharts()); |
| | | }); |
| | | |
| | | // ======== çå½å¨æ ======== |
| | | onMounted(async () => { |
| | | document.addEventListener('fullscreenchange', onFullscreenChange); |
| | | resizeObserver = new ResizeObserver(() => resizeAllCharts()); |
| | | if (dashboardRef.value) resizeObserver.observe(dashboardRef.value); |
| | | await loadData(); |
| | | await nextTick(); |
| | | await renderAllCharts(); |
| | | setupTimer(); |
| | | }); |
| | | |
| | | onBeforeUnmount(() => { |
| | | document.removeEventListener('fullscreenchange', onFullscreenChange); |
| | | resizeObserver?.disconnect(); |
| | | clearTimer(); |
| | | }); |
| | | </script> |
| | | |
| | | <template> |
| | | <div ref="dashboardRef" class="warehouse-dashboard" :class="{ 'is-fullscreen': isFullscreen }"> |
| | | <!-- ======== å¤´é¨ ======== --> |
| | | <header class="wh-header"> |
| | | <div class="wh-header-left"> |
| | | <div class="wh-logo"> |
| | | <IconifyIcon icon="lucide:warehouse" class="text-xl" /> |
| | | </div> |
| | | <div class="wh-title-group"> |
| | | <h1>ä»å¨ç©æµè¿è¥ä¸å¿</h1> |
| | | <p>åºåå¨è½¬ · æºè½åæ · ç©æµåå</p> |
| | | </div> |
| | | </div> |
| | | <div class="wh-header-divider" /> |
| | | <div class="wh-header-right"> |
| | | <div class="wh-status-tags"> |
| | | <span class="wh-status-tag"> |
| | | <i class="wh-status-dot online" /> ç³»ç»æ£å¸¸ |
| | | </span> |
| | | <span class="wh-status-tag"> |
| | | <i class="wh-status-dot" /> æ°æ®æ´æ° |
| | | </span> |
| | | </div> |
| | | <ClockWidget /> |
| | | <div class="wh-live-badge"> |
| | | <span class="wh-live-dot" /> |
| | | <span>宿¶</span> |
| | | <span v-if="lastUpdateTime" class="wh-live-time">· {{ lastUpdateTime }}</span> |
| | | </div> |
| | | <button class="wh-fs-btn" :title="isFullscreen ? 'éåºå
¨å±' : 'å
¨å±'" @click="toggleFullscreen"> |
| | | <IconifyIcon :icon="isFullscreen ? 'lucide:minimize-2' : 'lucide:maximize-2'" /> |
| | | </button> |
| | | </div> |
| | | </header> |
| | | |
| | | <!-- ======== å è½½æ ======== --> |
| | | <Spin v-if="loading && charts.length === 0" :spinning="true" tip="å 载仪表çä¸..."> |
| | | <div style="height: 400px" /> |
| | | </Spin> |
| | | |
| | | <!-- ======== 主ä½å
容 ======== --> |
| | | <template v-else> |
| | | <!-- KPI å¡çè¡ --> |
| | | <div class="wh-kpi-row"> |
| | | <div |
| | | v-for="(card, i) in kpiCards" |
| | | :key="card.id" |
| | | class="wh-kpi-card" |
| | | :style="{ '--kpi-color': card.color, animationDelay: `${i * 0.06}s` }" |
| | | > |
| | | <div class="wh-kpi-icon"> |
| | | <IconifyIcon :icon="card.icon" /> |
| | | </div> |
| | | <div class="wh-kpi-body"> |
| | | <span class="wh-kpi-label">{{ card.name }}</span> |
| | | <span |
| | | class="wh-kpi-value" |
| | | :ref="(el: unknown) => { |
| | | if (el && card.value) { |
| | | maybeAnimate(el as HTMLElement, card.id, card.value); |
| | | } |
| | | }" |
| | | >{{ card.name.includes('å¨è½¬ç') ? card.value : Math.round(card.value).toLocaleString() }}</span> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | |
| | | <!-- 䏿 主å¸å± --> |
| | | <div class="wh-main-grid"> |
| | | <!-- ===== å·¦æ ===== --> |
| | | <div class="wh-left-col"> |
| | | <div class="wh-panel"> |
| | | <div class="wh-panel-header"> |
| | | <span class="wh-panel-dot" /> |
| | | åºåç»æåæ |
| | | </div> |
| | | <EchartsUI ref="leftDonutRef" class="!h-full" :style="{ minHeight: '180px' }" /> |
| | | </div> |
| | | <div class="wh-panel"> |
| | | <div class="wh-panel-header"> |
| | | <span class="wh-panel-dot" /> |
| | | åºåè¶å¿ |
| | | </div> |
| | | <EchartsUI ref="leftAreaRef" class="!h-full" :style="{ minHeight: '160px' }" /> |
| | | </div> |
| | | <div class="wh-panel"> |
| | | <div class="wh-panel-header"> |
| | | <span class="wh-panel-dot" style="background: #FF3860;" /> |
| | | åºåé¢è¦ |
| | | </div> |
| | | <div class="wh-warning-list"> |
| | | <div |
| | | v-for="(w, wi) in warningList" |
| | | :key="wi" |
| | | class="wh-warn-item" |
| | | :class="w.type" |
| | | > |
| | | <div class="wh-warn-left"> |
| | | <span class="wh-warn-dot" :class="w.type" /> |
| | | <span class="wh-warn-name">{{ w.name }}</span> |
| | | </div> |
| | | <div class="wh-warn-right"> |
| | | <span class="wh-warn-tag" :class="w.type">{{ w.text }}</span> |
| | | <span class="wh-warn-qty">{{ w.qty.toLocaleString() }}</span> |
| | | </div> |
| | | </div> |
| | | <Empty v-if="warningList.length === 0" description="ææ é¢è¦" /> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | |
| | | <!-- ===== 䏿 - å°å¾ ===== --> |
| | | <div class="wh-center-col"> |
| | | <div class="wh-panel wh-map-panel"> |
| | | <div class="wh-panel-header"> |
| | | <span class="wh-panel-dot" /> |
| | | å
¨å½ä»å¨ç½ç» |
| | | </div> |
| | | <EchartsUI ref="mapRef" class="!h-full" :style="{ minHeight: '380px' }" /> |
| | | <div class="wh-map-stats"> |
| | | <div class="wh-map-stat"> |
| | | <span class="wh-stat-label">仿¥å
¥åº(ä¼°)</span> |
| | | <span class="wh-stat-value in">{{ mapStats.todayIn.toLocaleString() }}</span> |
| | | </div> |
| | | <div class="wh-map-stat"> |
| | | <span class="wh-stat-label">仿¥åºåº(ä¼°)</span> |
| | | <span class="wh-stat-value out">{{ mapStats.todayOut.toLocaleString() }}</span> |
| | | </div> |
| | | <div class="wh-map-stat"> |
| | | <span class="wh-stat-label">æ»åºå</span> |
| | | <span class="wh-stat-value total">{{ mapStats.totalStock.toLocaleString() }}</span> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | |
| | | <!-- ===== 峿 ===== --> |
| | | <div class="wh-right-col"> |
| | | <div class="wh-panel"> |
| | | <div class="wh-panel-header"> |
| | | <span class="wh-panel-dot" /> |
| | | åºå
¥åºè¶å¿ |
| | | </div> |
| | | <EchartsUI ref="rightBarRef" class="!h-full" :style="{ minHeight: '160px' }" /> |
| | | </div> |
| | | <div class="wh-panel"> |
| | | <div class="wh-panel-header"> |
| | | <span class="wh-panel-dot" /> |
| | | åºåç»æ |
| | | </div> |
| | | <EchartsUI ref="rightDonutRef" class="!h-full" :style="{ minHeight: '180px' }" /> |
| | | </div> |
| | | <div class="wh-panel"> |
| | | <div class="wh-panel-header"> |
| | | <span class="wh-panel-dot" style="background: #00FF88;" /> |
| | | 宿¶å¨æ |
| | | </div> |
| | | <div class="wh-timeline"> |
| | | <div |
| | | v-for="(evt, ei) in timelineEvents" |
| | | :key="ei" |
| | | class="wh-tl-item" |
| | | > |
| | | <div class="wh-tl-dot" :class="evt.type === 'å°è´§' ? 'in' : 'out'" /> |
| | | <div class="wh-tl-content"> |
| | | <span class="wh-tl-time">{{ evt.time }}</span> |
| | | <span class="wh-tl-title">{{ evt.title }}</span> |
| | | </div> |
| | | <span class="wh-tl-type" :class="evt.type === 'å°è´§' ? 'in' : 'out'"> |
| | | {{ evt.type }} |
| | | </span> |
| | | </div> |
| | | <Empty v-if="timelineEvents.length === 0" description="ææ å¨æ" /> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | |
| | | <!-- ======== åºé¨æ°æ®è¡¨æ ¼ ======== --> |
| | | <div class="wh-panel wh-table-panel"> |
| | | <div class="wh-panel-header"> |
| | | <span class="wh-panel-dot" /> |
| | | æè¿å
¥åºè®°å½ |
| | | <span class="wh-table-badge">宿¶æ»å¨</span> |
| | | </div> |
| | | <div v-if="tableData.length > 0" class="wh-table-wrap"> |
| | | <table class="wh-table"> |
| | | <thead> |
| | | <tr> |
| | | <th v-for="col in tableColumns" :key="col">{{ col }}</th> |
| | | </tr> |
| | | </thead> |
| | | <tbody> |
| | | <tr |
| | | v-for="(row, ri) in tableData" |
| | | :key="ri" |
| | | :style="{ animationDelay: `${ri * 50}ms` }" |
| | | > |
| | | <td v-for="col in tableColumns" :key="col"> |
| | | {{ getCellValue(row, col) }} |
| | | </td> |
| | | </tr> |
| | | </tbody> |
| | | </table> |
| | | </div> |
| | | <Empty v-else description="ææ æ°æ®" /> |
| | | </div> |
| | | </template> |
| | | </div> |
| | | </template> |
| | | |
| | | <style scoped> |
| | | /* ======== CSS åé ======== */ |
| | | .warehouse-dashboard { |
| | | --bg-deep: #020817; |
| | | --bg-card: rgba(10, 24, 52, 0.55); |
| | | --bg-card-hover: rgba(16, 34, 68, 0.72); |
| | | --border: rgba(0, 229, 255, 0.2); |
| | | --border-hover: rgba(0, 229, 255, 0.4); |
| | | --cyan: #00E5FF; |
| | | --green: #00FF88; |
| | | --yellow: #FFC107; |
| | | --red: #FF3860; |
| | | --text-primary: rgba(235, 240, 252, 0.95); |
| | | --text-secondary: rgba(185, 196, 220, 0.72); |
| | | --text-muted: rgba(145, 158, 185, 0.5); |
| | | |
| | | position: relative; |
| | | background: |
| | | radial-gradient(ellipse 70% 50% at 50% 0%, #0d1f42 0%, #060e24 35%, #020817 100%); |
| | | color: var(--text-primary); |
| | | padding: 16px 20px 24px; |
| | | font-family: 'PingFang SC', 'Microsoft YaHei', sans-serif; |
| | | overflow-x: hidden; |
| | | overflow-y: auto; |
| | | max-height: calc(100vh - 104px); |
| | | } |
| | | |
| | | /* èæ¯å
æ + ç½æ ¼ */ |
| | | .warehouse-dashboard::before { |
| | | content: ''; |
| | | position: absolute; inset: 0; pointer-events: none; z-index: 0; |
| | | background: |
| | | radial-gradient(ellipse 50% 45% at 50% 5%, rgba(0, 229, 255, 0.12) 0%, transparent 50%), |
| | | radial-gradient(ellipse 35% 30% at 20% 75%, rgba(0, 255, 136, 0.07) 0%, transparent 55%), |
| | | radial-gradient(ellipse 30% 30% at 80% 65%, rgba(139, 92, 246, 0.08) 0%, transparent 55%), |
| | | radial-gradient(ellipse 25% 25% at 50% 90%, rgba(0, 229, 255, 0.05) 0%, transparent 60%); |
| | | animation: bg-breathe 10s ease-in-out infinite alternate; |
| | | } |
| | | |
| | | @keyframes bg-breathe { |
| | | 0% { opacity: 0.7; } |
| | | 100% { opacity: 1; } |
| | | } |
| | | |
| | | .warehouse-dashboard::after { |
| | | content: ''; |
| | | position: absolute; inset: 0; pointer-events: none; z-index: 0; |
| | | background-image: |
| | | linear-gradient(rgba(0, 229, 255, 0.03) 1px, transparent 1px), |
| | | linear-gradient(90deg, rgba(0, 229, 255, 0.03) 1px, transparent 1px); |
| | | background-size: 64px 64px; |
| | | mask-image: radial-gradient(ellipse 60% 60% at 50% 35%, black 18%, transparent 78%); |
| | | -webkit-mask-image: radial-gradient(ellipse 60% 60% at 50% 35%, black 18%, transparent 78%); |
| | | } |
| | | |
| | | /* ======== å¤´é¨ ======== */ |
| | | .wh-header { |
| | | position: relative; z-index: 1; |
| | | display: flex; align-items: center; gap: 16px; |
| | | padding-bottom: 14px; margin-bottom: 16px; |
| | | border-bottom: 1px solid rgba(0,229,255,0.08); |
| | | } |
| | | |
| | | .wh-header-left { display: flex; align-items: center; gap: 12px; flex-shrink: 0; } |
| | | |
| | | .wh-logo { |
| | | display: flex; align-items: center; justify-content: center; |
| | | width: 42px; height: 42px; |
| | | background: linear-gradient(135deg, rgba(0,229,255,0.25), rgba(0,229,255,0.08)); |
| | | border: 1px solid rgba(0,229,255,0.25); |
| | | border-radius: 10px; color: var(--cyan); |
| | | box-shadow: 0 0 24px rgba(0,229,255,0.18), inset 0 1px 0 rgba(255,255,255,0.05); |
| | | } |
| | | |
| | | .wh-title-group h1 { |
| | | font-size: 18px; font-weight: 700; letter-spacing: 1px; |
| | | background: linear-gradient(90deg, #fff, var(--cyan)); |
| | | -webkit-background-clip: text; -webkit-text-fill-color: transparent; |
| | | background-clip: text; |
| | | } |
| | | |
| | | .wh-title-group p { |
| | | font-size: 11px; color: var(--text-secondary); margin-top: 2px; letter-spacing: 0.5px; |
| | | } |
| | | |
| | | .wh-header-divider { |
| | | flex: 1; height: 1px; |
| | | background: linear-gradient(90deg, transparent, rgba(0,229,255,0.15), transparent); |
| | | } |
| | | |
| | | .wh-header-right { display: flex; align-items: center; gap: 14px; flex-shrink: 0; } |
| | | |
| | | .wh-status-tags { display: flex; gap: 8px; } |
| | | |
| | | .wh-status-tag { |
| | | display: flex; align-items: center; gap: 5px; |
| | | font-size: 11px; color: var(--text-secondary); |
| | | padding: 3px 10px; background: rgba(0,229,255,0.06); |
| | | border-radius: 12px; border: 1px solid rgba(0,229,255,0.1); |
| | | } |
| | | |
| | | .wh-status-dot { |
| | | width: 5px; height: 5px; border-radius: 50%; background: var(--text-muted); |
| | | } |
| | | .wh-status-dot.online { background: var(--green); box-shadow: 0 0 6px var(--green); } |
| | | |
| | | .wh-live-badge { |
| | | display: flex; align-items: center; gap: 5px; |
| | | font-size: 11px; color: var(--text-secondary); |
| | | padding: 3px 10px; background: rgba(0,229,255,0.06); |
| | | border-radius: 12px; border: 1px solid rgba(0,229,255,0.1); |
| | | } |
| | | |
| | | .wh-live-dot { |
| | | width: 5px; height: 5px; border-radius: 50%; |
| | | background: #22c55e; box-shadow: 0 0 6px #22c55e; |
| | | animation: live-pulse 2s ease-in-out infinite; |
| | | } |
| | | @keyframes live-pulse { |
| | | 0%, 100% { opacity: 1; } |
| | | 50% { opacity: 0.3; } |
| | | } |
| | | |
| | | .wh-live-time { color: var(--text-muted); } |
| | | |
| | | .wh-fs-btn { |
| | | display: flex; align-items: center; justify-content: center; |
| | | width: 32px; height: 32px; border-radius: 6px; |
| | | border: 1px solid rgba(255,255,255,0.06); |
| | | background: rgba(255,255,255,0.02); |
| | | color: var(--text-secondary); cursor: pointer; |
| | | transition: all 0.2s; |
| | | } |
| | | .wh-fs-btn:hover { border-color: rgba(255,255,255,0.2); color: #fff; background: rgba(255,255,255,0.04); } |
| | | |
| | | /* ======== KPI è¡ ======== */ |
| | | .wh-kpi-row { |
| | | position: relative; z-index: 1; |
| | | display: grid; |
| | | grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); |
| | | gap: 12px; margin-bottom: 16px; |
| | | } |
| | | |
| | | .wh-kpi-card { |
| | | display: flex; align-items: center; gap: 12px; |
| | | padding: 14px 16px; |
| | | background: var(--bg-card); |
| | | backdrop-filter: blur(10px); |
| | | -webkit-backdrop-filter: blur(10px); |
| | | border: 1px solid var(--border); |
| | | border-radius: 10px; |
| | | cursor: default; |
| | | transition: all 0.35s cubic-bezier(0.4, 0, 0.2, 1); |
| | | animation: kpi-fade-up 0.55s cubic-bezier(0.4, 0, 0.2, 1) both; |
| | | min-width: 0; |
| | | position: relative; |
| | | overflow: hidden; |
| | | } |
| | | .wh-kpi-card::after { |
| | | content: ''; |
| | | position: absolute; top: 0; left: 0; right: 0; height: 1px; |
| | | background: linear-gradient(90deg, transparent, var(--kpi-color, var(--cyan)), transparent); |
| | | opacity: 0.35; |
| | | } |
| | | .wh-kpi-card:hover { |
| | | border-color: var(--kpi-color, var(--border-hover)); |
| | | box-shadow: |
| | | 0 0 20px color-mix(in srgb, var(--kpi-color, var(--cyan)) 20%, transparent), |
| | | 0 0 40px color-mix(in srgb, var(--kpi-color, var(--cyan)) 8%, transparent), |
| | | 0 4px 20px rgba(0, 0, 0, 0.35); |
| | | transform: translateY(-2px); |
| | | } |
| | | |
| | | .wh-kpi-icon { |
| | | display: flex; align-items: center; justify-content: center; |
| | | width: 40px; height: 40px; flex-shrink: 0; |
| | | border-radius: 8px; |
| | | background: color-mix(in srgb, var(--kpi-color, var(--cyan)) 15%, transparent); |
| | | color: var(--kpi-color, var(--cyan)); |
| | | font-size: 18px; |
| | | box-shadow: 0 0 12px color-mix(in srgb, var(--kpi-color, var(--cyan)) 20%, transparent); |
| | | } |
| | | |
| | | .wh-kpi-body { |
| | | flex: 1; min-width: 0; |
| | | display: flex; flex-direction: column; gap: 4px; |
| | | } |
| | | |
| | | .wh-kpi-label { |
| | | font-size: 11px; color: var(--text-secondary); letter-spacing: 0.3px; |
| | | white-space: nowrap; overflow: hidden; text-overflow: ellipsis; |
| | | } |
| | | |
| | | .wh-kpi-value { |
| | | font-size: 24px; font-weight: 800; line-height: 1; |
| | | font-variant-numeric: tabular-nums; |
| | | color: var(--text-primary); |
| | | } |
| | | |
| | | @keyframes kpi-fade-up { |
| | | from { opacity: 0; transform: translateY(16px); } |
| | | to { opacity: 1; transform: translateY(0); } |
| | | } |
| | | |
| | | /* ======== 䏿 ç½æ ¼ ======== */ |
| | | .wh-main-grid { |
| | | position: relative; z-index: 1; |
| | | display: grid; |
| | | grid-template-columns: 1fr 2fr 1fr; |
| | | gap: 12px; |
| | | margin-bottom: 16px; |
| | | } |
| | | |
| | | .wh-left-col, |
| | | .wh-right-col { |
| | | display: flex; flex-direction: column; gap: 12px; |
| | | min-width: 0; |
| | | } |
| | | |
| | | .wh-center-col { |
| | | min-width: 0; |
| | | } |
| | | |
| | | /* ======== 颿¿ ======== */ |
| | | .wh-panel { |
| | | background: var(--bg-card); |
| | | backdrop-filter: blur(8px); |
| | | -webkit-backdrop-filter: blur(8px); |
| | | border: 1px solid var(--border); |
| | | border-radius: 10px; |
| | | padding: 14px; |
| | | transition: all 0.35s; |
| | | animation: kpi-fade-up 0.5s cubic-bezier(0.4, 0, 0.2, 1) both; |
| | | position: relative; |
| | | } |
| | | .wh-panel::before { |
| | | content: ''; |
| | | position: absolute; inset: 0; border-radius: 10px; pointer-events: none; |
| | | background: linear-gradient(135deg, rgba(0, 229, 255, 0.04) 0%, transparent 50%, rgba(0, 255, 136, 0.03) 100%); |
| | | } |
| | | .wh-panel:hover { |
| | | border-color: var(--border-hover); |
| | | box-shadow: 0 0 24px rgba(0, 229, 255, 0.06), 0 0 48px rgba(0, 229, 255, 0.03); |
| | | } |
| | | |
| | | .wh-panel-header { |
| | | display: flex; align-items: center; gap: 8px; |
| | | font-size: 13px; font-weight: 600; |
| | | color: var(--text-primary); |
| | | margin-bottom: 10px; |
| | | letter-spacing: 0.5px; |
| | | } |
| | | |
| | | .wh-panel-dot { |
| | | width: 6px; height: 6px; border-radius: 50%; |
| | | background: var(--cyan); |
| | | box-shadow: 0 0 6px var(--cyan); |
| | | } |
| | | |
| | | .wh-map-panel { |
| | | display: flex; flex-direction: column; |
| | | height: 100%; |
| | | } |
| | | .wh-map-panel :deep(.echarts) { |
| | | flex: 1; |
| | | } |
| | | |
| | | /* ======== å°å¾åºé¨ç»è®¡ ======== */ |
| | | .wh-map-stats { |
| | | display: flex; gap: 12px; margin-top: 8px; padding-top: 10px; |
| | | border-top: 1px solid rgba(0,229,255,0.06); |
| | | } |
| | | |
| | | .wh-map-stat { |
| | | flex: 1; text-align: center; |
| | | } |
| | | |
| | | .wh-stat-label { |
| | | display: block; font-size: 10px; color: var(--text-secondary); margin-bottom: 4px; |
| | | } |
| | | |
| | | .wh-stat-value { |
| | | font-size: 20px; font-weight: 800; font-variant-numeric: tabular-nums; |
| | | background: linear-gradient(90deg, var(--cyan), #fff); |
| | | -webkit-background-clip: text; -webkit-text-fill-color: transparent; |
| | | background-clip: text; |
| | | } |
| | | .wh-stat-value.out { |
| | | background: linear-gradient(90deg, var(--green), #fff); |
| | | -webkit-background-clip: text; -webkit-text-fill-color: transparent; |
| | | background-clip: text; |
| | | } |
| | | .wh-stat-value.total { |
| | | background: linear-gradient(90deg, var(--yellow), #fff); |
| | | -webkit-background-clip: text; -webkit-text-fill-color: transparent; |
| | | background-clip: text; |
| | | } |
| | | |
| | | /* ======== é¢è¦å表 ======== */ |
| | | .wh-warning-list { |
| | | display: flex; flex-direction: column; gap: 8px; |
| | | max-height: 180px; overflow-y: auto; |
| | | } |
| | | |
| | | .wh-warn-item { |
| | | display: flex; align-items: center; justify-content: space-between; |
| | | padding: 8px 10px; border-radius: 6px; |
| | | background: rgba(255,255,255,0.02); |
| | | border: 1px solid rgba(255,255,255,0.03); |
| | | transition: all 0.25s; |
| | | } |
| | | .wh-warn-item:hover { background: rgba(255,255,255,0.04); } |
| | | |
| | | .wh-warn-left { display: flex; align-items: center; gap: 8px; } |
| | | |
| | | .wh-warn-dot { |
| | | width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; |
| | | } |
| | | .wh-warn-dot.danger { background: var(--red); box-shadow: 0 0 6px var(--red); } |
| | | .wh-warn-dot.warning { background: var(--yellow); box-shadow: 0 0 6px var(--yellow); } |
| | | .wh-warn-dot.normal { background: var(--green); } |
| | | |
| | | .wh-warn-name { font-size: 12px; color: var(--text-primary); } |
| | | |
| | | .wh-warn-right { display: flex; align-items: center; gap: 10px; } |
| | | |
| | | .wh-warn-tag { |
| | | font-size: 10px; padding: 2px 6px; border-radius: 4px; |
| | | } |
| | | .wh-warn-tag.danger { background: rgba(255,56,96,0.12); color: var(--red); } |
| | | .wh-warn-tag.warning { background: rgba(255,193,7,0.12); color: var(--yellow); } |
| | | .wh-warn-tag.normal { background: rgba(0,255,136,0.12); color: var(--green); } |
| | | |
| | | .wh-warn-qty { font-size: 12px; color: var(--text-secondary); font-variant-numeric: tabular-nums; } |
| | | |
| | | /* ======== æ¶é´è½´ ======== */ |
| | | .wh-timeline { |
| | | display: flex; flex-direction: column; gap: 8px; |
| | | max-height: 180px; overflow-y: auto; |
| | | } |
| | | |
| | | .wh-tl-item { |
| | | display: flex; align-items: center; gap: 10px; |
| | | padding: 6px 8px; border-radius: 6px; |
| | | border-left: 2px solid rgba(0,229,255,0.1); |
| | | transition: all 0.2s; |
| | | } |
| | | .wh-tl-item:hover { border-left-color: var(--cyan); background: rgba(0,229,255,0.03); } |
| | | |
| | | .wh-tl-dot { |
| | | width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; |
| | | } |
| | | .wh-tl-dot.in { background: var(--green); box-shadow: 0 0 5px var(--green); } |
| | | .wh-tl-dot.out { background: var(--cyan); box-shadow: 0 0 5px var(--cyan); } |
| | | |
| | | .wh-tl-content { |
| | | flex: 1; min-width: 0; |
| | | display: flex; flex-direction: column; gap: 2px; |
| | | } |
| | | |
| | | .wh-tl-time { font-size: 10px; color: var(--text-muted); } |
| | | |
| | | .wh-tl-title { |
| | | font-size: 11px; color: var(--text-primary); |
| | | white-space: nowrap; overflow: hidden; text-overflow: ellipsis; |
| | | } |
| | | |
| | | .wh-tl-type { |
| | | font-size: 10px; padding: 2px 8px; border-radius: 4px; flex-shrink: 0; |
| | | } |
| | | .wh-tl-type.in { background: rgba(0,255,136,0.1); color: var(--green); } |
| | | .wh-tl-type.out { background: rgba(0,229,255,0.1); color: var(--cyan); } |
| | | |
| | | /* ======== åºé¨è¡¨æ ¼ ======== */ |
| | | .wh-table-panel { position: relative; z-index: 1; } |
| | | |
| | | .wh-table-badge { |
| | | margin-left: auto; font-size: 10px; font-weight: 400; |
| | | padding: 2px 8px; border-radius: 10px; |
| | | background: rgba(0,255,136,0.08); color: var(--green); |
| | | border: 1px solid rgba(0,255,136,0.15); |
| | | } |
| | | |
| | | .wh-table-wrap { |
| | | overflow-x: auto; border-radius: 8px; |
| | | background: rgba(4, 14, 36, 0.55); |
| | | backdrop-filter: blur(6px); |
| | | -webkit-backdrop-filter: blur(6px); |
| | | border: 1px solid rgba(0, 229, 255, 0.08); |
| | | } |
| | | |
| | | .wh-table { |
| | | width: 100%; border-collapse: collapse; font-size: 12px; |
| | | } |
| | | |
| | | .wh-table thead th { |
| | | background: rgba(6, 18, 42, 0.8); |
| | | color: var(--text-secondary); |
| | | font-weight: 600; font-size: 10px; |
| | | text-transform: uppercase; letter-spacing: 0.8px; |
| | | padding: 10px 14px; text-align: left; |
| | | border-bottom: 1px solid rgba(0,229,255,0.12); |
| | | white-space: nowrap; |
| | | } |
| | | |
| | | .wh-table tbody td { |
| | | padding: 9px 14px; color: var(--text-primary); |
| | | border-bottom: 1px solid rgba(0, 229, 255, 0.04); |
| | | white-space: nowrap; |
| | | } |
| | | |
| | | .wh-table tbody tr { |
| | | transition: all 0.25s; |
| | | opacity: 0; |
| | | animation: row-fade-in 0.45s ease forwards; |
| | | } |
| | | |
| | | .wh-table tbody tr:hover { |
| | | background: rgba(0, 229, 255, 0.06); |
| | | box-shadow: inset 0 0 20px rgba(0, 229, 255, 0.03); |
| | | } |
| | | |
| | | @keyframes row-fade-in { |
| | | from { opacity: 0; transform: translateY(6px); } |
| | | to { opacity: 1; transform: translateY(0); } |
| | | } |
| | | |
| | | /* ======== å
¨å± ======== */ |
| | | .is-fullscreen { |
| | | max-height: none; min-height: 100vh; padding: 24px 28px; border-radius: 0; overflow-y: auto; |
| | | } |
| | | .is-fullscreen::before, |
| | | .is-fullscreen::after { |
| | | position: fixed; |
| | | } |
| | | .is-fullscreen .wh-kpi-row { gap: 16px; } |
| | | .is-fullscreen .wh-main-grid { gap: 16px; } |
| | | |
| | | /* ======== ç©ºç¶æ ======== */ |
| | | :deep(.ant-empty) { color: var(--text-muted); } |
| | | :deep(.ant-empty-description) { color: var(--text-muted); } |
| | | |
| | | /* ======== å è½½æ ======== */ |
| | | :deep(.ant-spin-text) { color: var(--text-secondary); } |
| | | |
| | | /* ======== ååºå¼ ======== */ |
| | | @media (max-width: 1400px) { |
| | | .wh-main-grid { |
| | | grid-template-columns: 1fr 1.5fr 1fr; |
| | | } |
| | | } |
| | | |
| | | @media (max-width: 1100px) { |
| | | .wh-main-grid { |
| | | grid-template-columns: 1fr; |
| | | } |
| | | .wh-kpi-row { |
| | | grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); |
| | | } |
| | | } |
| | | |
| | | @media (max-width: 640px) { |
| | | .warehouse-dashboard { padding: 10px; } |
| | | .wh-header { flex-wrap: wrap; } |
| | | .wh-header-divider { display: none; } |
| | | .wh-kpi-row { grid-template-columns: 1fr 1fr; } |
| | | .wh-kpi-value { font-size: 20px; } |
| | | } |
| | | </style> |