| | |
| | | {{ 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" > |
| | |
| | | </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> |
| | |
| | | <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: { |
| | |
| | | query: { |
| | | region: [], |
| | | monthRange: [], |
| | | organType: null, |
| | | page: 1, |
| | | limit: 10 |
| | | }, |
| | |
| | | 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' |
| | | }, |
| | |
| | | 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' |
| | | } |
| | | ], |
| | | |
| | |
| | | |
| | | // 概览数据 |
| | | 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('数据加载失败'); |
| | |
| | | }, |
| | | |
| | | // 更新概览数据 |
| | | 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; |
| | |
| | | |
| | | // 更新图表 |
| | | 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 }) |
| | | ] |
| | | }; |
| | | }, |
| | |
| | | |
| | | // 获取进度条颜色 |
| | | 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; |
| | | }, |
| | | |
| | | // 获取比较标签类型 |
| | |
| | | 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(); |
| | | }, |
| | | |
| | |
| | | } |
| | | }; |
| | | </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> |