| | |
| | | <script lang="ts" setup> |
| | | import { onMounted, ref } from 'vue'; |
| | | import type { EChartsOption, EchartsUIType } from '@vben/plugins/echarts'; |
| | | |
| | | import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'; |
| | | |
| | | import { Page } from '@vben/common-ui'; |
| | | import { EchartsUI, useEcharts } from '@vben/plugins/echarts'; |
| | | |
| | | import { Select, Spin, Table } from 'ant-design-vue'; |
| | | import { Empty, Select, Spin, Table } from 'ant-design-vue'; |
| | | import dayjs from 'dayjs'; |
| | | |
| | | import { getKpiOverview } from '#/api/bi/decision/kpi'; |
| | | import { |
| | | getKpiCompare, |
| | | getKpiOverview, |
| | | getKpiTrend, |
| | | } from '#/api/bi/decision/kpi'; |
| | | import type { DecisionKpiApi } from '#/api/bi/decision/kpi'; |
| | | |
| | | defineOptions({ name: 'DecisionTrendAnalysis' }); |
| | | |
| | | const loading = ref(false); |
| | | const trendData = ref<any[]>([]); |
| | | const chartLoading = ref(false); |
| | | const kpiList = ref<DecisionKpiApi.KpiItem[]>([]); |
| | | const selectedCategory = ref<string>(''); |
| | | const selectedKpi = ref<string>(''); |
| | | const trend = ref<DecisionKpiApi.KpiTrend | null>(null); |
| | | const compare = ref<DecisionKpiApi.KpiCompare | null>(null); |
| | | |
| | | const CATEGORY_OPTIONS = [ |
| | | { label: '全部', value: '' }, |
| | | { label: '供电量', value: 'power_supply' }, |
| | | { label: '能耗', value: 'energy' }, |
| | | { label: '设备运行', value: 'device_operation' }, |
| | | { label: '生产', value: 'production' }, |
| | | { label: '质量', value: 'quality' }, |
| | | { label: '采购', value: 'procurement' }, |
| | | { label: '营销', value: 'sales' }, |
| | | { label: '安全', value: 'safety' }, |
| | | ]; |
| | | |
| | | const kpiOptions = computed(() => |
| | | kpiList.value |
| | | .filter((k) => |
| | | selectedCategory.value ? k.category === selectedCategory.value : true, |
| | | ) |
| | | .map((k) => ({ |
| | | label: `${k.name}${k.unit ? `(${k.unit})` : ''}`, |
| | | value: k.code, |
| | | })), |
| | | ); |
| | | |
| | | const selectedKpiUnit = computed( |
| | | () => kpiList.value.find((k) => k.code === selectedKpi.value)?.unit ?? '', |
| | | ); |
| | | |
| | | const columns = [ |
| | | { title: 'KPI名称', dataIndex: 'name', key: 'name', width: 150 }, |
| | |
| | | { title: '预警状态', dataIndex: 'alertStatus', key: 'alertStatus', width: 100 }, |
| | | ]; |
| | | |
| | | async function loadData() { |
| | | async function loadKpis() { |
| | | loading.value = true; |
| | | try { |
| | | const overview = await getKpiOverview(); |
| | | let list = overview.kpis || []; |
| | | if (selectedCategory.value) { |
| | | list = list.filter((k) => k.category === selectedCategory.value); |
| | | kpiList.value = overview.kpis || []; |
| | | if (!selectedKpi.value && kpiOptions.value.length > 0) { |
| | | selectedKpi.value = kpiOptions.value[0].value; |
| | | } |
| | | trendData.value = list; |
| | | } catch { |
| | | trendData.value = []; |
| | | kpiList.value = []; |
| | | } finally { |
| | | loading.value = false; |
| | | } |
| | | } |
| | | |
| | | onMounted(loadData); |
| | | const chartRef = ref<EchartsUIType>(); |
| | | const { renderEcharts } = useEcharts(chartRef); |
| | | |
| | | function formatTime(time: string): string { |
| | | return dayjs(time).format('MM-DD HH:mm'); |
| | | } |
| | | |
| | | function buildChartOptions(t: DecisionKpiApi.KpiTrend): EChartsOption { |
| | | const labels = t.points.map((p) => formatTime(p.time)); |
| | | const values = t.points.map((p) => Number(p.value) || 0); |
| | | return { |
| | | backgroundColor: 'transparent', |
| | | grid: { bottom: 36, left: 12, right: 24, top: 28, containLabel: true }, |
| | | tooltip: { |
| | | trigger: 'axis', |
| | | backgroundColor: 'rgba(255,255,255,0.96)', |
| | | borderColor: '#e5e7eb', |
| | | textStyle: { color: '#1f2937', fontSize: 12 }, |
| | | axisPointer: { type: 'cross' }, |
| | | }, |
| | | xAxis: { |
| | | type: 'category', |
| | | boundaryGap: false, |
| | | data: labels, |
| | | axisLabel: { color: '#6b7280', fontSize: 11 }, |
| | | axisLine: { lineStyle: { color: '#e5e7eb' } }, |
| | | axisTick: { show: false }, |
| | | }, |
| | | yAxis: { |
| | | type: 'value', |
| | | name: t.unit || '', |
| | | nameTextStyle: { color: '#9ca3af', fontSize: 11 }, |
| | | axisLabel: { color: '#6b7280', fontSize: 11 }, |
| | | splitLine: { lineStyle: { color: '#f0f1f3', type: 'dashed' } }, |
| | | }, |
| | | series: [ |
| | | { |
| | | name: t.kpiName, |
| | | type: 'line', |
| | | data: values, |
| | | smooth: true, |
| | | symbol: 'circle', |
| | | symbolSize: 4, |
| | | lineStyle: { width: 2, color: '#1677ff' }, |
| | | itemStyle: { color: '#1677ff' }, |
| | | areaStyle: { |
| | | color: { |
| | | type: 'linear', |
| | | x: 0, y: 0, x2: 0, y2: 1, |
| | | colorStops: [ |
| | | { offset: 0, color: 'rgba(22,119,255,0.18)' }, |
| | | { offset: 1, color: 'rgba(22,119,255,0.01)' }, |
| | | ], |
| | | }, |
| | | }, |
| | | }, |
| | | ], |
| | | }; |
| | | } |
| | | |
| | | async function loadTrendDetail() { |
| | | if (!selectedKpi.value) { |
| | | trend.value = null; |
| | | compare.value = null; |
| | | return; |
| | | } |
| | | chartLoading.value = true; |
| | | try { |
| | | const [t, c] = await Promise.all([ |
| | | getKpiTrend(selectedKpi.value), |
| | | getKpiCompare(selectedKpi.value), |
| | | ]); |
| | | trend.value = t; |
| | | compare.value = c; |
| | | await renderEcharts(buildChartOptions(t)); |
| | | } catch { |
| | | trend.value = null; |
| | | compare.value = null; |
| | | } finally { |
| | | chartLoading.value = false; |
| | | } |
| | | } |
| | | |
| | | function formatCompareValue(v: number | undefined): string { |
| | | if (v == null || Number.isNaN(v)) return '--'; |
| | | return Number.isInteger(v) ? v.toLocaleString() : Number(v).toFixed(2); |
| | | } |
| | | |
| | | const changeRateText = computed(() => { |
| | | const rate = compare.value?.changeRate; |
| | | if (rate == null || Number.isNaN(rate)) return '--'; |
| | | const up = rate >= 0; |
| | | return `${up ? '↑' : '↓'} ${Math.abs(rate).toFixed(2)}%`; |
| | | }); |
| | | |
| | | const changeRateColor = computed(() => { |
| | | const rate = compare.value?.changeRate; |
| | | if (rate == null || Number.isNaN(rate)) return '#6b7280'; |
| | | return rate >= 0 ? '#22c55e' : '#ef4444'; |
| | | }); |
| | | |
| | | watch(selectedCategory, () => { |
| | | const first = kpiOptions.value[0]; |
| | | selectedKpi.value = first?.value ?? ''; |
| | | }); |
| | | |
| | | watch(selectedKpi, loadTrendDetail); |
| | | |
| | | onMounted(loadKpis); |
| | | onBeforeUnmount(() => { |
| | | chartRef.value = undefined; |
| | | }); |
| | | </script> |
| | | |
| | | <template> |
| | | <Page :auto-content-height="true"> |
| | | <div class="p-4"> |
| | | <div class="mb-4 flex items-center gap-4"> |
| | | <div class="mb-4 grid grid-cols-1 gap-3 lg:grid-cols-[auto_auto_1fr]"> |
| | | <h2 class="text-lg font-bold">趋势分析</h2> |
| | | <Select |
| | | v-model:value="selectedCategory" |
| | | :options="CATEGORY_OPTIONS" |
| | | style="width: 160px" |
| | | allow-clear |
| | | @change="loadData" |
| | | /> |
| | | <Select |
| | | v-model:value="selectedKpi" |
| | | :options="kpiOptions" |
| | | style="width: 280px" |
| | | placeholder="请选择 KPI" |
| | | show-search |
| | | option-filter-prop="label" |
| | | /> |
| | | </div> |
| | | |
| | | <!-- 环比研判卡片 --> |
| | | <Spin :spinning="chartLoading"> |
| | | <div v-if="selectedKpi" class="mb-4 grid grid-cols-2 gap-3 lg:grid-cols-4"> |
| | | <div class="rounded-lg border border-gray-200 p-4"> |
| | | <div class="text-xs text-gray-500">当前值</div> |
| | | <div class="mt-1 text-2xl font-bold text-gray-800"> |
| | | {{ formatCompareValue(compare?.currentValue) }} |
| | | <span class="ml-1 text-sm font-normal text-gray-400">{{ selectedKpiUnit }}</span> |
| | | </div> |
| | | <div v-if="compare?.currentTime" class="mt-1 text-xs text-gray-400"> |
| | | {{ formatTime(compare.currentTime) }} |
| | | </div> |
| | | </div> |
| | | <div class="rounded-lg border border-gray-200 p-4"> |
| | | <div class="text-xs text-gray-500">环比变化率</div> |
| | | <div class="mt-1 text-2xl font-bold" :style="{ color: changeRateColor }"> |
| | | {{ changeRateText }} |
| | | </div> |
| | | <div v-if="compare?.previousTime" class="mt-1 text-xs text-gray-400"> |
| | | 上期 {{ formatTime(compare.previousTime) }} |
| | | </div> |
| | | </div> |
| | | <div class="rounded-lg border border-gray-200 p-4"> |
| | | <div class="text-xs text-gray-500">近24期均值</div> |
| | | <div class="mt-1 text-2xl font-bold text-gray-800"> |
| | | {{ formatCompareValue(compare?.avgValue) }} |
| | | <span class="ml-1 text-sm font-normal text-gray-400">{{ selectedKpiUnit }}</span> |
| | | </div> |
| | | <div class="mt-1 text-xs text-gray-400">已统计 {{ compare?.periodCount ?? 0 }} 期</div> |
| | | </div> |
| | | <div class="rounded-lg border border-gray-200 p-4"> |
| | | <div class="text-xs text-gray-500">峰值 / 波谷</div> |
| | | <div class="mt-1 text-2xl font-bold text-gray-800"> |
| | | {{ formatCompareValue(compare?.maxValue) }} |
| | | <span class="text-sm font-normal text-gray-400">/</span> |
| | | {{ formatCompareValue(compare?.minValue) }} |
| | | <span class="ml-1 text-sm font-normal text-gray-400">{{ selectedKpiUnit }}</span> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </Spin> |
| | | |
| | | <!-- 趋势折线图 --> |
| | | <div class="mb-4 rounded-lg border border-gray-200 bg-white p-4"> |
| | | <div class="mb-2 flex items-center justify-between"> |
| | | <span class="font-medium">{{ trend?.kpiName ?? selectedKpi }}</span> |
| | | <span v-if="trend" class="text-xs text-gray-400"> |
| | | {{ trend.periodType }} 周期 · 共 {{ trend.points.length }} 个快照点 |
| | | </span> |
| | | </div> |
| | | <EchartsUI v-if="trend && trend.points.length > 0" ref="chartRef" class="!h-72 w-full" /> |
| | | <Empty v-else description="暂无历史快照数据,KPI 定义后从下一个整点开始聚合" /> |
| | | </div> |
| | | |
| | | <Spin :spinning="loading"> |
| | | <Table |
| | | :columns="columns" |
| | | :data-source="trendData" |
| | | :data-source="kpiList.filter((k) => |
| | | selectedCategory ? k.category === selectedCategory : true, |
| | | )" |
| | | :pagination="{ pageSize: 20 }" |
| | | row-key="code" |
| | | bordered |