<template>
|
<div style="padding: 20px;">
|
<!-- 页面标题和月份筛选 -->
|
<div class="w-full md:w-auto flex items-center gap-3"
|
style="margin-bottom: 20px;">
|
<el-date-picker v-model="dateRange"
|
type="monthrange"
|
format="YYYY-MM"
|
value-format="YYYY-MM"
|
range-separator="至"
|
start-placeholder="开始月份"
|
end-placeholder="结束月份"
|
:disabled-date="disabledDate"
|
@change="handleDateChange"
|
class="w-full md:w-auto"
|
style="margin-right: 30px;" />
|
<el-button type="primary"
|
icon="Refresh"
|
@click="resetDateRange"
|
size="default">
|
重置
|
</el-button>
|
</div>
|
<main class="container mx-auto px-4 pb-10">
|
<!-- 财务指标卡片 -->
|
<div class="stats-cards">
|
<div class="stat-card stat-card-blue">
|
<div class="stat-icon"><img src="@/assets/icons/png/walletBlue@2x.png"
|
alt="总营收" /></div>
|
<div class="stat-content">
|
<div class="stat-label">总营收</div>
|
<div class="stat-value">{{ formatMoney(pageInfo.totalIncome || 0) }}{{ Math.abs(pageInfo.totalIncome) < 10000 ? ' 元' : '' }}</div>
|
</div>
|
</div>
|
<div class="stat-card stat-card-orange">
|
<div class="stat-icon"><img src="@/assets/icons/png/walletOrange@2x.png"
|
alt="总支出" /></div>
|
<div class="stat-content">
|
<div class="stat-label">总支出</div>
|
<div class="stat-value">{{ formatMoney(pageInfo.totalExpense || 0) }}{{ Math.abs(pageInfo.totalExpense) < 10000 ? ' 元' : '' }}</div>
|
</div>
|
</div>
|
<div class="stat-card stat-card-green">
|
<div class="stat-icon"><img src="@/assets/icons/png/walletGreen@2x.png"
|
alt="应收账款" /></div>
|
<div class="stat-content">
|
<div class="stat-label">应收账款</div>
|
<div class="stat-value">{{ formatMoney(pageInfo.totalReceivable || 0) }}{{ Math.abs(pageInfo.totalReceivable) < 10000 ? ' 元' : '' }}</div>
|
</div>
|
</div>
|
<div class="stat-card stat-card-red">
|
<div class="stat-icon"><img src="@/assets/icons/png/walletRed@2x.png"
|
alt="应付账款" /></div>
|
<div class="stat-content">
|
<div class="stat-label">应付账款</div>
|
<div class="stat-value">{{ formatMoney(pageInfo.totalPayable || 0) }}{{ Math.abs(pageInfo.totalPayable) < 10000 ? ' 元' : '' }}</div>
|
</div>
|
</div>
|
<div class="stat-card stat-card-yellow">
|
<div class="stat-icon"><img src="@/assets/icons/png/walletYellow@2x.png"
|
alt="净利润" /></div>
|
<div class="stat-content">
|
<div class="stat-label">净利润</div>
|
<div class="stat-value">{{ formatMoney(pageInfo.netRevenue || 0) }}{{ Math.abs(pageInfo.netRevenue) < 10000 ? ' 元' : '' }}</div>
|
</div>
|
</div>
|
</div>
|
<!-- 图表区域 -->
|
<div class="charts-row">
|
<!-- 1. 收支构成分析 (双环形图 + 净利中心) -->
|
<el-card class="chart-card">
|
<template #header>
|
<div class="card-header">
|
<span class="header-title">收支构成及净利分析</span>
|
<el-tooltip content="左侧为收入构成,右侧为支出构成,中间展示盈亏净额"
|
placement="top">
|
<el-icon>
|
<QuestionFilled />
|
</el-icon>
|
</el-tooltip>
|
</div>
|
</template>
|
<div class="financial-overview-container">
|
<!-- 收入展示 (左侧) -->
|
<div style="width:60%">
|
<div class="overview-item income"
|
style="margin-bottom: 20px;">
|
<div class="overview-box">
|
<div class="icon-circle">
|
<el-icon>
|
<TrendCharts />
|
</el-icon>
|
</div>
|
<div class="data-content">
|
<div class="label">本期总收入</div>
|
<div class="value">{{ formatMoney(pageInfo.totalIncome) }}</div>
|
<div class="unit">RMB{{ Math.abs(pageInfo.totalIncome) < 10000 ? ' / 元' : '' }}</div>
|
</div>
|
<div class="bg-decoration">INCOME</div>
|
</div>
|
</div>
|
<div class="overview-item expense">
|
<div class="overview-box">
|
<div class="icon-circle">
|
<el-icon>
|
<Sell />
|
</el-icon>
|
</div>
|
<div class="data-content">
|
<div class="label">本期总支出</div>
|
<div class="value">{{ formatMoney(pageInfo.totalExpense) }}</div>
|
<div class="unit">RMB{{ Math.abs(pageInfo.totalExpense) < 10000 ? ' / 元' : '' }}</div>
|
</div>
|
<div class="bg-decoration">EXPENSE</div>
|
</div>
|
</div>
|
</div>
|
<!-- 净利润核心指示 (中间) -->
|
<div class="profit-indicator">
|
<div class="profit-gauge-wrapper">
|
<Echarts :chartStyle="chartStylePie"
|
:series="profitGaugeSeries"
|
:tooltip="gaugeTooltip"
|
style="height: 200px; width: 100%; max-width: 200px;">
|
</Echarts>
|
<div class="profit-center-text">
|
<div class="label">净利润</div>
|
<div class="value"
|
:class="pageInfo.netRevenue >= 0 ? 'plus' : 'minus'">
|
{{ pageInfo.netRevenue >= 0 ? '+' : '' }}{{ formatMoney(pageInfo.netRevenue) }}
|
</div>
|
<div class="rate">利润率: {{ pageInfo.totalIncome > 0 ? ((pageInfo.netRevenue / pageInfo.totalIncome) * 100).toFixed(1) : 0 }}%</div>
|
</div>
|
</div>
|
</div>
|
<!-- 支出展示 (右侧) -->
|
</div>
|
</el-card>
|
<!-- 2. 应收/应付对冲分析 (柱状图) -->
|
<el-card class="chart-card">
|
<template #header>
|
<div class="card-header">
|
<span class="header-title">应收/应付概览</span>
|
<el-tooltip content="对比当前各月份的应收账款与应付账款"
|
placement="top">
|
<el-icon>
|
<QuestionFilled />
|
</el-icon>
|
</el-tooltip>
|
</div>
|
</template>
|
<Echarts :chartStyle="chartStyle"
|
:grid="barGrid"
|
:legend="barLegend"
|
:series="barSeries"
|
:tooltip="barTooltip"
|
:xAxis="barXAxis"
|
:yAxis="barYAxis"
|
style="height: 270px; width: 100%;">
|
</Echarts>
|
</el-card>
|
</div>
|
<!-- 3. 财务综合趋势分析 (折线图) -->
|
<el-card class="trend-chart-card">
|
<template #header>
|
<div class="card-header">
|
<span class="header-title">财务绩效综合趋势</span>
|
<el-tooltip content="展示收入、支出及净利润的月度变化趋势"
|
placement="top">
|
<el-icon>
|
<QuestionFilled />
|
</el-icon>
|
</el-tooltip>
|
</div>
|
</template>
|
<Echarts :chartStyle="chartStyle"
|
:grid="trendGrid"
|
:legend="trendLegend"
|
:series="trendSeries"
|
:tooltip="trendTooltip"
|
:xAxis="trendXAxis"
|
:yAxis="trendYAxis"
|
style="height: 350px; width: 100%;">
|
</Echarts>
|
</el-card>
|
</main>
|
</div>
|
</template>
|
|
<script setup>
|
import {
|
ref,
|
computed,
|
onMounted,
|
reactive,
|
nextTick,
|
getCurrentInstance,
|
} from "vue";
|
import { QuestionFilled, TrendCharts, Sell } from "@element-plus/icons-vue";
|
import Echarts from "@/components/Echarts/echarts.vue";
|
import { accountStatementDetailsByMonth } from "@/api/financialManagement/financialStatements";
|
import dayjs from "dayjs";
|
|
const { proxy } = getCurrentInstance();
|
const dateRange = ref(null);
|
const pageInfo = reactive({
|
totalIncome: 0,
|
totalExpense: 0,
|
totalReceivable: 0,
|
totalPayable: 0,
|
netRevenue: 0,
|
});
|
|
const chartStyle = { width: "100%", height: "100%", position: "relative" };
|
const chartStylePie = { width: "100%", height: "100%" };
|
|
const monthlyTrendList = ref([]);
|
const receivablePayableList = ref([]);
|
|
// --- 1. 收支构成分析 (简化版逻辑) ---
|
const gaugeTooltip = { show: false };
|
|
const profitGaugeSeries = computed(() => {
|
const rate =
|
pageInfo.totalIncome > 0
|
? (pageInfo.netRevenue / pageInfo.totalIncome) * 100
|
: 0;
|
return [
|
{
|
type: "gauge",
|
startAngle: 210,
|
endAngle: -30,
|
min: 0,
|
max: 100,
|
splitNumber: 10,
|
radius: "100%",
|
progress: {
|
show: true,
|
width: 14,
|
itemStyle: { color: pageInfo.netRevenue >= 0 ? "#10b981" : "#f43f5e" },
|
},
|
pointer: { show: false },
|
axisLine: { lineStyle: { width: 14, color: [[1, "#f1f5f9"]] } },
|
axisTick: { show: false },
|
splitLine: { show: false },
|
axisLabel: { show: false },
|
anchor: { show: false },
|
title: { show: false },
|
detail: { show: false },
|
data: [{ value: Math.max(0, Math.min(100, rate)) }],
|
},
|
];
|
});
|
|
// --- 2. 应收/应付概览 (柱状图) ---
|
const barGrid = { left: "3%", right: "4%", bottom: "3%", containLabel: true };
|
const barLegend = { top: "0", right: "center" };
|
const barXAxis = computed(() => [
|
{
|
type: "category",
|
data: receivablePayableList.value.map(item => item.month || ""),
|
axisTick: { alignWithLabel: true },
|
},
|
]);
|
const barYAxis = [{ type: "value", name: "金额 (元)" }];
|
const barTooltip = { trigger: "axis", axisPointer: { type: "shadow" } };
|
const barSeries = computed(() => [
|
{
|
name: "应收账款",
|
type: "bar",
|
barWidth: "30%",
|
data: receivablePayableList.value.map(item => item.receivable || 0),
|
itemStyle: { color: "#10b981" },
|
},
|
{
|
name: "应付账款",
|
type: "bar",
|
barWidth: "30%",
|
data: receivablePayableList.value.map(item => item.payable || 0),
|
itemStyle: { color: "#ef4444" },
|
},
|
]);
|
|
// --- 3. 财务综合趋势分析 (折线图) ---
|
const trendGrid = { left: "3%", right: "4%", bottom: "3%", containLabel: true };
|
const trendLegend = { top: "0", right: "center" };
|
const trendXAxis = computed(() => [
|
{
|
type: "category",
|
boundaryGap: false,
|
data: monthlyTrendList.value.map(item => item.month || ""),
|
},
|
]);
|
const trendYAxis = [{ type: "value", name: "金额 (元)" }];
|
const trendTooltip = { trigger: "axis" };
|
const trendSeries = computed(() => [
|
{
|
name: "总营收",
|
type: "line",
|
smooth: true,
|
data: monthlyTrendList.value.map(item => item.income || 0),
|
itemStyle: { color: "#4f46e5" },
|
areaStyle: { opacity: 0.1 },
|
},
|
{
|
name: "总支出",
|
type: "line",
|
smooth: true,
|
data: monthlyTrendList.value.map(item => item.expense || 0),
|
itemStyle: { color: "#f97316" },
|
},
|
{
|
name: "净利润",
|
type: "line",
|
smooth: true,
|
data: monthlyTrendList.value.map(item => item.profit || 0),
|
lineStyle: { width: 4, type: "dashed" },
|
itemStyle: { color: "#10b981" },
|
},
|
]);
|
|
// --- 公用逻辑 ---
|
const formatMoney = val => {
|
return val;
|
};
|
|
const handleDateChange = val => {
|
if (val) getData();
|
};
|
|
const resetDateRange = () => {
|
dateRange.value = [
|
dayjs().subtract(5, "month").format("YYYY-MM"),
|
dayjs().format("YYYY-MM"),
|
];
|
getData();
|
};
|
|
const disabledDate = time => dayjs(time).isAfter(dayjs(), "month");
|
|
const getData = async () => {
|
if (!dateRange.value || dateRange.value.length !== 2) return;
|
|
const params = {
|
entryDateStart: dayjs(dateRange.value[0])
|
.startOf("month")
|
.format("YYYY-MM-DD"),
|
entryDateEnd: dayjs(dateRange.value[1]).endOf("month").format("YYYY-MM-DD"),
|
};
|
|
try {
|
const res = await accountStatementDetailsByMonth(params);
|
if (res.code === 200 && res.data) {
|
const data = res.data;
|
// 更新顶部汇总卡片数据
|
pageInfo.totalIncome = data.totalIncome || 0;
|
pageInfo.totalExpense = data.totalExpense || 0;
|
pageInfo.totalReceivable = data.accountsReceivable || 0;
|
pageInfo.totalPayable = data.accountsPayable || 0;
|
pageInfo.netRevenue = data.netRevenue || 0;
|
|
// 更新图表数据
|
monthlyTrendList.value = data.monthlyTrendList || [];
|
receivablePayableList.value = data.receivablePayableList || [];
|
}
|
} catch (error) {
|
console.error("获取财务报表数据失败:", error);
|
}
|
};
|
|
onMounted(() => {
|
resetDateRange();
|
});
|
</script>
|
|
<style scoped lang="scss">
|
.stats-cards {
|
display: grid;
|
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
gap: 20px;
|
margin-bottom: 24px;
|
}
|
|
.stat-card {
|
background: #fff;
|
border: 1px solid #edf2f7;
|
border-radius: 12px;
|
padding: 24px;
|
display: flex;
|
align-items: center;
|
gap: 16px;
|
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05);
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
|
&:hover {
|
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
|
transform: translateY(-4px);
|
}
|
|
.stat-icon {
|
width: 56px;
|
height: 56px;
|
background: #f7fafc;
|
border-radius: 12px;
|
display: flex;
|
align-items: center;
|
justify-content: center;
|
img {
|
width: 32px;
|
height: 32px;
|
}
|
}
|
|
.stat-content {
|
.stat-label {
|
font-size: 14px;
|
color: #718096;
|
margin-bottom: 4px;
|
}
|
.stat-value {
|
font-size: 20px;
|
font-weight: 700;
|
color: #2d3748;
|
}
|
}
|
}
|
|
.charts-row {
|
display: grid;
|
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
gap: 24px;
|
margin-bottom: 24px;
|
}
|
|
@media (min-width: 1200px) {
|
.charts-row {
|
grid-template-columns: repeat(2, 1fr);
|
}
|
}
|
|
.chart-card,
|
.trend-chart-card {
|
border-radius: 16px;
|
border: none;
|
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05);
|
|
.card-header {
|
display: flex;
|
align-items: center;
|
gap: 8px;
|
.header-title {
|
font-size: 16px;
|
font-weight: 600;
|
color: #1a202c;
|
}
|
.el-icon {
|
color: #a0aec0;
|
cursor: help;
|
}
|
}
|
}
|
|
.financial-overview-container {
|
display: flex;
|
justify-content: space-between;
|
align-items: center;
|
flex-wrap: nowrap;
|
gap: 10px;
|
padding: 20px 0;
|
width: 100%;
|
overflow: hidden;
|
|
.overview-item {
|
flex: 1;
|
min-width: 0; // 允许在 flex 容器中缩写,防止内容撑开
|
display: flex;
|
justify-content: center;
|
|
.overview-box {
|
position: relative;
|
width: 100%;
|
max-width: 320px;
|
height: 110px;
|
background: #f8fafc;
|
border-radius: 12px;
|
padding: 12px 16px;
|
display: flex;
|
align-items: center;
|
gap: 12px;
|
overflow: hidden;
|
transition: all 0.3s ease;
|
|
&:hover {
|
transform: translateY(-5px);
|
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
|
}
|
|
.icon-circle {
|
flex-shrink: 0;
|
width: 42px;
|
height: 42px;
|
border-radius: 10px;
|
display: flex;
|
align-items: center;
|
justify-content: center;
|
font-size: 20px;
|
z-index: 2;
|
}
|
|
.data-content {
|
z-index: 2;
|
min-width: 0;
|
.label {
|
font-size: 13px;
|
color: #718096;
|
margin-bottom: 2px;
|
font-weight: 500;
|
white-space: nowrap;
|
}
|
.value {
|
font-size: 18px;
|
font-weight: 800;
|
color: #1a202c;
|
line-height: 1.2;
|
overflow: hidden;
|
text-overflow: ellipsis;
|
white-space: nowrap;
|
}
|
.unit {
|
font-size: 11px;
|
color: #a0aec0;
|
}
|
}
|
|
.bg-decoration {
|
position: absolute;
|
right: -5px;
|
bottom: -5px;
|
font-size: 32px;
|
font-weight: 950;
|
color: rgba(0, 0, 0, 0.03);
|
font-style: italic;
|
user-select: none;
|
z-index: 1;
|
}
|
}
|
|
&.income {
|
.icon-circle {
|
background: #eef2ff;
|
color: #4f46e5;
|
}
|
.overview-box {
|
border-left: 5px solid #4f46e5;
|
}
|
}
|
|
&.expense {
|
.icon-circle {
|
background: #fff7ed;
|
color: #f97316;
|
}
|
.overview-box {
|
border-left: 5px solid #f97316;
|
}
|
}
|
}
|
|
.profit-indicator {
|
flex: 0 40%; // 固定宽度,不参与弹性缩放以保证仪表盘完整
|
display: flex;
|
justify-content: center;
|
align-items: center;
|
|
.profit-gauge-wrapper {
|
position: relative;
|
display: flex;
|
justify-content: center;
|
align-items: center;
|
width: 100%;
|
// max-width: 180px;
|
|
.profit-center-text {
|
position: absolute;
|
top: 50%;
|
left: 50%;
|
transform: translate(-50%, -50%);
|
text-align: center;
|
width: 100%;
|
|
.label {
|
font-size: 12px;
|
color: #718096;
|
font-weight: 500;
|
}
|
|
.value {
|
font-size: 20px;
|
font-weight: 800;
|
margin: 2px 0;
|
overflow: hidden;
|
text-overflow: ellipsis;
|
white-space: nowrap;
|
|
&.plus {
|
color: #10b981;
|
}
|
|
&.minus {
|
color: #f43f5e;
|
}
|
}
|
|
.rate {
|
font-size: 11px;
|
color: #a0aec0;
|
font-weight: 500;
|
}
|
}
|
}
|
}
|
|
// 针对非常窄的屏幕进行整体缩放
|
@media (max-width: 1400px) {
|
transform-origin: center;
|
// 如果容器太窄,通过缩小内部元素来适应
|
// 这里不使用 transform: scale 因为会影响布局流,改用内部尺寸微调
|
.overview-item .overview-box {
|
padding: 10px;
|
gap: 8px;
|
.value {
|
font-size: 16px;
|
}
|
.icon-circle {
|
width: 36px;
|
height: 36px;
|
font-size: 18px;
|
}
|
}
|
.profit-indicator {
|
flex: 0 40%;
|
.profit-gauge-wrapper .value {
|
font-size: 18px;
|
}
|
}
|
}
|
}
|
</style>
|