|
|
|
|
@@ -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>
|
|
|
|
|
|