448 lines
10 KiB
Vue
448 lines
10 KiB
Vue
<template>
|
||
<view class="wrap">
|
||
<scroll-view class="page" scroll-y @scrolltolower="onLoadMore">
|
||
<view class="empty-state" v-if="!loading && billList.length === 0">
|
||
<text class="empty-text">暂无账单数据</text>
|
||
</view>
|
||
|
||
<up-collapse v-if="billList.length" :border="false">
|
||
<up-collapse-item
|
||
v-for="(item, index) in billList"
|
||
:key="index"
|
||
:name="String(index)"
|
||
>
|
||
<!-- 标题:勾选框 + 月份 -->
|
||
<template #title>
|
||
<view class="collapse-header">
|
||
<view class="check-circle" :class="{ checked: item.selected }" @click.stop="toggleSelect(index)">
|
||
<text v-if="item.selected" class="check-icon">✓</text>
|
||
</view>
|
||
<text class="month-text">{{ item.month }}</text>
|
||
</view>
|
||
</template>
|
||
<!-- 右侧金额 -->
|
||
<template #value>
|
||
<text class="price-text">¥{{ item.totalAmount }}</text>
|
||
</template>
|
||
<!-- 展开内容:每条明细 -->
|
||
<view class="collapse-body">
|
||
<view class="detail-row fee-row" v-for="(detail, di) in item.details" :key="di">
|
||
<text class="detail-label">{{ detail.name }}</text>
|
||
<text class="detail-value">¥{{ detail.amount }}</text>
|
||
</view>
|
||
<view class="detail-row meta-row" v-if="item.unitNo || item.date">
|
||
<text class="detail-label">{{ item.unitNo }}</text>
|
||
<text class="detail-value">{{ item.date }}</text>
|
||
</view>
|
||
<view class="detail-row meta-row">
|
||
<text class="detail-label">原金额:¥{{ item.totalAmount }}</text>
|
||
<text class="detail-value">优惠:¥{{ formatNum(item.discount) }}</text>
|
||
</view>
|
||
</view>
|
||
</up-collapse-item>
|
||
</up-collapse>
|
||
|
||
<!-- 加载状态 -->
|
||
<view class="load-more-status" v-if="loadingMore">
|
||
<text class="loading-text">加载中...</text>
|
||
</view>
|
||
<view class="load-more-status" v-else-if="!hasMore && billList.length >= pageSize">
|
||
<text class="no-more-text">— 没有更多了 —</text>
|
||
</view>
|
||
</scroll-view>
|
||
|
||
<!-- 底部操作栏 -->
|
||
<view class="bottom-bar" v-if="billList.length">
|
||
<view class="select-all" @click="toggleSelectAll">
|
||
<view class="check-circle" :class="{ checked: isAllSelected }">
|
||
<text v-if="isAllSelected" class="check-icon">✓</text>
|
||
</view>
|
||
<text class="select-all-text">全选</text>
|
||
</view>
|
||
<view class="total-box">
|
||
<text class="total-label">合计:</text>
|
||
<text class="total-price">¥{{ selectedTotal.toFixed(2) }}</text>
|
||
</view>
|
||
<button class="pay-btn" :disabled="!canPay" @click="handleBatchPay">立即缴费</button>
|
||
</view>
|
||
</view>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, computed } from 'vue'
|
||
import { onLoad } from '@dcloudio/uni-app'
|
||
import { getTopUpDetailsByMonth } from '/pageSubPack/api/apiSub.js'
|
||
|
||
const loading = ref(false)
|
||
const billList = ref([])
|
||
const pageNum = ref(1)
|
||
const pageSize = ref(10)
|
||
const total = ref(0)
|
||
const hasMore = ref(true)
|
||
const loadingMore = ref(false)
|
||
|
||
// 从 paymentIndex 传来的数据中取 billResidentId
|
||
const cacheItem = uni.getStorageSync('arrearsPayment') || {}
|
||
const billResidentId = ref(cacheItem.billResidentId || cacheItem.id || '')
|
||
|
||
onLoad(() => {
|
||
fetchBills()
|
||
})
|
||
|
||
// 拉取按月汇总账单
|
||
const fetchBills = async (isLoadMore = false) => {
|
||
if (!billResidentId.value) return
|
||
if (isLoadMore) {
|
||
if (!hasMore.value || loadingMore.value) return
|
||
pageNum.value++
|
||
loadingMore.value = true
|
||
} else {
|
||
pageNum.value = 1
|
||
hasMore.value = true
|
||
loading.value = true
|
||
}
|
||
try {
|
||
const res = await getTopUpDetailsByMonth({
|
||
billResidentId: billResidentId.value,
|
||
villageId: uni.getStorageSync('currentVillageId') || '',
|
||
pageNum: pageNum.value,
|
||
pageSize: pageSize.value
|
||
})
|
||
const rows = res.rows || []
|
||
const typeNameMap = { '0': '物业管理服务费', '1': '车位管理费', '2': '生活垃圾处理费' }
|
||
const mapped = rows.map(r => {
|
||
const firstDetail = (r.details && r.details.length) ? r.details[0] : {}
|
||
const restDetails = (r.details || []).slice(1)
|
||
return {
|
||
month: r.month || '',
|
||
totalAmount: formatNum(r.totalAmount),
|
||
discount: 0,
|
||
selected: false,
|
||
periodDisplay: firstDetail.periodDisplay || '',
|
||
unitNo: cacheItem.unitNo || cacheItem.fullAddress || '',
|
||
date: r.month || '',
|
||
details: restDetails.map(d => ({
|
||
name: typeNameMap[d.type] || d.name || '缴费费用',
|
||
amount: formatNum(d.amount),
|
||
id: d.id,
|
||
payStatus: d.payStatus || '0'
|
||
})),
|
||
billIds: (r.details || []).map(d => d.id).filter(Boolean)
|
||
}
|
||
})
|
||
|
||
if (isLoadMore) {
|
||
billList.value = [...billList.value, ...mapped]
|
||
} else {
|
||
billList.value = mapped
|
||
}
|
||
total.value = res.total || 0
|
||
hasMore.value = billList.value.length < total.value
|
||
} catch (e) {
|
||
if (isLoadMore) pageNum.value--
|
||
console.error('获取账单失败', e)
|
||
} finally {
|
||
loading.value = false
|
||
loadingMore.value = false
|
||
}
|
||
}
|
||
|
||
const onLoadMore = () => {
|
||
fetchBills(true)
|
||
}
|
||
|
||
const formatNum = (val) => {
|
||
return (Number(val || 0)).toFixed(2)
|
||
}
|
||
|
||
const toggleSelect = (index) => {
|
||
const current = billList.value[index]
|
||
if (!current.selected) {
|
||
// 选中:检查前面所有项是否都已选中,不允许跳过
|
||
for (let i = 0; i < index; i++) {
|
||
if (!billList.value[i].selected) {
|
||
uni.showToast({ title: '请按顺序从第一个月开始选择', icon: 'none' })
|
||
return
|
||
}
|
||
}
|
||
billList.value[index].selected = true
|
||
} else {
|
||
// 取消选中:同时取消该项及后面所有已选中的
|
||
for (let i = index; i < billList.value.length; i++) {
|
||
billList.value[i].selected = false
|
||
}
|
||
}
|
||
}
|
||
|
||
const isAllSelected = computed(() =>
|
||
billList.value.length > 0 && billList.value.every(m => m.selected)
|
||
)
|
||
|
||
const toggleSelectAll = () => {
|
||
const val = !isAllSelected.value
|
||
billList.value.forEach(m => { m.selected = val })
|
||
}
|
||
|
||
const selectedTotal = computed(() =>
|
||
billList.value.filter(m => m.selected).reduce((sum, m) => sum + Number(m.totalAmount || 0), 0)
|
||
)
|
||
|
||
const canPay = computed(() => {
|
||
const selected = billList.value.filter(m => m.selected)
|
||
if (!selected.length) return false
|
||
// 必须从第一个开始且连续
|
||
return billList.value[0].selected && selected.length > 0
|
||
})
|
||
|
||
// 批量缴费
|
||
const handleBatchPay = () => {
|
||
const selected = billList.value.filter(m => m.selected)
|
||
if (!selected.length) return
|
||
|
||
const allIds = selected.flatMap(m => m.billIds)
|
||
const token = uni.getStorageSync('token') || ''
|
||
const userId = uni.getStorageSync('userId') || ''
|
||
|
||
const query = [
|
||
['acct', userId],
|
||
['orderType', '10'],
|
||
['billId', allIds.join(',')],
|
||
['deviceType', '3'],
|
||
['residentUserId', userId],
|
||
['token', token]
|
||
].map(([k, v]) => encodeURIComponent(k) + '=' + encodeURIComponent(v)).join('&')
|
||
|
||
// #ifdef APP-PLUS
|
||
uni.showLoading({ title: '加载中...' })
|
||
plus.share.getServices((services) => {
|
||
const wx = services.find(s => s.id === 'weixin')
|
||
uni.hideLoading()
|
||
if (!wx) return uni.showToast({ title: '未检测到微信客户端', icon: 'none' })
|
||
if (!wx.launchMiniProgram) return uni.showToast({ title: '当前微信版本太低,不支持拉起小程序', icon: 'none' })
|
||
wx.launchMiniProgram({
|
||
id: 'gh_a765aef0172c',
|
||
type: 1,
|
||
path: 'pages/arrearsPayment/arrearsPayment?' + query,
|
||
}, (res) => {
|
||
console.log('小程序回调:', JSON.stringify(res))
|
||
}, (err) => {
|
||
console.error('拉起失败:', JSON.stringify(err))
|
||
uni.showToast({ title: '拉起支付小程序失败', icon: 'none' })
|
||
})
|
||
})
|
||
// #endif
|
||
}
|
||
</script>
|
||
|
||
<style lang="scss">
|
||
page {
|
||
background-color: #f5f5f5;
|
||
}
|
||
</style>
|
||
|
||
<style scoped>
|
||
.wrap {
|
||
display: flex;
|
||
flex-direction: column;
|
||
min-height: 100vh;
|
||
background-color: #f5f5f5;
|
||
}
|
||
|
||
.page {
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
padding: 20rpx 30rpx;
|
||
box-sizing: border-box;
|
||
padding-bottom: 140rpx;
|
||
}
|
||
|
||
.empty-state {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 200rpx 0;
|
||
}
|
||
|
||
.empty-text {
|
||
font-size: 30rpx;
|
||
color: #ccc;
|
||
}
|
||
|
||
.load-more-status {
|
||
display: flex;
|
||
justify-content: center;
|
||
align-items: center;
|
||
padding: 20rpx 0;
|
||
}
|
||
|
||
.loading-text {
|
||
font-size: 26rpx;
|
||
color: #999;
|
||
}
|
||
|
||
.no-more-text {
|
||
font-size: 26rpx;
|
||
color: #ccc;
|
||
}
|
||
|
||
/* ---- 折叠面板 ---- */
|
||
::v-deep .u-collapse-item {
|
||
margin-bottom: 20rpx;
|
||
border-radius: 8rpx;
|
||
overflow: hidden;
|
||
background: #fff;
|
||
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);
|
||
}
|
||
|
||
::v-deep .u-collapse-item__content {
|
||
background: #F5FAFF;
|
||
padding: 0;
|
||
}
|
||
|
||
::v-deep .u-cell {
|
||
padding: 20rpx 18rpx !important;
|
||
}
|
||
|
||
/* 标题区 */
|
||
.collapse-header {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 16rpx;
|
||
}
|
||
|
||
.check-circle {
|
||
width: 40rpx;
|
||
height: 40rpx;
|
||
border-radius: 50%;
|
||
border: 3rpx solid #ddd;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.check-circle.checked {
|
||
background-color: #1677ff;
|
||
border-color: #1677ff;
|
||
}
|
||
|
||
.check-icon {
|
||
color: #fff;
|
||
font-size: 24rpx;
|
||
font-weight: bold;
|
||
line-height: 1;
|
||
}
|
||
|
||
.month-text {
|
||
font-size: 32rpx;
|
||
font-weight: 600;
|
||
color: #222;
|
||
}
|
||
|
||
/* 金额 */
|
||
.price-text {
|
||
font-size: 32rpx;
|
||
font-weight: 700;
|
||
color: #FF8F40;
|
||
}
|
||
|
||
/* 展开内容 */
|
||
.collapse-body {
|
||
padding: 20rpx 24rpx 30rpx;
|
||
margin: 0 24rpx;
|
||
background: #F5FAFF;
|
||
}
|
||
|
||
.detail-row {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: 14rpx 0;
|
||
}
|
||
|
||
.detail-label {
|
||
font-size: 28rpx;
|
||
}
|
||
|
||
.detail-value {
|
||
font-size: 28rpx;
|
||
}
|
||
|
||
/* 明细费用名 - 深色 */
|
||
.fee-row .detail-label,
|
||
.fee-row .detail-value {
|
||
color: #666666;
|
||
}
|
||
|
||
/* 日期/原金额/优惠 - 浅色 */
|
||
.meta-row .detail-label,
|
||
.meta-row .detail-value {
|
||
color: #999999;
|
||
}
|
||
|
||
/* ---- 底部操作栏 ---- */
|
||
.bottom-bar {
|
||
position: fixed;
|
||
bottom: 0;
|
||
left: 0;
|
||
right: 0;
|
||
display: flex;
|
||
align-items: center;
|
||
padding: 20rpx 30rpx;
|
||
padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
|
||
background: #fff;
|
||
box-shadow: 0 -2rpx 16rpx rgba(0, 0, 0, 0.06);
|
||
z-index: 10;
|
||
}
|
||
|
||
.select-all {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10rpx;
|
||
flex-shrink: 0;
|
||
margin-right: 16rpx;
|
||
}
|
||
|
||
.select-all-text {
|
||
font-size: 26rpx;
|
||
color: #666;
|
||
}
|
||
|
||
.total-box {
|
||
flex: 1;
|
||
display: flex;
|
||
align-items: baseline;
|
||
justify-content: center;
|
||
}
|
||
|
||
.total-label {
|
||
font-size: 26rpx;
|
||
color: #666;
|
||
}
|
||
|
||
.total-price {
|
||
font-size: 36rpx;
|
||
font-weight: 700;
|
||
color: #FF8F40;
|
||
}
|
||
|
||
.pay-btn {
|
||
flex-shrink: 0;
|
||
width: 180rpx;
|
||
height: 72rpx;
|
||
line-height: 72rpx;
|
||
background-color: #1677ff;
|
||
color: #fff;
|
||
font-size: 28rpx;
|
||
font-weight: 500;
|
||
border-radius: 8rpx;
|
||
border: none;
|
||
padding: 0;
|
||
}
|
||
|
||
.pay-btn[disabled] {
|
||
background-color: #ccc;
|
||
opacity: 0.7;
|
||
}
|
||
</style>
|