Files
xiaocai/client/src/pages/bills/index.vue
wangxiaogang 31f6487d61 feat: 前后端功能对齐 - 实现8个对齐差距
P0-1: 分类拖拽排序 - category-manage 添加拖拽 UI
P1-1: 反馈回复展示 - 新增 GET /feedback/mine + 前端我的反馈Tab
P1-2: 备份管理页面 - 新增下载端点 + backup-manage 页面
P2-1: 统计年度/周视图 - stats 支持 period 参数 + 前端维度切换器
P2-2: 数据导入 - 新增 POST /import 端点 + data-import 页面
P2-3: 数据导出服务端化 - 新增 GET /export 流式端点
P3-1: 健康检查展示 - health 移到 auth 前 + admin 状态卡片
P3-2~4: 交易标签系统 - tags CRUD + 交易关联 + 按标签筛选统计

后端: 新增 tag.ts/export.ts 路由, 改造 feedback/backup/transaction/stats
前端: 新增 DragSortList 组件, 3个新页面, 改造 7 个现有页面
QA 修复: 5个严重Bug + 4个潜在问题
2026-06-10 17:30:36 +08:00

485 lines
14 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>
<!-- 标签筛选条 -->
<scroll-view class="tag-filter-scroll" scroll-x v-if="tagStore.tags.length > 0">
<view class="tag-filter-row">
<view class="tag-filter-chip" :class="{ active: filterTagId === null }" @tap="filterTagId = null; loadTx(true)">
<text class="tag-filter-text">全部标签</text>
</view>
<view
v-for="tag in tagStore.tags"
:key="tag.id"
class="tag-filter-chip"
:class="{ active: filterTagId === tag.id }"
@tap="filterTagId = tag.id; loadTx(true)"
>
<view class="tag-filter-dot" :style="{ background: tag.color }"></view>
<text class="tag-filter-text">{{ tag.name }}</text>
</view>
</view>
</scroll-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="!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"
:disableSwipe="groupStore.isGroupMode && item.user_id !== userStore.userInfo?.id"
:swipeHint="!swipeHintDismissed && isFirstDeletable(item)"
@item-tap="onEdit(item)"
@delete="onDelete(item)"
/>
</view>
</view>
<view class="tx-list" v-if="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="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="state-box" v-if="txList.length === 0 && !loading && !loadError">
<Icon name="edit" :size="48" color="#BFB3B3" />
<text class="state-text">暂无账单记录</text>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { onShow, onReachBottom, onPullDownRefresh } from '@dcloudio/uni-app'
import { useUserStore } from '@/stores/user'
import { useGroupStore } from '@/stores/group'
import { useTagStore } from '@/stores/tag'
import { waitForReady } from '@/utils/app-ready'
import { getTransactions, deleteTransaction } from '@/api/transaction'
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 { Transaction, TransactionParams } from '@/api/transaction'
import type { FilterParams } from '@/api/filter'
const userStore = useUserStore()
const groupStore = useGroupStore()
const tagStore = useTagStore()
const loading = ref(false)
const filterType = ref('all')
const filterTagId = ref<number | null>(null)
const page = ref(1)
const pageSize = 20
const txList = ref<Transaction[]>([])
const total = ref(0)
const loadError = ref(false)
let loadSeq = 0 // 请求序号,防止并发覆盖
// 左滑提示:只对首个可删除项显示一次,用户看过后不再重复
const swipeHintDismissed = ref(false)
const HINT_KEY = 'xiaocai_swipe_hint_shown'
function isFirstDeletable(item: Transaction): boolean {
if (swipeHintDismissed.value) return false
// 群组模式下只有自己的记录可删除
if (groupStore.isGroupMode && item.user_id !== userStore.userInfo?.id) return false
// 找到第一个可删除的项
for (const group of groupedTx.value) {
for (const tx of group.items) {
if (groupStore.isGroupMode && tx.user_id !== userStore.userInfo?.id) continue
return tx.id === item.id
}
}
return false
}
// 动画播完后标记已显示
try {
if (uni.getStorageSync(HINT_KEY)) {
swipeHintDismissed.value = true
}
} catch {}
setTimeout(() => {
swipeHintDismissed.value = true
try { uni.setStorageSync(HINT_KEY, '1') } catch {}
}, 3000)
// 高级筛选
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()
tagStore.fetchTags().catch(() => {})
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: TransactionParams & { group_id?: number | null; category_ids?: string } = { page: page.value, pageSize, group_id: groupStore.currentGroupId }
if (filterType.value !== 'all') params.type = filterType.value
// 合并高级筛选参数
const af = advancedFilters.value
if (af.category_ids?.length) params.category_ids = af.category_ids.join(',')
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
if (filterTagId.value) params.tagId = filterTagId.value
try {
loading.value = true
loadError.value = false
const data = await getTransactions(params)
// 丢弃过期请求的结果
if (seq !== loadSeq) return
if (reset) {
txList.value = data.list
} else {
txList.value = [...txList.value, ...data.list]
}
total.value = data.total
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
} finally {
if (seq === loadSeq) loading.value = false
}
}
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 deleteTransaction(item.id)
txList.value = txList.value.filter(t => t.id !== item.id)
total.value--
// 更新筛选金额
if (item.type === 'expense') {
filteredExpense.value = Math.max(0, filteredExpense.value - item.amount)
} else {
filteredIncome.value = Math.max(0, filteredIncome.value - item.amount)
}
uni.showToast({ title: '已删除', icon: 'success' })
} catch {
uni.showToast({ title: '删除失败', icon: 'none' })
}
}
}
})
}
// 触底加载更多
onReachBottom(() => {
if (noMore.value || loading.value) return
page.value++
loadTx(false)
})
// 下拉刷新
onPullDownRefresh(() => {
loadTx(true).finally(() => uni.stopPullDownRefresh())
})
</script>
<style lang="scss" scoped>
@import '@/styles/mixins.scss';
.page {
@include page-base;
}
.header-fixed {
@include sticky-header;
}
.status-bar {
@include status-bar;
}
.page-title {
@include page-title;
}
.tabs-wrap {
@include tabs-wrap;
justify-content: center;
margin: 0 0 $space-md;
}
.tabs {
@include tabs;
}
.tab {
@include tab;
}
.filter-btn {
display: flex;
align-items: center;
gap: 6rpx;
padding: 12rpx 20rpx;
background: $surface-warm;
border-radius: $radius-lg;
border: 2rpx solid $border;
&.active {
border-color: $primary;
background: $primary-light;
}
&:active { opacity: 0.7; }
}
.filter-btn-text {
font-size: $font-md;
color: $text-sec;
.active & { color: $primary; }
}
.filter-stats {
display: flex;
gap: $space-md;
padding: 0 $space-xl $space-sm;
}
.filter-stats-text {
font-size: $font-md;
font-family: 'Fredoka', sans-serif;
color: $text-sec;
}
.tx-list { padding: 0 $space-xl; }
.date-group {
margin-bottom: $space-md;
}
.group-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: $space-sm 0;
border-bottom: 1rpx solid $border;
margin-bottom: $space-xs;
}
.group-date {
font-size: $font-base;
font-weight: 600;
color: $text;
}
.group-total {
font-family: 'Fredoka', sans-serif;
font-size: $font-md;
color: $text-sec;
&.expense { color: $text; }
}
.skeleton-item {
display: flex;
align-items: center;
padding: 28rpx 0;
gap: $space-md;
}
.skeleton-info {
flex: 1;
display: flex;
flex-direction: column;
gap: $space-xs;
}
.loading-more {
text-align: center;
padding: $space-lg 0;
}
.loading-text { font-size: $font-md; color: $text-muted; }
.state-box {
@include state-box;
justify-content: center;
}
.state-text {
@include state-text;
}
/* 标签筛选条 */
.tag-filter-scroll {
white-space: nowrap;
padding: 0 $space-xl $space-sm;
}
.tag-filter-row {
display: inline-flex;
gap: $space-xs;
}
.tag-filter-chip {
display: inline-flex;
align-items: center;
gap: 8rpx;
padding: $space-xs $space-md;
background: $surface;
border-radius: $radius-lg;
border: 2rpx solid $border;
flex-shrink: 0;
transition: all $transition-normal;
&.active {
background: $primary-light;
border-color: $primary;
}
&:active { opacity: 0.7; }
}
.tag-filter-dot {
width: 16rpx;
height: 16rpx;
border-radius: 50%;
flex-shrink: 0;
}
.tag-filter-text {
font-size: $font-sm;
color: $text-sec;
.active & {
color: $primary;
font-weight: 500;
}
}
</style>