Files
xiaocai/client/src/pages/bills/index.vue
wangxiaogang 83571de723 feat: 迭代三大模块 + 全系统 Bug 修复
新功能:
- 账单筛选统计:多维度筛选面板 + 保存方案 + 结果统计
- 通知公告:系统公告/群组通知/个人提醒 + 通知中心页面
- 管理员后台:数据看板 + 用户管理 + 发布公告

Bug 修复:
- 统计页 fetchPrevMonthData/fetchMaxSingle 添加 group_id
- 群组视图添加 t.group_id 过滤,防止私人数据泄露
- 通知列表添加群组通知条件
- 通知标记已读添加错误处理
- 管理员 API 添加 affectedRows 检查
- 筛选面板金额为 0 时正确回填
- profile onShow 补充 userStore.fetchUserInfo
- 全系统样式修复(overflow/box-sizing)
2026-06-06 17:55:29 +08:00

410 lines
11 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>
<view class="page">
<view class="header-fixed">
<view class="status-bar" :style="{ height: statusBarHeight + 'px' }"></view>
<text class="page-title">全部账单</text>
</view>
<view class="tabs-wrap">
<view class="tabs">
<view class="tab" :class="{ active: filterType === 'all' }" @tap="switchFilter('all')">全部</view>
<view class="tab" :class="{ active: filterType === 'expense' }" @tap="switchFilter('expense')">支出</view>
<view class="tab" :class="{ active: filterType === 'income' }" @tap="switchFilter('income')">收入</view>
</view>
<view class="filter-btn" :class="{ active: hasAdvancedFilter }" @tap="showFilter = true">
<Icon name="filter" :size="28" :color="hasAdvancedFilter ? '#FF8C69' : '#8B7E7E'" />
<text class="filter-btn-text">筛选</text>
</view>
</view>
<!-- 筛选结果统计 -->
<view class="filter-stats" v-if="hasAdvancedFilter && !loading">
<text class="filter-stats-text"> {{ total }} </text>
<text class="filter-stats-text" v-if="filteredExpense > 0">支出 {{ formatAmount(filteredExpense) }}</text>
<text class="filter-stats-text" v-if="filteredIncome > 0">收入 {{ formatAmount(filteredIncome) }}</text>
</view>
<!-- 筛选面板 -->
<FilterPanel v-if="showFilter" :initialFilters="advancedFilters" @close="showFilter = false" @apply="onFilterApply" />
<view class="tx-list" v-if="!txStore.loading || txList.length > 0">
<view v-for="group in groupedTx" :key="group.date" class="date-group">
<view class="group-header">
<text class="group-date">{{ group.label }}</text>
<text class="group-total" :class="{ expense: group.expense > 0 }">
{{ group.expense > 0 ? '- ' + formatAmount(group.expense) : '' }}
{{ group.income > 0 ? '+ ' + formatAmount(group.income) : '' }}
</text>
</view>
<TransactionItem
v-for="item in group.items"
:key="item.id"
:item="item"
:hideDate="true"
:showCreator="groupStore.isGroupMode"
:currentUserId="userStore.userInfo?.id"
@item-tap="onEdit(item)"
@delete="onDelete(item)"
/>
</view>
</view>
<view class="tx-list" v-if="txStore.loading && txList.length === 0">
<view v-for="g in 2" :key="g" class="date-group">
<view class="group-header">
<Skeleton width="120rpx" height="26rpx" variant="text" />
<Skeleton width="160rpx" height="24rpx" variant="text" />
</view>
<view v-for="i in 3" :key="i" class="skeleton-item">
<Skeleton width="80rpx" height="80rpx" variant="circle" />
<view class="skeleton-info">
<Skeleton width="160rpx" height="28rpx" variant="text" />
<Skeleton width="240rpx" height="24rpx" variant="text" />
</view>
<Skeleton width="120rpx" height="32rpx" variant="rect" />
</view>
</view>
</view>
<view class="loading-more" v-if="txStore.loading && txList.length > 0">
<text class="loading-text">加载中...</text>
</view>
<view class="loading-more" v-if="noMore && txList.length > 0">
<text class="loading-text">没有更多了</text>
</view>
<view class="state-box" v-if="loadError">
<Icon name="alert-circle" :size="48" color="#BFB3B3" />
<text class="state-text">加载失败下拉刷新重试</text>
</view>
<view class="empty" v-if="txList.length === 0 && !txStore.loading && !loadError">
<text class="empty-text">暂无账单记录</text>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { onShow, onReachBottom, onPullDownRefresh } from '@dcloudio/uni-app'
import { useTransactionStore } from '@/stores/transaction'
import { useUserStore } from '@/stores/user'
import { useGroupStore } from '@/stores/group'
import { waitForReady } from '@/utils/app-ready'
import TransactionItem from '@/components/TransactionItem/TransactionItem.vue'
import Icon from '@/components/Icon/Icon.vue'
import Skeleton from '@/components/Skeleton/Skeleton.vue'
import FilterPanel from './filter-panel.vue'
import { statusBarHeight } from '@/utils/system'
import { formatAmount, formatDate } from '@/utils/format'
import type { FilterParams } from '@/api/filter'
const txStore = useTransactionStore()
const userStore = useUserStore()
const groupStore = useGroupStore()
const filterType = ref('all')
const page = ref(1)
const pageSize = 20
const txList = ref<any[]>([])
const total = ref(0)
const loadError = ref(false)
let loadSeq = 0 // 请求序号,防止并发覆盖
// 高级筛选
const showFilter = ref(false)
const advancedFilters = ref<FilterParams>({})
const filteredExpense = ref(0)
const filteredIncome = ref(0)
const hasAdvancedFilter = computed(() => {
const f = advancedFilters.value
return !!(f.category_ids?.length || f.startDate || f.endDate || f.minAmount || f.maxAmount || f.keyword)
})
const noMore = computed(() => txList.value.length >= total.value && total.value > 0)
const groupedTx = computed(() => {
const groups: Record<string, { date: string; label: string; expense: number; income: number; items: any[] }> = {}
for (const tx of txList.value) {
if (!groups[tx.date]) {
groups[tx.date] = { date: tx.date, label: formatDate(tx.date), expense: 0, income: 0, items: [] }
}
groups[tx.date].items.push(tx)
if (tx.type === 'expense') {
groups[tx.date].expense += tx.amount
} else {
groups[tx.date].income += tx.amount
}
}
return Object.values(groups).sort((a, b) => b.date.localeCompare(a.date))
})
const initialLoaded = ref(false)
onMounted(async () => {
await waitForReady()
loadTx(true).finally(() => { initialLoaded.value = true })
})
// 静默刷新(切换群组/返回时)
onShow(() => {
if (initialLoaded.value) loadTx(true, true)
})
function switchFilter(type: string) {
filterType.value = type
loadTx(true)
}
async function loadTx(reset = false, silent = false) {
if (reset) {
page.value = 1
if (!silent) txList.value = []
}
const seq = ++loadSeq
const params: any = { page: page.value, pageSize }
if (filterType.value !== 'all') params.type = filterType.value
// 合并高级筛选参数
const af = advancedFilters.value
if (af.category_ids?.length === 1) params.category_id = af.category_ids[0]
if (af.startDate) params.startDate = af.startDate
if (af.endDate) params.endDate = af.endDate
if (af.minAmount) params.minAmount = af.minAmount
if (af.maxAmount) params.maxAmount = af.maxAmount
if (af.keyword) params.keyword = af.keyword
try {
loadError.value = false
const data = await txStore.fetchTransactions(params)
// 丢弃过期请求的结果
if (seq !== loadSeq) return
if (reset) {
txList.value = txStore.transactions
} else {
txList.value = [...txList.value, ...txStore.transactions]
}
total.value = txStore.total
if (data) {
filteredExpense.value = data.filteredExpense || 0
filteredIncome.value = data.filteredIncome || 0
}
} catch (e) {
if (seq !== loadSeq) return
console.error('Load bills error:', e)
loadError.value = true
}
}
function onFilterApply(filters: FilterParams) {
advancedFilters.value = filters
showFilter.value = false
loadTx(true)
}
function onEdit(item: any) {
if (groupStore.isGroupMode && item.user_id !== userStore.userInfo?.id) {
uni.showToast({ title: '只能编辑自己的记录', icon: 'none' })
return
}
uni.navigateTo({ url: `/pages/add/index?editId=${item.id}` })
}
async function onDelete(item: any) {
if (groupStore.isGroupMode && item.user_id !== userStore.userInfo?.id) {
uni.showToast({ title: '只能删除自己的记录', icon: 'none' })
return
}
uni.showModal({
title: '删除记录',
content: `确定删除这笔 ${item.type === 'expense' ? '支出' : '收入'} ¥${(item.amount / 100).toFixed(2)}`,
success: async (res) => {
if (res.confirm) {
try {
await txStore.deleteTransaction(item.id)
txList.value = txList.value.filter(t => t.id !== item.id)
total.value--
uni.showToast({ title: '已删除', icon: 'success' })
} catch {
uni.showToast({ title: '删除失败', icon: 'none' })
}
}
}
})
}
// 触底加载更多
onReachBottom(() => {
if (noMore.value || txStore.loading) return
page.value++
loadTx(false)
})
// 下拉刷新
onPullDownRefresh(() => {
loadTx(true).finally(() => uni.stopPullDownRefresh())
})
</script>
<style lang="scss" scoped>
.page {
min-height: 100vh;
background: #FFF8F0;
padding: 0 0 180rpx;
}
.header-fixed {
position: sticky;
top: 0;
z-index: 100;
background: #FFF8F0;
}
.status-bar {
background: #FFF8F0;
}
.page-title {
display: block;
text-align: center;
font-size: 36rpx;
font-weight: 600;
color: #2D1B1B;
padding: 16rpx 0 24rpx;
}
.tabs-wrap {
display: flex;
align-items: center;
justify-content: center;
margin: 0 0 24rpx;
gap: 16rpx;
}
.tabs {
display: inline-flex;
background: #FFF0E6;
border-radius: 24rpx;
padding: 8rpx;
}
.tab {
padding: 16rpx 40rpx;
border-radius: 20rpx;
font-size: 28rpx;
color: #8B7E7E;
transition: all 0.2s;
&.active {
background: #FFFFFF;
color: #FF8C69;
font-weight: 600;
box-shadow: 2rpx 2rpx 8rpx rgba(45, 27, 27, 0.06);
}
}
.filter-btn {
display: flex;
align-items: center;
gap: 6rpx;
padding: 12rpx 20rpx;
background: #FFF0E6;
border-radius: 20rpx;
border: 2rpx solid #F0E0D6;
&.active {
border-color: #FF8C69;
background: #FFE8E0;
}
&:active { opacity: 0.7; }
}
.filter-btn-text {
font-size: 24rpx;
color: #8B7E7E;
.active & { color: #FF8C69; }
}
.filter-stats {
display: flex;
gap: 24rpx;
padding: 0 40rpx 16rpx;
}
.filter-stats-text {
font-size: 24rpx;
font-family: 'Fredoka', sans-serif;
color: #8B7E7E;
}
.tx-list { padding: 0 40rpx; }
.date-group {
margin-bottom: 24rpx;
}
.group-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16rpx 0;
border-bottom: 1rpx solid #F0E0D6;
margin-bottom: 8rpx;
}
.group-date {
font-size: 26rpx;
font-weight: 600;
color: #2D1B1B;
}
.group-total {
font-family: 'Fredoka', sans-serif;
font-size: 24rpx;
color: #8B7E7E;
&.expense { color: #2D1B1B; }
}
.skeleton-item {
display: flex;
align-items: center;
padding: 28rpx 0;
gap: 24rpx;
}
.skeleton-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 8rpx;
}
.loading-more {
text-align: center;
padding: 32rpx 0;
}
.loading-text { font-size: 24rpx; color: #BFB3B3; }
.empty {
text-align: center;
padding: 120rpx 0;
}
.empty-text { font-size: 28rpx; color: #BFB3B3; }
.state-box {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16rpx;
padding: 120rpx 0;
}
.state-text {
font-size: 28rpx;
color: #BFB3B3;
}
</style>