| | |
| | | <el-col :span="12"> |
| | | <el-card> |
| | | <div slot="header" class="clearfix"> |
| | | <span>弃用率构成分析</span> |
| | | <span>地区获取/弃用率对比</span> |
| | | </div> |
| | | <EChartsWrapper |
| | | :id="'abandon-chart'" |
| | |
| | | <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> |
| | |
| | | <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', |
| | |
| | | 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' |
| | | }, |
| | |
| | | { |
| | | 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' |
| | | } |
| | | ], |
| | |
| | | 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('数据加载失败'); |
| | |
| | | } |
| | | }, |
| | | |
| | | // 更新仪表盘 |
| | | 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 }] |
| | | } |
| | | ] |
| | | }; |
| | |
| | | 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('导出功能开发中...'); |