添加图表数据展示功能
- 后端新增/chart-data接口用于获取图表所需数据 - 实现近6个月出入库金额统计和药品分类占比功能 - 前端集成echarts图表组件展示数据可视化 - 添加月度出入库柱状图和药品分类饼图 - 优化仪表盘界面布局和样式
This commit is contained in:
@@ -28,4 +28,9 @@ public class DashboardController {
|
||||
@RequestParam(defaultValue = "10") Integer size) {
|
||||
return Result.success(dashboardService.getExpiryWarning(new Page<>(page, size)));
|
||||
}
|
||||
|
||||
@GetMapping("/chart-data")
|
||||
public Result<Map<String, Object>> chartData() {
|
||||
return Result.success(dashboardService.getChartData());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,10 +5,13 @@ import com.kronecker.backend.entity.Stock;
|
||||
import com.kronecker.backend.entity.StockInRecord;
|
||||
import com.kronecker.backend.entity.StockOutRecord;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
public interface DashboardService {
|
||||
java.util.Map<String, Object> getStatistics();
|
||||
Map<String, Object> getStatistics();
|
||||
Page<Stock> getExpiryWarning(Page<Stock> page);
|
||||
Page<StockInRecord> getRecentStockIn(Page<StockInRecord> page);
|
||||
Page<StockOutRecord> getRecentStockOut(Page<StockOutRecord> page);
|
||||
Map<String, Object> getChartData();
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.kronecker.backend.entity.*;
|
||||
import com.kronecker.backend.mapper.*;
|
||||
import com.kronecker.backend.entity.*;
|
||||
import com.kronecker.backend.mapper.*;
|
||||
import com.kronecker.backend.service.DashboardService;
|
||||
import com.kronecker.backend.utils.RedisCacheService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@@ -17,6 +19,7 @@ import java.util.*;
|
||||
public class DashboardServiceImpl implements DashboardService {
|
||||
|
||||
private final MedicineMapper medicineMapper;
|
||||
private final MedicineCategoryMapper medicineCategoryMapper;
|
||||
private final StockMapper stockMapper;
|
||||
private final StockInRecordMapper stockInRecordMapper;
|
||||
private final StockOutRecordMapper stockOutRecordMapper;
|
||||
@@ -97,4 +100,50 @@ public class DashboardServiceImpl implements DashboardService {
|
||||
records.forEach(r -> { if (r.getCustomerId() != null) r.setCustomerName(custMap.getOrDefault(r.getCustomerId(), "")); });
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, Object> getChartData() {
|
||||
Map<String, Object> data = new HashMap<>();
|
||||
|
||||
// 近6个月出入库金额统计
|
||||
List<Map<String, Object>> monthlyStats = new ArrayList<>();
|
||||
for (int i = 5; i >= 0; i--) {
|
||||
String month = LocalDate.now().minusMonths(i).format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM"));
|
||||
Double inAmount = stockInRecordMapper.selectList(new LambdaQueryWrapper<StockInRecord>()
|
||||
.eq(StockInRecord::getStatus, 1)
|
||||
.likeRight(StockInRecord::getInDate, month))
|
||||
.stream().mapToDouble(r -> r.getTotalAmount().doubleValue()).sum();
|
||||
Double outAmount = stockOutRecordMapper.selectList(new LambdaQueryWrapper<StockOutRecord>()
|
||||
.eq(StockOutRecord::getStatus, 1)
|
||||
.likeRight(StockOutRecord::getOutDate, month))
|
||||
.stream().mapToDouble(r -> r.getTotalAmount().doubleValue()).sum();
|
||||
Map<String, Object> m = new HashMap<>();
|
||||
m.put("month", month);
|
||||
m.put("inAmount", Math.round(inAmount * 100.0) / 100.0);
|
||||
m.put("outAmount", Math.round(outAmount * 100.0) / 100.0);
|
||||
monthlyStats.add(m);
|
||||
}
|
||||
data.put("monthlyStats", monthlyStats);
|
||||
|
||||
// 药品分类库存占比
|
||||
List<Map<String, Object>> categoryStats = new ArrayList<>();
|
||||
List<MedicineCategory> categories = medicineCategoryMapper.selectList(null);
|
||||
for (MedicineCategory cat : categories) {
|
||||
int count = medicineMapper.selectCount(new LambdaQueryWrapper<Medicine>().eq(Medicine::getCategoryId, cat.getId())).intValue();
|
||||
if (count > 0) {
|
||||
Map<String, Object> cs = new HashMap<>();
|
||||
cs.put("name", cat.getName());
|
||||
cs.put("value", count);
|
||||
categoryStats.add(cs);
|
||||
}
|
||||
}
|
||||
data.put("categoryStats", categoryStats);
|
||||
|
||||
// 库存总价值
|
||||
double totalStockValue = stockMapper.selectList(new LambdaQueryWrapper<Stock>().gt(Stock::getQuantity, 0))
|
||||
.stream().mapToDouble(s -> s.getCostPrice().doubleValue() * s.getQuantity()).sum();
|
||||
data.put("totalStockValue", Math.round(totalStockValue * 100.0) / 100.0);
|
||||
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
43
frontend/package-lock.json
generated
43
frontend/package-lock.json
generated
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user