WXL
2026-09-17 3df460667d74868d0a54fce12d5e881bbd6c0c1a
青岛维护统计
已删除5个文件
已修改28个文件
已添加6个文件
3632 ■■■■■ 文件已修改
dist (2).zip 补丁 | 查看 | 原始文档 | blame | 历史
src/api/businessApi/ethicalReview.js 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/businessApi/index.js 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/businessApi/statistics.js 60 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/businessApi/statisticsMock.js 157 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/assets/logo.zip 补丁 | 查看 | 原始文档 | blame | 历史
src/assets/styles/index.scss 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/FilePreviewDialog/index.vue 63 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/layout/components/AppMain.vue 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/store/modules/permission.js 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/utils/chartStyle.js 139 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/utils/statisticsMode.js 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/utils/totals.js 42 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/business/GetWitness/GetWitnessInfo.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/business/OrganUtilization/OrganUtilizationInfo.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/business/affirm/affirmInfo.vue 49 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/business/allocation/allocationInfo.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/business/assess/assessInfo.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/business/course/ccss.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/business/course/components/EthicalReviewStage.vue 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/business/course/index.vue 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/business/decide/DecideInfo.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/business/ethicalReview/ethicalReviewInfo.vue 29 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/business/ethicalReview/index.vue 131 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/business/followupVisit/info.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/business/maintain/maintainInfo.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/business/transfer/TransportEdit.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/statistics/case.vue 369 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/statistics/components/CaseDetail.vue 92 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/statistics/donor.vue 580 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/statistics/index.vue 50 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/statistics/organ.vue 239 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/statistics/rate.vue 406 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/statistics/utilization.vue 657 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/statistics/willingness.vue 464 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
管理端.zip 补丁 | 查看 | 原始文档 | blame | 历史
管理端7.20.zip 补丁 | 查看 | 原始文档 | blame | 历史
管理端8.20.zip 补丁 | 查看 | 原始文档 | blame | 历史
管理端8.3.zip 补丁 | 查看 | 原始文档 | blame | 历史
dist (2).zip
Binary files differ
src/api/businessApi/ethicalReview.js
@@ -32,6 +32,14 @@
    params: query
  });
}
// ä¼¦ç†å®¡æŸ¥æ¡£æ¡ˆä¸‹è½½
export function ethicalReviewDownload(query) {
  return request({
    url: "/project/ethicalreviewopinions/download",
    method: "get",
    params: query
  });
}
// ä¼¦ç†å®¡æŸ¥ä¿¡æ¯è¯¦æƒ…
export function ethicalreviewInfo(id) {
  return request({
src/api/businessApi/index.js
@@ -9,3 +9,4 @@
export * from "./maintain";
export * from "./OrganUtilization";
export * from "./transfer";
export * from "./statistics";
src/api/businessApi/statistics.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,60 @@
import request from '@/utils/request'
import { isStatisticsMock } from '@/utils/statisticsMode'
import { mockTotals } from './statisticsMock'
// mock æ¨¡å¼ä¸‹è¿”回模拟数据,否则请求真实接口
function post(url, data) {
  if (isStatisticsMock()) {
    return Promise.resolve({ code: 200, msg: '操作成功', data: mockTotals(url, data) })
  }
  return request({
    url,
    method: 'post',
    data
  })
}
// æ¡ˆä¾‹è¶‹åŠ¿åŒºåŸŸ
export function caseTrendArea(data) {
  return post('/opo/totals/caseTrendArea', data)
}
// æ¡ˆä¾‹è¶‹åŠ¿
export function caseTrend(data) {
  return post('/opo/totals/caseTrend', data)
}
// æ¡ˆä¾‹è¶‹åŠ¿æœˆä»½
export function caseTrendMoon(data) {
  return post('/opo/totals/caseTrendMoon', data)
}
// å¹³å‡å¹´é¾„
export function caseAvgAge(data) {
  return post('/opo/totals/caseAvgAge', data)
}
// æ€§åˆ«ä¸Žè¡€åž‹åˆ†å¸ƒ
export function caseBloodType(data) {
  return post('/opo/totals/caseBloodType', data)
}
// æçŒ®è€…年龄性别分布
export function caseSex(data) {
  return post('/opo/totals/caseSex', data)
}
// æçŒ®å™¨å®˜ç»Ÿè®¡æ˜Žç»†
export function organ(data) {
  return post('/opo/totals/organ', data)
}
// å™¨å®˜èŽ·å–/移植趋势
export function organMoon(data) {
  return post('/opo/totals/organMoon', data)
}
// å™¨å®˜ç±»åž‹åˆ†å¸ƒ
export function organType(data) {
  return post('/opo/totals/organType', data)
}
// æ€»ç»Ÿè®¡ï¼ˆæ€»æ½œåœ¨æçŒ®æ¡ˆä¾‹ / æ€»å®ŒæˆæçŒ®æ•°ï¼‰
export function totals(data) {
  return post('/opo/totals/totals', data)
}
// æ¡ˆä¾‹æ˜Žç»†åˆ—表
export function trendList(data) {
  return post('/opo/totals/trendList', data)
}
src/api/businessApi/statisticsMock.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,157 @@
// ç»Ÿè®¡æ¨¡å—模拟数据
// å“åº”字段与 /opo/totals/* è¿”回的 Totals(opo统计) å¯¹è±¡ä¿æŒä¸€è‡´
// mock æ¨¡å¼ä¸‹ç”± statistics.js è¿”回,页面逻辑无需区分数据来源
const REGIONS = ['青岛地区', '日照地区', '济南地区', '烟台地区', '威海地区', '潍坊地区', '临沂地区'];
const MONTHS = ['2024-01', '2024-02', '2024-03', '2024-04', '2024-05', '2024-06'];
const YEARS = ['2022', '2023', '2024'];
const ORGANS = ['全肝', '肾脏', '心脏', '肺', '胰腺', '眼角膜'];
const BLOOD_TYPES = ['A', 'B', 'O', 'AB'];
const AGE_GROUPS = ['1', '2', '3', '4'];
// ç¡®å®šæ€§ä¼ªéšæœºï¼Œä¿è¯æ¯æ¬¡æ¸²æŸ“数据一致
function createRand(seed) {
  let s = seed % 2147483647;
  if (s <= 0) s += 2147483646;
  return function () {
    s = (s * 16807) % 2147483647;
    return (s - 1) / 2147483646;
  };
}
// æ¡ˆä¾‹è¶‹åŠ¿ï¼ˆæœˆä»½ç»´åº¦ï¼‰
function mockCaseTrend() {
  const r = createRand(11);
  return MONTHS.map((m) => {
    const potential = 40 + Math.round(r() * 40);
    return { donateTime: m, potentialCount: potential, completeCount: Math.round(potential * (0.6 + r() * 0.2)), totalCount: potential };
  });
}
// æ¡ˆä¾‹è¶‹åŠ¿ï¼ˆåœ°åŒºç»´åº¦ï¼‰
function mockCaseTrendArea() {
  const r = createRand(22);
  return REGIONS.map((area) => {
    const potential = 30 + Math.round(r() * 70);
    return { area, potentialCount: potential, completeCount: Math.round(potential * (0.6 + r() * 0.2)) };
  });
}
// æ¡ˆä¾‹æ˜Žç»†ï¼ˆåœ°åŒº x æœˆä»½ï¼‰
function mockCaseTrendMoon() {
  const r = createRand(33);
  const rows = [];
  REGIONS.forEach((area) => {
    MONTHS.forEach((m) => {
      const potential = 5 + Math.round(r() * 15);
      rows.push({ area, donateTime: m, potentialCount: potential, completeCount: Math.round(potential * (0.5 + r() * 0.4)), totalCount: potential });
    });
  });
  return rows;
}
// å¹³å‡å¹´é¾„
function mockCaseAvgAge() {
  return [{ avgAge: 45.6 }];
}
// æ€§åˆ«ä¸Žè¡€åž‹åˆ†å¸ƒ
function mockCaseBloodType() {
  const r = createRand(44);
  return BLOOD_TYPES.map((boodType) => {
    const man = 20 + Math.round(r() * 30);
    const woman = 20 + Math.round(r() * 30);
    return { boodType, manCount: man, womanCount: woman, boodTypeCount: man + woman };
  });
}
// æçŒ®è€…年龄性别分布
function mockCaseSex() {
  const r = createRand(55);
  return AGE_GROUPS.map((ageGroup) => ({
    ageGroup,
    manCount: 15 + Math.round(r() * 40),
    womanCount: 15 + Math.round(r() * 40)
  }));
}
// æçŒ®å™¨å®˜ç»Ÿè®¡æ˜Žç»†ï¼ˆå¹´ä»½ x å™¨å®˜ x åœ°åŒºï¼‰
function mockOrgan() {
  const r = createRand(66);
  const rows = [];
  YEARS.forEach((year) => {
    ORGANS.forEach((organName) => {
      REGIONS.forEach((area) => {
        const get = 10 + Math.round(r() * 40);
        rows.push({ donateTime: year, organName, area, getCount: get, useCount: Math.round(get * (0.7 + r() * 0.25)) });
      });
    });
  });
  return rows;
}
// å™¨å®˜èŽ·å–/移植趋势(月份维度)
function mockOrganMoon() {
  const r = createRand(77);
  return MONTHS.map((m) => {
    const get = 50 + Math.round(r() * 60);
    return { donateTime: m, getCount: get, useCount: Math.round(get * (0.75 + r() * 0.2)) };
  });
}
// å™¨å®˜ç±»åž‹åˆ†å¸ƒ
function mockOrganType() {
  const r = createRand(88);
  return ORGANS.map((organName) => {
    const get = 40 + Math.round(r() * 80);
    return { organName, getCount: get, useCount: Math.round(get * (0.75 + r() * 0.2)) };
  });
}
// æ€»ç»Ÿè®¡ï¼ˆæ€»æ½œåœ¨æçŒ®æ¡ˆä¾‹ / æ€»å®ŒæˆæçŒ®æ•°ï¼‰
function mockTotalsAll() {
  return [{ totalPotential: 486, totalCompletion: 327 }];
}
// æ¡ˆä¾‹æ˜Žç»†åˆ—表(trendList)
function mockTrendList() {
  const r = createRand(99);
  const NAMES = ['张三', '李四', '王五', '赵六', '陈七', '刘八', '周九'];
  const HOSPITALS = ['青岛大学附属医院', '青岛市立医院', '青岛市中心医院', '山东大学齐鲁医院(青岛)'];
  const DEATHS = ['脑外伤', '心搏骤停', '脑血管意外', '颅脑肿瘤', '缺氧性脑病'];
  const WORKFLOWS = [1, 2, 3, 4, 5, 6, 7, 99];
  return NAMES.map((name, i) => {
    const month = MONTHS[i % MONTHS.length];
    const day = String(1 + Math.floor(r() * 27)).padStart(2, '0');
    return {
      id: 1000 + i,
      caseNo: `C${month.replace('-', '')}00${i + 1}`,
      name,
      inpatientNo: `ZY${month.replace('-', '')}00${i + 1}`,
      workflow: WORKFLOWS[i % WORKFLOWS.length],
      organName: ORGANS[i % ORGANS.length],
      donatetime: `${month}-${day} 10:30`,
      deathReason: DEATHS[i % DEATHS.length],
      treatmentHospitalName: HOSPITALS[i % HOSPITALS.length]
    };
  });
}
const MOCK_MAP = {
  '/opo/totals/caseTrend': mockCaseTrend,
  '/opo/totals/caseTrendArea': mockCaseTrendArea,
  '/opo/totals/caseTrendMoon': mockCaseTrendMoon,
  '/opo/totals/caseAvgAge': mockCaseAvgAge,
  '/opo/totals/caseBloodType': mockCaseBloodType,
  '/opo/totals/caseSex': mockCaseSex,
  '/opo/totals/organ': mockOrgan,
  '/opo/totals/organMoon': mockOrganMoon,
  '/opo/totals/organType': mockOrganType,
  '/opo/totals/totals': mockTotalsAll,
  '/opo/totals/trendList': mockTrendList
};
export function mockTotals(url) {
  const fn = MOCK_MAP[url];
  return fn ? fn() : [];
}
src/assets/logo.zip
Binary files differ
src/assets/styles/index.scss
@@ -132,6 +132,12 @@
  // background-color: rgba(94, 255, 0, 0.198);
}
// business æ¨¡å—:大宽屏居中限宽留白,小屏自动铺满
.app-main.business-mode > * {
  max-width: 1200px;
  margin: 0 auto;
}
.components-container {
  margin: 30px 50px;
  position: relative;
src/components/FilePreviewDialog/index.vue
@@ -61,7 +61,7 @@
    <!-- PDF预览 -->
    <div v-else-if="fileType === 'pdf'" class="preview-container pdf-preview">
      <div class="pdf-toolbar">
        <el-button-group>
        <el-button-group v-if="!useNativePdf">
          <el-button
            size="mini"
            @click="changePage(currentPage - 1)"
@@ -81,7 +81,7 @@
          </el-button>
        </el-button-group>
        <el-button-group class="zoom-controls">
        <el-button-group v-if="!useNativePdf" class="zoom-controls">
          <el-button size="mini" @click="zoomOut" :disabled="scale <= 50">
            <i class="el-icon-zoom-out"></i> ç¼©å°
          </el-button>
@@ -99,7 +99,15 @@
        </el-button>
      </div>
      <div class="pdf-content" v-loading="pdfLoading">
      <!-- pdf.js å› è·¨åŸŸ(CORS)加载失败时,降级为浏览器原生 PDF é¢„览(iframe å¯¼èˆªä¸å—同源策略限制) -->
      <iframe
        v-if="useNativePdf"
        :src="fileUrl"
        class="pdf-native-iframe"
        frameborder="0"
      ></iframe>
      <div class="pdf-content" v-else v-loading="pdfLoading">
        <pdf
          ref="pdf"
          :src="pdfUrl"
@@ -214,6 +222,8 @@
      pageCount: 0,
      scale: 100,
      pageRotate: 0,
      // pdf.js åŠ è½½å¤±è´¥ï¼ˆå¦‚ CORS)时降级为浏览器原生预览
      useNativePdf: false,
      // å›¾ç‰‡ç›¸å…³
      imageScale: 100
    };
@@ -267,30 +277,11 @@
    },
    /** è®¡ç®— baseUrlHt */
    calculateBaseUrl() {
      // èŽ·å–å½“å‰æµè§ˆå™¨åœ°å€
      const currentUrl = window.location.href;
      console.log("当前浏览器地址:", currentUrl);
      try {
        // ä½¿ç”¨ URL å¯¹è±¡è§£æž
        const urlObj = new URL(currentUrl);
        // èŽ·å–ä¸»æœºåï¼ˆIP æˆ–域名)
        const hostname = urlObj.hostname;
        // æ‹¼æŽ¥ç«¯å£ :9095
        this.baseUrlHt = `http://${hostname}:9095`;
        console.log("计算得到的 baseUrlHt:", this.baseUrlHt);
      } catch (error) {
        console.error("解析URL失败:", error);
        // å¤‡ç”¨æ–¹æ¡ˆï¼šä½¿ç”¨æ­£åˆ™æå–
        const match = currentUrl.match(/https?:\/\/([^:\/]+)/);
        if (match) {
          this.baseUrlHt = `http://${match[1]}:9095`;
        } else {
          // æœ€åŽçš„备用方案
          this.baseUrlHt =
            process.env.VUE_APP_BASE_API || "http://localhost:9095";
        }
      }
      // èµ°å½“前域名(同域相对路径),由 nginx çš„ /profile/ ä»£ç†è½¬å‘到后端 9095。
      // ç›´è¿ž 9095 ç«¯å£ä¼šå› ç«¯å£ä¸åŒäº§ç”Ÿè·¨åŸŸ(CORS),而后端存在双重 CORS å“åº”头问题,
      // åŒåŸŸè¯·æ±‚不触发 CORS æ£€æŸ¥ï¼Œå¯ä»Žæ ¹æºä¸Šç»•开该问题(图片/PDF/下载全部生效)。
      this.baseUrlHt = "";
      console.log("文件访问走同域代理: /profile/");
    },
    /** åˆå§‹åŒ–预览 */
@@ -302,6 +293,7 @@
      // å¤„理URL,替换域名部分
      this.fileUrl = this.processFileUrl(originalUrl);
      console.log(this.fileUrl,'this.fileUrl');
      this.fileType = this.getFileType(this.fileName);
@@ -312,6 +304,7 @@
      this.scale = 100;
      this.imageScale = 100;
      this.pageCount = 0;
      this.useNativePdf = false;
      // æ¨¡æ‹ŸåŠ è½½å»¶è¿Ÿ
      setTimeout(() => {
@@ -360,8 +353,13 @@
    pdfErrorHandler(error) {
      console.error("PDF加载失败:", error);
      this.pdfLoading = false;
      this.$message.error("PDF文件加载失败,请尝试下载后查看");
      this.previewVisible = false;
      // pdf.js é€šè¿‡ fetch åŠ è½½è·¨åŸŸ PDF å— CORS é™åˆ¶ï¼Œå¤±è´¥æ—¶é™çº§ä¸ºæµè§ˆå™¨åŽŸç”Ÿé¢„è§ˆ
      // ï¼ˆiframe å±žäºŽå¯¼èˆªè¯·æ±‚,不受同源策略限制,可以正常加载跨域 PDF)
      this.useNativePdf = true;
      this.$message({
        type: "info",
        message: "已切换为浏览器原生预览模式"
      });
    },
    /** å›¾ç‰‡åŠ è½½å®Œæˆ */
@@ -513,6 +511,13 @@
    align-items: flex-start;
  }
  /* åŽŸç”Ÿ PDF é¢„览(CORS é™çº§æ–¹æ¡ˆï¼‰ */
  .pdf-native-iframe {
    flex: 1;
    border: none;
    background: #f8f9fa;
  }
  /* Office预览样式 */
  .office-content {
    flex: 1;
src/layout/components/AppMain.vue
@@ -1,5 +1,5 @@
<template>
  <section class="app-main">
  <section class="app-main" :class="{ 'business-mode': isBusiness }">
    <transition name="fade-transform" mode="out-in">
      <keep-alive :include="cachedViews">
        <router-view :key="key" />
@@ -17,6 +17,15 @@
    },
    key() {
      return this.$route.path
    },
    isBusiness() {
      return (this.$route.matched || []).some((r) => {
        const cp = r.meta && r.meta.componentPath;
        if (typeof cp !== 'string' || cp.indexOf('business/') !== 0) return false;
        // ä¸šåŠ¡æ¨¡å—çš„ index åˆ—表页不需要限宽;course/index æ˜¯è¯¦æƒ…页,保留限宽
        if (/^business\/[^/]+\/index$/.test(cp) && cp !== 'business/course/index') return false;
        return true;
      });
    }
  }
}
src/store/modules/permission.js
@@ -61,6 +61,11 @@
      route.children = filterChildren(route.children)
    }
    if (route.component) {
      // è®°å½•原始组件路径,供布局层判断所属模块(business/project ç­‰ï¼‰
      if (typeof route.component === 'string') {
        route.meta = route.meta || {};
        route.meta.componentPath = route.component; // ä¾‹å¦‚ "business/assess/index"
      }
      // Layout ParentView ç»„件特殊处理
      if (route.component === 'Layout') {
        route.component = Layout
src/utils/chartStyle.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,139 @@
// ç»Ÿè®¡å›¾è¡¨ç»Ÿä¸€æ¸…爽简洁风格(echarts 4 å…¼å®¹ï¼‰
// ä¾› statistics/*.vue å¤ç”¨ï¼Œä¿è¯è§†è§‰ä¸€è‡´
const toRgba = (hex, alpha) => {
  const n = parseInt(hex.slice(1), 16);
  const r = (n >> 16) & 255;
  const g = (n >> 8) & 255;
  const b = n & 255;
  return `rgba(${r}, ${g}, ${b}, ${alpha})`;
};
export const COLORS = {
  blue: '#5B8FF9',
  green: '#5AD8A6',
  amber: '#F6BD16',
  coral: '#E8684A',
  sky: '#6DC8EC',
  purple: '#9270CA',
  orange: '#FF9D4D',
  teal: '#269A99'
};
export const PALETTE = Object.values(COLORS);
const tipBase = {
  backgroundColor: '#ffffff',
  borderColor: '#e8eaf0',
  borderWidth: 1,
  padding: [10, 14],
  textStyle: { color: '#333', fontSize: 13 },
  extraCssText: 'box-shadow:0 6px 18px rgba(31,45,61,.1);border-radius:10px;'
};
// åæ ‡è½´ç±» tooltip(柱状/折线)
export function axisTooltip() {
  return { trigger: 'axis', ...tipBase, axisPointer: { lineStyle: { color: '#c9d2e0' } } };
}
// å›¾å½¢ç±» tooltip(饼图)
export function itemTooltip() {
  return { trigger: 'item', ...tipBase, formatter: '{b}:{c} ({d}%)' };
}
export function grid() {
  return { left: 8, right: 16, top: 44, bottom: 8, containLabel: true };
}
// ç±»ç›®è½´
export function xAxis(data) {
  return {
    type: 'category',
    data,
    axisLine: { show: false },
    axisTick: { show: false },
    axisLabel: { color: '#8a919f', fontSize: 12 }
  };
}
// æ•°å€¼è½´
export function yAxis(name) {
  return {
    type: 'value',
    name,
    nameTextStyle: { color: '#b0b6c3', fontSize: 12 },
    axisLabel: { color: '#8a919f', fontSize: 12 },
    splitLine: { lineStyle: { type: 'dashed', color: '#f0f1f5' } }
  };
}
export function legend(data) {
  return {
    data,
    icon: 'roundRect',
    itemWidth: 10,
    itemHeight: 10,
    itemGap: 18,
    textStyle: { color: '#8a919f', fontSize: 12 }
  };
}
// æŸ±çŠ¶ç³»åˆ—
export function barSeries(name, data, color, opts = {}) {
  return {
    name,
    type: 'bar',
    data,
    barMaxWidth: opts.maxWidth || 20,
    itemStyle: { color, borderRadius: opts.radius || [4, 4, 0, 0] },
    stack: opts.stack,
    yAxisIndex: opts.yAxisIndex
  };
}
// æŠ˜çº¿ç³»åˆ—
export function lineSeries(name, data, color, opts = {}) {
  const s = {
    name,
    type: 'line',
    data,
    smooth: true,
    symbol: 'circle',
    symbolSize: opts.symbolSize || 6,
    lineStyle: { width: 3, color },
    itemStyle: { color },
    yAxisIndex: opts.yAxisIndex
  };
  if (opts.area) {
    s.areaStyle = {
      color: {
        type: 'linear',
        x: 0,
        y: 0,
        x2: 0,
        y2: 1,
        colorStops: [
          { offset: 0, color: toRgba(color, 0.24) },
          { offset: 1, color: toRgba(color, 0) }
        ]
      }
    };
  }
  return s;
}
// çŽ¯å½¢å›¾ç³»åˆ—
export function donutSeries(name, data) {
  return {
    name,
    type: 'pie',
    radius: ['52%', '72%'],
    center: ['50%', '50%'],
    avoidLabelOverlap: false,
    itemStyle: { borderColor: '#fff', borderWidth: 2 },
    label: { show: false },
    labelLine: { show: false },
    data,
    color: PALETTE
  };
}
src/utils/statisticsMode.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,21 @@
// ç»Ÿè®¡æ¨¡å—数据源开关:mock(模拟数据) / real(真实接口)
// å…¨å±€å…±äº«ï¼Œåˆ‡æ¢åŽç»Ÿè®¡å„页面读取同一状态
import Vue from 'vue';
const KEY = 'opo-statistics-mock';
const initial = localStorage.getItem(KEY) === '1';
const state = Vue.observable({
  useMock: initial
});
export function setStatisticsMock(useMock) {
  state.useMock = !!useMock;
  localStorage.setItem(KEY, state.useMock ? '1' : '0');
}
export function isStatisticsMock() {
  return state.useMock;
}
export default state;
src/utils/totals.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,42 @@
// /opo/totals/* ç»Ÿè®¡æŽ¥å£å…¬å…±å·¥å…·
// æŽ¥å£è¯·æ±‚体为 Totals(opo统计) å¯¹è±¡ï¼Œå“åº”为 RuoYi Result { code, msg, data, total }
// data å¯èƒ½æ˜¯ Totals æ•°ç»„、单个 Totals å¯¹è±¡ï¼Œæˆ–包了一层 {list/rows} çš„对象
// å½’一化返回结果为 Totals æ•°ç»„
export function toTotalsList(result) {
  const data = result && result.data;
  if (!data) return [];
  if (Array.isArray(data)) return data;
  if (Array.isArray(data.list)) return data.list;
  if (Array.isArray(data.rows)) return data.rows;
  // å•个统计对象
  return [data];
}
// ç»„装查询条件,只传有值的字段
// area: åœ°åŒºåç§°(中文);startTime/endTime: yyyy-MM;ageGroup/sex/boodType/organName ä¸ºç­›é€‰ç»´åº¦
export function buildTotalsQuery({ area, startTime, endTime, ageGroup, sex, boodType, organName } = {}) {
  const q = {};
  if (area) q.area = area;
  if (startTime) q.startTime = startTime;
  if (endTime) q.endTime = endTime;
  if (ageGroup) q.ageGroup = ageGroup;
  if (sex) q.sex = sex;
  if (boodType) q.boodType = boodType;
  if (organName) q.organName = organName;
  return q;
}
// æœˆä»½/时间字符串统一成 yyyy-MM
export function toMonth(value) {
  if (!value) return '';
  const s = String(value);
  const m = s.match(/(\d{4})-(\d{2})/);
  return m ? `${m[1]}-${m[2]}` : s;
}
// æ•°å­—安全取整
export function toNum(v) {
  const n = Number(v);
  return Number.isFinite(n) ? n : 0;
}
src/views/business/GetWitness/GetWitnessInfo.vue
@@ -1759,8 +1759,6 @@
.paper-form {
  border-left: 1px solid #d0d3db;
  background: #fff;
  /* è¶…宽屏限制表单最大宽度,右侧留白,避免字段分布过开 */
  max-width: 1400px;
}
.paper-form >>> .form-row {
src/views/business/OrganUtilization/OrganUtilizationInfo.vue
@@ -2073,8 +2073,6 @@
.paper-form {
  border-left: 1px solid #d0d3db;
  background: #fff;
  /* è¶…宽屏限制表单最大宽度,右侧留白,避免字段分布过开 */
  max-width: 1400px;
}
.paper-form >>> .form-row {
src/views/business/affirm/affirmInfo.vue
@@ -1,20 +1,27 @@
<template>
  <div class="confirmation-detail">
    <case-basic-info :case-id="caseId" :show-attachment="true":column="3" />
    <case-basic-info :case-id="caseId" :show-attachment="true" :column="3" />
    <el-card class="detail-card paper-card">
      <!-- åŸºç¡€ä¿¡æ¯ -->
      <div slot="header" class="clearfix">
        <span class="detail-title">捐献确认信息</span>
      </div>
      <el-form :model="form" ref="form" label-width="120px" class="paper-form">
        <div class="form-row">
          <el-form-item label="协调员1" prop="coordinatedusernameo" class="col-1-3">
          <el-form-item
            label="协调员1"
            prop="coordinatedusernameo"
            class="col-1-3"
          >
            <el-input v-model="form.coordinatedusernameo" />
          </el-form-item>
          <el-form-item label="协调员2" prop="coordinatedusernamet" class="col-1-3">
          <el-form-item
            label="协调员2"
            prop="coordinatedusernamet"
            class="col-1-3"
          >
            <el-input v-model="form.coordinatedusernamet" />
          </el-form-item>
          <!-- <el-col :span="8">
@@ -49,7 +56,7 @@
        </el-alert>
        <div style="margin-top: 15px;">
          <el-button
          style="float: right; margin-bottom: 5px;"
            style="float: right; margin-bottom: 5px;"
            type="primary"
            size="mini"
            icon="el-icon-plus"
@@ -144,7 +151,11 @@
        </div>
        <div class="form-row">
          <el-form-item label="家属意见备注" prop="relativeRemark" class="col-full">
          <el-form-item
            label="家属意见备注"
            prop="relativeRemark"
            class="col-full"
          >
            <el-input
              type="textarea"
              :rows="3"
@@ -618,9 +629,6 @@
    },
    getAttachmentsByType(type) {
      console.log(type);
      console.log(this.attachmentData[type]);
      return this.attachmentData[type] || [];
    },
@@ -689,7 +697,6 @@
        uploader: "当前用户"
      };
      this.attachmentData[this.currentUploadType].push(newAttachment);
      console.log(this.attachmentData,'this.attachmentData');
      this.updateAssessannexField();
      this.$message.success("上传成功");
@@ -1079,8 +1086,6 @@
.paper-form {
  border-left: 1px solid #d0d3db;
  background: #fff;
  /* è¶…宽屏限制表单最大宽度,右侧留白,避免字段分布过开 */
  max-width: 1400px;
}
.paper-form >>> .form-row {
@@ -1117,10 +1122,18 @@
  display: none;
}
.paper-form >>> .col-1-3 { flex: 0 0 33.3333%; }
.paper-form >>> .col-half { flex: 0 0 50%; }
.paper-form >>> .col-2-3 { flex: 0 0 66.6666%; }
.paper-form >>> .col-full { flex: 0 0 100%; }
.paper-form >>> .col-1-3 {
  flex: 0 0 33.3333%;
}
.paper-form >>> .col-half {
  flex: 0 0 50%;
}
.paper-form >>> .col-2-3 {
  flex: 0 0 66.6666%;
}
.paper-form >>> .col-full {
  flex: 0 0 100%;
}
.paper-form >>> .el-form-item__label {
  flex: 0 0 150px;
@@ -1139,7 +1152,9 @@
  line-height: 1.3;
  white-space: nowrap;
}
.paper-form >>> .el-form-item.is-required:not(.is-no-asterisk) > .el-form-item__label:before {
.paper-form
  >>> .el-form-item.is-required:not(.is-no-asterisk)
  > .el-form-item__label:before {
  color: #f56c6c;
  margin-right: 2px;
}
src/views/business/allocation/allocationInfo.vue
@@ -1885,8 +1885,6 @@
.paper-form {
  border-left: 1px solid #d0d3db;
  background: #fff;
  /* è¶…宽屏限制表单最大宽度,右侧留白,避免字段分布过开 */
  max-width: 1400px;
}
.paper-form >>> .form-row {
src/views/business/assess/assessInfo.vue
@@ -1558,8 +1558,6 @@
.paper-form {
  border-left: 1px solid #d0d3db;
  background: #fff;
  /* è¶…宽屏限制表单最大宽度,右侧留白,避免字段分布过开 */
  max-width: 1400px;
}
.paper-form >>> .form-row {
src/views/business/course/ccss.vue
@@ -486,8 +486,6 @@
.register-form {
  border-left: 1px solid #d0d3db;
  background: #fff;
  /* è¶…宽屏限制表单最大宽度,右侧留白,避免字段分布过开 */
  max-width: 1400px;
}
/* æ¯ä¸€è¡Œä¸º flex è¡Œï¼Œè‡ªå¸¦ä¸Šè¾¹æ¡†ä¿è¯ç½‘格连续性 */
src/views/business/course/components/EthicalReviewStage.vue
@@ -580,7 +580,7 @@
    // ä¼¦ç†å®¡æŸ¥ç»“论文本(只读展示)
    expertConclusionLabel() {
      const map = { 0: "不同意", 1: "同意", 2: "审查中" };
      const map = { 0: "不同意", 1: "同意", 2: "审查中", 3: "弃权", 4: "修改后同意" };
      return map[this.form.expertConclusion] || "-";
    },
@@ -947,7 +947,9 @@
      console.log(file, "file");
      this.currentPreviewFile = {
        fileName: file,
        // ç­¾åé™„ä»¶ URL é€šå¸¸æ— æ‰©å±•名(如 .../file-1787645145583),
        // é¢„览组件依赖文件名扩展名识别文件类型,这里补 .png è®©å…¶æŒ‰å›¾ç‰‡é¢„览
        fileName: "手写签名.png",
        fileUrl: file,
        fileType: "png"
      };
@@ -1038,7 +1040,7 @@
    getReviewStatusFilter(status) {
      const statusMap = {
        "0": "info", // å¾…接收
        "1": "warning", // æœªæŽ¥æ”¶
        "1": "warning", // å·²å‘送
        "2": "success", // å·²æŽ¥æ”¶
        "3": "danger", // è¶…æ—¶
        "4": "danger", // ä¸­æ­¢
@@ -1050,7 +1052,7 @@
    getReviewStatusText(status) {
      const statusMap = {
        "0": "待接收",
        "1": "未接收",
        "1": "已发送",
        "2": "已接收",
        "3": "超时",
        "4": "中止",
@@ -1062,18 +1064,22 @@
    // ç»“论过滤器
    getConclusionFilter(conclusion) {
      const conclusionMap = {
        "0": "danger", // ä¸åŒæ„
        "1": "success", // åŒæ„
        "2": "warning", // å®¡æŸ¥ä¸­
        "0": "danger" // ä¸åŒæ„
        "3": "info", // å¼ƒæƒ
        "4": "success" // ä¿®æ”¹åŽåŒæ„
      };
      return conclusionMap[conclusion] || "info";
    },
    getConclusionText(conclusion) {
      const conclusionMap = {
        "0": "不同意",
        "1": "同意",
        "2": "审查中",
        "0": "不同意"
        "3": "弃权",
        "4": "修改后同意"
      };
      return conclusionMap[conclusion] || "未知";
    },
src/views/business/course/index.vue
@@ -374,9 +374,8 @@
  flex-direction: column;
  gap: 20px;
  min-height: 0;
  min-width: 0;
  /* é‡è¦ï¼šå…è®¸flex子项压缩 */
  max-width: 1600px;
  /* è¶…宽屏上限:避免描述列表每列过宽、内容稀疏,右侧留出适量空白 */
}
.basic-info-section {
@@ -433,6 +432,8 @@
  flex-direction: column;
  min-height: 400px;
  /* æœ€å°é«˜åº¦ */
  min-width: 0;
  /* é‡è¦ï¼šå…è®¸ flex å­é¡¹æ”¶ç¼© */
  /* max-height: 1000px; */
  /* æœ€å¤§é«˜åº¦ï¼Œå¯æ ¹æ®éœ€è¦è°ƒæ•´ */
  overflow: hidden;
@@ -442,11 +443,21 @@
.stage-content-wrapper {
  flex: 1;
  overflow-y: auto;
  overflow-x: auto;
  /* é˜¶æ®µè¯¦æƒ…内部可滚动 */
  margin-top: 20px;
  padding-right: 8px;
  min-height: 0;
  /* é‡è¦ */
  min-width: 0;
  /* é‡è¦ï¼šå…è®¸ flex å­é¡¹æ”¶ç¼©ï¼Œå¦åˆ™å›ºå®šåˆ—宽的表格会把内容撑宽并溢出 */
}
/* çº¦æŸå„ Stage å­ç»„件根节点宽度,配合上方 min-width:0,
   è®© el-table åœ¨è‡ªèº«å†…部出现横向滚动条,而不是被外层 overflow:hidden è£æŽ‰ */
.stage-content-wrapper :deep(> div) {
  width: 100%;
  min-width: 0;
  box-sizing: border-box;
}
.stage-content-wrapper::-webkit-scrollbar {
src/views/business/decide/DecideInfo.vue
@@ -1387,8 +1387,6 @@
.paper-form {
  border-left: 1px solid #d0d3db;
  background: #fff;
  /* è¶…宽屏限制表单最大宽度,右侧留白,避免字段分布过开 */
  max-width: 1400px;
}
.paper-form >>> .form-row {
src/views/business/ethicalReview/ethicalReviewInfo.vue
@@ -41,6 +41,8 @@
              <el-option label="同意" value="1" />
              <el-option label="审查中" value="2" />
              <el-option label="不同意" value="0" />
              <el-option label="弃权" value="3" />
              <el-option label="修改后同意" value="4" />
            </el-select>
          </el-form-item>
          <el-form-item label="伦理审查结论时间" prop="expertTime" class="col-1-3">
@@ -725,7 +727,7 @@
          <el-row :gutter="20" style="margin-top: 20px;">
            <el-col :span="12">
              <div class="history-stat-item">
                <div class="history-stat-label">未接收数量</div>
                <div class="history-stat-label">已发送未接收数量</div>
                <div class="history-stat-value">
                  {{ expertHistoryData.notAcceptCount || 0 }}
                </div>
@@ -1333,7 +1335,9 @@
      console.log(file, "file");
      this.currentPreviewFile = {
        fileName: file,
        // ç­¾åé™„ä»¶ URL é€šå¸¸æ— æ‰©å±•名(如 .../file-1787645145583),
        // é¢„览组件依赖文件名扩展名识别文件类型,这里补 .png è®©å…¶æŒ‰å›¾ç‰‡é¢„览
        fileName: "手写签名.png",
        fileUrl: file,
        fileType: "png"
      };
@@ -1423,8 +1427,8 @@
    // å®¡æŸ¥çŠ¶æ€è¿‡æ»¤å™¨
    getReviewStatusFilter(status) {
      const statusMap = {
        "0": "info", // å¾…接收
        "1": "warning", // æœªæŽ¥æ”¶
        "0": "info", // å¾…发送
        "1": "warning", // å·²å‘送
        "2": "success", // å·²æŽ¥æ”¶
        "3": "danger", // è¶…æ—¶
        "4": "danger", // ä¸­æ­¢
@@ -1435,8 +1439,8 @@
    getReviewStatusText(status) {
      const statusMap = {
        "0": "待接收",
        "1": "未接收",
        "0": "待发送",
        "1": "已发送",
        "2": "已接收",
        "3": "超时",
        "4": "中止",
@@ -1448,18 +1452,22 @@
    // ç»“论过滤器
    getConclusionFilter(conclusion) {
      const conclusionMap = {
        "0": "danger", // ä¸åŒæ„
        "1": "success", // åŒæ„
        "2": "warning", // å®¡æŸ¥ä¸­
        "0": "danger" // ä¸åŒæ„
        "3": "info", // å¼ƒæƒ
        "4": "success" // ä¿®æ”¹åŽåŒæ„
      };
      return conclusionMap[conclusion] || "info";
    },
    getConclusionText(conclusion) {
      const conclusionMap = {
        "0": "不同意",
        "1": "同意",
        "2": "审查中",
        "0": "不同意"
        "3": "弃权",
        "4": "修改后同意"
      };
      return conclusionMap[conclusion] || "未知";
    },
@@ -2005,7 +2013,7 @@
          deptName: expert.unitname || "",
          title: expert.title || "",
          donorno: expert.telephone || "",
          receiveStatus: "0", // å¾…接收
          receiveStatus: "0", // å¾…发送
          expertconclusion: "",
          expertopinion: "",
          conclusionannex: "",
@@ -2204,6 +2212,7 @@
              if (index != -1) {
                this.form.ethicalreviewopinionsList[index].receiveStatus = "1"; // å·²æŽ¥æ”¶
                this.form.ethicalreviewopinionsList[index].expertconclusion = "2"; // å®¡æŸ¥ä¸­
                this.form.ethicalreviewopinionsList[
                  index
                ].startTime = this.sendForm.startTime;
@@ -2750,8 +2759,6 @@
.paper-form {
  border-left: 1px solid #d0d3db;
  background: #fff;
  /* è¶…宽屏限制表单最大宽度,右侧留白,避免字段分布过开 */
  max-width: 1400px;
}
.paper-form >>> .form-row {
src/views/business/ethicalReview/index.vue
@@ -86,12 +86,6 @@
            >修改</el-button
          > -->
          <el-button
            type="warning"
            icon="el-icon-download"
            @click="handleExport"
            >导出</el-button
          >
          <el-button
            type="info"
            icon="el-icon-check"
            :disabled="single"
@@ -121,12 +115,7 @@
          prop="inpatientno"
          width="120"
        />
        <el-table-column
          label="姓名"
          align="center"
          prop="name"
          width="100"
        />
        <el-table-column label="姓名" align="center" prop="name" width="100" />
        <el-table-column label="性别" align="center" prop="sex" width="80">
          <template slot-scope="scope">
@@ -195,7 +184,7 @@
        <el-table-column
          label="操作"
          align="center"
          width="180"
          width="240"
          fixed="right"
          class-name="small-padding fixed-width"
        >
@@ -222,6 +211,13 @@
              :disabled="scope.row.status === '2'"
              >结束</el-button
            >
            <el-button
              size="mini"
              type="text"
              icon="el-icon-download"
              @click="handleDownload(scope.row)"
              >下载</el-button
            >
          </template>
        </el-table-column>
      </el-table>
@@ -239,13 +235,23 @@
</template>
<script>
import { reviewinitiateBaseInfoList, ethicalreviewedit,  } from "@/api/businessApi";
import {
  reviewinitiateBaseInfoList,
  ethicalreviewedit,
  ethicalReviewDownload
} from "@/api/businessApi";
import Pagination from "@/components/Pagination";
import { getToken } from "@/utils/auth";
export default {
  name: "EthicsReviewList",
  components: { Pagination },
  dicts: ["sys_user_sex", "ReviewForm_status",'sys_ethical','expert_Conclusion'],
  dicts: [
    "sys_user_sex",
    "ReviewForm_status",
    "sys_ethical",
    "expert_Conclusion"
  ],
  data() {
    return {
      // é®ç½©å±‚
@@ -291,9 +297,14 @@
      delete params.reviewTimeRange;
      // å¦‚果有时间范围参数
      if (this.queryParams.reviewTimeRange && this.queryParams.reviewTimeRange.length === 2) {
        params.expertTimeStart = this.queryParams.reviewTimeRange[0] + ' 00:00:00';
        params.expertTimeEnd = this.queryParams.reviewTimeRange[1] + ' 23:59:59';
      if (
        this.queryParams.reviewTimeRange &&
        this.queryParams.reviewTimeRange.length === 2
      ) {
        params.expertTimeStart =
          this.queryParams.reviewTimeRange[0] + " 00:00:00";
        params.expertTimeEnd =
          this.queryParams.reviewTimeRange[1] + " 23:59:59";
      }
      reviewinitiateBaseInfoList(params)
@@ -333,7 +344,7 @@
    handleView(row) {
      this.$router.push({
        path: "/case/ethicalReviewInfo",
        query: { infoid: row.infoid,id:row.id }
        query: { infoid: row.infoid, id: row.id }
      });
    },
    // æ–°å¢žæŒ‰é’®æ“ä½œ
@@ -345,12 +356,13 @@
      const infoid = row.infoid;
      this.$router.push({
        path: "/case/ethicalReviewInfo",
        query: { infoid: infoid,id:row.id }
        query: { infoid: infoid, id: row.id }
      });
    },
    // ç»“束审查操作
    handleEndReview(row) {
      const selectedRow = row || (this.selectedRows.length === 1 ? this.selectedRows[0] : null);
      const selectedRow =
        row || (this.selectedRows.length === 1 ? this.selectedRows[0] : null);
      if (!selectedRow) {
        this.$message.warning("请选择一条记录");
        return;
@@ -365,7 +377,7 @@
          // è°ƒç”¨ä¿®æ”¹æŽ¥å£ï¼Œå°†çŠ¶æ€æ”¹ä¸ºç»“æŸï¼ˆstatus: '2')
          const updateData = {
            infoid: selectedRow.infoid,
            status: '2',
            status: "2",
            // å¯ä»¥ä¿ç•™åŽŸæœ‰æ•°æ®
            expertName: selectedRow.expertName,
            expertConclusion: selectedRow.expertConclusion,
@@ -385,50 +397,41 @@
        })
        .catch(() => {});
    },
    // å¯¼å‡ºæŒ‰é’®æ“ä½œ
    handleExport() {
      const queryParams = { ...this.queryParams };
      delete queryParams.pageNum;
      delete queryParams.pageSize;
      // å¤„理时间范围参数
      if (queryParams.reviewTimeRange && queryParams.reviewTimeRange.length === 2) {
        queryParams.expertTimeStart = queryParams.reviewTimeRange[0] + ' 00:00:00';
        queryParams.expertTimeEnd = queryParams.reviewTimeRange[1] + ' 23:59:59';
      }
      delete queryParams.reviewTimeRange;
      this.$confirm("是否确认导出所有伦理审查数据?", "警告", {
        confirmButtonText: "确定",
        cancelButtonText: "取消",
        type: "warning"
      })
        .then(() => {
          this.loading = true;
          // return ethicalreviewexport(queryParams);
        })
        .then(response => {
          if (response.code === 200) {
            this.$message.success("导出成功");
            // å¤„理导出文件下载
            if (response.data) {
              const blob = new Blob([response.data], { type: 'application/vnd.ms-excel' });
              const downloadElement = document.createElement('a');
              const href = window.URL.createObjectURL(blob);
              downloadElement.href = href;
              downloadElement.download = '伦理审查列表.xlsx';
              document.body.appendChild(downloadElement);
              downloadElement.click();
              document.body.removeChild(downloadElement);
              window.URL.revokeObjectURL(href);
            }
          } else {
            this.$message.error(response.msg || "导出失败");
    // ä¸‹è½½ä¼¦ç†å®¡æŸ¥æ¡£æ¡ˆ
    handleDownload(row) {
      ethicalReviewDownload({ nitiateId: row.id })
        .then(res => {
          if (res.code !== 200 || !res.data || !res.data.downloadUrl) {
            this.$message.error(res.msg || "下载失败");
            return;
          }
          this.loading = false;
          const fileUrl = res.data;
          // æ‹¼æŽ¥ä¸‹è½½åœ°å€ï¼šorigin + /dev-api(开发代理前缀) + /profile + downloadUrl,
          // åŽç«¯é™æ€æ–‡ä»¶å®žé™…挂在 /profile ä¸‹ï¼ŒdownloadUrl å°‘了这层前缀
          const urlBase = process.env.VUE_APP_BASE_API;
          const curWWWPath = window.document.location.href;
          const pos = curWWWPath.indexOf(window.document.location.pathname);
          const fullUrl =
            curWWWPath.substring(0, pos) + urlBase + "/profile" + fileUrl.downloadUrl;
          fetch(fullUrl, {
            headers: { Authorization: "Bearer " + getToken() }
          })
            .then(response => response.blob())
            .then(blob => {
              const url = window.URL.createObjectURL(new Blob([blob]));
              const link = document.createElement("a");
              link.href = url;
              link.setAttribute("download", fileUrl.downloadName);
              document.body.appendChild(link);
              link.click();
              link.parentNode.removeChild(link);
              window.URL.revokeObjectURL(url);
            });
        })
        .catch(() => {
          this.loading = false;
        .catch(error => {
          console.error("下载档案失败:", error);
          this.$message.error("下载失败");
        });
    },
    // æ—¶é—´æ ¼å¼åŒ–
src/views/business/followupVisit/info.vue
@@ -740,8 +740,6 @@
.paper-form {
  border-left: 1px solid #d0d3db;
  background: #fff;
  /* è¶…宽屏限制表单最大宽度,右侧留白,避免字段分布过开 */
  max-width: 1400px;
}
.paper-form >>> .form-row {
src/views/business/maintain/maintainInfo.vue
@@ -1375,8 +1375,6 @@
.paper-form {
  border-left: 1px solid #d0d3db;
  background: #fff;
  /* è¶…宽屏限制表单最大宽度,右侧留白,避免字段分布过开 */
  max-width: 1400px;
}
.paper-form >>> .form-row {
src/views/business/transfer/TransportEdit.vue
@@ -839,8 +839,6 @@
.paper-form {
  border-left: 1px solid #d0d3db;
  background: #fff;
  /* è¶…宽屏限制表单最大宽度,右侧留白,避免字段分布过开 */
  max-width: 1400px;
}
.paper-form >>> .form-row {
src/views/statistics/case.vue
@@ -35,16 +35,25 @@
        </el-form-item>
        <el-form-item>
          <el-button type="primary" icon="el-icon-search" @click="handleSearch">查询</el-button>
          <el-button icon="el-icon-refresh" @click="handleReset">重置</el-button>
          <el-button type="success" icon="el-icon-download" @click="handleExport">导出</el-button>
          <el-button type="primary" icon="el-icon-search" @click="handleSearch"
            >查询</el-button
          >
          <el-button icon="el-icon-refresh" @click="handleReset"
            >重置</el-button
          >
          <el-button
            type="success"
            icon="el-icon-download"
            @click="handleExport"
            >导出</el-button
          >
        </el-form-item>
      </el-form>
    </el-card>
    <!-- æ•°æ®æ¦‚览 -->
    <el-row :gutter="20" class="overview-row">
      <el-col :span="6" v-for="item in overviewData" :key="item.title">
      <el-col :span="8" v-for="item in overviewData" :key="item.title">
        <el-card class="overview-card">
          <div class="overview-content">
            <div class="overview-icon" :style="{ backgroundColor: item.color }">
@@ -71,7 +80,7 @@
              type="text"
              @click="toggleChartType('trend')"
            >
              {{ chartTypes.trend === 'line' ? '柱状图' : '折线图' }}
              {{ chartTypes.trend === "line" ? "柱状图" : "折线图" }}
            </el-button>
          </div>
          <EChartsWrapper
@@ -117,15 +126,23 @@
        border
        stripe
      >
        <el-table-column label="地区" prop="region" align="center"  />
        <el-table-column label="月份" prop="month" align="center" >
        <el-table-column label="地区" prop="region" align="center" />
        <el-table-column label="月份" prop="month" align="center">
          <template slot-scope="{ row }">
            {{ formatMonth(row.month) }}
          </template>
        </el-table-column>
        <el-table-column label="潜在捐献案例数" prop="potentialCount" align="center"  />
        <el-table-column label="完成捐献数量" prop="completedCount" align="center"  />
        <el-table-column label="完成率" prop="completionRate" align="center" >
        <el-table-column
          label="潜在捐献案例数"
          prop="potentialCount"
          align="center"
        />
        <el-table-column
          label="完成捐献数量"
          prop="completedCount"
          align="center"
        />
        <el-table-column label="完成率" prop="completionRate" align="center">
          <template slot-scope="{ row }">
            <el-progress
              :percentage="row.completionRate"
@@ -135,13 +152,9 @@
            <span>{{ row.completionRate }}%</span>
          </template>
        </el-table-column>
        <el-table-column label="案例明细" align="center" >
        <el-table-column label="案例明细" align="center">
          <template slot-scope="{ row }">
            <el-button
              type="text"
              size="mini"
              @click="viewCaseDetail(row)"
            >
            <el-button type="text" size="mini" @click="viewCaseDetail(row)">
              æŸ¥çœ‹({{ row.caseCount || 0 }})
            </el-button>
          </template>
@@ -161,26 +174,21 @@
    </el-card>
    <!-- æ¡ˆä¾‹æ˜Žç»†å¼¹çª— -->
    <el-dialog
      title="案例明细"
      :visible.sync="showDetail"
      append-to-body
    >
      <case-detail
        :query="detailQuery"
        @close="showDetail = false"
      />
    <el-dialog title="案例明细" :visible.sync="showDetail" append-to-body>
      <case-detail :query="detailQuery" @close="showDetail = false" />
    </el-dialog>
  </div>
</template>
<script>
import EChartsWrapper from '@/components/charts/EChartsWrapper.vue';
import CaseDetail from './components/CaseDetail.vue';
import EChartsWrapper from "@/components/charts/EChartsWrapper.vue";
import CaseDetail from "./components/CaseDetail.vue";
import { caseTrendArea, caseTrend, caseTrendMoon, totals,trendList } from "@/api/businessApi";
import { toTotalsList, toMonth, toNum } from "@/utils/totals";
import statisticsMode from "@/utils/statisticsMode";
import { COLORS, axisTooltip, grid, xAxis, yAxis, legend, barSeries, lineSeries } from "@/utils/chartStyle";
export default {
  name: 'CaseStatistics',
  name: "CaseStatistics",
  components: {
    EChartsWrapper,
    CaseDetail
@@ -195,15 +203,15 @@
        limit: 10
      },
      // åœ°åŒºé€‰é¡¹
      // åœ°åŒºé€‰é¡¹ï¼ˆvalue ç›´æŽ¥ä¼ åœ°åŒºåç§°ç»™æŽ¥å£ area å­—段)
      regionOptions: [
        { 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: "临沂地区" }
      ],
      // åŠ è½½çŠ¶æ€
@@ -215,15 +223,32 @@
      // æ¦‚览数据
      overviewData: [
        { title: '总潜在捐献案例', value: 0, desc: '统计周期内', icon: 'el-icon-s-flag', color: '#409EFF' },
        { title: '总完成捐献数量', value: 0, desc: '统计周期内', icon: 'el-icon-circle-check', color: '#67C23A' },
        { title: '平均完成率', value: '0%', desc: '统计周期内', icon: 'el-icon-s-data', color: '#E6A23C' },
        { title: '涉及案例数', value: 0, desc: '统计周期内', icon: 'el-icon-document', color: '#F56C6C' }
        {
          title: "总潜在捐献案例",
          value: 0,
          desc: "统计周期内",
          icon: "el-icon-s-flag",
          color: "#5B8FF9"
        },
        {
          title: "总完成捐献数量",
          value: 0,
          desc: "统计周期内",
          icon: "el-icon-circle-check",
          color: "#5AD8A6"
        },
        {
          title: "平均完成率",
          value: "0%",
          desc: "统计周期内",
          icon: "el-icon-s-data",
          color: "#F6BD16"
        }
      ],
      // å›¾è¡¨ç±»åž‹
      chartTypes: {
        trend: 'line'
        trend: "line"
      },
      // å›¾è¡¨æ•°æ®
@@ -234,200 +259,157 @@
      // å¼¹çª—控制
      showDetail: false,
      detailQuery: {},
      // æ¨¡æ‹Ÿæ•°æ®
      mockData: {
        table: [
          { id: 1, region: '青岛地区', month: '2024-01', potentialCount: 15, completedCount: 8, completionRate: 53.3, caseCount: 5 },
          { id: 2, region: '青岛地区', month: '2024-02', potentialCount: 18, completedCount: 10, completionRate: 55.6, caseCount: 6 },
          { id: 3, region: '日照地区', month: '2024-01', potentialCount: 8, completedCount: 4, completionRate: 50.0, caseCount: 3 },
          { id: 4, region: '日照地区', month: '2024-02', potentialCount: 10, completedCount: 6, completionRate: 60.0, caseCount: 4 },
          { id: 5, region: '济南地区', month: '2024-01', potentialCount: 20, completedCount: 12, completionRate: 60.0, caseCount: 7 },
          { id: 6, region: '济南地区', month: '2024-02', potentialCount: 22, completedCount: 14, completionRate: 63.6, caseCount: 8 }
        ],
        trend: [
          { month: '2024-01', potential: 55, completed: 31 },
          { month: '2024-02', potential: 64, completed: 39 },
          { month: '2024-03', potential: 58, completed: 35 },
          { month: '2024-04', potential: 62, completed: 40 },
          { month: '2024-05', potential: 68, completed: 45 },
          { month: '2024-06', potential: 72, completed: 50 }
        ],
        region: [
          { region: '青岛地区', potential: 150, completed: 85 },
          { region: '日照地区', potential: 80, completed: 45 },
          { region: '济南地区', potential: 200, completed: 120 },
          { region: '烟台地区', potential: 100, completed: 60 }
        ]
      }
      detailQuery: {}
    };
  },
  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.monthRange && this.query.monthRange.length === 2) {
        q.startTime = this.query.monthRange[0];
        q.endTime = this.query.monthRange[1];
      }
      return q;
    },
    // åŠ è½½æ•°æ®
    async loadData() {
      this.loading = true;
      try {
        // æ¨¡æ‹ŸæŽ¥å£è°ƒç”¨
        await new Promise(resolve => setTimeout(resolve, 500));
        const q = this.buildQuery();
        const [areaRes, trendRes, moonRes, totalsRes] = await Promise.all([
          caseTrendArea(q),
          caseTrend(q),
          caseTrendMoon(q),
          totals(q)
        ]);
        // å¤„理表格数据
        let filteredData = [...this.mockData.table];
        // åœ°åŒºåˆ†å¸ƒå¯¹æ¯”
        this.chartData.region = toTotalsList(areaRes).map(item => ({
          region: item.area || item.region || "",
          potential: toNum(item.potentialCount),
          completed: toNum(item.completeCount)
        }));
        if (this.query.region.length > 0) {
          filteredData = filteredData.filter(item =>
            this.query.region.includes(item.region.replace('地区', '').toLowerCase())
          );
        }
        // æ¡ˆä¾‹è¶‹åŠ¿
        this.chartData.trend = toTotalsList(trendRes).map(item => ({
          month: toMonth(item.donateTime) || toMonth(item.month),
          potential: toNum(item.potentialCount),
          completed: toNum(item.completeCount)
        }));
        if (this.query.monthRange && this.query.monthRange.length === 2) {
          const [start, end] = this.query.monthRange;
          filteredData = filteredData.filter(item =>
            item.month >= start && item.month <= end
          );
        }
        // æ˜Žç»†è¡¨ï¼ˆåœ°åŒº x æœˆä»½ï¼‰
        const all = toTotalsList(moonRes).map(item => {
          const potential = toNum(item.potentialCount);
          const completed = toNum(item.completeCount);
          const rate =
            potential > 0
              ? Number(((completed / potential) * 100).toFixed(1))
              : 0;
          return {
            region: item.area || item.region || "",
            month: toMonth(item.donateTime) || toMonth(item.month),
            potentialCount: potential,
            completedCount: completed,
            completionRate: rate,
            caseCount: toNum(item.totalCount) || potential
          };
        });
        this.total = all.length;
        const start = (this.query.page - 1) * this.query.limit;
        this.tableData = all.slice(start, start + this.query.limit);
        this.tableData = filteredData;
        this.total = filteredData.length;
        // æ›´æ–°æ¦‚览数据
        this.updateOverviewData(filteredData);
        // æ›´æ–°å›¾è¡¨æ•°æ®
        this.chartData.trend = [...this.mockData.trend];
        this.chartData.region = [...this.mockData.region];
        this.updateOverviewData(toTotalsList(totalsRes));
      } catch (error) {
        console.error('加载数据失败:', error);
        this.$message.error('数据加载失败');
        console.error("加载数据失败:", error);
        this.$message.error("数据加载失败");
      } finally {
        this.loading = false;
      }
    },
    // æ›´æ–°æ¦‚览数据
    updateOverviewData(data) {
      if (data.length === 0) return;
      const totalPotential = data.reduce((sum, item) => sum + item.potentialCount, 0);
      const totalCompleted = data.reduce((sum, item) => sum + item.completedCount, 0);
      const avgRate = data.length > 0
        ? (data.reduce((sum, item) => sum + item.completionRate, 0) / data.length).toFixed(1)
        : 0;
      const totalCases = data.reduce((sum, item) => sum + (item.caseCount || 0), 0);
    // æ›´æ–°æ¦‚览数据(总潜在/总完成捐献数来自 /opo/totals/totals æŽ¥å£ï¼‰
    updateOverviewData(totalsList) {
      const totalPotential = totalsList.reduce(
        (sum, item) => sum + toNum(item.totalPotential),
        0
      );
      const totalCompleted = totalsList.reduce(
        (sum, item) => sum + toNum(item.totalCompletion),
        0
      );
      const avgRate =
        totalPotential > 0
          ? ((totalCompleted / totalPotential) * 100).toFixed(1)
          : 0;
      this.overviewData[0].value = totalPotential;
      this.overviewData[1].value = totalCompleted;
      this.overviewData[2].value = `${avgRate}%`;
      this.overviewData[3].value = totalCases;
    },
    // èŽ·å–è¶‹åŠ¿å›¾è¡¨é…ç½®
    getTrendChartOptions() {
      const isLine = this.chartTypes.trend === 'line';
      const isLine = this.chartTypes.trend === "line";
      const xData = this.chartData.trend.map(item => item.month);
      const potential = this.chartData.trend.map(item => item.potential);
      const completed = this.chartData.trend.map(item => item.completed);
      const series = isLine
        ? [
            lineSeries("潜在捐献数", potential, COLORS.blue, { area: true }),
            lineSeries("完成捐献数", completed, COLORS.green, { area: true })
          ]
        : [
            barSeries("潜在捐献数", potential, COLORS.blue),
            barSeries("完成捐献数", completed, COLORS.green)
          ];
      return {
        tooltip: {
          trigger: 'axis',
          axisPointer: {
            type: 'shadow'
          }
        },
        legend: {
          data: ['潜在捐献数', '完成捐献数']
        },
        grid: {
          left: '3%',
          right: '4%',
          bottom: '3%',
          containLabel: true
        },
        xAxis: {
          type: 'category',
          data: this.chartData.trend.map(item => this.formatMonth(item.month))
        },
        yAxis: {
          type: 'value',
          name: '数量(个)'
        },
        series: [
          {
            name: '潜在捐献数',
            type: isLine ? 'line' : 'bar',
            data: this.chartData.trend.map(item => item.potential),
            itemStyle: { color: '#409EFF' },
            smooth: isLine,
            lineStyle: isLine ? { width: 3 } : {}
          },
          {
            name: '完成捐献数',
            type: isLine ? 'line' : 'bar',
            data: this.chartData.trend.map(item => item.completed),
            itemStyle: { color: '#67C23A' },
            smooth: isLine,
            lineStyle: isLine ? { width: 3 } : {}
          }
        ]
        tooltip: axisTooltip(),
        legend: legend(["潜在捐献数", "完成捐献数"]),
        grid: grid(),
        xAxis: xAxis(xData),
        yAxis: yAxis(),
        series
      };
    },
    // èŽ·å–åœ°åŒºå›¾è¡¨é…ç½®
    getRegionChartOptions() {
      return {
        tooltip: {
          trigger: 'axis',
          axisPointer: {
            type: 'shadow'
          }
        },
        legend: {
          data: ['潜在捐献数', '完成捐献数']
        },
        grid: {
          left: '3%',
          right: '4%',
          bottom: '3%',
          containLabel: true
        },
        xAxis: {
          type: 'category',
          data: this.chartData.region.map(item => item.region)
        },
        yAxis: {
          type: 'value',
          name: '数量(个)'
        },
        tooltip: axisTooltip(),
        legend: legend(["潜在捐献数", "完成捐献数"]),
        grid: grid(),
        xAxis: xAxis(this.chartData.region.map(item => item.region)),
        yAxis: yAxis(),
        series: [
          {
            name: '潜在捐献数',
            type: 'bar',
            data: this.chartData.region.map(item => item.potential),
            itemStyle: { color: '#409EFF' }
          },
          {
            name: '完成捐献数',
            type: 'bar',
            data: this.chartData.region.map(item => item.completed),
            itemStyle: { color: '#67C23A' }
          }
          barSeries("潜在捐献数", this.chartData.region.map(item => item.potential), COLORS.blue),
          barSeries("完成捐献数", this.chartData.region.map(item => item.completed), COLORS.green)
        ]
      };
    },
    // èŽ·å–è¿›åº¦æ¡é¢œè‰²
    getProgressColor(percentage) {
      if (percentage >= 60) return '#67C23A';
      if (percentage >= 50) return '#E6A23C';
      return '#F56C6C';
      if (percentage >= 60) return COLORS.green;
      if (percentage >= 50) return COLORS.amber;
      return COLORS.coral;
    },
    // æ ¼å¼åŒ–月份显示
    formatMonth(month) {
      if (!month) return '';
      return month.replace('-', 'å¹´') + '月';
      if (!month) return "";
      return month.replace("-", "å¹´") + "月";
    },
    // å¤„理查询
@@ -456,13 +438,12 @@
    // å¤„理导出
    handleExport() {
      this.$message.info('导出功能开发中...');
      // è¿™é‡Œå¯ä»¥è°ƒç”¨å¯¼å‡ºæŽ¥å£
      this.$message.info("导出功能开发中...");
    },
    // åˆ‡æ¢å›¾è¡¨ç±»åž‹
    toggleChartType(type) {
      this.chartTypes[type] = this.chartTypes[type] === 'line' ? 'bar' : 'line';
      this.chartTypes[type] = this.chartTypes[type] === "line" ? "bar" : "line";
    },
    // æŸ¥çœ‹æ¡ˆä¾‹è¯¦æƒ…
@@ -537,7 +518,7 @@
.overview-desc {
  font-size: 12px;
  color: #C0C4CC;
  color: #c0c4cc;
}
.chart-row {
src/views/statistics/components/CaseDetail.vue
@@ -8,27 +8,25 @@
      border
      stripe
    >
      <el-table-column label="案例编号" prop="caseNo" align="center"  />
      <el-table-column label="捐献者姓名" prop="donorName" align="center"  />
      <el-table-column label="住院号" prop="inpatientNo" align="center"  />
      <el-table-column label="案例状态" prop="status" align="center" >
      <el-table-column label="案例编号" prop="caseNo" align="center" />
      <el-table-column label="捐献者姓名" prop="name" align="center" />
      <el-table-column label="住院号" prop="inpatientNo" align="center" />
      <el-table-column label="案例状态" prop="workflow" align="center">
        <template slot-scope="{ row }">
          <el-tag :type="getStatusTag(row.status)" size="small">
            {{ getStatusText(row.status) }}
          <el-tag :type="getStatusTag(row.workflow)" size="small">
            {{ getStatusText(row.workflow) }}
          </el-tag>
        </template>
      </el-table-column>
      <el-table-column label="捐献器官" prop="organs" align="center" >
      <el-table-column label="捐献器官" prop="organName" align="center">
        <template slot-scope="{ row }">
          <span v-if="row.organs && row.organs.length > 0">
            {{ row.organs.join('、') }}
          </span>
          <span v-if="row.organName">{{ row.organName }}</span>
          <span v-else>-</span>
        </template>
      </el-table-column>
      <el-table-column label="捐献时间" prop="donationTime" align="center"  />
      <el-table-column label="死亡原因" prop="deathCause" align="center"  />
      <el-table-column label="医院" prop="hospital" align="center" width="200" />
      <el-table-column label="案例时间" prop="donatetime" align="center" />
      <el-table-column label="死亡原因" prop="deathReason" align="center" />
      <el-table-column label="医院" prop="treatmentHospitalName" align="center" width="200" />
    </el-table>
    <div style="text-align: center; margin-top: 20px;">
@@ -38,6 +36,9 @@
</template>
<script>
import { trendList } from '@/api/businessApi';
import { toTotalsList } from '@/utils/totals';
export default {
  name: 'CaseDetail',
  props: {
@@ -49,21 +50,64 @@
  data() {
    return {
      loading: false,
      caseList: [
        { caseNo: 'C202401001', donorName: '张三', inpatientNo: 'ZY202401001', status: 'completed', organs: ['肝脏', '肾脏'], donationTime: '2024-01-15 10:30', deathCause: '脑外伤', hospital: '青岛大学附属医院' },
        { caseNo: 'C202401002', donorName: '李四', inpatientNo: 'ZY202401002', status: 'completed', organs: ['心脏', '眼角膜'], donationTime: '2024-01-20 14:20', deathCause: '心搏骤停', hospital: '青岛市立医院' },
        { caseNo: 'C202401003', donorName: '王五', inpatientNo: 'ZY202401003', status: 'potential', organs: [], donationTime: null, deathCause: '脑血管意外', hospital: '青岛市中心医院' }
      ]
      caseList: []
    };
  },
  created() {
    this.loadList();
  },
  watch: {
    query: {
      handler() {
        this.loadList();
      },
      deep: true
    }
  },
  methods: {
    getStatusTag(status) {
      const map = { completed: 'success', potential: 'warning', abandoned: 'danger' };
      return map[status] || 'info';
    // åŠ è½½æ¡ˆä¾‹æ˜Žç»†ï¼š/opo/totals/trendList
    async loadList() {
      this.loading = true;
      try {
        const params = {};
        if (this.query.region) params.area = this.query.region;
        if (this.query.month) {
          params.startTime = `${this.query.month}-01`;
          params.endTime = this.endOfMonth(this.query.month);
        }
        const res = await trendList(params);
        this.caseList = toTotalsList(res);
      } catch (error) {
        console.error('加载案例明细失败:', error);
        this.$message.error('案例明细加载失败');
      } finally {
        this.loading = false;
      }
    },
    getStatusText(status) {
      const map = { completed: '已完成', potential: '潜在捐献', abandoned: '放弃捐献' };
      return map[status] || '未知';
    // æœˆä»½(yyyy-MM) -> å½“月最后一天(yyyy-MM-dd)
    endOfMonth(month) {
      const [y, m] = String(month).split('-').map(Number);
      const lastDay = new Date(y, m, 0).getDate();
      return `${month}-${String(lastDay).padStart(2, '0')}`;
    },
    // å·¥ä½œæµçŠ¶æ€æ–‡æ¡ˆ
    getStatusText(workflow) {
      const map = {
        1: '供者维护',
        2: '捐献意愿',
        3: '医学评估',
        4: '死亡判定',
        5: '伦理审查',
        6: '器官分配',
        7: '器官利用',
        99: '捐献完成'
      };
      return map[workflow] || '未知';
    },
    getStatusTag(workflow) {
      if (workflow === 99) return 'success';
      if (workflow) return 'warning';
      return 'info';
    }
  }
};
src/views/statistics/donor.vue
@@ -67,23 +67,27 @@
            <span>中国分类分布</span>
          </div>
          <EChartsWrapper
            :id="'category-chart'"
            :options="categoryChartOptions"
            v-if="useMock"
            :id="'china-category-chart'"
            :options="chinaCategoryOptions"
            height="400px"
          />
          <el-empty v-else description="后端暂未提供中国分类统计接口"></el-empty>
        </el-card>
      </el-col>
      <el-col :span="12">
        <el-card>
          <div slot="header" class="clearfix">
            <span>死亡原因分析</span>
            <span>死亡原因分布</span>
          </div>
          <EChartsWrapper
            :id="'death-chart'"
            :options="deathChartOptions"
            v-if="useMock"
            :id="'death-cause-chart'"
            :options="deathCauseOptions"
            height="400px"
          />
          <el-empty v-else description="后端暂未提供死亡原因统计接口"></el-empty>
        </el-card>
      </el-col>
    </el-row>
@@ -94,71 +98,54 @@
        <span>捐献者详细信息</span>
      </div>
      <el-table
        v-loading="loading"
        :data="tableData"
        style="width: 100%"
        border
        stripe
      >
        <el-table-column label="捐献者编号" prop="donorNo" align="center"  />
        <el-table-column label="姓名" prop="name" align="center"  />
        <el-table-column label="性别" prop="gender" align="center" >
          <template slot-scope="{ row }">
            <el-tag :type="row.gender === '男' ? 'primary' : 'danger'" size="small">
              {{ row.gender }}
            </el-tag>
          </template>
        </el-table-column>
        <el-table-column label="年龄" prop="age" align="center"  />
        <el-table-column label="血型" prop="bloodType" align="center" >
          <template slot-scope="{ row }">
            <el-tag :type="getBloodTypeTag(row.bloodType)" size="small">
              {{ row.bloodType }}
            </el-tag>
          </template>
        </el-table-column>
        <el-table-column label="地区" prop="region" align="center"  />
        <el-table-column label="捐献时间" prop="donationTime" align="center"  />
        <el-table-column label="中国分类" prop="chinaCategory" align="center"  />
        <el-table-column label="死亡原因" prop="deathCause" align="center"  />
        <el-table-column label="捐献器官" prop="organs" align="center" >
          <template slot-scope="{ row }">
            <span>{{ row.organs.join('、') }}</span>
          </template>
        </el-table-column>
        <el-table-column label="操作" align="center" " fixed="right">
          <template slot-scope="{ row }">
            <el-button
              type="text"
              size="mini"
              @click="viewDonorDetail(row)"
            >
              è¯¦æƒ…
            </el-button>
          </template>
        </el-table-column>
      </el-table>
      <template v-if="useMock">
        <el-table
          v-loading="loading"
          :data="tableData"
          style="width: 100%"
          border
          stripe
        >
          <el-table-column label="捐献者编号" prop="donorNo" align="center" />
          <el-table-column label="姓名" prop="name" align="center" />
          <el-table-column label="性别" prop="gender" align="center" />
          <el-table-column label="年龄" prop="age" align="center" />
          <el-table-column label="血型" prop="bloodType" align="center" />
          <el-table-column label="地区" prop="region" align="center" />
          <el-table-column label="捐献时间" prop="donationTime" 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-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]"
        />
      </template>
      <el-empty v-else description="后端暂未提供捐献者详细信息接口"></el-empty>
    </el-card>
  </div>
</template>
<script>
// ä¿®æ­£å¯¼å…¥è·¯å¾„
import FilterPanel from '@/components/charts/FilterPanel.vue';
import EChartsWrapper from '@/components/charts/EChartsWrapper.vue';
import { caseSex, caseBloodType, caseAvgAge } from '@/api/businessApi';
import { toTotalsList, toNum } from '@/utils/totals';
import statisticsMode, { isStatisticsMock } from '@/utils/statisticsMode';
import { COLORS, PALETTE, axisTooltip, grid, legend } from '@/utils/chartStyle';
const AGE_GROUP_MAP = {
  '1': '<17岁',
  '2': '18-49岁',
  '3': '50-69岁',
  '4': '>69岁'
};
export default {
  name: 'DonorStatistics',
@@ -187,13 +174,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'
        },
@@ -206,19 +193,6 @@
          endPlaceholder: '结束月份',
          valueFormat: 'yyyy-MM',
          width: '300px'
        },
        {
          label: '年龄段',
          prop: 'ageRange',
          type: 'select',
          multiple: true,
          options: [
            { label: '<17岁', value: '0-17' },
            { label: '18-49岁', value: '18-49' },
            { label: '50-69岁', value: '50-69' },
            { label: '>69岁', value: '70-100' }
          ],
          width: '200px'
        },
        {
          label: '性别',
@@ -258,115 +232,109 @@
      // å›¾è¡¨é…ç½®
      ageChartOptions: {},
      genderBloodChartOptions: {},
      categoryChartOptions: {},
      deathChartOptions: {},
      // æŽ¥å£è¿”回数据
      sexData: [],
      bloodData: [],
      // æ— æŽ¥å£å›¾è¡¨çš„æ¨¡æ‹Ÿæ•°æ®ï¼ˆä»… mock æ¨¡å¼å±•示)
      chinaCategoryData: [
        { name: '中国一类(DBD)', value: 45 },
        { name: '中国二类(DCD)', value: 35 },
        { name: '中国三类(DBCD)', value: 20 }
      ],
      deathCauseData: [
        { name: '脑血管疾病', value: 40 },
        { name: '颅脑损伤', value: 30 },
        { name: '心脏疾病', value: 15 },
        { name: '其他原因', value: 15 }
      ],
      // æçŒ®è€…详细信息模拟数据(无后端接口,仅 mock æ¨¡å¼å±•示)
      donorList: [
        { donorNo: 'D2024001', name: '张伟', gender: '男', age: 45, bloodType: 'A', region: '青岛地区', donationTime: '2024-03-15' },
        { donorNo: 'D2024002', name: '王芳', gender: '女', age: 38, bloodType: 'B', region: '青岛地区', donationTime: '2024-03-22' },
        { donorNo: 'D2024003', name: '李娜', gender: '女', age: 52, bloodType: 'O', region: '日照地区', donationTime: '2024-04-02' },
        { donorNo: 'D2024004', name: '刘洋', gender: '男', age: 29, bloodType: 'AB', region: '青岛地区', donationTime: '2024-04-18' },
        { donorNo: 'D2024005', name: '陈静', gender: '女', age: 47, bloodType: 'A', region: '济南地区', donationTime: '2024-05-06' },
        { donorNo: 'D2024006', name: '杨强', gender: '男', age: 33, bloodType: 'B', region: '烟台地区', donationTime: '2024-05-21' },
        { donorNo: 'D2024007', name: '赵敏', gender: '女', age: 41, bloodType: 'O', region: '青岛地区', donationTime: '2024-06-03' },
        { donorNo: 'D2024008', name: '黄磊', gender: '男', age: 55, bloodType: 'A', region: '潍坊地区', donationTime: '2024-06-17' },
        { donorNo: 'D2024009', name: '周杰', gender: '男', age: 62, bloodType: 'O', region: '临沂地区', donationTime: '2024-07-01' },
        { donorNo: 'D2024010', name: '吴霞', gender: '女', age: 26, bloodType: 'AB', region: '日照地区', donationTime: '2024-07-15' },
        { donorNo: 'D2024011', name: '徐磊', gender: '男', age: 48, bloodType: 'B', region: '青岛地区', donationTime: '2024-08-09' },
        { donorNo: 'D2024012', name: '孙丽', gender: '女', age: 35, bloodType: 'O', region: '威海地区', donationTime: '2024-08-24' },
        { donorNo: 'D2024013', name: '马超', gender: '男', age: 44, bloodType: 'A', region: '济南地区', donationTime: '2024-09-05' },
        { donorNo: 'D2024014', name: '朱婷', gender: '女', age: 58, bloodType: 'B', region: '青岛地区', donationTime: '2024-09-19' },
        { donorNo: 'D2024015', name: '胡军', gender: '男', age: 31, bloodType: 'O', region: '烟台地区', donationTime: '2024-10-08' },
        { donorNo: 'D2024016', name: '郭涛', gender: '男', age: 68, bloodType: 'A', region: '青岛地区', donationTime: '2024-10-22' },
        { donorNo: 'D2024017', name: '何平', gender: '女', age: 39, bloodType: 'AB', region: '潍坊地区', donationTime: '2024-11-06' },
        { donorNo: 'D2024018', name: '高翔', gender: '男', age: 50, bloodType: 'B', region: '临沂地区', donationTime: '2024-11-20' },
        { donorNo: 'D2024019', name: '林芳', gender: '女', age: 27, bloodType: 'O', region: '青岛地区', donationTime: '2024-12-04' },
        { donorNo: 'D2024020', name: '罗刚', gender: '男', age: 42, bloodType: 'A', region: '日照地区', donationTime: '2024-12-18' },
        { donorNo: 'D2023021', name: '郑爽', gender: '女', age: 34, bloodType: 'B', region: '青岛地区', donationTime: '2023-06-10' },
        { donorNo: 'D2023022', name: '梁宇', gender: '男', age: 57, bloodType: 'O', region: '济南地区', donationTime: '2023-07-25' },
        { donorNo: 'D2023023', name: '谢娜', gender: '女', age: 49, bloodType: 'A', region: '威海地区', donationTime: '2023-08-14' },
        { donorNo: 'D2023024', name: '韩冰', gender: '男', age: 23, bloodType: 'AB', region: '青岛地区', donationTime: '2023-09-30' }
      ],
      // è¡¨æ ¼æ•°æ®
      tableData: [],
      total: 0,
      // æ¨¡æ‹Ÿæ•°æ®
      mockData: {
        table: [
          { id: 1, donorNo: 'D2024001', name: '张三', gender: '男', age: 45, bloodType: 'A', region: '青岛地区', donationTime: '2024-01-15', chinaCategory: '中国一类(DBD)', deathCause: '脑外伤', organs: ['全肝', '左肾', '右肾'] },
          { id: 2, donorNo: 'D2024002', name: '李四', gender: '女', age: 32, bloodType: 'B', region: '青岛地区', donationTime: '2024-01-20', chinaCategory: '中国二类(DCD)', deathCause: '心搏骤停', organs: ['心脏', '眼角膜'] },
          { id: 3, donorNo: 'D2024003', name: '王五', gender: '男', age: 58, bloodType: 'O', region: '日照地区', donationTime: '2024-02-10', chinaCategory: '中国三类(DBCD)', deathCause: '脑血管意外', organs: ['全肝', '胰腺'] },
          { id: 4, donorNo: 'D2024004', name: '赵六', gender: '男', age: 28, bloodType: 'AB', region: '济南地区', donationTime: '2024-02-15', chinaCategory: '中国一类(DBD)', deathCause: '交通事故', organs: ['全肺', '左肾', '右肾'] },
          { id: 5, donorNo: 'D2024005', name: '钱七', gender: '女', age: 65, bloodType: 'A', region: '烟台地区', donationTime: '2024-02-20', chinaCategory: '中国二类(DCD)', deathCause: '心肌梗死', organs: ['心脏'] },
          { id: 6, donorNo: 'D2024006', name: '孙八', gender: '男', age: 42, bloodType: 'B', region: '威海地区', donationTime: '2024-03-05', chinaCategory: '中国一类(DBD)', deathCause: '脑肿瘤', organs: ['左肾', '右肾', '眼角膜'] },
          { id: 7, donorNo: 'D2024007', name: '周九', gender: '男', age: 37, bloodType: 'O', region: '潍坊地区', donationTime: '2024-03-10', chinaCategory: '中国三类(DBCD)', deathCause: '脑外伤', organs: ['肝脏', '胰腺', '眼角膜'] },
          { id: 8, donorNo: 'D2024008', name: '吴十', gender: '女', age: 25, bloodType: 'AB', region: '临沂地区', donationTime: '2024-03-15', chinaCategory: '中国二类(DCD)', deathCause: '交通事故', organs: ['心脏', '肺'] }
        ],
        age: [
          { ageRange: '<17岁', male: 5, female: 3 },
          { ageRange: '18-49岁', male: 45, female: 30 },
          { ageRange: '50-69岁', male: 35, female: 25 },
          { ageRange: '>69岁', male: 10, female: 8 }
        ],
        gender: [
          { name: '男性', value: 95 },
          { name: '女性', value: 66 }
        ],
        blood: [
          { name: 'A型', value: 45 },
          { name: 'B型', value: 38 },
          { name: 'O型', value: 52 },
          { name: 'AB型', value: 26 }
        ],
        category: [
          { name: '中国一类(DBD)', value: 85 },
          { name: '中国二类(DCD)', value: 55 },
          { name: '中国三类(DBCD)', value: 21 }
        ],
        death: [
          { name: '脑外伤', value: 45 },
          { name: '心搏骤停', value: 35 },
          { name: '脑血管意外', value: 35 },
          { name: '交通事故', value: 20 },
          { name: '心肌梗死', value: 25 },
          { name: '其他', value: 15 }
        ]
      }
      total: 0
    };
  },
  computed: {
    useMock() {
      return isStatisticsMock();
    },
    chinaCategoryOptions() {
      return this.buildPieOptions('中国分类', this.chinaCategoryData);
    },
    deathCauseOptions() {
      return this.buildPieOptions('死亡原因', this.deathCauseData);
    }
  },
  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.monthRange && this.query.monthRange.length === 2) {
        q.startTime = this.query.monthRange[0];
        q.endTime = this.query.monthRange[1];
      }
      if (this.query.sex) q.sex = this.query.sex;
      if (this.query.bloodType) q.boodType = this.query.bloodType;
      return q;
    },
    // åŠ è½½æ•°æ®
    async loadData() {
      this.loading = true;
      try {
        await new Promise(resolve => setTimeout(resolve, 500));
        const q = this.buildQuery();
        const [sexRes, bloodRes, avgRes] = await Promise.all([
          caseSex(q),
          caseBloodType(q),
          caseAvgAge(q)
        ]);
        // å¤„理表格数据
        let filteredData = [...this.mockData.table];
        this.sexData = toTotalsList(sexRes);
        this.bloodData = toTotalsList(bloodRes);
        const avgList = toTotalsList(avgRes);
        if (this.query.region.length > 0) {
          filteredData = filteredData.filter(item =>
            this.query.region.includes(item.region.replace('地区', '').toLowerCase())
          );
        }
        if (this.query.monthRange && this.query.monthRange.length === 2) {
          const [start, end] = this.query.monthRange;
          filteredData = filteredData.filter(item =>
            item.donationTime >= start && item.donationTime <= end
          );
        }
        if (this.query.ageRange.length > 0) {
          filteredData = filteredData.filter(item => {
            const age = item.age;
            return this.query.ageRange.some(range => {
              if (range === '0-17') return age < 18;
              if (range === '18-49') return age >= 18 && age <= 49;
              if (range === '50-69') return age >= 50 && age <= 69;
              if (range === '70-100') return age >= 70;
              return false;
            });
          });
        }
        if (this.query.gender) {
          filteredData = filteredData.filter(item => item.gender === this.query.gender);
        }
        if (this.query.bloodType) {
          filteredData = filteredData.filter(item => item.bloodType === this.query.bloodType);
        }
        // åˆ†é¡µ
        const start = (this.query.page - 1) * this.query.limit;
        this.tableData = filteredData.slice(start, start + this.query.limit);
        this.total = filteredData.length;
        // æ›´æ–°æ¦‚览数据
        this.updateOverviewData(filteredData);
        // æ›´æ–°å›¾è¡¨
        this.updateOverviewData(avgList);
        this.updateCharts();
        // æçŒ®è€…详细信息无 /opo/totals èšåˆæŽ¥å£ï¼Œä½¿ç”¨æ¨¡æ‹Ÿæ•°æ®è¡¥å…¨
        this.updateDonorTable();
      } catch (error) {
        console.error('加载数据失败:', error);
        this.$message.error('数据加载失败');
@@ -376,220 +344,164 @@
    },
    // æ›´æ–°æ¦‚览数据
    updateOverviewData(data) {
      if (data.length === 0) return;
      const total = data.length;
      const males = data.filter(item => item.gender === '男').length;
      const females = data.filter(item => item.gender === '女').length;
      const avgAge = data.reduce((sum, item) => sum + item.age, 0) / total;
    updateOverviewData(avgList) {
      const males = this.sexData.reduce((sum, item) => sum + toNum(item.manCount), 0);
      const females = this.sexData.reduce((sum, item) => sum + toNum(item.womanCount), 0);
      const total = males + females;
      const avgAge = avgList.length
        ? toNum(avgList.reduce((sum, item) => sum + toNum(item.avgAge), 0) / avgList.length)
        : 0;
      this.overviewData[0].value = total;
      this.overviewData[1].value = `${((males / total) * 100).toFixed(1)}%`;
      this.overviewData[2].value = `${((females / total) * 100).toFixed(1)}%`;
      this.overviewData[1].value = total > 0 ? `${((males / total) * 100).toFixed(1)}%` : '0%';
      this.overviewData[2].value = total > 0 ? `${((females / total) * 100).toFixed(1)}%` : '0%';
      this.overviewData[3].value = avgAge.toFixed(1);
    },
    // æ›´æ–°å›¾è¡¨
    updateCharts() {
      // å¹´é¾„分布图
      // å¹´é¾„分布图(左男右女,清爽发散金字塔)
      this.ageChartOptions = {
        tooltip: {
          trigger: 'axis',
          axisPointer: {
            type: 'shadow'
          }
        tooltip: axisTooltip(),
        legend: legend(['男性', '女性']),
        grid: grid(),
        xAxis: {
          type: 'value',
          axisLine: { show: false },
          axisTick: { show: false },
          axisLabel: { color: '#8a919f', fontSize: 12, formatter: value => Math.abs(value) },
          splitLine: { lineStyle: { type: 'dashed', color: '#f0f1f5' } }
        },
        legend: {
          data: ['男性', '女性']
        yAxis: {
          type: 'category',
          data: this.sexData.map(item => AGE_GROUP_MAP[item.ageGroup] || item.ageGroup || '未知'),
          axisLine: { show: false },
          axisTick: { show: false },
          axisLabel: { color: '#606266', fontSize: 13 }
        },
        grid: {
          left: '3%',
          right: '4%',
          bottom: '3%',
          containLabel: true
        },
        xAxis: [
          {
            type: 'value',
            position: 'bottom',
            axisLabel: {
              formatter: function(value) {
                return Math.abs(value);
              }
            }
          }
        ],
        yAxis: [
          {
            type: 'category',
            axisTick: {
              show: false
            },
            data: this.mockData.age.map(item => item.ageRange)
          }
        ],
        series: [
          {
            name: '男性',
            type: 'bar',
            stack: '性别',
            data: this.mockData.age.map(item => item.male),
            itemStyle: { color: '#409EFF' }
            barMaxWidth: 18,
            data: this.sexData.map(item => toNum(item.manCount)),
            itemStyle: { color: COLORS.blue, borderRadius: [4, 0, 0, 4] }
          },
          {
            name: '女性',
            type: 'bar',
            stack: '性别',
            data: this.mockData.age.map(item => -item.female),
            itemStyle: { color: '#F56C6C' }
            barMaxWidth: 18,
            data: this.sexData.map(item => -toNum(item.womanCount)),
            itemStyle: { color: COLORS.coral, borderRadius: [0, 4, 4, 0] }
          }
        ]
      };
      // æ€§åˆ«ä¸Žè¡€åž‹ç»„合图
      // æ€§åˆ«ä¸Žè¡€åž‹åˆ†å¸ƒå›¾ï¼ˆåŒå¿ƒçŽ¯ï¼Œé…è‰²æ¸…çˆ½ï¼‰
      const males = this.bloodData.reduce((sum, item) => sum + toNum(item.manCount), 0);
      const females = this.bloodData.reduce((sum, item) => sum + toNum(item.womanCount), 0);
      const bloodPie = this.bloodData.map(item => ({
        name: item.boodType || '未知',
        value: toNum(item.boodTypeCount) || (toNum(item.manCount) + toNum(item.womanCount))
      }));
      this.genderBloodChartOptions = {
        tooltip: {
          trigger: 'item',
          formatter: '{a} <br/>{b} : {c} ({d}%)'
        },
        legend: {
          data: [
            '男性', '女性',
            'A型', 'B型', 'O型', 'AB型'
          ],
          top: 20
        },
        grid: {
          top: '20%',
          bottom: '20%',
          left: '10%',
          right: '10%'
        },
        legend: { data: ['男性', '女性', ...this.bloodData.map(item => item.boodType || '未知')], top: 20 },
        series: [
          {
            name: '性别分布',
            type: 'pie',
            radius: [0, '30%'],
            radius: [0, '40%'],
            center: ['25%', '50%'],
            label: {
              position: 'inner',
              fontSize: 12
            },
            data: this.mockData.gender
            label: { position: 'inner', fontSize: 12 },
            data: [
              { name: '男性', value: males },
              { name: '女性', value: females }
            ],
            color: [COLORS.blue, COLORS.coral]
          },
          {
            name: '血型分布',
            type: 'pie',
            radius: ['40%', '55%'],
            radius: ['55%', '75%'],
            center: ['25%', '50%'],
            data: this.mockData.blood
          },
          {
            name: '性别血型对比',
            type: 'pie',
            radius: [0, '30%'],
            center: ['75%', '50%'],
            label: {
              position: 'inner',
              fontSize: 12
            },
            data: [
              { name: '男-A', value: 20 },
              { name: '男-B', value: 15 },
              { name: '男-O', value: 25 },
              { name: '男-AB', value: 10 },
              { name: '女-A', value: 12 },
              { name: '女-B', value: 8 },
              { name: '女-O', value: 10 },
              { name: '女-AB', value: 5 }
            ]
            label: { fontSize: 14, color: '#606266' },
            data: bloodPie,
            color: [COLORS.amber, COLORS.teal, COLORS.purple, COLORS.orange]
          }
        ]
      };
    },
      // ä¸­å›½åˆ†ç±»åˆ†å¸ƒå›¾
      this.categoryChartOptions = {
    // æçŒ®è€…详细信息(无接口,模拟数据补全 + è¿‡æ»¤åˆ†é¡µï¼‰
    updateDonorTable() {
      let list = this.donorList;
      if (this.query.region && this.query.region.length) {
        list = list.filter(item => this.query.region.includes(item.region));
      }
      if (this.query.monthRange && this.query.monthRange.length === 2) {
        const start = this.query.monthRange[0];
        const end = this.query.monthRange[1];
        list = list.filter(item => {
          const m = item.donationTime.slice(0, 7);
          return m >= start && m <= end;
        });
      }
      if (this.query.gender) {
        list = list.filter(item => item.gender === this.query.gender);
      }
      if (this.query.bloodType) {
        list = list.filter(item => item.bloodType === this.query.bloodType);
      }
      this.total = list.length;
      const start = (this.query.page - 1) * this.query.limit;
      this.tableData = list.slice(start, start + this.query.limit);
    },
    // ç”Ÿæˆé¥¼å›¾é…ç½®ï¼ˆæ— æŽ¥å£å›¾è¡¨çš„ mock å±•示)
    buildPieOptions(name, data) {
      return {
        color: PALETTE,
        tooltip: {
          trigger: 'item',
          formatter: '{a} <br/>{b} : {c} ({d}%)'
        },
        legend: {
          orient: 'vertical',
          right: 10,
          top: 'center',
          data: this.mockData.category.map(item => item.name)
          left: 'left',
          top: 'middle',
          itemWidth: 10,
          itemHeight: 10,
          itemGap: 16,
          textStyle: { fontSize: 14, color: '#606266' },
          data: data.map(item => item.name)
        },
        series: [
          {
            name: '中国分类',
            name,
            type: 'pie',
            radius: ['30%', '70%'],
            center: ['40%', '50%'],
            roseType: 'radius',
            label: {
              show: true
            },
            radius: '55%',
            center: ['55%', '50%'],
            itemStyle: { borderColor: '#fff', borderWidth: 2 },
            label: { fontSize: 15, color: '#606266', formatter: '{b} {d}%' },
            labelLine: { lineStyle: { color: '#c0c4cc' } },
            data,
            emphasis: {
              label: {
                show: true
              itemStyle: {
                shadowBlur: 10,
                shadowOffsetX: 0,
                shadowColor: 'rgba(0, 0, 0, 0.5)'
              }
            },
            data: this.mockData.category
            }
          }
        ]
      };
      // æ­»äº¡åŽŸå› åˆ†æžå›¾
      this.deathChartOptions = {
        tooltip: {
          trigger: 'item',
          formatter: '{b}: {c} ({d}%)'
        },
        legend: {
          orient: 'vertical',
          left: 10,
          top: 'center',
          data: this.mockData.death.map(item => item.name)
        },
        series: [
          {
            name: '死亡原因',
            type: 'pie',
            radius: ['30%', '60%'],
            center: ['60%', '50%'],
            avoidLabelOverlap: false,
            label: {
              show: true,
              formatter: '{b|{b}}\n{c|{c} ({d}%)}',
              rich: {
                b: {
                  fontSize: 12,
                  lineHeight: 20
                },
                c: {
                  fontSize: 10,
                  color: '#999'
                }
              }
            },
            emphasis: {
              label: {
                show: true,
                fontSize: '16',
                fontWeight: 'bold'
              }
            },
            data: this.mockData.death
          }
        ]
      };
    },
    // èŽ·å–è¡€åž‹æ ‡ç­¾ç±»åž‹
    getBloodTypeTag(type) {
      const map = { 'A': 'danger', 'B': 'primary', 'O': 'success', 'AB': 'warning' };
      return map[type] || 'info';
    },
    // å¤„理查询
@@ -628,14 +540,6 @@
    // å¤„理导出
    handleExport() {
      this.$message.info('导出功能开发中...');
    },
    // æŸ¥çœ‹æçŒ®è€…详情
    viewDonorDetail(row) {
      this.$router.push({
        path: '/donor/detail',
        query: { id: row.id }
      });
    }
  }
};
src/views/statistics/index.vue
@@ -1,6 +1,21 @@
<!-- src/views/statistics/index.vue -->
<template>
  <div class="statistics-home">
    <el-card class="mode-card" shadow="never">
      <div class="mode-bar">
        <span class="mode-label">统计数据源:</span>
        <el-switch
          v-model="useMock"
          active-text="模拟数据"
          inactive-text="真实接口"
          active-color="#E6A23C"
          inactive-color="#409EFF"
          @change="handleModeChange"
        />
        <span class="mode-tip">切换后进入各统计页面生效(模拟数据用于演示,真实接口读取后端)</span>
      </div>
    </el-card>
    <el-row :gutter="20">
      <el-col :span="6" v-for="item in menuList" :key="item.path">
        <el-card
@@ -24,38 +39,41 @@
</template>
<script>
import { isStatisticsMock, setStatisticsMock } from '@/utils/statisticsMode';
export default {
  name: 'StatisticsHome',
  data() {
    return {
      useMock: isStatisticsMock(),
      menuList: [
        {
          title: '案例统计',
          desc: '地区、月份维度的案例统计分析',
          icon: 'el-icon-s-data',
          color: '#409EFF',
          path: '/statistics/case'
          path: '/statics/caseStatistics'
        },
        {
          title: '捐献器官统计',
          desc: '器官获取、移植数量统计',
          icon: 'el-icon-s-claim',
          color: '#67C23A',
          path: '/statistics/organ'
          path: '/statics/organstatistics'
        },
        {
          title: '获取率统计',
          desc: '捐献获取率、器官弃用率分析',
          icon: 'el-icon-s-flag',
          color: '#E6A23C',
          path: '/statistics/rate'
          path: '/statics/statisticsStatistics'
        },
        {
          title: '捐献意愿统计',
          desc: '捐献意愿趋势与分布分析',
          icon: 'el-icon-star-on',
          color: '#F56C6C',
          path: '/statistics/willingness'
          path: '/statics/donorstatistics'
        },
        {
          title: '捐献者分析',
@@ -75,6 +93,9 @@
    };
  },
  methods: {
    handleModeChange(val) {
      setStatisticsMock(val);
    },
    goToPage(path) {
      this.$router.push(path);
    }
@@ -87,6 +108,27 @@
  padding: 20px;
}
.mode-card {
  margin-bottom: 20px;
}
.mode-bar {
  display: flex;
  align-items: center;
}
.mode-label {
  font-size: 14px;
  color: #303133;
  margin-right: 10px;
}
.mode-tip {
  margin-left: 16px;
  font-size: 12px;
  color: #909399;
}
.stat-card {
  cursor: pointer;
  margin-bottom: 20px;
src/views/statistics/organ.vue
@@ -81,20 +81,20 @@
        border
        stripe
      >
        <el-table-column label="年份" prop="year" align="center"  />
        <el-table-column label="器官名称" prop="organName" align="center"  />
        <el-table-column label="获取数量" prop="acquisitionCount" align="center"  />
        <el-table-column label="移植数量" prop="transplantCount" align="center"  />
        <el-table-column label="移植率" prop="transplantRate" align="center" >
        <el-table-column label="年份" prop="year" align="center" />
        <el-table-column label="器官名称" prop="organName" align="center" />
        <el-table-column label="获取数量" prop="acquisitionCount" align="center" />
        <el-table-column label="移植数量" prop="transplantCount" align="center" />
        <el-table-column label="移植率" prop="transplantRate" align="center">
          <template slot-scope="{ row }">
            <el-tag :type="getRateTag(row.transplantRate)" size="small">
              {{ row.transplantRate }}%
            </el-tag>
          </template>
        </el-table-column>
        <el-table-column label="弃用数量" prop="abandonCount" align="center"  />
        <el-table-column label="地区" prop="region" align="center"  />
        <el-table-column label="操作" align="center"  fixed="right">
        <el-table-column label="弃用数量" prop="abandonCount" align="center" />
        <el-table-column label="地区" prop="region" align="center" />
        <el-table-column label="操作" align="center" fixed="right">
          <template slot-scope="{ row }">
            <el-button
              type="text"
@@ -139,6 +139,10 @@
import FilterPanel from '@/components/charts/FilterPanel.vue';
import EChartsWrapper from '@/components/charts/EChartsWrapper.vue';
import OrganDetail from './components/OrganDetail.vue';
import { organ, organMoon, organType } from '@/api/businessApi';
import { toTotalsList, toMonth, toNum } from '@/utils/totals';
import statisticsMode from '@/utils/statisticsMode';
import { COLORS, PALETTE, axisTooltip, grid, xAxis, yAxis, legend, barSeries } from '@/utils/chartStyle';
export default {
  name: 'OrganStatistics',
@@ -166,13 +170,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'
        },
@@ -191,12 +195,12 @@
          prop: 'organType',
          type: 'select',
          options: [
            { label: '全肝', value: 'liver' },
            { label: '肾脏', value: 'kidney' },
            { label: '心脏', value: 'heart' },
            { label: '肺', value: 'lung' },
            { label: '胰腺', value: 'pancreas' },
            { label: '眼角膜', value: 'cornea' }
            { label: '全肝', value: '全肝' },
            { label: '肾脏', value: '肾脏' },
            { label: '心脏', value: '心脏' },
            { label: '肺', value: '肺' },
            { label: '胰腺', value: '胰腺' },
            { label: '眼角膜', value: '眼角膜' }
          ],
          width: '200px'
        }
@@ -211,93 +215,97 @@
      // æ¦‚览数据
      overviewData: [
        { title: '总获取数量', value: 0, desc: '统计周期内', icon: 'el-icon-s-claim', color: '#409EFF' },
        { title: '总移植数量', value: 0, desc: '统计周期内', icon: 'el-icon-success', color: '#67C23A' },
        { title: '总移植率', value: '0%', desc: '统计周期内', icon: 'el-icon-s-data', color: '#E6A23C' },
        { title: '总弃用数量', value: 0, desc: '统计周期内', icon: 'el-icon-warning', color: '#F56C6C' }
        { title: '总获取数量', value: 0, desc: '统计周期内', icon: 'el-icon-s-claim', color: '#5B8FF9' },
        { title: '总移植数量', value: 0, desc: '统计周期内', icon: 'el-icon-success', color: '#5AD8A6' },
        { title: '总移植率', value: '0%', desc: '统计周期内', icon: 'el-icon-s-data', color: '#F6BD16' },
        { title: '总弃用数量', value: 0, desc: '统计周期内', icon: 'el-icon-warning', color: '#E8684A' }
      ],
      // å›¾è¡¨é…ç½®
      trendChartOptions: {},
      distributionChartOptions: {},
      // å›¾è¡¨æ•°æ®
      chartData: {
        trend: [],
        distribution: []
      },
      // å¼¹çª—控制
      showOrganDetail: false,
      detailQuery: {},
      // æ¨¡æ‹Ÿæ•°æ®
      mockData: {
        table: [
          { id: 1, year: '2024', organName: '全肝', acquisitionCount: 15, transplantCount: 12, transplantRate: 80.0, abandonCount: 3, region: '青岛地区' },
          { id: 2, year: '2024', organName: '肾脏', acquisitionCount: 20, transplantCount: 18, transplantRate: 90.0, abandonCount: 2, region: '青岛地区' },
          { id: 3, year: '2024', organName: '全肝', acquisitionCount: 8, transplantCount: 6, transplantRate: 75.0, abandonCount: 2, region: '日照地区' },
          { id: 4, year: '2024', organName: '心脏', acquisitionCount: 5, transplantCount: 4, transplantRate: 80.0, abandonCount: 1, region: '青岛地区' },
          { id: 5, year: '2023', organName: '全肝', acquisitionCount: 12, transplantCount: 10, transplantRate: 83.3, abandonCount: 2, region: '青岛地区' },
          { id: 6, year: '2023', organName: '眼角膜', acquisitionCount: 25, transplantCount: 22, transplantRate: 88.0, abandonCount: 3, region: '济南地区' }
        ],
        trend: [
          { year: '2020', acquisition: 120, transplant: 100 },
          { year: '2021', acquisition: 150, transplant: 130 },
          { year: '2022', acquisition: 180, transplant: 160 },
          { year: '2023', acquisition: 220, transplant: 200 },
          { year: '2024', acquisition: 250, transplant: 230 }
        ],
        distribution: [
          { name: '全肝', value: 85 },
          { name: '肾脏', value: 120 },
          { name: '心脏', value: 45 },
          { name: '肺', value: 60 },
          { name: '胰腺', value: 30 },
          { name: '眼角膜', value: 200 }
        ]
      }
      detailQuery: {}
    };
  },
  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];
      }
      if (this.query.organType) {
        q.organName = this.query.organType;
      }
      return q;
    },
    // åŠ è½½æ•°æ®
    async loadData() {
      this.loading = true;
      try {
        await new Promise(resolve => setTimeout(resolve, 500));
        const q = this.buildQuery();
        const [moonRes, typeRes, organRes] = await Promise.all([
          organMoon(q),
          organType(q),
          organ(q)
        ]);
        // å¤„理表格数据
        let filteredData = [...this.mockData.table];
        // èŽ·å–/移植趋势
        this.chartData.trend = toTotalsList(moonRes).map(item => ({
          time: toMonth(item.donateTime) || toMonth(item.month),
          acquisition: toNum(item.getCount),
          transplant: toNum(item.useCount)
        }));
        if (this.query.region.length > 0) {
          filteredData = filteredData.filter(item =>
            this.query.region.includes(item.region.replace('地区', '').toLowerCase())
          );
        }
        // å™¨å®˜ç±»åž‹åˆ†å¸ƒ
        this.chartData.distribution = toTotalsList(typeRes).map(item => ({
          name: item.organName || '',
          value: toNum(item.getCount)
        }));
        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)
          );
        }
        if (this.query.organType) {
          const organMap = { liver: '肝', kidney: '肾', heart: '心脏', lung: '肺', pancreas: '胰腺', cornea: '眼角膜' };
          const organName = organMap[this.query.organType];
          if (organName) {
            filteredData = filteredData.filter(item => item.organName.includes(organName));
          }
        }
        // åˆ†é¡µ
        // æ˜Žç»†è¡¨
        const all = toTotalsList(organRes).map(item => {
          const get = toNum(item.getCount);
          const use = toNum(item.useCount);
          const rate = get > 0 ? Number(((use / get) * 100).toFixed(1)) : 0;
          return {
            year: String(item.donateTime || item.year || '').slice(0, 4),
            organName: item.organName || '',
            acquisitionCount: get,
            transplantCount: use,
            transplantRate: rate,
            abandonCount: get - use,
            region: item.area || item.region || ''
          };
        });
        this.total = all.length;
        const start = (this.query.page - 1) * this.query.limit;
        this.tableData = filteredData.slice(start, start + this.query.limit);
        this.total = filteredData.length;
        this.tableData = all.slice(start, start + this.query.limit);
        // æ›´æ–°æ¦‚览数据
        this.updateOverviewData(filteredData);
        // æ›´æ–°æ¦‚览
        this.updateOverviewData(all);
        // æ›´æ–°å›¾è¡¨
        this.updateCharts();
      } catch (error) {
        console.error('加载数据失败:', error);
        this.$message.error('数据加载失败');
@@ -308,12 +316,10 @@
    // æ›´æ–°æ¦‚览数据
    updateOverviewData(data) {
      if (data.length === 0) return;
      const totalAcquisition = data.reduce((sum, item) => sum + item.acquisitionCount, 0);
      const totalTransplant = data.reduce((sum, item) => sum + item.transplantCount, 0);
      const totalAbandon = data.reduce((sum, item) => sum + item.abandonCount, 0);
      const transplantRate = totalAcquisition > 0 ? (totalTransplant / totalAcquisition * 100).toFixed(1) : 0;
      const totalAcquisition = data.reduce((sum, item) => sum + toNum(item.acquisitionCount), 0);
      const totalTransplant = data.reduce((sum, item) => sum + toNum(item.transplantCount), 0);
      const totalAbandon = data.reduce((sum, item) => sum + toNum(item.abandonCount), 0);
      const transplantRate = totalAcquisition > 0 ? ((totalTransplant / totalAcquisition) * 100).toFixed(1) : 0;
      this.overviewData[0].value = totalAcquisition;
      this.overviewData[1].value = totalTransplant;
@@ -325,47 +331,20 @@
    updateCharts() {
      // è¶‹åŠ¿å›¾
      this.trendChartOptions = {
        tooltip: {
          trigger: 'axis',
          axisPointer: {
            type: 'shadow'
          }
        },
        legend: {
          data: ['获取数量', '移植数量']
        },
        grid: {
          left: '3%',
          right: '4%',
          bottom: '3%',
          containLabel: true
        },
        xAxis: {
          type: 'category',
          data: this.mockData.trend.map(item => item.year + 'å¹´')
        },
        yAxis: {
          type: 'value',
          name: '数量(个)'
        },
        tooltip: axisTooltip(),
        legend: legend(['获取数量', '移植数量']),
        grid: grid(),
        xAxis: xAxis(this.chartData.trend.map(item => item.time)),
        yAxis: yAxis(),
        series: [
          {
            name: '获取数量',
            type: 'bar',
            data: this.mockData.trend.map(item => item.acquisition),
            itemStyle: { color: '#409EFF' }
          },
          {
            name: '移植数量',
            type: 'bar',
            data: this.mockData.trend.map(item => item.transplant),
            itemStyle: { color: '#67C23A' }
          }
          barSeries('获取数量', this.chartData.trend.map(item => item.acquisition), COLORS.blue),
          barSeries('移植数量', this.chartData.trend.map(item => item.transplant), COLORS.green)
        ]
      };
      // åˆ†å¸ƒå›¾
      this.distributionChartOptions = {
        color: PALETTE,
        tooltip: {
          trigger: 'item',
          formatter: '{a} <br/>{b} : {c} ({d}%)'
@@ -373,15 +352,23 @@
        legend: {
          orient: 'vertical',
          left: 'left',
          data: this.mockData.distribution.map(item => item.name)
          top: 'middle',
          itemWidth: 10,
          itemHeight: 10,
          itemGap: 16,
          textStyle: { fontSize: 14, color: '#606266' },
          data: this.chartData.distribution.map(item => item.name)
        },
        series: [
          {
            name: '器官分布',
            type: 'pie',
            radius: '55%',
            center: ['50%', '60%'],
            data: this.mockData.distribution,
            center: ['55%', '50%'],
            itemStyle: { borderColor: '#fff', borderWidth: 2 },
            label: { fontSize: 14, color: '#606266', formatter: '{b} {d}%' },
            labelLine: { lineStyle: { color: '#c0c4cc' } },
            data: this.chartData.distribution,
            emphasis: {
              itemStyle: {
                shadowBlur: 10,
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('导出功能开发中...');
src/views/statistics/utilization.vue
@@ -29,124 +29,56 @@
    <el-card style="margin-bottom: 20px;">
      <div slot="header" class="clearfix">
        <h3>2. å™¨å®˜å¼ƒç”¨åŽŸå› åˆ†æž</h3>
        <el-form :inline="true" style="float: right;">
          <el-form-item label="器官类型" style="margin-bottom: 0;">
            <el-select
              v-model="abandonQuery.organType"
              placeholder="请选择器官类型"
              clearable
              size="mini"
              style="width: 150px"
              @change="handleAbandonQuery"
            >
              <el-option
                v-for="item in organOptions"
                :key="item.value"
                :label="item.label"
                :value="item.value"
              />
            </el-select>
          </el-form-item>
        </el-form>
      </div>
      <EChartsWrapper
        v-if="useMock"
        :id="'abandon-analysis-chart'"
        :options="abandonChartOptions"
        height="400px"
      />
      <el-empty v-else description="后端暂未提供器官弃用原因统计接口"></el-empty>
    </el-card>
    <!-- ç¬¬ä¸‰éƒ¨åˆ†ï¼šæ¯ä¾›ä½“器官获取数量 -->
    <el-card>
      <div slot="header" class="clearfix">
        <h3>3. æ¯ä¾›ä½“器官获取数量</h3>
        <el-form :inline="true" style="float: right;">
          <el-form-item label="年份" style="margin-bottom: 0;">
            <el-date-picker
              v-model="perDonorQuery.year"
              type="year"
              placeholder="选择年份"
              value-format="yyyy"
              size="mini"
              style="width: 120px"
              @change="handlePerDonorQuery"
            />
          </el-form-item>
          <el-form-item label="器官类型" style="margin-bottom: 0;">
            <el-select
              v-model="perDonorQuery.organType"
              placeholder="请选择器官类型"
              clearable
              size="mini"
              style="width: 150px"
              @change="handlePerDonorQuery"
            >
              <el-option
                v-for="item in organOptions"
                :key="item.value"
                :label="item.label"
                :value="item.value"
              />
            </el-select>
          </el-form-item>
        </el-form>
      </div>
      <el-table
        v-loading="loading"
        :data="tableData"
        style="width: 100%"
        border
        stripe
      >
        <el-table-column label="年份" prop="year" align="center"  />
        <el-table-column label="供体姓名" prop="donorName" align="center"  />
        <el-table-column label="获取数量" prop="acquisitionCount" align="center" >
          <template slot-scope="{ row }">
            <el-tag :type="getCountTag(row.acquisitionCount)" size="small">
              {{ row.acquisitionCount }}
            </el-tag>
          </template>
        </el-table-column>
        <el-table-column label="全肝" prop="liver" align="center"  />
        <el-table-column label="左半肝" prop="leftLiver" align="center"  />
        <el-table-column label="右半肝" prop="rightLiver" align="center"  />
        <el-table-column label="心脏" prop="heart" align="center"  />
        <el-table-column label="小肠" prop="intestine" align="center"  />
        <el-table-column label="胰腺" prop="pancreas" align="center"  />
        <el-table-column label="左肾" prop="leftKidney" align="center"  />
        <el-table-column label="右肾" prop="rightKidney" align="center"  />
        <el-table-column label="全肺" prop="fullLung" align="center"  />
        <el-table-column label="左肺" prop="leftLung" align="center"  />
        <el-table-column label="右肺" prop="rightLung" align="center"  />
        <el-table-column label="左眼角膜" prop="leftCornea" align="center"  />
        <el-table-column label="右眼角膜" prop="rightCornea" align="center"  />
        <el-table-column label="地区" prop="region" align="center"  />
        <el-table-column label="操作" align="center"  fixed="right">
          <template slot-scope="{ row }">
            <el-button
              type="text"
              size="mini"
              @click="viewDonorDetail(row)"
            >
              è¯¦æƒ…
            </el-button>
          </template>
        </el-table-column>
      </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]"
      />
      <template v-if="useMock">
        <el-table
          v-loading="loading"
          :data="perDonorTable"
          style="width: 100%"
          border
          stripe
        >
          <el-table-column label="年份" prop="year" align="center" />
          <el-table-column label="供体姓名" prop="donorName" align="center" />
          <el-table-column label="获取数量" prop="acquisitionCount" align="center">
            <template slot-scope="{ row }">
              <el-tag :type="getCountTag(row.acquisitionCount)" size="small">
                {{ row.acquisitionCount }}
              </el-tag>
            </template>
          </el-table-column>
          <el-table-column label="全肝" prop="liver" align="center" />
          <el-table-column label="左半肝" prop="leftLiver" align="center" />
          <el-table-column label="右半肝" prop="rightLiver" align="center" />
          <el-table-column label="心脏" prop="heart" align="center" />
          <el-table-column label="小肠" prop="intestine" align="center" />
          <el-table-column label="胰腺" prop="pancreas" align="center" />
          <el-table-column label="左肾" prop="leftKidney" align="center" />
          <el-table-column label="右肾" prop="rightKidney" align="center" />
          <el-table-column label="全肺" prop="fullLung" align="center" />
          <el-table-column label="左肺" prop="leftLung" align="center" />
          <el-table-column label="右肺" prop="rightLung" align="center" />
          <el-table-column label="左眼角膜" prop="leftCornea" align="center" />
          <el-table-column label="右眼角膜" prop="rightCornea" align="center" />
          <el-table-column label="地区" prop="region" align="center" />
        </el-table>
      </template>
      <el-empty v-else description="后端暂未提供每供体器官获取明细接口"></el-empty>
    </el-card>
  </div>
</template>
@@ -154,6 +86,10 @@
<script>
import FilterPanel from '@/components/charts/FilterPanel.vue';
import EChartsWrapper from '@/components/charts/EChartsWrapper.vue';
import { organType } from '@/api/businessApi';
import { toTotalsList, toNum } from '@/utils/totals';
import statisticsMode, { isStatisticsMock } from '@/utils/statisticsMode';
import { COLORS, axisTooltip, grid, xAxis, yAxis, legend, barSeries } from '@/utils/chartStyle';
export default {
  name: 'OrganUtilization',
@@ -167,8 +103,6 @@
      query: {
        region: [],
        monthRange: [],
        ageRange: [],
        chinaCategory: [],
        organType: null,
        page: 1,
        limit: 10
@@ -182,13 +116,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'
        },
@@ -203,60 +137,19 @@
          width: '300px'
        },
        {
          label: '年龄段',
          prop: 'ageRange',
          type: 'select',
          multiple: true,
          options: [
            { label: '<17岁', value: '0-17' },
            { label: '18-49岁', value: '18-49' },
            { label: '50-69岁', value: '50-69' },
            { label: '>69岁', value: '70-100' }
          ],
          width: '200px'
        },
        {
          label: '中国分类',
          prop: 'chinaCategory',
          type: 'select',
          multiple: true,
          options: [
            { label: '中国一类(DBD)', value: '1' },
            { label: '中国二类(DCD)', value: '2' },
            { label: '中国三类(DBCD)', value: '3' }
          ],
          width: '200px'
        },
        {
          label: '器官类型',
          prop: 'organType',
          type: 'select',
          options: [
            { label: '全肝', value: 'liver' },
            { label: '肾脏', value: 'kidney' },
            { label: '心脏', value: 'heart' },
            { label: '肺', value: 'lung' },
            { label: '眼角膜', value: 'cornea' }
            { label: '全肝', value: '全肝' },
            { label: '肾脏', value: '肾脏' },
            { label: '心脏', value: '心脏' },
            { label: '肺', value: '肺' },
            { label: '胰腺', value: '胰腺' },
            { label: '眼角膜', value: '眼角膜' }
          ],
          width: '200px'
        }
      ],
      // å™¨å®˜é€‰é¡¹
      organOptions: [
        { label: '全肝', value: 'liver' },
        { label: '左半肝', value: 'leftLiver' },
        { label: '右半肝', value: 'rightLiver' },
        { label: '心脏', value: 'heart' },
        { label: '小肠', value: 'intestine' },
        { label: '胰腺', value: 'pancreas' },
        { label: '左肾', value: 'leftKidney' },
        { label: '右肾', value: 'rightKidney' },
        { label: '全肺', value: 'fullLung' },
        { label: '左肺', value: 'leftLung' },
        { label: '右肺', value: 'rightLung' },
        { label: '左眼角膜', value: 'leftCornea' },
        { label: '右眼角膜', value: 'rightCornea' }
      ],
      // åŠ è½½çŠ¶æ€
@@ -264,322 +157,33 @@
      // å›¾è¡¨é…ç½®
      acquisitionChartOptions: {},
      abandonChartOptions: {},
      // è¡¨æ ¼æ•°æ®
      tableData: [],
      total: 0,
      // å™¨å®˜ç±»åž‹åˆ†å¸ƒæ•°æ®
      organData: [],
      // å¼ƒç”¨åˆ†æžæŸ¥è¯¢å‚æ•°
      abandonQuery: {
        organType: null
      },
      // æ¯ä¾›ä½“查询参数
      perDonorQuery: {
        year: '2024',
        organType: null
      },
      // æ¨¡æ‹Ÿæ•°æ®
      mockData: {
        acquisition: [
          {
            month: '2024-01',
            region: '青岛地区',
            liver: { acquisition: 8, transplant: 7 },
            kidney: { acquisition: 12, transplant: 10 },
            heart: { acquisition: 3, transplant: 3 },
            lung: { acquisition: 5, transplant: 4 },
            cornea: { acquisition: 20, transplant: 18 }
          },
          {
            month: '2024-02',
            region: '青岛地区',
            liver: { acquisition: 10, transplant: 9 },
            kidney: { acquisition: 15, transplant: 13 },
            heart: { acquisition: 4, transplant: 3 },
            lung: { acquisition: 6, transplant: 5 },
            cornea: { acquisition: 25, transplant: 22 }
          },
          {
            month: '2024-01',
            region: '日照地区',
            liver: { acquisition: 5, transplant: 4 },
            kidney: { acquisition: 8, transplant: 7 },
            heart: { acquisition: 2, transplant: 2 },
            lung: { acquisition: 3, transplant: 2 },
            cornea: { acquisition: 15, transplant: 13 }
          }
        ],
        abandon: [
          { name: '器官质量不合格', value: 45 },
          { name: '供体疾病', value: 25 },
          { name: '运输问题', value: 15 },
          { name: '受体匹配问题', value: 10 },
          { name: '其他原因', value: 5 }
        ],
        table: [
          {
            id: 1,
            year: '2024',
            donorName: '张三',
            acquisitionCount: 5,
            liver: 1,
            leftLiver: 0,
            rightLiver: 0,
            heart: 0,
            intestine: 0,
            pancreas: 0,
            leftKidney: 1,
            rightKidney: 1,
            fullLung: 0,
            leftLung: 0,
            rightLung: 0,
            leftCornea: 1,
            rightCornea: 1,
            region: '青岛地区'
          },
          {
            id: 2,
            year: '2024',
            donorName: '李四',
            acquisitionCount: 3,
            liver: 0,
            leftLiver: 0,
            rightLiver: 0,
            heart: 1,
            intestine: 0,
            pancreas: 0,
            leftKidney: 0,
            rightKidney: 0,
            fullLung: 0,
            leftLung: 1,
            rightLung: 1,
            leftCornea: 0,
            rightCornea: 0,
            region: '青岛地区'
          },
          {
            id: 3,
            year: '2024',
            donorName: '王五',
            acquisitionCount: 6,
            liver: 1,
            leftLiver: 0,
            rightLiver: 0,
            heart: 0,
            intestine: 0,
            pancreas: 1,
            leftKidney: 1,
            rightKidney: 1,
            fullLung: 0,
            leftLung: 0,
            rightLung: 0,
            leftCornea: 1,
            rightCornea: 1,
            region: '日照地区'
          },
          {
            id: 4,
            year: '2024',
            donorName: '赵六',
            acquisitionCount: 4,
            liver: 0,
            leftLiver: 0,
            rightLiver: 0,
            heart: 0,
            intestine: 0,
            pancreas: 0,
            leftKidney: 1,
            rightKidney: 1,
            fullLung: 0,
            leftLung: 0,
            rightLung: 0,
            leftCornea: 1,
            rightCornea: 1,
            region: '济南地区'
          },
          {
            id: 5,
            year: '2023',
            donorName: '钱七',
            acquisitionCount: 3,
            liver: 0,
            leftLiver: 0,
            rightLiver: 0,
            heart: 1,
            intestine: 0,
            pancreas: 0,
            leftKidney: 0,
            rightKidney: 0,
            fullLung: 1,
            leftLung: 0,
            rightLung: 1,
            leftCornea: 0,
            rightCornea: 0,
            region: '烟台地区'
          }
        ]
      }
      // æ— æŽ¥å£éƒ¨åˆ†çš„æ¨¡æ‹Ÿæ•°æ®ï¼ˆä»… mock æ¨¡å¼å±•示)
      abandonReasons: [
        { name: '器官质量不合格', value: 45 },
        { name: '供体疾病', value: 25 },
        { name: '运输问题', value: 15 },
        { name: '受体匹配问题', value: 10 },
        { name: '其他原因', value: 5 }
      ],
      perDonorTable: [
        { id: 1, year: '2024', donorName: '张三', acquisitionCount: 5, liver: 1, leftLiver: 0, rightLiver: 0, heart: 0, intestine: 0, pancreas: 0, leftKidney: 1, rightKidney: 1, fullLung: 0, leftLung: 0, rightLung: 0, leftCornea: 1, rightCornea: 1, region: '青岛地区' },
        { id: 2, year: '2024', donorName: '李四', acquisitionCount: 3, liver: 0, leftLiver: 0, rightLiver: 0, heart: 1, intestine: 0, pancreas: 0, leftKidney: 0, rightKidney: 0, fullLung: 0, leftLung: 1, rightLung: 1, leftCornea: 0, rightCornea: 0, region: '青岛地区' },
        { id: 3, year: '2024', donorName: '王五', acquisitionCount: 6, liver: 1, leftLiver: 0, rightLiver: 0, heart: 0, intestine: 0, pancreas: 1, leftKidney: 1, rightKidney: 1, fullLung: 0, leftLung: 0, rightLung: 0, leftCornea: 1, rightCornea: 1, region: '日照地区' },
        { id: 4, year: '2024', donorName: '赵六', acquisitionCount: 4, liver: 0, leftLiver: 0, rightLiver: 0, heart: 0, intestine: 0, pancreas: 0, leftKidney: 1, rightKidney: 1, fullLung: 0, leftLung: 0, rightLung: 0, leftCornea: 1, rightCornea: 1, region: '济南地区' },
        { id: 5, year: '2023', donorName: '钱七', acquisitionCount: 3, liver: 0, leftLiver: 0, rightLiver: 0, heart: 1, intestine: 0, pancreas: 0, leftKidney: 0, rightKidney: 0, fullLung: 1, leftLung: 0, rightLung: 1, leftCornea: 0, rightCornea: 0, region: '烟台地区' }
      ]
    };
  },
  created() {
    this.loadData();
  },
  methods: {
    // åŠ è½½æ•°æ®
    async loadData() {
      this.loading = true;
      try {
        await new Promise(resolve => setTimeout(resolve, 500));
        // å¤„理表格数据
        let filteredData = [...this.mockData.table];
        if (this.query.region.length > 0) {
          filteredData = filteredData.filter(item =>
            this.query.region.includes(item.region.replace('地区', '').toLowerCase())
          );
        }
        if (this.query.monthRange && this.query.monthRange.length === 2) {
          // è¿™é‡Œå¯ä»¥æ·»åŠ æœˆä»½ç­›é€‰é€»è¾‘
        }
        if (this.query.ageRange.length > 0) {
          // è¿™é‡Œå¯ä»¥æ·»åŠ å¹´é¾„ç­›é€‰é€»è¾‘
        }
        if (this.query.chinaCategory.length > 0) {
          // è¿™é‡Œå¯ä»¥æ·»åŠ ä¸­å›½åˆ†ç±»ç­›é€‰é€»è¾‘
        }
        if (this.query.organType) {
          const organMap = { liver: 'liver', kidney: ['leftKidney', 'rightKidney'], heart: 'heart', lung: ['fullLung', 'leftLung', 'rightLung'], cornea: ['leftCornea', 'rightCornea'] };
          const organProp = organMap[this.query.organType];
          if (organProp) {
            if (Array.isArray(organProp)) {
              filteredData = filteredData.filter(item =>
                organProp.some(prop => item[prop] > 0)
              );
            } else {
              filteredData = filteredData.filter(item => item[organProp] > 0);
            }
          }
        }
        // åˆ†é¡µ
        const start = (this.query.page - 1) * this.query.limit;
        this.tableData = filteredData.slice(start, start + this.query.limit);
        this.total = filteredData.length;
        // æ›´æ–°å›¾è¡¨
        this.updateCharts();
      } catch (error) {
        console.error('加载数据失败:', error);
        this.$message.error('数据加载失败');
      } finally {
        this.loading = false;
      }
  computed: {
    useMock() {
      return isStatisticsMock();
    },
    // æ›´æ–°å›¾è¡¨
    updateCharts() {
      // èŽ·å–ç§»æ¤ç»Ÿè®¡å›¾
      const regions = [...new Set(this.mockData.acquisition.map(item => item.region))];
      const months = [...new Set(this.mockData.acquisition.map(item => item.month))];
      const organTypes = ['liver', 'kidney', 'heart', 'lung', 'cornea'];
      const organNames = ['肝脏', '肾脏', '心脏', '肺', '眼角膜'];
      const seriesData = [];
      const xAxisData = [];
      // ç”Ÿæˆx轴数据
      months.forEach(month => {
        regions.forEach(region => {
          xAxisData.push(`${month}\n${region}`);
        });
      });
      // ç”Ÿæˆç³»åˆ—数据
      organTypes.forEach((organType, index) => {
        const data = [];
        months.forEach(month => {
          regions.forEach(region => {
            const item = this.mockData.acquisition.find(d => d.month === month && d.region === region);
            if (item) {
              data.push(item[organType]?.acquisition || 0);
            } else {
              data.push(0);
            }
          });
        });
        seriesData.push({
          name: `${organNames[index]}-获取`,
          type: 'bar',
          stack: '总量',
          data: data,
          itemStyle: this.getSeriesColor(index * 2)
        });
        const transplantData = [];
        months.forEach(month => {
          regions.forEach(region => {
            const item = this.mockData.acquisition.find(d => d.month === month && d.region === region);
            if (item) {
              transplantData.push(item[organType]?.transplant || 0);
            } else {
              transplantData.push(0);
            }
          });
        });
        seriesData.push({
          name: `${organNames[index]}-移植`,
          type: 'bar',
          stack: '总量',
          data: transplantData,
          itemStyle: this.getSeriesColor(index * 2 + 1)
        });
      });
      this.acquisitionChartOptions = {
        tooltip: {
          trigger: 'axis',
          axisPointer: {
            type: 'shadow'
          }
        },
        legend: {
          data: seriesData.map(item => item.name),
          top: 20
        },
        grid: {
          left: '3%',
          right: '4%',
          bottom: '3%',
          containLabel: true
        },
        xAxis: {
          type: 'category',
          data: xAxisData,
          axisLabel: {
            interval: 0,
            rotate: 45
          }
        },
        yAxis: {
          type: 'value',
          name: '数量(个)'
        },
        series: seriesData
      };
      // å¼ƒç”¨åˆ†æžå›¾
      this.abandonChartOptions = {
    abandonChartOptions() {
      return {
        tooltip: {
          trigger: 'item',
          formatter: '{a} <br/>{b} : {c} ({d}%)'
@@ -587,7 +191,7 @@
        legend: {
          orient: 'vertical',
          left: 'left',
          data: this.mockData.abandon.map(item => item.name)
          data: this.abandonReasons.map(item => item.name)
        },
        series: [
          {
@@ -595,7 +199,7 @@
            type: 'pie',
            radius: '55%',
            center: ['50%', '60%'],
            data: this.mockData.abandon,
            data: this.abandonReasons,
            emphasis: {
              itemStyle: {
                shadowBlur: 10,
@@ -606,18 +210,65 @@
          }
        ]
      };
    }
  },
  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.monthRange && this.query.monthRange.length === 2) {
        q.startTime = this.query.monthRange[0];
        q.endTime = this.query.monthRange[1];
      }
      if (this.query.organType) {
        q.organName = this.query.organType;
      }
      return q;
    },
    // èŽ·å–ç³»åˆ—é¢œè‰²
    getSeriesColor(index) {
      const colors = [
        '#409EFF', '#8cc5ff',  // è‚è„
        '#67C23A', '#95d475',  // è‚¾è„
        '#E6A23C', '#f3c96b',  // å¿ƒè„
        '#F56C6C', '#f89898',  // è‚º
        '#909399', '#b1b3b8'   // çœ¼è§’膜
      ];
      return { color: colors[index % colors.length] };
    // åŠ è½½æ•°æ®
    async loadData() {
      this.loading = true;
      try {
        const q = this.buildQuery();
        const typeRes = await organType(q);
        this.organData = toTotalsList(typeRes).map(item => ({
          name: item.organName || '未知',
          acquisition: toNum(item.getCount),
          transplant: toNum(item.useCount)
        }));
        this.updateCharts();
      } catch (error) {
        console.error('加载数据失败:', error);
        this.$message.error('数据加载失败');
      } finally {
        this.loading = false;
      }
    },
    // æ›´æ–°å›¾è¡¨
    updateCharts() {
      this.acquisitionChartOptions = {
        tooltip: axisTooltip(),
        legend: legend(['获取数量', '移植数量']),
        grid: grid(),
        xAxis: xAxis(this.organData.map(item => item.name)),
        yAxis: yAxis('数量(个)'),
        series: [
          barSeries('获取数量', this.organData.map(item => item.acquisition), COLORS.blue),
          barSeries('移植数量', this.organData.map(item => item.transplant), COLORS.green)
        ]
      };
    },
    // èŽ·å–æ•°é‡æ ‡ç­¾
@@ -638,8 +289,6 @@
      this.query = {
        region: [],
        monthRange: [],
        ageRange: [],
        chinaCategory: [],
        organType: null,
        page: 1,
        limit: 10
@@ -647,42 +296,16 @@
      this.loadData();
    },
    // å¤„理分页
    handlePagination(page) {
      this.query.page = page;
      this.loadData();
    },
    // å¤„理分页大小变化
    handlePageSizeChange(size) {
      this.query.limit = size;
      this.query.page = 1;
      this.loadData();
    },
    // å¤„理弃用查询
    handleAbandonQuery() {
      // è¿™é‡Œå¯ä»¥æ ¹æ®abandonQuery筛选数据
      console.log('弃用查询:', this.abandonQuery);
    },
    // å¤„理每供体查询
    handlePerDonorQuery() {
      this.loadData();
    },
    // å¤„理导出
    handleExport() {
      this.$message.info('导出功能开发中...');
    },
    // æŸ¥çœ‹æçŒ®è€…详情
    viewDonorDetail(row) {
      this.$router.push({
        path: '/donor/detail',
        query: { id: row.id }
      });
    }
  }
};
</script>
<style scoped>
.statistics-page {
  padding: 20px;
}
</style>
src/views/statistics/willingness.vue
@@ -79,7 +79,6 @@
            {{ formatMonth(row.month) }}
          </template>
        </el-table-column>
        <el-table-column label="器官名称" prop="organName" align="center"  />
        <el-table-column label="案例数量" prop="caseCount" align="center"  />
        <el-table-column label="捐献数量" prop="donationCount" align="center"  />
        <el-table-column label="捐献意愿" prop="willingnessRate" align="center" >
@@ -98,6 +97,18 @@
          </template>
        </el-table-column>
      </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>
@@ -105,7 +116,12 @@
<script>
import FilterPanel from '@/components/charts/FilterPanel.vue';
import EChartsWrapper from '@/components/charts/EChartsWrapper.vue';
import { caseTrend, caseTrendArea, 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';
// æçŒ®æ„æ„¿ = å®ŒæˆæçŒ® / æ½œåœ¨æçŒ®ï¼Œç»Ÿä¸€å¤ç”¨ case ç›¸å…³æŽ¥å£æŽ¨å¯¼
export default {
  name: 'WillingnessStatistics',
  components: {
@@ -118,7 +134,6 @@
      query: {
        region: [],
        monthRange: [],
        organType: null,
        page: 1,
        limit: 10
      },
@@ -131,13 +146,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'
        },
@@ -150,20 +165,6 @@
          endPlaceholder: '结束月份',
          valueFormat: 'yyyy-MM',
          width: '300px'
        },
        {
          label: '器官类型',
          prop: 'organType',
          type: 'select',
          options: [
            { label: '全肝', value: 'liver' },
            { label: '肾脏', value: 'kidney' },
            { label: '心脏', value: 'heart' },
            { label: '肺', value: 'lung' },
            { label: '眼角膜', value: 'cornea' },
            { label: '多器官', value: 'multi' }
          ],
          width: '200px'
        }
      ],
@@ -172,92 +173,125 @@
      // æ¦‚览数据
      overviewData: [
        { title: '总案例数', value: 0, desc: '统计周期内', icon: 'el-icon-s-flag', color: '#409EFF' },
        { title: '总捐献数量', value: 0, desc: '统计周期内', icon: 'el-icon-check', color: '#67C23A' },
        { title: '平均捐献意愿', value: '0%', desc: '统计周期内', icon: 'el-icon-s-data', color: '#E6A23C' },
        { title: '趋势变化', value: '0%', desc: '统计周期内', icon: 'el-icon-trend', color: '#F56C6C' }
        { title: '总案例数', value: 0, desc: '统计周期内', icon: 'el-icon-s-flag', color: '#5B8FF9' },
        { title: '总捐献数量', value: 0, desc: '统计周期内', icon: 'el-icon-check', color: '#5AD8A6' },
        { title: '平均捐献意愿', value: '0%', desc: '统计周期内', icon: 'el-icon-s-data', color: '#F6BD16' },
        { title: '趋势变化', value: '0%', desc: '统计周期内', icon: 'el-icon-trend', color: '#E8684A' }
      ],
      // å›¾è¡¨é…ç½®
      trendChartOptions: {},
      regionChartOptions: {},
      // å›¾è¡¨æ•°æ®
      trendData: [],
      regionData: [],
      // è¡¨æ ¼æ•°æ®
      tableData: [],
      total: 0,
      // æ¨¡æ‹Ÿæ•°æ®
      mockData: {
        table: [
          { id: 1, region: '青岛地区', month: '2024-01', organName: '全肝', caseCount: 15, donationCount: 10, willingnessRate: 66.7, monthCompare: 2.5 },
          { id: 2, region: '青岛地区', month: '2024-01', organName: '肾脏', caseCount: 20, donationCount: 16, willingnessRate: 80.0, monthCompare: 1.2 },
          { id: 3, region: '日照地区', month: '2024-01', organName: '全肝', caseCount: 8, donationCount: 5, willingnessRate: 62.5, monthCompare: 0.8 },
          { id: 4, region: '日照地区', month: '2024-01', organName: '眼角膜', caseCount: 12, donationCount: 9, willingnessRate: 75.0, monthCompare: 3.1 },
          { id: 5, region: '青岛地区', month: '2024-02', organName: '全肝', caseCount: 18, donationCount: 12, willingnessRate: 66.7, monthCompare: 0.0 },
          { id: 6, region: '青岛地区', month: '2024-02', organName: '心脏', caseCount: 5, donationCount: 4, willingnessRate: 80.0, monthCompare: 5.2 },
          { id: 7, region: '济南地区', month: '2024-02', organName: '全肝', caseCount: 25, donationCount: 20, willingnessRate: 80.0, monthCompare: 1.8 },
          { id: 8, region: '济南地区', month: '2024-02', organName: '多器官', caseCount: 8, donationCount: 6, willingnessRate: 75.0, monthCompare: 2.3 }
        ],
        trend: [
          { month: '2023-10', willingness: 72.5, cases: 80, donations: 58 },
          { month: '2023-11', willingness: 73.8, cases: 85, donations: 63 },
          { month: '2023-12', willingness: 75.2, cases: 90, donations: 68 },
          { month: '2024-01', willingness: 76.8, cases: 95, donations: 73 },
          { month: '2024-02', willingness: 78.3, cases: 100, donations: 78 },
          { month: '2024-03', willingness: 79.1, cases: 105, donations: 83 }
        ],
        region: [
          { region: '青岛地区', willingness: 78.3, cases: 150, donations: 118 },
          { region: '日照地区', willingness: 72.5, cases: 80, donations: 58 },
          { region: '济南地区', willingness: 82.6, cases: 200, donations: 165 },
          { region: '烟台地区', willingness: 75.8, cases: 100, donations: 76 }
        ]
      }
      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.monthRange && this.query.monthRange.length === 2) {
        q.startTime = this.query.monthRange[0];
        q.endTime = this.query.monthRange[1];
      }
      return q;
    },
    // è®¡ç®—意愿率
    calcWillingness(potential, complete) {
      const p = toNum(potential);
      return p > 0 ? Number(((toNum(complete) / p) * 100).toFixed(1)) : 0;
    },
    // åŠ è½½æ•°æ®
    async loadData() {
      this.loading = true;
      try {
        await new Promise(resolve => setTimeout(resolve, 500));
        const q = this.buildQuery();
        const [trendRes, areaRes, moonRes] = await Promise.all([
          caseTrend(q),
          caseTrendArea(q),
          caseTrendMoon(q)
        ]);
        // å¤„理表格数据
        let filteredData = [...this.mockData.table];
        // è¶‹åŠ¿ï¼ˆæœˆä»½ç»´åº¦ï¼‰
        this.trendData = toTotalsList(trendRes)
          .map(item => {
            const potential = toNum(item.potentialCount);
            const complete = toNum(item.completeCount);
            return {
              month: toMonth(item.donateTime) || toMonth(item.month),
              potential,
              complete,
              willingness: this.calcWillingness(potential, complete)
            };
          })
          .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())
          );
        }
        // åœ°åŒºå¯¹æ¯”
        this.regionData = toTotalsList(areaRes).map(item => {
          const potential = toNum(item.potentialCount);
          const complete = toNum(item.completeCount);
          return {
            region: item.area || item.region || '',
            potential,
            complete,
            willingness: this.calcWillingness(potential, complete)
          };
        });
        if (this.query.monthRange && this.query.monthRange.length === 2) {
          const [start, end] = this.query.monthRange;
          filteredData = filteredData.filter(item =>
            item.month >= start && item.month <= end
          );
        }
        // æ˜Žç»†è¡¨ï¼ˆåœ°åŒº x æœˆä»½ï¼‰
        const rows = toTotalsList(moonRes)
          .map(item => {
            const potential = toNum(item.potentialCount);
            const complete = toNum(item.completeCount);
            return {
              region: item.area || item.region || '',
              month: toMonth(item.donateTime) || toMonth(item.month),
              potential,
              complete
            };
          })
          .sort((a, b) => String(a.month).localeCompare(String(b.month)));
        if (this.query.organType) {
          const organMap = { liver: '肝', kidney: '肾', heart: '心脏', lung: '肺', cornea: '眼角膜', multi: '多器官' };
          const organName = organMap[this.query.organType];
          if (organName) {
            filteredData = filteredData.filter(item => item.organName.includes(organName));
        // è®¡ç®—环比变化(同一地区,相对上一月份意愿率变化,百分点)
        rows.forEach(row => {
          const willingness = this.calcWillingness(row.potential, row.complete);
          // è¯¥åœ°åŒºæŒ‰æœˆä»½æŽ’序后的上一个月
          const regionRows = rows.filter(r => r.region === row.region);
          const idx = regionRows.indexOf(row);
          let monthCompare = 0;
          if (idx > 0) {
            const prev = regionRows[idx - 1];
            const prevWillingness = this.calcWillingness(prev.potential, prev.complete);
            monthCompare = Number((willingness - prevWillingness).toFixed(1));
          }
        }
          row.willingnessRate = willingness;
          row.caseCount = row.potential;
          row.donationCount = row.complete;
          row.monthCompare = monthCompare;
        });
        this.tableData = filteredData;
        this.total = filteredData.length;
        this.total = rows.length;
        const start = (this.query.page - 1) * this.query.limit;
        this.tableData = rows.slice(start, start + this.query.limit);
        // æ›´æ–°æ¦‚览数据
        this.updateOverviewData(filteredData);
        // æ›´æ–°å›¾è¡¨
        this.updateOverviewData(rows);
        this.updateCharts();
      } catch (error) {
        console.error('加载数据失败:', error);
        this.$message.error('数据加载失败');
@@ -267,20 +301,18 @@
    },
    // æ›´æ–°æ¦‚览数据
    updateOverviewData(data) {
      if (data.length === 0) return;
    updateOverviewData(rows) {
      const totalCases = rows.reduce((sum, r) => sum + toNum(r.potential), 0);
      const totalDonations = rows.reduce((sum, r) => sum + toNum(r.complete), 0);
      const avgWillingness = this.calcWillingness(totalCases, totalDonations);
      const totalCases = data.reduce((sum, item) => sum + item.caseCount, 0);
      const totalDonations = data.reduce((sum, item) => sum + item.donationCount, 0);
      const avgWillingness = data.length > 0
        ? (data.reduce((sum, item) => sum + parseFloat(item.willingnessRate), 0) / data.length).toFixed(1)
        : 0;
      // è®¡ç®—趋势变化(取最近两个月的平均变化)
      const recentData = data.slice(-2);
      const trend = recentData.length >= 2
        ? ((recentData[1].willingnessRate - recentData[0].willingnessRate) / recentData[0].willingnessRate * 100).toFixed(1)
        : 0;
      // è¶‹åŠ¿å˜åŒ–ï¼šæœ€è¿‘ä¸¤ä¸ªæœˆæ„æ„¿çŽ‡å·®å€¼
      let trend = 0;
      if (this.trendData.length >= 2) {
        const last = this.trendData[this.trendData.length - 1].willingness;
        const prev = this.trendData[this.trendData.length - 2].willingness;
        trend = Number((last - prev).toFixed(1));
      }
      this.overviewData[0].value = totalCases;
      this.overviewData[1].value = totalDonations;
@@ -290,139 +322,55 @@
    // æ›´æ–°å›¾è¡¨
    updateCharts() {
      // è¶‹åŠ¿å›¾
      const dualAxis = [
        {
          type: 'value',
          name: '意愿率(%)',
          nameTextStyle: { color: '#b0b6c3', fontSize: 12 },
          axisLabel: { color: '#8a919f', fontSize: 12, formatter: '{value}%' },
          splitLine: { lineStyle: { type: 'dashed', color: '#f0f1f5' } }
        },
        {
          type: 'value',
          name: '数量(个)',
          nameTextStyle: { color: '#b0b6c3', fontSize: 12 },
          axisLabel: { color: '#8a919f', fontSize: 12 },
          splitLine: { show: false }
        }
      ];
      // è¶‹åŠ¿å›¾ï¼ˆæ„æ„¿çŽ‡æŠ˜çº¿ + æ•°é‡æŸ±çŠ¶ï¼ŒåŒè½´ï¼‰
      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.month.replace('-', 'å¹´') + '月')
          boundaryGap: true,
          data: this.trendData.map(item => item.month),
          axisLine: { show: false },
          axisTick: { show: false },
          axisLabel: { color: '#8a919f', fontSize: 12 }
        },
        yAxis: [
          {
            type: 'value',
            name: '意愿率(%)',
            min: 60,
            max: 90,
            axisLabel: {
              formatter: '{value}%'
            }
          },
          {
            type: 'value',
            name: '数量(个)',
            position: 'right'
          }
        ],
        yAxis: dualAxis,
        series: [
          {
            name: '捐献意愿',
            type: 'line',
            smooth: true,
            data: this.mockData.trend.map(item => item.willingness),
            itemStyle: { color: '#409EFF' },
            markLine: {
              data: [
                { yAxis: 75, name: '目标线' }
              ],
              lineStyle: {
                type: 'dashed',
                color: '#F56C6C'
              }
            }
          },
          {
            name: '案例数量',
            type: 'bar',
            yAxisIndex: 1,
            data: this.mockData.trend.map(item => item.cases),
            itemStyle: { color: '#67C23A' }
          },
          {
            name: '捐献数量',
            type: 'bar',
            yAxisIndex: 1,
            data: this.mockData.trend.map(item => item.donations),
            itemStyle: { color: '#E6A23C' }
          }
          lineSeries('捐献意愿', this.trendData.map(item => item.willingness), COLORS.blue, { area: true }),
          barSeries('案例数量', this.trendData.map(item => item.potential), COLORS.green, { yAxisIndex: 1 }),
          barSeries('捐献数量', this.trendData.map(item => item.complete), COLORS.amber, { yAxisIndex: 1 })
        ]
      };
      // åœ°åŒºå¯¹æ¯”图
      this.regionChartOptions = {
        tooltip: {
          trigger: 'axis',
          axisPointer: {
            type: 'shadow'
          }
        },
        legend: {
          data: ['捐献意愿', '案例数量', '捐献数量']
        },
        grid: {
          left: '3%',
          right: '4%',
          bottom: '3%',
          containLabel: true
        },
        xAxis: {
          type: 'category',
          data: this.mockData.region.map(item => item.region)
        },
        yAxis: [
          {
            type: 'value',
            name: '意愿率(%)',
            min: 60,
            max: 90,
            axisLabel: {
              formatter: '{value}%'
            }
          },
          {
            type: 'value',
            name: '数量(个)',
            position: 'right'
          }
        ],
        tooltip: axisTooltip(),
        legend: legend(['捐献意愿', '案例数量', '捐献数量']),
        grid: grid(),
        xAxis: xAxis(this.regionData.map(item => item.region)),
        yAxis: dualAxis,
        series: [
          {
            name: '捐献意愿',
            type: 'bar',
            data: this.mockData.region.map(item => item.willingness),
            itemStyle: { color: '#409EFF' }
          },
          {
            name: '案例数量',
            type: 'bar',
            yAxisIndex: 1,
            data: this.mockData.region.map(item => item.cases),
            itemStyle: { color: '#67C23A' }
          },
          {
            name: '捐献数量',
            type: 'bar',
            yAxisIndex: 1,
            data: this.mockData.region.map(item => item.donations),
            itemStyle: { color: '#E6A23C' }
          }
          barSeries('捐献意愿', this.regionData.map(item => item.willingness), COLORS.blue),
          barSeries('案例数量', this.regionData.map(item => item.potential), COLORS.green, { yAxisIndex: 1 }),
          barSeries('捐献数量', this.regionData.map(item => item.complete), COLORS.amber, { yAxisIndex: 1 })
        ]
      };
    },
@@ -435,9 +383,9 @@
    // èŽ·å–è¿›åº¦æ¡é¢œè‰²
    getProgressColor(percentage) {
      if (percentage >= 80) return '#67C23A';
      if (percentage >= 70) return '#E6A23C';
      return '#F56C6C';
      if (percentage >= 80) return COLORS.green;
      if (percentage >= 70) return COLORS.amber;
      return COLORS.coral;
    },
    // èŽ·å–æ¯”è¾ƒæ ‡ç­¾ç±»åž‹
@@ -458,10 +406,22 @@
      this.query = {
        region: [],
        monthRange: [],
        organType: null,
        page: 1,
        limit: 10
      };
      this.loadData();
    },
    // å¤„理分页
    handlePagination(page) {
      this.query.page = page;
      this.loadData();
    },
    // å¤„理分页大小变化
    handlePageSizeChange(size) {
      this.query.limit = size;
      this.query.page = 1;
      this.loadData();
    },
@@ -472,3 +432,63 @@
  }
};
</script>
<style scoped>
.statistics-page {
  padding: 20px;
}
.overview-row {
  margin-bottom: 20px;
}
.overview-card {
  height: 100%;
}
.overview-content {
  display: flex;
  align-items: center;
}
.overview-icon {
  width: 50px;
  height: 50px;
  border-radius: 8px;
  display: flex;
  align-items: center;
  justify-content: center;
  margin-right: 15px;
}
.overview-icon i {
  font-size: 24px;
  color: white;
}
.overview-info {
  flex: 1;
}
.overview-title {
  font-size: 14px;
  color: #909399;
  margin-bottom: 4px;
}
.overview-value {
  font-size: 24px;
  font-weight: bold;
  color: #303133;
  margin-bottom: 4px;
}
.overview-desc {
  font-size: 12px;
  color: #C0C4CC;
}
.chart-row {
  margin-bottom: 20px;
}
</style>
¹ÜÀí¶Ë.zip
Binary files differ
¹ÜÀí¶Ë7.20.zip
Binary files differ
¹ÜÀí¶Ë8.20.zip
Binary files differ
¹ÜÀí¶Ë8.3.zip
Binary files differ