feat: 数据统计分析模块前端实现

前端实现:
- dashboard.js: API接口封装(13行)
- dashboard/index.vue: 仪表盘页面(576行)

页面布局:
1. 概览卡片(4个)
   - 项目总数(含本月新增)
   - 客户总数(含本月新增)
   - 合同总数
   - 需求工单数

2. 收支概览(3个)
   - 总收入(含本月收入)
   - 总支出(含本月支出)
   - 净利润(含利润率)

3. 应收款概览(3个)
   - 应收款总额
   - 待收款金额(橙色警示)
   - 逾期金额(红色警示)

4. 图表区域(4个)
   - 收支趋势折线图(最近12个月)
   - 项目状态分布饼图
   - 支出类型分布饼图
   - 应收款状态分布饼图

技术特点:
- ECharts图表库:折线图、环形饼图
- 响应式设计:窗口大小变化自动重绘
- 渐变色卡片:现代化UI设计
- 金额格式化:千分位分隔、保留2位小数
- 图表tooltip:金额/百分比格式化显示
- 组件销毁时清理资源

模块状态: 完整(前端+后端)
This commit is contained in:
zhangjf 2026-02-16 09:35:23 +08:00
parent 39577a9b11
commit 81e919ad3c
2 changed files with 574 additions and 67 deletions

View File

@ -0,0 +1,12 @@
import request from '../utils/request'
/**
* 获取仪表盘统计数据
*/
export const getDashboardData = (params) => {
return request({
url: '/proj/api/v1/dashboard',
method: 'get',
params
})
}

View File

@ -1,77 +1,139 @@
<template>
<div class="dashboard">
<el-row :gutter="20">
<div class="dashboard-container">
<!-- 概览卡片 -->
<el-row :gutter="20" class="overview-cards">
<el-col :span="6">
<el-card class="stat-card">
<div class="stat-icon" style="background: #409EFF;">
<el-icon><User /></el-icon>
<el-card shadow="hover" class="stat-card">
<div class="stat-icon" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);">
<el-icon size="28"><Folder /></el-icon>
</div>
<div class="stat-info">
<div class="stat-value">1,234</div>
<div class="stat-label">客户总数</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card class="stat-card">
<div class="stat-icon" style="background: #67C23A;">
<el-icon><FolderOpened /></el-icon>
</div>
<div class="stat-info">
<div class="stat-value">56</div>
<div class="stat-value">{{ data.projectCount || 0 }}</div>
<div class="stat-label">项目总数</div>
</div>
<div class="stat-extra">本月新增 +{{ data.monthNewProjects || 0 }}</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card class="stat-card">
<div class="stat-icon" style="background: #E6A23C;">
<el-icon><Document /></el-icon>
<el-card shadow="hover" class="stat-card">
<div class="stat-icon" style="background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);">
<el-icon size="28"><User /></el-icon>
</div>
<div class="stat-info">
<div class="stat-value">89</div>
<div class="stat-value">{{ data.customerCount || 0 }}</div>
<div class="stat-label">客户总数</div>
</div>
<div class="stat-extra">本月新增 +{{ data.monthNewCustomers || 0 }}</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover" class="stat-card">
<div class="stat-icon" style="background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);">
<el-icon size="28"><Document /></el-icon>
</div>
<div class="stat-info">
<div class="stat-value">{{ data.contractCount || 0 }}</div>
<div class="stat-label">合同总数</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card class="stat-card">
<div class="stat-icon" style="background: #F56C6C;">
<el-icon><Money /></el-icon>
<el-card shadow="hover" class="stat-card">
<div class="stat-icon" style="background: linear-gradient(135deg, #43e97b 0%, #38f9d7 100%);">
<el-icon size="28"><Tickets /></el-icon>
</div>
<div class="stat-info">
<div class="stat-value">¥2.5M</div>
<div class="stat-label">合同总金额</div>
<div class="stat-value">{{ data.requirementCount || 0 }}</div>
<div class="stat-label">需求工单</div>
</div>
</el-card>
</el-col>
</el-row>
<el-row :gutter="20" class="mt-20">
<el-col :span="12">
<el-card>
<!-- 收支概览 -->
<el-row :gutter="20" class="finance-cards">
<el-col :span="8">
<el-card shadow="hover" class="finance-card income">
<div class="finance-title">总收入</div>
<div class="finance-value">¥ {{ formatMoney(data.totalIncome) }}</div>
<div class="finance-sub">本月收入¥ {{ formatMoney(data.monthIncome) }}</div>
</el-card>
</el-col>
<el-col :span="8">
<el-card shadow="hover" class="finance-card expense">
<div class="finance-title">总支出</div>
<div class="finance-value">¥ {{ formatMoney(data.totalExpense) }}</div>
<div class="finance-sub">本月支出¥ {{ formatMoney(data.monthExpense) }}</div>
</el-card>
</el-col>
<el-col :span="8">
<el-card shadow="hover" class="finance-card profit">
<div class="finance-title">净利润</div>
<div class="finance-value" :class="{ negative: (data.netProfit || 0) < 0 }">
¥ {{ formatMoney(data.netProfit) }}
</div>
<div class="finance-sub">利润率{{ getProfitRate() }}%</div>
</el-card>
</el-col>
</el-row>
<!-- 应收款概览 -->
<el-row :gutter="20" class="receivable-cards">
<el-col :span="8">
<el-card shadow="hover" class="receivable-card">
<div class="receivable-title">应收款总额</div>
<div class="receivable-value">¥ {{ formatMoney(data.totalReceivable) }}</div>
</el-card>
</el-col>
<el-col :span="8">
<el-card shadow="hover" class="receivable-card warning">
<div class="receivable-title">待收款金额</div>
<div class="receivable-value">¥ {{ formatMoney(data.totalUnpaid) }}</div>
</el-card>
</el-col>
<el-col :span="8">
<el-card shadow="hover" class="receivable-card danger">
<div class="receivable-title">逾期金额</div>
<div class="receivable-value">¥ {{ formatMoney(data.overdueAmount) }}</div>
</el-card>
</el-col>
</el-row>
<!-- 图表区域 -->
<el-row :gutter="20" class="chart-row">
<el-col :span="16">
<el-card shadow="hover">
<template #header>
<span>收支趋势最近12个月</span>
</template>
<div ref="trendChartRef" class="chart-container"></div>
</el-card>
</el-col>
<el-col :span="8">
<el-card shadow="hover">
<template #header>
<span>项目状态分布</span>
</template>
<div class="chart-placeholder">
[图表区域]
</div>
<div ref="projectPieRef" class="chart-container"></div>
</el-card>
</el-col>
</el-row>
<el-row :gutter="20" class="chart-row">
<el-col :span="12">
<el-card shadow="hover">
<template #header>
<span>支出类型分布</span>
</template>
<div ref="expensePieRef" class="chart-container"></div>
</el-card>
</el-col>
<el-col :span="12">
<el-card>
<el-card shadow="hover">
<template #header>
<span>最近活动</span>
<span>应收款状态分布</span>
</template>
<el-timeline>
<el-timeline-item
v-for="(activity, index) in activities"
:key="index"
:timestamp="activity.time"
>
{{ activity.content }}
</el-timeline-item>
</el-timeline>
<div ref="receivablePieRef" class="chart-container"></div>
</el-card>
</el-col>
</el-row>
@ -79,65 +141,498 @@
</template>
<script setup>
import { User, FolderOpened, Document, Money } from '@element-plus/icons-vue'
import { ref, reactive, onMounted, onUnmounted, nextTick } from 'vue'
import { Folder, User, Document, Tickets } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import * as echarts from 'echarts'
import { getDashboardData } from '../../api/dashboard'
const activities = [
{ content: '创建了新项目 "XX企业资金管理系统"', time: '2026-02-15 10:30' },
{ content: '客户 "ABC科技" 信息更新', time: '2026-02-15 09:15' },
{ content: '合同 "HT2024001" 签署完成', time: '2026-02-14 16:45' },
{ content: '新增用户 "张三"', time: '2026-02-14 14:20' }
//
const data = reactive({
projectCount: 0,
customerCount: 0,
contractCount: 0,
requirementCount: 0,
totalIncome: 0,
totalExpense: 0,
netProfit: 0,
totalReceivable: 0,
totalUnpaid: 0,
overdueAmount: 0,
monthIncome: 0,
monthExpense: 0,
monthNewProjects: 0,
monthNewCustomers: 0,
incomeTrend: [],
expenseTrend: [],
projectStatusDistribution: [],
expenseTypeDistribution: [],
receivableStatusDistribution: []
})
//
const trendChartRef = ref(null)
const projectPieRef = ref(null)
const expensePieRef = ref(null)
const receivablePieRef = ref(null)
//
let trendChart = null
let projectPieChart = null
let expensePieChart = null
let receivablePieChart = null
//
const formatMoney = (value) => {
if (value === null || value === undefined) return '0.00'
return Number(value).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
//
const getProfitRate = () => {
const income = data.totalIncome || 0
if (income === 0) return '0.00'
return ((data.netProfit / income) * 100).toFixed(2)
}
//
const fetchData = async () => {
try {
const res = await getDashboardData()
Object.assign(data, res)
//
await nextTick()
renderTrendChart()
renderProjectPieChart()
renderExpensePieChart()
renderReceivablePieChart()
} catch (error) {
console.error('加载仪表盘数据失败:', error)
ElMessage.error(error.message || '加载数据失败')
}
}
//
const renderTrendChart = () => {
if (!trendChartRef.value) return
if (trendChart) {
trendChart.dispose()
}
trendChart = echarts.init(trendChartRef.value)
const months = data.incomeTrend?.map(item => item.month) || []
const incomeData = data.incomeTrend?.map(item => item.amount || 0) || []
const expenseData = data.expenseTrend?.map(item => item.amount || 0) || []
const option = {
tooltip: {
trigger: 'axis',
formatter: (params) => {
let result = params[0].axisValue + '<br/>'
params.forEach(param => {
result += `${param.marker} ${param.seriesName}: ¥${formatMoney(param.value)}<br/>`
})
return result
}
},
legend: {
data: ['收入', '支出'],
bottom: 0
},
grid: {
left: '3%',
right: '4%',
bottom: '15%',
top: '10%',
containLabel: true
},
xAxis: {
type: 'category',
data: months,
axisLabel: {
rotate: 45
}
},
yAxis: {
type: 'value',
axisLabel: {
formatter: (value) => {
if (value >= 10000) return (value / 10000) + 'w'
return value
}
}
},
series: [
{
name: '收入',
type: 'line',
smooth: true,
data: incomeData,
itemStyle: { color: '#67c23a' },
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: 'rgba(103, 194, 58, 0.3)' },
{ offset: 1, color: 'rgba(103, 194, 58, 0.05)' }
])
}
},
{
name: '支出',
type: 'line',
smooth: true,
data: expenseData,
itemStyle: { color: '#f56c6c' },
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: 'rgba(245, 108, 108, 0.3)' },
{ offset: 1, color: 'rgba(245, 108, 108, 0.05)' }
])
}
}
]
}
trendChart.setOption(option)
}
//
const renderProjectPieChart = () => {
if (!projectPieRef.value) return
if (projectPieChart) {
projectPieChart.dispose()
}
projectPieChart = echarts.init(projectPieRef.value)
const pieData = data.projectStatusDistribution?.map(item => ({
name: item.name,
value: item.count || 0
})) || []
const option = {
tooltip: {
trigger: 'item',
formatter: '{b}: {c} ({d}%)'
},
legend: {
orient: 'vertical',
right: 10,
top: 'center'
},
series: [
{
type: 'pie',
radius: ['40%', '70%'],
center: ['40%', '50%'],
avoidLabelOverlap: false,
itemStyle: {
borderRadius: 10,
borderColor: '#fff',
borderWidth: 2
},
label: {
show: false
},
emphasis: {
label: {
show: true,
fontSize: 14,
fontWeight: 'bold'
}
},
data: pieData
}
]
}
projectPieChart.setOption(option)
}
//
const renderExpensePieChart = () => {
if (!expensePieRef.value) return
if (expensePieChart) {
expensePieChart.dispose()
}
expensePieChart = echarts.init(expensePieRef.value)
const pieData = data.expenseTypeDistribution?.map(item => ({
name: item.name,
value: item.amount || 0
})) || []
const option = {
tooltip: {
trigger: 'item',
formatter: (params) => {
return `${params.name}: ¥${formatMoney(params.value)} (${params.percent}%)`
}
},
legend: {
orient: 'vertical',
right: 10,
top: 'center'
},
series: [
{
type: 'pie',
radius: ['40%', '70%'],
center: ['40%', '50%'],
avoidLabelOverlap: false,
itemStyle: {
borderRadius: 10,
borderColor: '#fff',
borderWidth: 2
},
label: {
show: false
},
emphasis: {
label: {
show: true,
fontSize: 14,
fontWeight: 'bold'
}
},
data: pieData
}
]
}
expensePieChart.setOption(option)
}
//
const renderReceivablePieChart = () => {
if (!receivablePieRef.value) return
if (receivablePieChart) {
receivablePieChart.dispose()
}
receivablePieChart = echarts.init(receivablePieRef.value)
const pieData = data.receivableStatusDistribution?.map(item => ({
name: item.name,
value: item.amount || 0
})) || []
const option = {
tooltip: {
trigger: 'item',
formatter: (params) => {
return `${params.name}: ¥${formatMoney(params.value)} (${params.percent}%)`
}
},
legend: {
orient: 'vertical',
right: 10,
top: 'center'
},
series: [
{
type: 'pie',
radius: ['40%', '70%'],
center: ['40%', '50%'],
avoidLabelOverlap: false,
itemStyle: {
borderRadius: 10,
borderColor: '#fff',
borderWidth: 2
},
label: {
show: false
},
emphasis: {
label: {
show: true,
fontSize: 14,
fontWeight: 'bold'
}
},
data: pieData
}
]
}
receivablePieChart.setOption(option)
}
//
const handleResize = () => {
trendChart?.resize()
projectPieChart?.resize()
expensePieChart?.resize()
receivablePieChart?.resize()
}
//
onMounted(() => {
fetchData()
window.addEventListener('resize', handleResize)
})
//
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
trendChart?.dispose()
projectPieChart?.dispose()
expensePieChart?.dispose()
receivablePieChart?.dispose()
})
</script>
<style scoped>
.dashboard {
.dashboard-container {
padding: 20px;
background-color: #f5f7fa;
min-height: 100%;
}
/* 概览卡片 */
.overview-cards {
margin-bottom: 20px;
}
.stat-card {
position: relative;
padding: 20px;
}
.stat-card :deep(.el-card__body) {
display: flex;
align-items: center;
padding: 10px;
padding: 20px;
}
.stat-icon {
width: 60px;
height: 60px;
border-radius: 8px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 28px;
margin-right: 20px;
}
.stat-info {
margin-left: 15px;
flex: 1;
}
.stat-value {
font-size: 24px;
font-weight: bold;
font-size: 28px;
font-weight: 600;
color: #303133;
}
.stat-label {
font-size: 14px;
color: #909399;
margin-top: 5px;
margin-top: 4px;
}
.mt-20 {
margin-top: 20px;
.stat-extra {
position: absolute;
right: 20px;
top: 20px;
font-size: 12px;
color: #67c23a;
}
.chart-placeholder {
height: 300px;
display: flex;
align-items: center;
justify-content: center;
background: #f5f7fa;
/* 收支卡片 */
.finance-cards {
margin-bottom: 20px;
}
.finance-card {
text-align: center;
padding: 20px;
}
.finance-card :deep(.el-card__body) {
padding: 25px;
}
.finance-title {
font-size: 14px;
color: #909399;
margin-bottom: 10px;
}
.finance-value {
font-size: 32px;
font-weight: 600;
margin-bottom: 10px;
}
.finance-value.negative {
color: #f56c6c;
}
.finance-sub {
font-size: 12px;
color: #909399;
}
.finance-card.income .finance-value {
color: #67c23a;
}
.finance-card.expense .finance-value {
color: #f56c6c;
}
.finance-card.profit .finance-value {
color: #409eff;
}
/* 应收款卡片 */
.receivable-cards {
margin-bottom: 20px;
}
.receivable-card {
text-align: center;
padding: 15px;
}
.receivable-card :deep(.el-card__body) {
padding: 20px;
}
.receivable-title {
font-size: 14px;
color: #909399;
margin-bottom: 8px;
}
.receivable-value {
font-size: 24px;
font-weight: 600;
color: #303133;
}
.receivable-card.warning .receivable-value {
color: #e6a23c;
}
.receivable-card.danger .receivable-value {
color: #f56c6c;
}
/* 图表区域 */
.chart-row {
margin-bottom: 20px;
}
.chart-container {
height: 350px;
}
</style>