This commit is contained in:
2026-02-25 22:06:50 +08:00
parent 24153ce321
commit 7ef5eb84d5
120 changed files with 1385 additions and 568 deletions

View File

@@ -9,6 +9,7 @@
"version": "0.1.0",
"dependencies": {
"axios": "^1.6.7",
"echarts": "^6.0.0",
"pinia": "^2.1.7",
"tdesign-vue-next": "^1.8.8",
"vue": "^3.4.15",
@@ -291,6 +292,16 @@
"node": ">= 0.4"
}
},
"node_modules/echarts": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/echarts/-/echarts-6.0.0.tgz",
"integrity": "sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "2.3.0",
"zrender": "6.0.0"
}
},
"node_modules/entities": {
"version": "7.0.1",
"license": "BSD-2-Clause",
@@ -1425,11 +1436,16 @@
"version": "1.6.0",
"license": "MIT"
},
"node_modules/tslib": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
"license": "0BSD"
},
"node_modules/typescript": {
"version": "5.9.3",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -1449,7 +1465,6 @@
"version": "5.4.21",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.21.3",
"postcss": "^8.4.43",
@@ -1522,7 +1537,6 @@
"node_modules/vue": {
"version": "3.5.27",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-dom": "3.5.27",
"@vue/compiler-sfc": "3.5.27",
@@ -1575,6 +1589,15 @@
"peerDependencies": {
"vue": "^3.5.0"
}
},
"node_modules/zrender": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/zrender/-/zrender-6.0.0.tgz",
"integrity": "sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg==",
"license": "BSD-3-Clause",
"dependencies": {
"tslib": "2.3.0"
}
}
}
}

View File

@@ -10,6 +10,7 @@
},
"dependencies": {
"axios": "^1.6.7",
"echarts": "^6.0.0",
"pinia": "^2.1.7",
"tdesign-vue-next": "^1.8.8",
"vue": "^3.4.15",

View File

@@ -74,4 +74,9 @@ export const api = {
stats: () => http.get('/admin/stats'),
statsTrends: (period?: string) => http.get('/admin/stats/trends', { params: { period } }),
todayTodos: () => http.get('/admin/stats/today-todos'),
statsRevenueReport: (params?: any) => http.get('/admin/stats/report/revenue', { params }),
statsRevenueSources: (params?: any) => http.get('/admin/stats/report/revenue-sources', { params }),
statsDrugSales: (params?: any) => http.get('/admin/stats/report/drug-sales', { params }),
statsDoctorPerformance: (params?: any) => http.get('/admin/stats/report/doctor-performance', { params }),
statsDepartmentPerformance: (params?: any) => http.get('/admin/stats/report/department-performance', { params }),
};

View File

@@ -1,49 +1,407 @@
<template>
<div class="page">
<h2 class="page-title">统计报表</h2>
<div class="panel">
<div v-if="error" class="error-message">
<t-alert theme="error" :message="error" />
<div class="page stats-page">
<div class="stats-header">
<div>
<h2 class="page-title">统计报表</h2>
<p class="sub-title">ECharts 可视化报表</p>
</div>
<div v-else-if="items.length === 0" class="empty-state">
<p>暂无统计数据</p>
<div class="header-actions">
<t-select v-model="period" :options="periodOptions" style="width: 140px" />
<t-input-number v-model="limit" :min="1" :max="100" style="width: 120px" />
<t-button theme="primary" @click="loadAll" :loading="loading">刷新</t-button>
</div>
<t-descriptions v-else :items="items" />
</div>
<t-alert v-if="error" theme="error" :message="error" class="error-alert" />
<t-row :gutter="[16, 16]" class="summary-cards">
<t-col :xs="12" :sm="6" :md="4" v-for="item in summaryCards" :key="item.label">
<div class="metric-card">
<div class="metric-label">{{ item.label }}</div>
<div class="metric-value">{{ item.value }}</div>
</div>
</t-col>
</t-row>
<t-row :gutter="[16, 16]">
<t-col :xs="12" :md="6">
<div class="panel report-panel">
<div class="panel-title">收入趋势</div>
<div ref="revenueTrendRef" class="chart"></div>
</div>
</t-col>
<t-col :xs="12" :md="6">
<div class="panel report-panel">
<div class="panel-title">收入来源占比</div>
<div ref="revenueSourcesRef" class="chart"></div>
</div>
</t-col>
</t-row>
<t-row :gutter="[16, 16]" class="report-row">
<t-col :xs="12" :md="4">
<div class="panel report-panel">
<div class="panel-title">药品销量排行</div>
<div ref="drugSalesRef" class="chart"></div>
</div>
</t-col>
<t-col :xs="12" :md="4">
<div class="panel report-panel">
<div class="panel-title">医生业绩排行</div>
<div ref="doctorPerformanceRef" class="chart"></div>
</div>
</t-col>
<t-col :xs="12" :md="4">
<div class="panel report-panel">
<div class="panel-title">科室业绩</div>
<div ref="departmentPerformanceRef" class="chart"></div>
</div>
</t-col>
</t-row>
<div class="panel report-panel summary-panel">
<div class="panel-title">收入摘要</div>
<t-descriptions :items="revenueItems" column="2" />
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { MessagePlugin } from 'tdesign-vue-next';
import * as echarts from 'echarts';
import { api } from '../api';
const items = ref([] as any[]);
const error = ref('');
type SummaryData = {
appointments?: number;
visits?: number;
drugs?: number;
revenue?: number | string;
orders?: number;
pets?: number;
customers?: number;
};
const load = async () => {
const loading = ref(false);
const error = ref('');
const period = ref('month');
const limit = ref(10);
const summary = ref<SummaryData>({});
const revenue = ref<any>({});
const revenueSources = ref<any[]>([]);
const revenueTrend = ref<any[]>([]);
const drugSales = ref<any[]>([]);
const doctorPerformance = ref<any[]>([]);
const departmentPerformance = ref<any[]>([]);
const periodOptions = [
{ label: '今日', value: 'day' },
{ label: '本周', value: 'week' },
{ label: '本月', value: 'month' },
{ label: '本年', value: 'year' },
];
const revenueTrendRef = ref<HTMLDivElement | null>(null);
const revenueSourcesRef = ref<HTMLDivElement | null>(null);
const drugSalesRef = ref<HTMLDivElement | null>(null);
const doctorPerformanceRef = ref<HTMLDivElement | null>(null);
const departmentPerformanceRef = ref<HTMLDivElement | null>(null);
let revenueTrendChart: echarts.ECharts | null = null;
let revenueSourcesChart: echarts.ECharts | null = null;
let drugSalesChart: echarts.ECharts | null = null;
let doctorPerformanceChart: echarts.ECharts | null = null;
let departmentPerformanceChart: echarts.ECharts | null = null;
const asCurrency = (value: any) => `¥${Number(value || 0).toFixed(2)}`;
const asPercent = (value: any) => `${Number(value || 0).toFixed(2)}%`;
const summaryCards = computed(() => [
{ label: '今日预约', value: summary.value.appointments || 0 },
{ label: '待就诊', value: summary.value.visits || 0 },
{ label: '药品库存', value: summary.value.drugs || 0 },
{ label: '今日收入', value: asCurrency(summary.value.revenue) },
{ label: '订单总数', value: summary.value.orders || 0 },
{ label: '顾客总数', value: summary.value.customers || 0 },
]);
const revenueItems = computed(() => [
{ label: '时间范围', content: `${revenue.value.startDate || '-'}${revenue.value.endDate || '-'}` },
{ label: '订单数', content: revenue.value.orderCount || 0 },
{ label: '总收入', content: asCurrency(revenue.value.totalAmount) },
{ label: '客单价', content: asCurrency(revenue.value.averageAmount) },
{ label: '环比增长', content: asPercent(revenue.value.growthRate) },
]);
const buildLineChart = () => {
if (!revenueTrendRef.value) return;
revenueTrendChart = revenueTrendChart || echarts.init(revenueTrendRef.value);
revenueTrendChart.setOption({
tooltip: { trigger: 'axis' },
grid: { left: 36, right: 16, top: 24, bottom: 28 },
xAxis: {
type: 'category',
data: revenueTrend.value.map((item) => item.date),
axisLabel: { color: '#6b7280' },
},
yAxis: {
type: 'value',
axisLabel: { color: '#6b7280' },
},
series: [
{
type: 'line',
smooth: true,
data: revenueTrend.value.map((item) => Number(item.amount || 0)),
areaStyle: { opacity: 0.12 },
lineStyle: { width: 3, color: '#0d9488' },
itemStyle: { color: '#0d9488' },
},
],
});
};
const buildPieChart = () => {
if (!revenueSourcesRef.value) return;
revenueSourcesChart = revenueSourcesChart || echarts.init(revenueSourcesRef.value);
revenueSourcesChart.setOption({
tooltip: { trigger: 'item' },
legend: { bottom: 0, textStyle: { color: '#6b7280' } },
series: [
{
type: 'pie',
radius: ['40%', '68%'],
data: revenueSources.value.map((item) => ({
name: item.paymentMethod,
value: Number(item.totalAmount || 0),
})),
label: { formatter: '{b}\n{d}%' },
},
],
});
};
const buildBarChart = (
chartRef: HTMLDivElement | null,
chartIns: echarts.ECharts | null,
names: string[],
values: number[],
color: string,
) => {
if (!chartRef) return chartIns;
const chart = chartIns || echarts.init(chartRef);
chart.setOption({
tooltip: { trigger: 'axis' },
grid: { left: 36, right: 16, top: 24, bottom: 28 },
xAxis: {
type: 'category',
data: names,
axisLabel: {
color: '#6b7280',
interval: 0,
rotate: names.length > 5 ? 25 : 0,
},
},
yAxis: { type: 'value', axisLabel: { color: '#6b7280' } },
series: [
{
type: 'bar',
data: values,
itemStyle: { color, borderRadius: [6, 6, 0, 0] },
barMaxWidth: 36,
},
],
});
return chart;
};
const renderCharts = () => {
buildLineChart();
buildPieChart();
drugSalesChart = buildBarChart(
drugSalesRef.value,
drugSalesChart,
drugSales.value.map((item) => item.drugName || '未知药品'),
drugSales.value.map((item) => Number(item.totalQuantity || 0)),
'#2563eb',
);
doctorPerformanceChart = buildBarChart(
doctorPerformanceRef.value,
doctorPerformanceChart,
doctorPerformance.value.map((item) => item.doctorName || '未知医生'),
doctorPerformance.value.map((item) => Number(item.totalAmount || 0)),
'#ea580c',
);
departmentPerformanceChart = buildBarChart(
departmentPerformanceRef.value,
departmentPerformanceChart,
departmentPerformance.value.map((item) => item.department || '未分配科室'),
departmentPerformance.value.map((item) => Number(item.totalAmount || 0)),
'#7c3aed',
);
};
const loadAll = async () => {
loading.value = true;
error.value = '';
try {
error.value = '';
const res = await api.stats();
if (res.code === 0) {
items.value = [
{ label: '订单数量', value: res.data.orders || 0 },
{ label: '预约数量', value: res.data.appointments || 0 },
{ label: '就诊数量', value: res.data.visits || 0 },
{ label: '宠物数量', value: res.data.pets || 0 },
{ label: '顾客数量', value: res.data.customers || 0 },
{ label: '订单收入合计', value: res.data.orderAmountTotal || 0 },
];
} else {
error.value = res.message || '加载失败';
MessagePlugin.error(error.value);
}
const params = { period: period.value };
const [summaryRes, revenueRes, sourceRes, drugRes, doctorRes, deptRes] = await Promise.all([
api.stats(),
api.statsRevenueReport(params),
api.statsRevenueSources(params),
api.statsDrugSales({ ...params, limit: limit.value }),
api.statsDoctorPerformance({ ...params, limit: limit.value }),
api.statsDepartmentPerformance(params),
]);
if (summaryRes.code !== 0) throw new Error(summaryRes.message || '统计摘要加载失败');
if (revenueRes.code !== 0) throw new Error(revenueRes.message || '收入报表加载失败');
if (sourceRes.code !== 0) throw new Error(sourceRes.message || '收入来源加载失败');
if (drugRes.code !== 0) throw new Error(drugRes.message || '药品销量加载失败');
if (doctorRes.code !== 0) throw new Error(doctorRes.message || '医生业绩加载失败');
if (deptRes.code !== 0) throw new Error(deptRes.message || '科室业绩加载失败');
summary.value = summaryRes.data || {};
revenue.value = revenueRes.data || {};
revenueSources.value = sourceRes.data?.sources || [];
revenueTrend.value = revenueRes.data?.trend || [];
drugSales.value = drugRes.data?.ranking || [];
doctorPerformance.value = doctorRes.data?.ranking || [];
departmentPerformance.value = deptRes.data?.departments || [];
await nextTick();
renderCharts();
} catch (err: any) {
error.value = err.message || '加载统计数据失败';
error.value = err?.message || '统计报表加载失败';
MessagePlugin.error(error.value);
console.error('加载统计数据失败:', err);
} finally {
loading.value = false;
}
};
onMounted(load);
const resizeAllCharts = () => {
revenueTrendChart?.resize();
revenueSourcesChart?.resize();
drugSalesChart?.resize();
doctorPerformanceChart?.resize();
departmentPerformanceChart?.resize();
};
const disposeAllCharts = () => {
revenueTrendChart?.dispose();
revenueSourcesChart?.dispose();
drugSalesChart?.dispose();
doctorPerformanceChart?.dispose();
departmentPerformanceChart?.dispose();
revenueTrendChart = null;
revenueSourcesChart = null;
drugSalesChart = null;
doctorPerformanceChart = null;
departmentPerformanceChart = null;
};
watch([period, limit], () => {
loadAll();
});
onMounted(() => {
loadAll();
window.addEventListener('resize', resizeAllCharts);
});
onBeforeUnmount(() => {
window.removeEventListener('resize', resizeAllCharts);
disposeAllCharts();
});
</script>
<style scoped>
.stats-page {
display: flex;
flex-direction: column;
gap: 16px;
}
.stats-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
}
.sub-title {
margin: 6px 0 0;
color: var(--text-secondary);
font-size: 13px;
}
.header-actions {
display: flex;
gap: 8px;
align-items: center;
}
.error-alert {
margin-bottom: 4px;
}
.summary-cards {
margin-bottom: 4px;
}
.metric-card {
background: #fff;
border: 1px solid var(--border-light);
border-radius: 12px;
padding: 14px;
}
.metric-label {
color: var(--text-secondary);
font-size: 12px;
margin-bottom: 6px;
}
.metric-value {
color: var(--text);
font-size: 24px;
font-weight: 700;
line-height: 1;
}
.report-row {
margin-top: 0;
}
.report-panel {
min-height: 340px;
}
.summary-panel {
min-height: auto;
}
.panel-title {
font-size: 16px;
font-weight: 700;
margin-bottom: 14px;
}
.chart {
width: 100%;
height: 270px;
}
@media (max-width: 900px) {
.stats-header {
flex-direction: column;
}
.header-actions {
width: 100%;
flex-wrap: wrap;
}
}
</style>

View File

@@ -296,7 +296,7 @@ body {
/* 页面标题 */
.page-title {
font-size: 32px;
font-size: 20px;
font-weight: 800;
margin: 0 0 12px;
color: var(--text);