zhangjf 81e919ad3c feat: 数据统计分析模块前端实现
前端实现:
- dashboard.js: API接口封装(13行)
- dashboard/index.vue: 仪表盘页面(576行)

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

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

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

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

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

模块状态: 完整(前端+后端)
2026-02-16 09:35:23 +08:00

639 lines
15 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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>