Files
estate/src/views/system/statistics/Payment/index.vue
2026-06-16 15:09:09 +08:00

757 lines
19 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="personnel p-2 h-full flex flex-col">
<div class="flex flex-items-center searchBox">
<div class="searchLabel">选择日期</div>
<el-date-picker
v-model="dataTime"
type="date"
placeholder="选择日期"
value-format="YYYY-MM-DD"
format="YYYY-MM-DD"
style="width: 400px; margin-right: 10px"
></el-date-picker>
<el-button type="primary" @click="querySearch">查询</el-button>
<el-button @click="reset">重置</el-button>
</div>
<div style="margin-bottom: 15px; position: relative; z-index: 2">
<el-row :gutter="20">
<el-col :lg="14" :xl="14">
<div class="cardbox">
<div>收入来源</div>
<div class="card_infoall">
<div>应缴费用{{ formatMoneyWithUnit(form.totalDue) }}</div>
<div>已收{{ formatMoneyWithUnit(form.totalPaid) }}</div>
<div>待收{{ formatMoneyWithUnit(form.totalUnpaid) }}</div>
</div>
<div class="cardbody" style="height: 165px">
<div
v-for="(opt, index) in incomeChartOptions"
:key="index"
class="chart-item flex flex-col items-center"
style="width: 165px; height: 100%"
>
<Echarts :options="opt.options as EChartsOption" style="width: 100%; height: 165px"></Echarts>
<!-- 如果需要额外显示名称可以在这里加 div但图表中心已显示 -->
<div class="chart-item-name">{{ opt.name }}</div>
</div>
</div>
</div>
</el-col>
<el-col :lg="10" :xl="10">
<div class="cardbox">
<div>欠费分布</div>
<div class="cardbody w-full" style="height: 150px">
<Echarts :options="options2"></Echarts>
</div>
</div>
</el-col>
</el-row>
</div>
<div style="margin-bottom: 15px; position: relative; z-index: 2">
<el-row :gutter="20">
<el-col :lg="12" :xl="14">
<div class="cardbox w-full">
<div>欠费分布</div>
<div class="cardbody w-full" style="height: 330px">
<Echarts :options="options3"></Echarts>
</div>
</div>
</el-col>
<el-col :lg="12" :xl="10">
<div class="cardbox w-full" style="height: 400px">
<div style="height: 50px; margin-bottom: 20px">缴费来源</div>
<div flex flex-items-center justify-center>
<ul class="legendbox">
<li v-for="(item, index) in paytypelist" :key="index">
<i
:style="{
backgroundColor: item.color
}"
></i>
<span class="ml-2">{{ item.label }}</span>
</li>
</ul>
<ul class="circlebox">
<li :data-level="index" :style="{ backgroundColor: item.color }" v-for="(item, index) in paytypelist" :key="index">
{{ item.number }}%
</li>
</ul>
</div>
</div>
</el-col>
</el-row>
</div>
<div style="position: relative; z-index: 2">
<el-row>
<el-col :span="24">
<!-- <div class="cardbox w-full" style="height: 450px">
<Echarts :options="flowLineOption"></Echarts>
</div> -->
<div class="cardbox w-full">
<div>缴费走势分析</div>
<div class="cardbody w-full" style="height: 400px">
<Echarts :options="flowLineOption"></Echarts>
</div>
</div>
</el-col>
</el-row>
</div>
<Teleport to="body">
<i v-if="open" class="bgColor"></i>
</Teleport>
</div>
</template>
<script setup lang="ts">
// TODO 缴费分析
import { ref } from 'vue';
import Echarts from '@/components/Echarts/index.vue';
import { formatMoneyWithUnit } from '@/utils/money';
import { EChartsOption } from 'echarts';
import { getLast7Days, getPastDayDate } from '@/utils/time';
import { income_statsApi } from '@/api/system/Payment/index';
const open = ref(false);
const dataTime = ref('2025-01-01');
const form = ref({
'totalDue': '',
'totalPaid': '',
'totalUnpaid': '',
'paidPercentage': '',
'payWx': null,
'payZfb': null,
'payOffline': null,
'totalPay': null,
'feeTypeStats': [],
'dailyStats': []
});
const defaultDay = 30; // 默认查询近30天数据
/** 收入来源 */
/** 收入来源图表配置 */
const incomeChartOptions = ref<
{
name: string;
options?: EChartsOption;
}[]
>([
{
'name': '停车费'
},
{
'name': '物业费'
},
{
'name': '水电费'
},
{
'name': '商铺租金'
},
{
'name': '维修费'
}
]);
/** 缴费方式 */
const options2 = shallowRef<EChartsOption>({
legend: {
top: 'center',
left: '0',
orient: 'vertical',
itemGap: 50
},
clockwise: true,
series: [
{
name: 'Access From',
type: 'pie',
startAngle: 280,
radius: ['50%', '100%'],
avoidLabelOverlap: false,
left: '50%',
label: {
show: false,
position: 'center'
},
tooltip: {
show: false
},
emphasis: {
label: {
show: false,
fontSize: 20,
fontWeight: 'bold'
}
},
labelLine: {
show: false
},
data: [
{ value: 50, name: '支付宝', itemStyle: { color: '#0a74fe' } },
{ value: 30, name: '微信', itemStyle: { color: '#f74043' } },
{ value: 20, name: '线下', itemStyle: { color: '#fcd152' } }
]
}
]
});
// 【新增】定义12个月份
const monthX = ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月'];
/** 欠费分布 散点图 */
/** 欠费分布 散点图 (带特效版) */
const options3 = shallowRef<EChartsOption>({
tooltip: {
trigger: 'item',
formatter: (params: any) => {
return `${monthX[params.value[0]]}<br/>欠费金额: <b>${params.value[1]}</b> 万元`;
},
backgroundColor: 'rgba(255, 255, 255, 0.9)',
borderColor: '#eee',
textStyle: { color: '#333' }
},
grid: {
left: '1%',
right: '2%',
top: '12%',
bottom: '12%',
containLabel: true
},
xAxis: {
type: 'category',
data: monthX,
boundaryGap: false,
axisTick: {
alignWithLabel: true,
length: 5, // 刻度线长度
lineStyle: { color: '#ccc' }
},
axisLine: { lineStyle: { color: '#eee' }, onZero: false },
axisLabel: {
color: '#666',
interval: 0, // 强制显示所有月份
margin: 12 // 标签与轴线的距离
},
splitLine: {
show: true,
lineStyle: {
color: '#f0f0f0',
type: 'dashed',
width: 1
}
}
},
yAxis: {
type: 'value',
name: '金额(万元)',
nameTextStyle: { color: '#999', padding: [0, 0, 0, 10] },
splitLine: {
show: true,
lineStyle: {
color: '#f0f0f0',
type: 'dashed' // 虚线网格更轻盈
}
},
axisLabel: { color: '#999', margin: 20 }
},
series: [
{
name: '欠费金额',
type: 'scatter',
symbolSize: (data: any) => {
const value = data[1];
return Math.min(Math.max(value * 1.5, 10), 40);
},
itemStyle: {
color: (params: any) => {
const index = params.dataIndex;
if (index % 2 === 0) {
return '#739ff0';
} else {
return '#86c9d0';
}
}
},
// 【特效 2】高亮状态鼠标悬停
emphasis: {
scale: 1.2 // 悬停时额外放大
},
data: [
[0, 12.5],
[1, 10.2],
[2, 15.8],
[3, 9.5],
[4, 11.0],
[5, 18.3],
[6, 14.2],
[7, 16.5],
[8, 13.1],
[9, 10.8],
[10, 12.4],
[11, 19.6]
]
}
]
});
// ! 进十日缴费数量情况 -------------------------------------------------------------------------------------
// 近10天日期
const dayX = getLast7Days(10);
// 小区真实规模:人员流入 / 流出 数据
const inData = [97, 145, 63, 112, 88, 156, 74, 131, 102, 59];
const outData = [121, 85, 142, 71, 135, 93, 116, 66, 148, 104];
/** 进十日缴费数量情况 - 平滑折线图 */
const flowLineOption = shallowRef<EChartsOption>({
tooltip: {
trigger: 'axis',
confine: true
},
grid: {
left: '3%',
right: '4%',
top: '12%',
bottom: '8%',
containLabel: true
},
legend: {
data: ['已缴费', '未缴费'],
right: 10,
top: 0
},
xAxis: {
type: 'category',
data: dayX,
boundaryGap: false,
axisLine: { lineStyle: { color: '#eee' } }
},
yAxis: {
type: 'value',
splitLine: {
show: true,
lineStyle: { color: '#eee' }
}
},
series: [
{
name: '已缴费',
type: 'line',
smooth: true, // 平滑折线 核心
data: inData,
symbol: 'circle',
symbolSize: 6,
itemStyle: { color: '#f94144' },
lineStyle: { width: 4 },
areaStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: '#fbd7d9' },
{ offset: 1, color: '#fbd7d9' }
]
}
}
},
{
name: '未缴费',
type: 'line',
smooth: true, // 平滑折线 核心
data: outData,
symbol: 'circle',
symbolSize: 6,
itemStyle: { color: '#f9c74f' },
lineStyle: { width: 4 },
areaStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: '#fcf2dc' },
{ offset: 1, color: '#fcf2dc' }
]
}
}
}
]
});
// ! 进十日缴费数量情况 -------------------------------------------------------------------------------------
const paytypelist = ref([
{ label: '微信', value: 'payWx', number: 0, color: '#4a3afe' },
{ label: '支付宝', value: 'payZfb', number: 0, color: '#1e1b39' },
{ label: '线下', value: 'payOffline', number: 0, color: '#c893fd' }
]);
function getlist() {
const startData = getPastDayDate(defaultDay);
income_statsApi(dataTime.value === '' ? startData : dataTime.value).then((res) => {
form.value = res.data;
const { payOffline, payWx, payZfb, feeTypeStats, dailyStats } = res.data;
const total = (payOffline ?? 0) + (payWx ?? 0) + (payZfb ?? 0);
if (total > 0) {
form.value.payOffline = Number.isNaN((payOffline / total) * 100) ? 0 : ((payOffline / total) * 100).toFixed(2);
form.value.payWx = Number.isNaN((payWx / total) * 100) ? 0 : ((payWx / total) * 100).toFixed(2);
form.value.payZfb = Number.isNaN((payZfb / total) * 100) ? 0 : ((payWx / total) * 100).toFixed(2);
paytypelist.value[0].number = form.value.payOffline;
paytypelist.value[1].number = form.value.payWx;
paytypelist.value[2].number = form.value.payZfb;
paytypelist.value.sort((a, b) => Number(b.number) - Number(a.number));
}
// 收入来源
// 2. 处理收入来源 (新逻辑)
if (feeTypeStats && Array.isArray(feeTypeStats)) {
updateIncomeChart(feeTypeStats);
}
// 3. 【新增】处理缴费走势 (近十日缴费情况)
if (dailyStats && Array.isArray(dailyStats)) {
updateFlowLineChart(dailyStats);
}
});
}
/**
* 更新缴费走势图表
* @param stats 后端返回的 dailyStats 数组
*/
function updateFlowLineChart(stats: any[]) {
if (!stats.length) {
flowLineOption.value.series = [];
flowLineOption.value.xAxis.data = [];
return;
}
// 提取数据
const dates = stats.map((item) => item.statDate);
const paidData = stats.map((item) => Number(item.totalPaid) || 0);
const unpaidData = stats.map((item) => Number(item.totalUnpaid) || 0);
// 更新图表配置
// 使用 shallowRef 时,建议替换整个对象或确保深层属性被正确追踪
// 这里我们直接修改 series 和 xAxis 的数据
flowLineOption.value = {
...flowLineOption.value,
xAxis: {
...flowLineOption.value.xAxis,
data: dates
},
series: [
{
name: '已缴费',
type: 'line',
smooth: true,
data: paidData,
symbol: 'circle',
symbolSize: 6,
itemStyle: { color: '#f94144' },
lineStyle: { width: 4 },
areaStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: '#fbd7d9' },
{ offset: 1, color: '#fbd7d9' }
]
}
}
},
{
name: '未缴费',
type: 'line',
smooth: true,
data: unpaidData,
symbol: 'circle',
symbolSize: 6,
itemStyle: { color: '#f9c74f' },
lineStyle: { width: 4 },
areaStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: '#fcf2dc' },
{ offset: 1, color: '#fcf2dc' }
]
}
}
}
]
};
}
/**
* 更新收入来源图表数组
* @param stats 后端返回的 feeTypeStats 数组
*/
function updateIncomeChart(stats: any[]) {
let statslist = stats;
if (statslist.length === 0) {
incomeChartOptions.value = [];
statslist = [
{
'feeName': '停车费'
},
{
'feeName': '物业费'
},
{
'feeName': '水电费'
},
{
'feeName': '商铺租金'
},
{
'feeName': '维修费'
}
];
}
// 【修改点 2】映射生成多个独立的 ECharts 配置
incomeChartOptions.value = statslist.map((item, index) => {
const percentage = Number(item.paidPercentage) || 0;
const color = '#409eff';
return {
name: item.feeName, // 图表名称
options: {
tooltip: {
show: false,
trigger: 'item',
formatter: '{b}: {c}%'
},
series: [
{
type: 'pie',
radius: ['50%', '70%'], // 环形大小
center: ['50%', '50%'], // 每个图表各自居中
select: {
scale: false
},
labelLine: {
show: false
},
data: [
{
value: 100 - percentage,
itemStyle: {
color: '#ebeef5' // 背景色
},
emphasis: {
itemStyle: {
color: '#ebeef5'
},
scale: false
}
},
{
value: percentage,
name: item.feeName,
itemStyle: { color: color },
label: {
show: true,
position: 'center', // 显示在圆环中心
formatter: '{c}%', // 显示百分比数值
fontSize: 14,
fontWeight: 'bold',
color: '#4ce19c'
},
emphasis: {
scale: true
}
}
]
}
]
}
};
});
console.log(incomeChartOptions.value);
}
function querySearch() {
getlist();
}
function reset() {
dataTime.value = '';
querySearch();
}
onMounted(() => {
open.value = true;
getlist();
});
onUnmounted(() => {
open.value = false;
});
</script>
<style scoped lang="scss">
.personnel {
background: linear-gradient(180deg, #deedff 0%, #f8f9fe 50%);
overflow: auto;
.el-button {
width: 80px;
height: 32px;
border-radius: 4px;
}
.searchBox {
margin-bottom: 20px;
}
.searchLabel {
width: 100px;
font-size: 0.95rem;
}
/* 入住清空 */
.cardbox {
background: linear-gradient(180deg, rgba(255, 255, 255, 0.2) 0%, #ffffff 70%);
border-radius: 16px 16px 16px 16px;
border: 2px solid #ffffff;
font-size: 20px;
padding: 20px;
min-height: 275px;
@media (max-width: 1600px) {
font-size: 18px;
}
@media (max-width: 1300px) {
font-size: 20px;
}
@media (max-width: 1200px) {
margin-bottom: 20px;
}
.card_infoall {
padding-left: 40px;
display: flex;
align-items: center;
gap: 20px;
height: 20px;
line-height: 20px;
color: rgba(51, 51, 51, 1);
font-size: 14px;
margin-top: 15px;
}
.cardbody {
padding: 0 30px;
display: flex;
align-items: center;
justify-content: space-between;
.chart-item {
flex-shrink: 0;
}
.card_leftbox {
text-align: center;
.labelbox {
font-size: 16px;
}
.valuebox {
font-weight: 600;
font-size: 36px;
color: #222222;
}
}
.echartsbox {
width: 400px;
}
}
}
.legendbox {
font-size: 15px;
li {
margin-bottom: 20px;
position: relative;
width: 100px;
height: 50px;
line-height: 50px;
text-align: left;
padding-left: 30px;
i {
display: block;
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
display: block;
width: 20px;
height: 20px;
border-radius: 50%;
}
}
}
.circlebox {
margin-left: 60px;
position: relative;
width: 300px;
height: 200px;
transform: translateY(-40px);
li {
position: absolute;
border-radius: 50%;
border: 3px solid white;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-weight: 450;
transition: all 0.3s ease; // 添加过渡动画
// 默认样式 (作为 Level 1 或基础样式)
width: 120px;
height: 120px;
font-size: 1.2rem;
right: 40px;
top: 40px;
z-index: 10;
background-color: #c893fd; // 默认颜色,会被 data-bg 覆盖
// Level 0: 最大值 (排序后的第一个)
&[data-level='0'] {
width: 180px;
height: 180px;
left: 0;
top: 15px;
right: auto;
font-size: 1.8rem;
z-index: 9; // 层级最低,
}
// Level 1: 中间值
&[data-level='1'] {
width: 130px;
height: 130px;
left: 150px; // 重叠在大球右侧
top: 0;
right: auto;
font-size: 1.3rem;
z-index: 10;
}
// Level 2: 最小值
&[data-level='2'] {
width: 90px;
height: 90px;
left: 150px; // 更靠右
top: 100px; // 靠下
right: auto;
font-size: 1rem;
z-index: 11; // 层级最高,浮在最上面
}
}
}
}
</style>