WXL
2026-09-17 3df460667d74868d0a54fce12d5e881bbd6c0c1a
src/views/statistics/rate.vue
@@ -53,7 +53,7 @@
      <el-col :span="12">
        <el-card>
          <div slot="header" class="clearfix">
            <span>弃用率构成分析</span>
            <span>地区获取/弃用率对比</span>
          </div>
          <EChartsWrapper
            :id="'abandon-chart'"
@@ -104,6 +104,18 @@
        <el-table-column label="器官总数" prop="totalOrgans" align="center"  />
        <el-table-column label="移植总数" prop="totalTransplants" align="center"  />
      </el-table>
      <el-pagination
        v-show="total > 0"
        :total="total"
        :page.sync="query.page"
        :limit.sync="query.limit"
        @current-change="handlePagination"
        @size-change="handlePageSizeChange"
        style="margin-top: 20px;"
        layout="total, sizes, prev, pager, next, jumper"
        :page-sizes="[10, 20, 50, 100]"
      />
    </el-card>
  </div>
</template>
@@ -111,6 +123,10 @@
<script>
import FilterPanel from '@/components/charts/FilterPanel.vue';
import EChartsWrapper from '@/components/charts/EChartsWrapper.vue';
import { organ, organMoon, caseTrendMoon } from '@/api/businessApi';
import { toTotalsList, toMonth, toNum } from '@/utils/totals';
import statisticsMode from '@/utils/statisticsMode';
import { COLORS, axisTooltip, grid, xAxis, legend, barSeries, lineSeries } from '@/utils/chartStyle';
export default {
  name: 'RateStatistics',
@@ -136,13 +152,13 @@
          type: 'select',
          multiple: true,
          options: [
            { label: '青岛地区', value: 'qingdao' },
            { label: '日照地区', value: 'rizhao' },
            { label: '济南地区', value: 'jinan' },
            { label: '烟台地区', value: 'yantai' },
            { label: '威海地区', value: 'weihai' },
            { label: '潍坊地区', value: 'weifang' },
            { label: '临沂地区', value: 'linyi' }
            { label: '青岛地区', value: '青岛地区' },
            { label: '日照地区', value: '日照地区' },
            { label: '济南地区', value: '济南地区' },
            { label: '烟台地区', value: '烟台地区' },
            { label: '威海地区', value: '威海地区' },
            { label: '潍坊地区', value: '潍坊地区' },
            { label: '临沂地区', value: '临沂地区' }
          ],
          width: '300px'
        },
@@ -166,25 +182,25 @@
        {
          key: 'acquisition',
          title: '捐献获取率',
          value: '85.5%',
          value: '0%',
          trendIcon: 'el-icon-top',
          trendText: '↑2.7%',
          trendText: '—',
          trendClass: 'up'
        },
        {
          key: 'avg',
          title: '平均器官数',
          value: '3.2',
          value: '0',
          trendIcon: 'el-icon-top',
          trendText: '↑0.2',
          trendText: '—',
          trendClass: 'up'
        },
        {
          key: 'abandon',
          title: '器官弃用率',
          value: '12.3%',
          value: '0%',
          trendIcon: 'el-icon-bottom',
          trendText: '↓2.7%',
          trendText: '—',
          trendClass: 'down'
        }
      ],
@@ -193,69 +209,150 @@
      trendChartOptions: {},
      abandonChartOptions: {},
      // 图表数据
      trendData: [],
      regionCompareData: [],
      // 表格数据
      tableData: [],
      total: 0,
      // 模拟数据
      mockData: {
        table: [
          { id: 1, year: '2024', region: '青岛地区', acquisitionRate: 85.5, avgOrgans: 3.2, abandonRate: 12.3, totalDonation: 280, totalOrgans: 896, totalTransplants: 765 },
          { id: 2, year: '2024', region: '日照地区', acquisitionRate: 80.3, avgOrgans: 2.9, abandonRate: 15.8, totalDonation: 140, totalOrgans: 406, totalTransplants: 325 },
          { id: 3, year: '2024', region: '济南地区', acquisitionRate: 89.1, avgOrgans: 3.4, abandonRate: 9.5, totalDonation: 320, totalOrgans: 1088, totalTransplants: 970 },
          { id: 4, year: '2023', region: '青岛地区', acquisitionRate: 82.8, avgOrgans: 3.0, abandonRate: 15.0, totalDonation: 250, totalOrgans: 750, totalTransplants: 620 },
          { id: 5, year: '2023', region: '日照地区', acquisitionRate: 78.5, avgOrgans: 2.7, abandonRate: 18.2, totalDonation: 120, totalOrgans: 324, totalTransplants: 252 },
          { id: 6, year: '2023', region: '济南地区', acquisitionRate: 85.2, avgOrgans: 3.1, abandonRate: 12.5, totalDonation: 280, totalOrgans: 868, totalTransplants: 740 }
        ],
        trend: [
          { year: '2020', acquisition: 78.5, avgOrgans: 2.8, abandon: 18.5 },
          { year: '2021', acquisition: 81.2, avgOrgans: 3.0, abandon: 16.3 },
          { year: '2022', acquisition: 84.6, avgOrgans: 3.2, abandon: 13.8 },
          { year: '2023', acquisition: 87.3, avgOrgans: 3.4, abandon: 11.2 },
          { year: '2024', acquisition: 89.1, avgOrgans: 3.5, abandon: 9.5 }
        ],
        abandon: [
          { name: '器官质量不合格', value: 45 },
          { name: '供体疾病', value: 25 },
          { name: '运输问题', value: 15 },
          { name: '受体匹配问题', value: 10 },
          { name: '其他原因', value: 5 }
        ]
      }
      total: 0
    };
  },
  created() {
    this.loadData();
    // 数据源(mock/真实接口)切换时重新加载
    this.$watch(() => statisticsMode.useMock, () => this.loadData());
  },
  methods: {
    // 组装查询条件
    buildQuery() {
      const q = {};
      if (this.query.region && this.query.region.length) {
        q.area = this.query.region.join(',');
      }
      if (this.query.yearRange && this.query.yearRange.length === 2) {
        q.startTime = this.query.yearRange[0];
        q.endTime = this.query.yearRange[1];
      }
      return q;
    },
    // 加载数据
    async loadData() {
      this.loading = true;
      try {
        await new Promise(resolve => setTimeout(resolve, 500));
        const q = this.buildQuery();
        const [organRes, moonRes, caseMoonRes] = await Promise.all([
          organ(q),
          organMoon(q),
          caseTrendMoon(q)
        ]);
        // 处理表格数据
        let filteredData = [...this.mockData.table];
        // 器官按月趋势:获取率(移植/获取)、弃用率
        this.trendData = toTotalsList(moonRes)
          .map(item => {
            const get = toNum(item.getCount);
            const use = toNum(item.useCount);
            return {
              month: toMonth(item.donateTime) || toMonth(item.month),
              acquisition: get > 0 ? Number(((use / get) * 100).toFixed(1)) : 0,
              abandon: get > 0 ? Number((((get - use) / get) * 100).toFixed(1)) : 0
            };
          })
          .sort((a, b) => String(a.month).localeCompare(String(b.month)));
        if (this.query.region.length > 0) {
          filteredData = filteredData.filter(item =>
            this.query.region.includes(item.region.replace('地区', '').toLowerCase())
          );
        }
        // 按 年份|地区 汇总器官数据
        const organByKey = {};
        toTotalsList(organRes).forEach(item => {
          const year = String(item.donateTime || item.year || '').slice(0, 4);
          const region = item.area || item.region || '';
          const key = `${year}|${region}`;
          if (!organByKey[key]) organByKey[key] = { year, region, organs: 0, transplants: 0 };
          organByKey[key].organs += toNum(item.getCount);
          organByKey[key].transplants += toNum(item.useCount);
        });
        if (this.query.yearRange && this.query.yearRange.length === 2) {
          const [start, end] = this.query.yearRange;
          filteredData = filteredData.filter(item =>
            parseInt(item.year) >= parseInt(start) && parseInt(item.year) <= parseInt(end)
          );
        }
        // 按 年份|地区 汇总案例数据(捐献总数、潜在案例)
        const caseByKey = {};
        toTotalsList(caseMoonRes).forEach(item => {
          const month = toMonth(item.donateTime) || toMonth(item.month);
          const year = String(month).slice(0, 4);
          const region = item.area || item.region || '';
          const key = `${year}|${region}`;
          if (!caseByKey[key]) caseByKey[key] = { year, region, potential: 0, complete: 0 };
          caseByKey[key].potential += toNum(item.potentialCount);
          caseByKey[key].complete += toNum(item.completeCount);
        });
        this.tableData = filteredData;
        this.total = filteredData.length;
        // 合并 年份|地区 明细
        const keys = new Set([...Object.keys(organByKey), ...Object.keys(caseByKey)]);
        const rows = [];
        keys.forEach(key => {
          const o = organByKey[key] || {};
          const c = caseByKey[key] || {};
          const year = o.year || c.year || '';
          const region = o.region || c.region || '';
          const organs = o.organs || 0;
          const transplants = o.transplants || 0;
          const potential = c.potential || 0;
          const complete = c.complete || 0;
        // 更新图表
          rows.push({
            year,
            region,
            acquisitionRate: potential > 0 ? Number(((complete / potential) * 100).toFixed(1)) : 0,
            avgOrgans: complete > 0 ? Number((organs / complete).toFixed(1)) : 0,
            abandonRate: organs > 0 ? Number((((organs - transplants) / organs) * 100).toFixed(1)) : 0,
            totalDonation: complete,
            totalOrgans: organs,
            totalTransplants: transplants
          });
        });
        rows.sort((a, b) => String(a.year + a.region).localeCompare(String(b.year + b.region)));
        this.total = rows.length;
        const start = (this.query.page - 1) * this.query.limit;
        this.tableData = rows.slice(start, start + this.query.limit);
        // 地区维度的获取率/弃用率,用潜在/完成、器官/移植原始数据汇总
        const regionCompare = {};
        toTotalsList(caseMoonRes).forEach(item => {
          const region = item.area || item.region || '';
          if (!regionCompare[region]) regionCompare[region] = { region, potential: 0, complete: 0 };
          regionCompare[region].potential += toNum(item.potentialCount);
          regionCompare[region].complete += toNum(item.completeCount);
        });
        const organRegion = {};
        toTotalsList(organRes).forEach(item => {
          const region = item.area || item.region || '';
          if (!organRegion[region]) organRegion[region] = { organs: 0, transplants: 0 };
          organRegion[region].organs += toNum(item.getCount);
          organRegion[region].transplants += toNum(item.useCount);
        });
        const regionNames = new Set([...Object.keys(regionCompare), ...Object.keys(organRegion)]);
        this.regionCompareData = [...regionNames].map(region => {
          const c = regionCompare[region] || {};
          const o = organRegion[region] || {};
          const potential = c.potential || 0;
          const complete = c.complete || 0;
          const organs = o.organs || 0;
          const transplants = o.transplants || 0;
          return {
            region,
            acquisition: potential > 0 ? Number(((complete / potential) * 100).toFixed(1)) : 0,
            abandon: organs > 0 ? Number((((organs - transplants) / organs) * 100).toFixed(1)) : 0
          };
        });
        const totals = {
          totalPotential: Object.values(caseByKey).reduce((s, c) => s + c.potential, 0),
          totalDonation: Object.values(caseByKey).reduce((s, c) => s + c.complete, 0),
          totalOrgans: Object.values(organByKey).reduce((s, o) => s + o.organs, 0),
          totalTransplants: Object.values(organByKey).reduce((s, o) => s + o.transplants, 0)
        };
        this.updateOverviewData(totals);
        this.updateCharts();
      } catch (error) {
        console.error('加载数据失败:', error);
        this.$message.error('数据加载失败');
@@ -264,157 +361,107 @@
      }
    },
    // 更新仪表盘
    updateOverviewData(totals) {
      const { totalPotential, totalDonation, totalOrgans, totalTransplants } = totals;
      const acquisitionRate = totalPotential > 0 ? Number(((totalDonation / totalPotential) * 100).toFixed(1)) : 0;
      const avgOrgans = totalDonation > 0 ? Number((totalOrgans / totalDonation).toFixed(1)) : 0;
      const abandonRate = totalOrgans > 0 ? Number((((totalOrgans - totalTransplants) / totalOrgans) * 100).toFixed(1)) : 0;
      this.dashboardData[0].value = `${acquisitionRate}%`;
      this.dashboardData[1].value = `${avgOrgans}`;
      this.dashboardData[2].value = `${abandonRate}%`;
      this.dashboardData[0].trendText = acquisitionRate >= 80 ? '↑达标' : '偏低';
      this.dashboardData[2].trendText = abandonRate <= 15 ? '↓达标' : '偏高';
    },
    // 更新图表
    updateCharts() {
      // 趋势图
      // 获取率趋势图
      this.trendChartOptions = {
        tooltip: {
          trigger: 'axis',
          axisPointer: {
            type: 'cross',
            label: {
              backgroundColor: '#6a7985'
            }
          }
        },
        legend: {
          data: ['捐献获取率', '平均器官数', '器官弃用率']
        },
        grid: {
          left: '3%',
          right: '4%',
          bottom: '3%',
          containLabel: true
        },
        tooltip: axisTooltip(),
        legend: legend(['器官利用率', '器官弃用率']),
        grid: grid(),
        xAxis: {
          type: 'category',
          boundaryGap: false,
          data: this.mockData.trend.map(item => item.year + '年')
          data: this.trendData.map(item => item.month),
          axisLine: { show: false },
          axisTick: { show: false },
          axisLabel: { color: '#8a919f', fontSize: 12 }
        },
        yAxis: {
          type: 'value',
          axisLabel: {
            formatter: '{value}'
          }
          axisLabel: { color: '#8a919f', fontSize: 12, formatter: '{value}%' },
          splitLine: { lineStyle: { type: 'dashed', color: '#f0f1f5' } }
        },
        series: [
          {
            name: '捐献获取率',
            type: 'line',
            data: this.mockData.trend.map(item => item.acquisition),
            itemStyle: { color: '#409EFF' },
            smooth: true
          },
          {
            name: '平均器官数',
            type: 'line',
            data: this.mockData.trend.map(item => item.avgOrgans * 20), // 缩放显示
            itemStyle: { color: '#67C23A' },
            smooth: true
          },
          {
            name: '器官弃用率',
            type: 'line',
            data: this.mockData.trend.map(item => item.abandon),
            itemStyle: { color: '#F56C6C' },
            smooth: true
          }
          lineSeries('器官利用率', this.trendData.map(item => item.acquisition), COLORS.blue, { area: true }),
          lineSeries('器官弃用率', this.trendData.map(item => item.abandon), COLORS.coral)
        ]
      };
      // 弃用率构成图
      // 地区获取/弃用率对比图
      this.abandonChartOptions = {
        tooltip: {
          trigger: 'item',
          formatter: '{a} <br/>{b} : {c} ({d}%)'
        },
        legend: {
          orient: 'vertical',
          left: 'left',
          data: this.mockData.abandon.map(item => item.name)
        tooltip: axisTooltip(),
        legend: legend(['捐献获取率', '器官弃用率']),
        grid: grid(),
        xAxis: xAxis(this.regionCompareData.map(item => item.region)),
        yAxis: {
          type: 'value',
          axisLabel: { color: '#8a919f', fontSize: 12, formatter: '{value}%' },
          splitLine: { lineStyle: { type: 'dashed', color: '#f0f1f5' } }
        },
        series: [
          {
            name: '弃用原因',
            type: 'pie',
            radius: ['40%', '70%'],
            avoidLabelOverlap: false,
            label: {
              show: false,
              position: 'center'
            },
            emphasis: {
              label: {
                show: true,
                fontSize: '20',
                fontWeight: 'bold'
              }
            },
            labelLine: {
              show: false
            },
            data: this.mockData.abandon
          }
          barSeries('捐献获取率', this.regionCompareData.map(item => item.acquisition), COLORS.blue),
          barSeries('器官弃用率', this.regionCompareData.map(item => item.abandon), COLORS.coral)
        ]
      };
    },
    // 获取仪表盘图表配置
    // 获取仪表盘图表配置(表盘 + 指针,数值由上方大数字展示,刻度外置)
    getDashboardOptions(item) {
      const value = parseFloat(item.value);
      const max = item.key === 'avg' ? 5 : 100;
      const data = [{ value, name: item.title }];
      const color = item.key === 'abandon' ? COLORS.coral : item.key === 'acquisition' ? COLORS.blue : COLORS.green;
      // 平均器官数范围小用整数刻度,百分比用 0/25/50/75/100 五个刻度,避免数字拥挤
      const splitNumber = item.key === 'avg' ? 5 : 4;
      return {
        tooltip: {
          formatter: '{b}: {c}' + (item.key === 'abandon' ? '%' : item.key === 'acquisition' ? '%' : '')
        },
        series: [
          {
            name: item.title,
            type: 'gauge',
            progress: {
              show: true,
              width: 18
            },
            startAngle: 210,
            endAngle: -30,
            min: 0,
            max,
            splitNumber,
            radius: '60%',
            center: ['50%', '52%'],
            axisLine: {
              lineStyle: {
                width: 18
                width: 10,
                color: [[value / max, color], [1, '#eef0f5']]
              }
            },
            axisTick: {
              show: false
            pointer: {
              length: '60%',
              width: 4,
              itemStyle: { color }
            },
            splitLine: {
              length: 15,
              lineStyle: {
                width: 2,
                color: '#999'
              }
            },
            anchor: { show: false },
            axisTick: { show: false },
            // length 归零 + 负距离,使刻度数字落在表盘圆弧外侧(外围)
            splitLine: { show: false, length: 0 },
            axisLabel: {
              distance: 25,
              color: '#999',
              distance: -18,
              color: '#8a919f',
              fontSize: 12
            },
            anchor: {
              show: true,
              showAbove: true,
              size: 25,
              itemStyle: {
                borderWidth: 10
              }
            },
            title: {
              show: false
            },
            detail: {
              valueAnimation: true,
              fontSize: 20,
              offsetCenter: [0, '70%']
            },
            data: data,
            max: max
            title: { show: false },
            detail: { show: false },
            data: [{ value, name: item.title }]
          }
        ]
      };
@@ -455,6 +502,19 @@
      this.loadData();
    },
    // 处理分页
    handlePagination(page) {
      this.query.page = page;
      this.loadData();
    },
    // 处理分页大小变化
    handlePageSizeChange(size) {
      this.query.limit = size;
      this.query.page = 1;
      this.loadData();
    },
    // 处理导出
    handleExport() {
      this.$message.info('导出功能开发中...');