feat: add dashboard enhancement with ECharts charts (#20)

- Add 6 dashboard query methods to RlzOrderMapper (status/type/trend/ranking)
- Add dashboard endpoint /system/view/dashboard combining all stats
- Rewrite index.vue with ECharts: summary cards, order/revenue trend,
  status pie, hospital and caregiver ranking charts
- Add getDashboard API to finance.js

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
renjianbo
2026-05-17 09:11:50 +08:00
parent 39a371b9ac
commit f3e2fd2829
7 changed files with 401 additions and 121 deletions

View File

@@ -69,3 +69,12 @@ export function getJiaoyiDetail(id) {
method: 'get'
})
}
// ========== 仪表盘 ==========
export function getDashboard() {
return request({
url: '/system/view/dashboard',
method: 'get'
})
}

View File

@@ -1,121 +1,251 @@
<template>
<div class="app-container home">
<el-row :gutter="20">
<el-col :sm="24" :lg="12" style="padding-left: 20px">
<h2>瑞来兹医助后台管理框架</h2>
<p>
一直想做一款后台管理系统看了很多优秀的开源项目但是发现没有合适自己的于是利用空闲休息时间开始自己写一套后台系统如此有了瑞来兹医助管理系统她可以用于所有的Web应用程序如网站管理后台网站会员中心CMSCRMOA等等当然您也可以对她进行深度定制以做出更强系统所有前端后台代码封装过后十分精简易上手出错概率低同时支持移动客户端访问系统会陆续更新一些实用功能
</p>
<p>
<b>当前版本:</b> <span>v{{ version }}</span>
</p>
</el-col>
</el-row>
<el-divider />
<!-- <div class="iframe-out" >
<iframe
id="ifr"
:src="src"
ref="iframe"
style="min-height: 60vh; width: 100%"
marginheight="0"
marginwidth="0"
frameborder="0"
scrolling="auto"
></iframe>
</div> -->
</div>
</template>
<script>
import { getAuthToken } from "@/api/system/user";
export default {
name: "Index",
data() {
return {
// 版本号
version: "3.8.3",
src: ""
};
},
created() {
// getAuthToken().then((response=>{
// let tokens= response.authToken;
// this.src="http://124.70.96.132:8000/ztbPortal/rest/companyLogin/loginUser1?"+tokens;
// alert(this.src);
// }))
},
methods: {
goTarget(href) {
window.open(href, "_blank");
},
},
};
</script>
<style scoped lang="scss">
.home {
blockquote {
padding: 10px 20px;
margin: 0 0 20px;
font-size: 17.5px;
border-left: 5px solid #eee;
}
hr {
margin-top: 20px;
margin-bottom: 20px;
border: 0;
border-top: 1px solid #eee;
}
.col-item {
margin-bottom: 20px;
}
ul {
padding: 0;
margin: 0;
}
font-family: "open sans", "Helvetica Neue", Helvetica, Arial, sans-serif;
font-size: 13px;
color: #676a6c;
overflow-x: hidden;
ul {
list-style-type: none;
}
h4 {
margin-top: 0px;
}
h2 {
margin-top: 10px;
font-size: 26px;
font-weight: 100;
}
p {
margin-top: 10px;
b {
font-weight: 700;
}
}
.update-log {
ol {
display: block;
list-style-type: decimal;
margin-block-start: 1em;
margin-block-end: 1em;
margin-inline-start: 0;
margin-inline-end: 0;
padding-inline-start: 40px;
}
}
}
</style>
<template>
<div class="app-container home">
<!-- 统计卡片 -->
<el-row :gutter="20" class="mb20">
<el-col :xs="24" :sm="12" :lg="6">
<div class="stat-card card-total">
<div class="stat-icon"><i class="el-icon-document" /></div>
<div class="stat-body">
<div class="stat-value">{{ summary.totalOrders }}</div>
<div class="stat-label">订单总数</div>
</div>
</div>
</el-col>
<el-col :xs="24" :sm="12" :lg="6">
<div class="stat-card card-today">
<div class="stat-icon"><i class="el-icon-date" /></div>
<div class="stat-body">
<div class="stat-value">{{ summary.todayOrders }}</div>
<div class="stat-label">今日订单</div>
</div>
</div>
</el-col>
<el-col :xs="24" :sm="12" :lg="6">
<div class="stat-card card-revenue">
<div class="stat-icon"><i class="el-icon-money" /></div>
<div class="stat-body">
<div class="stat-value">{{ summary.totalRevenue }}</div>
<div class="stat-label">总收入</div>
</div>
</div>
</el-col>
<el-col :xs="24" :sm="12" :lg="6">
<div class="stat-card card-pending">
<div class="stat-icon"><i class="el-icon-bell" /></div>
<div class="stat-body">
<div class="stat-value">{{ summary.pendingOrders }}</div>
<div class="stat-label">待处理订单</div>
</div>
</div>
</el-col>
</el-row>
<!-- 第一行图表订单趋势 + 状态分布 -->
<el-row :gutter="20">
<el-col :span="16">
<el-card>
<div slot="header"><span>近30天订单/收入趋势</span></div>
<div ref="orderTrendChart" style="height:350px" v-loading="loading" />
</el-card>
</el-col>
<el-col :span="8">
<el-card>
<div slot="header"><span>订单状态分布</span></div>
<div ref="statusPieChart" style="height:350px" v-loading="loading" />
</el-card>
</el-col>
</el-row>
<!-- 第二行图表医院排名 + 陪护排名 -->
<el-row :gutter="20" style="margin-top:20px">
<el-col :span="12">
<el-card>
<div slot="header"><span>医院服务量排名 (TOP10)</span></div>
<div ref="hospitalChart" style="height:350px" v-loading="loading" />
</el-card>
</el-col>
<el-col :span="12">
<el-card>
<div slot="header"><span>陪护师服务排名 (TOP10)</span></div>
<div ref="caregiverChart" style="height:350px" v-loading="loading" />
</el-card>
</el-col>
</el-row>
</div>
</template>
<script>
import echarts from 'echarts';
import { getDashboard } from "@/api/system/finance";
const STATUS_MAP = {
'-2': '已取消', '-1': '拒绝接单', '0': '待接单', '1': '已接单',
'2': '已支付', '3': '服务中', '4': '已完成',
'5': '申请退款', '6': '退款中', '7': '已退款', '8': '已结算'
};
export default {
name: "Index",
data() {
return {
version: "3.8.3",
loading: false,
dashboard: {},
summary: { totalOrders: 0, todayOrders: 0, totalRevenue: 0, pendingOrders: 0 }
};
},
mounted() {
this.loadDashboard();
},
methods: {
loadDashboard() {
this.loading = true;
getDashboard().then(response => {
this.dashboard = response.data || response;
this.calcSummary();
this.loading = false;
this.$nextTick(() => { this.renderAllCharts(); });
}).catch(() => { this.loading = false; });
},
calcSummary() {
const d = this.dashboard;
// 订单总数
const statusStats = d.orderStatusStats || [];
this.summary.totalOrders = statusStats.reduce((s, r) => s + parseInt(r.cnt || 0), 0);
// 今日订单
const trend = d.dailyOrderTrend || [];
const today = new Date().toISOString().slice(0, 10);
const todayItem = trend.find(r => (r.date || '').slice(0, 10) === today);
this.summary.todayOrders = todayItem ? parseInt(todayItem.cnt || 0) : 0;
// 总收入
const revTrend = d.dailyRevenueTrend || [];
this.summary.totalRevenue = revTrend.reduce((s, r) => s + parseFloat(r.revenue || 0), 0).toFixed(2);
// 待处理: 待接单 + 申请退款
const pending = statusStats.filter(r => ['0', '5'].includes(r.status));
this.summary.pendingOrders = pending.reduce((s, r) => s + parseInt(r.cnt || 0), 0);
},
renderAllCharts() {
this.renderOrderTrendChart();
this.renderStatusPieChart();
this.renderHospitalChart();
this.renderCaregiverChart();
},
renderOrderTrendChart() {
const el = this.$refs.orderTrendChart;
if (!el) return;
const chart = echarts.init(el);
const orderTrend = this.dashboard.dailyOrderTrend || [];
const revenueTrend = this.dashboard.dailyRevenueTrend || [];
const dates = orderTrend.map(r => (r.date || '').slice(5));
const orders = orderTrend.map(r => parseInt(r.cnt || 0));
const revenues = orderTrend.map(r => {
const rev = revenueTrend.find(v => v.date === r.date);
return rev ? parseFloat(rev.revenue || 0) : 0;
});
chart.setOption({
tooltip: { trigger: 'axis', axisPointer: { type: 'cross' } },
legend: { data: ['订单数', '收入(元)'] },
grid: { left: 50, right: 50, bottom: 30, top: 30 },
xAxis: { type: 'category', data: dates },
yAxis: [
{ type: 'value', name: '订单数' },
{ type: 'value', name: '收入(元)' }
],
series: [
{ name: '订单数', type: 'bar', data: orders, itemStyle: { color: '#409EFF' } },
{ name: '收入(元)', type: 'line', yAxisIndex: 1, data: revenues, smooth: true, itemStyle: { color: '#67C23A' } }
]
});
window.addEventListener('resize', () => chart.resize());
},
renderStatusPieChart() {
const el = this.$refs.statusPieChart;
if (!el) return;
const chart = echarts.init(el);
const data = (this.dashboard.orderStatusStats || []).map(r => ({
name: STATUS_MAP[r.status] || ('状态' + r.status),
value: parseInt(r.cnt || 0)
}));
chart.setOption({
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
series: [{
type: 'pie',
radius: ['40%', '70%'],
itemStyle: { borderRadius: 4, borderColor: '#fff', borderWidth: 2 },
label: { show: true, formatter: '{b}\n{d}%' },
data: data
}]
});
window.addEventListener('resize', () => chart.resize());
},
renderHospitalChart() {
const el = this.$refs.hospitalChart;
if (!el) return;
const chart = echarts.init(el);
const data = this.dashboard.hospitalRanking || [];
chart.setOption({
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
grid: { left: 100, right: 50, bottom: 30, top: 10 },
xAxis: { type: 'value' },
yAxis: { type: 'category', data: data.map(r => r.name).reverse(), inverse: true,
axisLabel: { width: 90, overflow: 'truncate' } },
series: [{
type: 'bar',
data: data.map(r => parseInt(r.cnt || 0)).reverse(),
itemStyle: { color: new echarts.graphic.LinearGradient(0, 0, 1, 0, [
{ offset: 0, color: '#409EFF' }, { offset: 1, color: '#67C23A' }
])}
}]
});
window.addEventListener('resize', () => chart.resize());
},
renderCaregiverChart() {
const el = this.$refs.caregiverChart;
if (!el) return;
const chart = echarts.init(el);
const data = this.dashboard.caregiverRanking || [];
chart.setOption({
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
grid: { left: 100, right: 50, bottom: 30, top: 10 },
xAxis: { type: 'value' },
yAxis: { type: 'category', data: data.map(r => r.name).reverse(), inverse: true,
axisLabel: { width: 90, overflow: 'truncate' } },
series: [{
type: 'bar',
data: data.map(r => parseInt(r.cnt || 0)).reverse(),
itemStyle: { color: new echarts.graphic.LinearGradient(0, 0, 1, 0, [
{ offset: 0, color: '#E6A23C' }, { offset: 1, color: '#F56C6C' }
])}
}]
});
window.addEventListener('resize', () => chart.resize());
}
}
};
</script>
<style scoped lang="scss">
.home {
.mb20 { margin-bottom: 20px; }
.stat-card {
display: flex; align-items: center; padding: 20px; border-radius: 8px;
color: #fff; overflow: hidden; position: relative;
.stat-icon {
font-size: 48px; opacity: 0.3; margin-right: 16px;
}
.stat-body {
.stat-value { font-size: 28px; font-weight: bold; line-height: 1.2; }
.stat-label { font-size: 13px; margin-top: 4px; opacity: 0.85; }
}
}
.card-total { background: linear-gradient(135deg, #667eea, #764ba2); }
.card-today { background: linear-gradient(135deg, #43e97b, #38f9d7); }
.card-revenue { background: linear-gradient(135deg, #f093fb, #f5576c); }
.card-pending { background: linear-gradient(135deg, #fa709a, #fee140); }
hr { margin-top: 20px; margin-bottom: 20px; border: 0; border-top: 1px solid #eee; }
h2 { margin-top: 10px; font-size: 26px; font-weight: 100; }
p { margin-top: 10px; b { font-weight: 700; } }
}
</style>

View File

@@ -137,6 +137,22 @@ public class OrderViewController extends BaseController
return AjaxResult.success(result);
}
/**
* 仪表盘统计数据
*/
@GetMapping("/dashboard")
public AjaxResult dashboard()
{
java.util.Map<String, Object> result = new java.util.HashMap<>();
result.put("orderStatusStats", iRlzOrderService.getOrderStatusStats());
result.put("orderTypeStats", iRlzOrderService.getOrderTypeStats());
result.put("dailyOrderTrend", iRlzOrderService.getDailyOrderTrend());
result.put("dailyRevenueTrend", iRlzOrderService.getDailyRevenueTrend());
result.put("hospitalRanking", iRlzOrderService.getHospitalRanking());
result.put("caregiverRanking", iRlzOrderService.getCaregiverRanking());
return AjaxResult.success(result);
}
/**
* 获取VIEW详细信息
*/

View File

@@ -63,4 +63,34 @@ public interface RlzOrderMapper
* 收入统计
*/
public List<java.util.Map<String, Object>> getIncomeStats(java.util.Map<String, Object> params);
/**
* 仪表盘-订单状态分布
*/
public List<java.util.Map<String, Object>> getOrderStatusStats();
/**
* 仪表盘-服务类型分布
*/
public List<java.util.Map<String, Object>> getOrderTypeStats();
/**
* 仪表盘-日订单趋势(近30天)
*/
public List<java.util.Map<String, Object>> getDailyOrderTrend();
/**
* 仪表盘-日收入趋势(近30天)
*/
public List<java.util.Map<String, Object>> getDailyRevenueTrend();
/**
* 仪表盘-医院服务量排名
*/
public List<java.util.Map<String, Object>> getHospitalRanking();
/**
* 仪表盘-陪护服务排名
*/
public List<java.util.Map<String, Object>> getCaregiverRanking();
}

View File

@@ -78,4 +78,34 @@ public interface IRlzOrderService
Long insertOrderPz(RlzOrder rlzOrder);
List<java.util.Map<String, Object>> getIncomeStats(java.util.Map<String, Object> params);
/**
* 仪表盘-订单状态分布
*/
List<java.util.Map<String, Object>> getOrderStatusStats();
/**
* 仪表盘-服务类型分布
*/
List<java.util.Map<String, Object>> getOrderTypeStats();
/**
* 仪表盘-日订单趋势(近30天)
*/
List<java.util.Map<String, Object>> getDailyOrderTrend();
/**
* 仪表盘-日收入趋势(近30天)
*/
List<java.util.Map<String, Object>> getDailyRevenueTrend();
/**
* 仪表盘-医院服务量排名
*/
List<java.util.Map<String, Object>> getHospitalRanking();
/**
* 仪表盘-陪护服务排名
*/
List<java.util.Map<String, Object>> getCaregiverRanking();
}

View File

@@ -349,4 +349,34 @@ public String weixinPayOrderNext(String decryptOrder) {
public List<java.util.Map<String, Object>> getIncomeStats(java.util.Map<String, Object> params) {
return rlzOrderMapper.getIncomeStats(params);
}
@Override
public List<java.util.Map<String, Object>> getOrderStatusStats() {
return rlzOrderMapper.getOrderStatusStats();
}
@Override
public List<java.util.Map<String, Object>> getOrderTypeStats() {
return rlzOrderMapper.getOrderTypeStats();
}
@Override
public List<java.util.Map<String, Object>> getDailyOrderTrend() {
return rlzOrderMapper.getDailyOrderTrend();
}
@Override
public List<java.util.Map<String, Object>> getDailyRevenueTrend() {
return rlzOrderMapper.getDailyRevenueTrend();
}
@Override
public List<java.util.Map<String, Object>> getHospitalRanking() {
return rlzOrderMapper.getHospitalRanking();
}
@Override
public List<java.util.Map<String, Object>> getCaregiverRanking() {
return rlzOrderMapper.getCaregiverRanking();
}
}

View File

@@ -244,6 +244,41 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
</foreach>
</delete>
<select id="getOrderStatusStats" resultType="map">
SELECT status, COUNT(*) as cnt FROM rlz_order GROUP BY status ORDER BY status
</select>
<select id="getOrderTypeStats" resultType="map">
SELECT COALESCE(NULLIF(yuliu10,''), '其他') as name, COUNT(*) as cnt
FROM rlz_order GROUP BY yuliu10 ORDER BY cnt DESC
</select>
<select id="getDailyOrderTrend" resultType="map">
SELECT DATE(create_time) as date, COUNT(*) as cnt
FROM rlz_order WHERE create_time >= DATE_SUB(NOW(), INTERVAL 30 DAY)
GROUP BY DATE(create_time) ORDER BY date
</select>
<select id="getDailyRevenueTrend" resultType="map">
SELECT DATE(create_time) as date,
COALESCE(SUM(CASE WHEN status IN ('4','8') THEN CAST(COALESCE(NULLIF(jiesuan_money,''), yugu_money) AS DECIMAL(10,2)) ELSE 0 END), 0) as revenue
FROM rlz_order WHERE create_time >= DATE_SUB(NOW(), INTERVAL 30 DAY)
GROUP BY DATE(create_time) ORDER BY date
</select>
<select id="getHospitalRanking" resultType="map">
SELECT COALESCE(h.hospitalname, '未指定') as name, COUNT(*) as cnt
FROM rlz_order o LEFT JOIN sys_hospital h ON o.hospital_id = h.id
GROUP BY o.hospital_id, h.hospitalname ORDER BY cnt DESC LIMIT 10
</select>
<select id="getCaregiverRanking" resultType="map">
SELECT COALESCE(ub.nick_name, CONCAT('用户#', o.b_id)) as name, COUNT(*) as cnt
FROM rlz_order o LEFT JOIN sys_user ub ON o.b_id = ub.user_id
WHERE o.b_id IS NOT NULL
GROUP BY o.b_id, ub.nick_name ORDER BY cnt DESC LIMIT 10
</select>
<select id="getIncomeStats" parameterType="map" resultType="map">
SELECT
<choose>