2 天以前 dc1d067c566bbde1c7170186960a8bd27f210a47
src/views/bi/decision/trend-analysis/index.vue
@@ -1,27 +1,56 @@
<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 },
@@ -31,43 +60,216 @@
  { 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