添加图表数据展示功能

- 后端新增/chart-data接口用于获取图表所需数据
- 实现近6个月出入库金额统计和药品分类占比功能
- 前端集成echarts图表组件展示数据可视化
- 添加月度出入库柱状图和药品分类饼图
- 优化仪表盘界面布局和样式
This commit is contained in:
2026-07-08 09:11:23 +08:00
parent 3f8af96578
commit 46c242c772
8 changed files with 184 additions and 26 deletions

View File

@@ -10,9 +10,11 @@
"dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"axios": "^1.18.1",
"echarts": "^6.1.0",
"element-plus": "^2.14.2",
"pinia": "^2.3.1",
"vue": "^3.5.39",
"vue-echarts": "^8.0.1",
"vue-router": "^4.6.4"
},
"devDependencies": {
@@ -1097,6 +1099,22 @@
"node": ">= 0.4"
}
},
"node_modules/echarts": {
"version": "6.1.0",
"resolved": "https://registry.npmmirror.com/echarts/-/echarts-6.1.0.tgz",
"integrity": "sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "2.3.0",
"zrender": "6.1.0"
}
},
"node_modules/echarts/node_modules/tslib": {
"version": "2.3.0",
"resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.3.0.tgz",
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
"license": "0BSD"
},
"node_modules/element-plus": {
"version": "2.14.2",
"resolved": "https://registry.npmmirror.com/element-plus/-/element-plus-2.14.2.tgz",
@@ -2081,6 +2099,16 @@
}
}
},
"node_modules/vue-echarts": {
"version": "8.0.1",
"resolved": "https://registry.npmmirror.com/vue-echarts/-/vue-echarts-8.0.1.tgz",
"integrity": "sha512-23rJTFLu1OUEGRWjJGmdGt8fP+8+ja1gVgzMYPIPaHWpXegcO1viIAaeu2H4QHESlVeHzUAHIxKXGrwjsyXAaA==",
"license": "MIT",
"peerDependencies": {
"echarts": "^6.0.0",
"vue": "^3.3.0"
}
},
"node_modules/vue-router": {
"version": "4.6.4",
"resolved": "https://registry.npmmirror.com/vue-router/-/vue-router-4.6.4.tgz",
@@ -2095,6 +2123,21 @@
"peerDependencies": {
"vue": "^3.5.0"
}
},
"node_modules/zrender": {
"version": "6.1.0",
"resolved": "https://registry.npmmirror.com/zrender/-/zrender-6.1.0.tgz",
"integrity": "sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==",
"license": "BSD-3-Clause",
"dependencies": {
"tslib": "2.3.0"
}
},
"node_modules/zrender/node_modules/tslib": {
"version": "2.3.0",
"resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.3.0.tgz",
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
"license": "0BSD"
}
}
}

View File

@@ -11,9 +11,11 @@
"dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"axios": "^1.18.1",
"echarts": "^6.1.0",
"element-plus": "^2.14.2",
"pinia": "^2.3.1",
"vue": "^3.5.39",
"vue-echarts": "^8.0.1",
"vue-router": "^4.6.4"
},
"devDependencies": {

View File

@@ -7,3 +7,7 @@ export function getStatistics() {
export function getExpiryWarning() {
return request.get('/dashboard/expiry-warning')
}
export function getChartData() {
return request.get('/dashboard/chart-data')
}

View File

@@ -3,6 +3,8 @@ import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import ECharts from 'vue-echarts'
import 'echarts'
import App from './App.vue'
import router from './router'
import permission from './directives/permission'
@@ -15,6 +17,7 @@ for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
app.component('v-chart', ECharts)
app.use(createPinia())
app.use(router)
app.use(ElementPlus, { size: 'default' })

View File

@@ -1,6 +1,6 @@
<script setup>
import { ref, onMounted } from 'vue'
import { getStatistics, getExpiryWarning } from '@/api/dashboard'
import { ref, computed, onMounted } from 'vue'
import { getStatistics, getExpiryWarning, getChartData } from '@/api/dashboard'
import { getStockInList } from '@/api/stockIn'
import { getStockOutList } from '@/api/stockOut'
import { Goods, Box, DocumentChecked, Warning, Clock } from '@element-plus/icons-vue'
@@ -9,33 +9,59 @@ const statistics = ref({ medicineCount: 0, stockBatchCount: 0, pendingStockIn: 0
const expiryList = ref([])
const recentInList = ref([])
const recentOutList = ref([])
const chartData = ref({ monthlyStats: [], categoryStats: [], totalStockValue: 0 })
const statCards = [
{ key: 'medicineCount', label: '药品总数', color: '#409eff', bg: 'linear-gradient(135deg, #409eff, #66b1ff)', icon: Goods },
{ key: 'stockBatchCount', label: '库存批次', color: '#67c23a', bg: 'linear-gradient(135deg, #67c23a, #95d475)', icon: Box },
{ key: 'pendingStockIn', label: '待审入库', color: '#e6a23c', bg: 'linear-gradient(135deg, #e6a23c, #f3d19e)', icon: DocumentChecked },
{ key: 'expiryWarningCount', label: '效期预警', color: '#f56c6c', bg: 'linear-gradient(135deg, #f56c6c, #fab6b6)', icon: Warning },
{ key: 'medicineCount', label: '药品总数', icon: Goods, bg: 'linear-gradient(135deg,#409eff,#66b1ff)' },
{ key: 'stockBatchCount', label: '库存批次', icon: Box, bg: 'linear-gradient(135deg,#67c23a,#95d475)' },
{ key: 'pendingStockIn', label: '待审入库', icon: DocumentChecked, bg: 'linear-gradient(135deg,#e6a23c,#f3d19e)' },
{ key: 'expiryWarningCount', label: '效期预警', icon: Warning, bg: 'linear-gradient(135deg,#f56c6c,#fab6b6)' },
]
function expiryColor(date) {
if (!date) return ''
const days = Math.ceil((new Date(date) - new Date()) / (1000 * 60 * 60 * 24))
if (days < 30) return 'danger'
if (days < 90) return 'warning'
return 'success'
return days < 30 ? 'danger' : days < 90 ? 'warning' : 'success'
}
// 柱状图:月度出入库
const barOption = computed(() => ({
tooltip: { trigger: 'axis' },
legend: { data: ['入库金额', '出库金额'], bottom: 0 },
grid: { left: 60, right: 20, top: 20, bottom: 30 },
xAxis: { type: 'category', data: chartData.value.monthlyStats?.map(m => m.month.substring(5)) || [] },
yAxis: { type: 'value', axisLabel: { formatter: v => v >= 10000 ? (v / 10000).toFixed(1) + '万' : v } },
series: [
{ name: '入库金额', type: 'bar', data: chartData.value.monthlyStats?.map(m => m.inAmount) || [], itemStyle: { color: '#67c23a', borderRadius: [4, 4, 0, 0] }, barMaxWidth: 30 },
{ name: '出库金额', type: 'bar', data: chartData.value.monthlyStats?.map(m => m.outAmount) || [], itemStyle: { color: '#f56c6c', borderRadius: [4, 4, 0, 0] }, barMaxWidth: 30 },
],
}))
// 饼图:药品分类占比
const pieOption = computed(() => ({
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
legend: { type: 'scroll', orient: 'vertical', right: 10, top: 20, bottom: 20 },
series: [{
type: 'pie', radius: ['45%', '75%'], center: ['35%', '55%'],
itemStyle: { borderRadius: 6, borderColor: '#fff', borderWidth: 3 },
label: { show: false },
data: chartData.value.categoryStats || [],
}],
}))
onMounted(async () => {
try {
const [statRes, warnRes, inRes, outRes] = await Promise.allSettled([
const [statRes, warnRes, inRes, outRes, chartRes] = await Promise.allSettled([
getStatistics(), getExpiryWarning(),
getStockInList({ page: 1, size: 4 }),
getStockOutList({ page: 1, size: 4 }),
getChartData(),
])
if (statRes.status === 'fulfilled') statistics.value = statRes.value.data || {}
if (warnRes.status === 'fulfilled') expiryList.value = warnRes.value.data?.records || warnRes.value.data || []
if (inRes.status === 'fulfilled') recentInList.value = inRes.value.data?.records || []
if (outRes.status === 'fulfilled') recentOutList.value = outRes.value.data?.records || []
if (chartRes.status === 'fulfilled') chartData.value = chartRes.value.data || {}
} catch { /* */ }
})
</script>
@@ -45,10 +71,12 @@ onMounted(async () => {
<div class="page-header">
<span class="page-title">仪表盘</span>
<span style="color:#909399;font-size:13px">
<el-icon><Clock /></el-icon> {{ new Date().toLocaleDateString('zh-CN', { year:'numeric', month:'long', day:'numeric', weekday:'long' }) }}
<el-icon><Clock /></el-icon>
{{ new Date().toLocaleDateString('zh-CN', { year:'numeric', month:'long', day:'numeric', weekday:'long' }) }}
</span>
</div>
<!-- 统计卡片 -->
<div class="stat-cards">
<div v-for="card in statCards" :key="card.key" class="stat-card">
<div class="stat-icon" :style="{ background: card.bg }">
@@ -61,12 +89,28 @@ onMounted(async () => {
</div>
</div>
<!-- 图表区 -->
<div style="display:grid;grid-template-columns:3fr 2fr;gap:20px;margin-bottom:20px">
<div class="section-card">
<h4 style="margin-bottom:8px">近6个月出入库金额</h4>
<v-chart :option="barOption" style="height:280px" autoresize />
</div>
<div class="section-card">
<h4 style="margin-bottom:8px">药品分类占比</h4>
<v-chart :option="pieOption" style="height:280px" autoresize />
</div>
</div>
<!-- 底部双栏 -->
<div style="display:grid;grid-template-columns:1fr 1fr;gap:20px">
<div class="section-card">
<h4 style="margin-bottom:12px;color:#e6a23c"><el-icon><Warning /></el-icon> 效期预警90天内到期</h4>
<el-table :data="expiryList" size="small" empty-text="暂无预警 ">
<el-table-column prop="medicineName" label="药品" min-width="140" />
<el-table-column prop="batchNo" label="批号" width="110" />
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
<h4 style="color:#e6a23c"><el-icon><Warning /></el-icon> 效期预警</h4>
<span style="font-size:12px;color:#909399">90天内到期 · {{ expiryList.length }} </span>
</div>
<el-table :data="expiryList" size="small" empty-text="暂无预警 " max-height="220">
<el-table-column prop="medicineName" label="药品" min-width="120" />
<el-table-column prop="batchNo" label="批号" width="100" />
<el-table-column prop="quantity" label="库存" width="70" align="center" />
<el-table-column prop="expiryDate" label="有效期至" width="120" align="center">
<template #default="{ row }">
@@ -76,31 +120,36 @@ onMounted(async () => {
</el-table>
</div>
<div class="section-card">
<h4 style="margin-bottom:12px;color:#409eff"><el-icon><DocumentChecked /></el-icon> 最近业务</h4>
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
<h4 style="color:#409eff"><el-icon><DocumentChecked /></el-icon> 最近业务</h4>
<span style="font-size:13px;color:#409eff;font-weight:600">
库存总值 ¥{{ (chartData.totalStockValue || 0).toLocaleString() }}
</span>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px">
<div>
<h5 style="margin-bottom:8px;font-size:13px;color:#909399">最近入库</h5>
<h5 style="margin-bottom:8px;font-size:13px;color:#67c23a">最近入库</h5>
<div v-if="recentInList.length">
<div v-for="item in recentInList" :key="item.id" class="record-item">
<span style="font-size:13px;font-weight:500">{{ item.recordNo }}</span>
<span style="font-size:12px;font-weight:500">{{ item.recordNo }}</span>
<el-tag :type="item.status===1?'success':item.status===0?'warning':'info'" size="small" effect="plain">
{{ item.status===1?'已入库':item.status===0?'待审核':'已取消' }}
{{ item.status===1?'已入库':'待审核' }}
</el-tag>
</div>
</div>
<el-empty v-else description="暂无" :image-size="40" />
<el-empty v-else description="暂无" :image-size="36" />
</div>
<div>
<h5 style="margin-bottom:8px;font-size:13px;color:#909399">最近出库</h5>
<h5 style="margin-bottom:8px;font-size:13px;color:#f56c6c">最近出库</h5>
<div v-if="recentOutList.length">
<div v-for="item in recentOutList" :key="item.id" class="record-item">
<span style="font-size:13px;font-weight:500">{{ item.recordNo }}</span>
<span style="font-size:12px;font-weight:500">{{ item.recordNo }}</span>
<el-tag :type="item.status===1?'success':item.status===0?'warning':'info'" size="small" effect="plain">
{{ item.status===1?'已出库':item.status===0?'待审核':'已取消' }}
{{ item.status===1?'已出库':'待审核' }}
</el-tag>
</div>
</div>
<el-empty v-else description="暂无" :image-size="40" />
<el-empty v-else description="暂无" :image-size="36" />
</div>
</div>
</div>
@@ -111,7 +160,7 @@ onMounted(async () => {
<style lang="scss" scoped>
.record-item {
display: flex; justify-content: space-between; align-items: center;
padding: 8px 0; border-bottom: 1px dashed #ebeef5;
padding: 6px 0; border-bottom: 1px dashed #ebeef5;
&:last-child { border-bottom: none; }
}
</style>