前端实现:
- dashboard.js: API接口封装(13行)
- dashboard/index.vue: 仪表盘页面(576行)
页面布局:
1. 概览卡片(4个)
- 项目总数(含本月新增)
- 客户总数(含本月新增)
- 合同总数
- 需求工单数
2. 收支概览(3个)
- 总收入(含本月收入)
- 总支出(含本月支出)
- 净利润(含利润率)
3. 应收款概览(3个)
- 应收款总额
- 待收款金额(橙色警示)
- 逾期金额(红色警示)
4. 图表区域(4个)
- 收支趋势折线图(最近12个月)
- 项目状态分布饼图
- 支出类型分布饼图
- 应收款状态分布饼图
技术特点:
- ECharts图表库:折线图、环形饼图
- 响应式设计:窗口大小变化自动重绘
- 渐变色卡片:现代化UI设计
- 金额格式化:千分位分隔、保留2位小数
- 图表tooltip:金额/百分比格式化显示
- 组件销毁时清理资源
模块状态:✅ 完整(前端+后端)
639 lines
15 KiB
Vue
639 lines
15 KiB
Vue
<template>
|
||
<div class="dashboard-container">
|
||
<!-- 概览卡片 -->
|
||
<el-row :gutter="20" class="overview-cards">
|
||
<el-col :span="6">
|
||
<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">{{ 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 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">{{ 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 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">{{ data.requirementCount || 0 }}</div>
|
||
<div class="stat-label">需求工单</div>
|
||
</div>
|
||
</el-card>
|
||
</el-col>
|
||
</el-row>
|
||
|
||
<!-- 收支概览 -->
|
||
<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 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 shadow="hover">
|
||
<template #header>
|
||
<span>应收款状态分布</span>
|
||
</template>
|
||
<div ref="receivablePieRef" class="chart-container"></div>
|
||
</el-card>
|
||
</el-col>
|
||
</el-row>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
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 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-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: 20px;
|
||
}
|
||
|
||
.stat-icon {
|
||
width: 60px;
|
||
height: 60px;
|
||
border-radius: 12px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: #fff;
|
||
margin-right: 20px;
|
||
}
|
||
|
||
.stat-info {
|
||
flex: 1;
|
||
}
|
||
|
||
.stat-value {
|
||
font-size: 28px;
|
||
font-weight: 600;
|
||
color: #303133;
|
||
}
|
||
|
||
.stat-label {
|
||
font-size: 14px;
|
||
color: #909399;
|
||
margin-top: 4px;
|
||
}
|
||
|
||
.stat-extra {
|
||
position: absolute;
|
||
right: 20px;
|
||
top: 20px;
|
||
font-size: 12px;
|
||
color: #67c23a;
|
||
}
|
||
|
||
/* 收支卡片 */
|
||
.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>
|