Files
test/uniapp/pages/bills/index.vue
T
2026-06-11 09:53:11 +08:00

493 lines
13 KiB
Vue

<template>
<view class="app-page" :class="themeClass">
<section-card title="账单筛选" subtitle="按月份、分类、账户、金额区间和关键词组合查询">
<view class="chip-row">
<view
v-for="item in typeOptions"
:key="item.value"
class="pill-button"
:class="{ active: filters.type === item.value }"
@click="filters.type = item.value; filters.categoryId = ''"
>
{{ item.label }}
</view>
</view>
<view class="chip-row compact-row">
<view
v-for="item in periodOptions"
:key="item.value"
class="pill-button mini-pill"
:class="{ active: filters.period === item.value }"
@click="filters.period = item.value"
>
{{ item.label }}
</view>
<picker mode="date" fields="month" :value="monthPickerValue" @change="onMonthChange">
<view class="pill-button mini-pill">{{ selectedMonthLabel }}</view>
</picker>
</view>
<view class="form-grid">
<view class="input-shell"><input v-model="filters.keyword" placeholder="备注、分类或账户关键词" /></view>
<view class="double-grid">
<view class="input-shell"><input v-model="filters.minAmount" type="digit" placeholder="最低金额" /></view>
<view class="input-shell"><input v-model="filters.maxAmount" type="digit" placeholder="最高金额" /></view>
</view>
</view>
<view class="picker-row">
<picker :range="categoryOptionNames" :value="selectedCategoryIndex" @change="onCategoryChange">
<view class="input-shell picker-shell"><text>{{ selectedCategoryName }}</text><text class="tiny-text">分类</text></view>
</picker>
<picker :range="accountOptionNames" :value="selectedAccountIndex" @change="onAccountChange">
<view class="input-shell picker-shell"><text>{{ selectedAccountName }}</text><text class="tiny-text">账户</text></view>
</picker>
</view>
<view class="action-row">
<view class="ghost-button" @click="clearFilters">清空筛选</view>
<view class="primary-button" @click="openEditor()">新增账单</view>
</view>
</section-card>
<section-card title="账单概览" :subtitle="`共 ${filteredBills.length} 笔记录,点击编辑,长按可删除`">
<template #action>
<text class="section-link" @click="toggleSelectionMode">{{ selectionMode ? '退出批量' : '批量删除' }}</text>
</template>
<view class="summary-row">
<view class="summary-item surface-strong">
<text class="tiny-text">支出</text>
<text style="margin-left: 16rpx;"></text>
<text class="summary-value negative">{{ formatCurrency(summaryExpense) }}</text>
</view>
<view class="summary-item surface-strong">
<text class="tiny-text">收入</text>
<text style="margin-left: 16rpx;"></text>
<text class="summary-value positive">{{ formatCurrency(summaryIncome) }}</text>
</view>
<view class="summary-item surface-strong">
<text class="tiny-text">结余</text>
<text style="margin-left: 16rpx;"></text>
<text class="summary-value">{{ formatCurrency(summaryIncome - summaryExpense) }}</text>
</view>
</view>
<view v-if="filteredBills.length" class="bill-list">
<view
v-for="bill in filteredBills"
:key="bill.id"
class="bill-row"
@click="selectionMode ? toggleChecked(bill.id) : openEditor(bill)"
@longpress="handleBillLongPress(bill)"
>
<view class="row-main">
<view v-if="selectionMode" class="check-box" :class="{ checked: selectedIds.includes(bill.id) }"></view>
<view class="bill-dot" :style="{ background: getCategory(bill).color || '#7b8794' }"></view>
<view class="bill-body">
<text class="bill-title">{{ getCategory(bill).name || '未分类' }}</text>
<text class="bill-meta">{{ getAccount(bill).name || '账户' }} · {{ formatDateLabel(bill.date) }} · {{ bill.note || '无备注' }}</text>
</view>
</view>
<text class="bill-amount" :class="bill.type === 'income' ? 'positive' : 'negative'">
{{ bill.type === 'income' ? '+' : '-' }}{{ formatCurrency(bill.amount).replace('¥', '') }}
</text>
</view>
</view>
<view v-else class="empty-card">
<text class="section-subtitle">当前筛选条件下没有符合条件的账单记录</text>
</view>
</section-card>
<view v-if="selectionMode" class="surface-card batch-bar">
<text class="batch-text">已选 {{ selectedIds.length }} </text>
<view class="ghost-button" @click="selectAllVisible">全选</view>
<view class="ghost-button danger-button" @click="removeSelected">删除所选</view>
</view>
<app-tab-bar current="bills" />
<bill-editor-popup
:visible="editorVisible"
:entry="editingBill"
:categories="store.state.categories"
:accounts="store.state.accounts"
@close="closeEditor"
@save="saveBill"
/>
</view>
</template>
<script setup>
import { computed, reactive, ref } from 'vue'
import { onPullDownRefresh } from '@dcloudio/uni-app'
import SectionCard from '../../components/SectionCard.vue'
import AppTabBar from '../../components/AppTabBar.vue'
import BillEditorPopup from '../../components/BillEditorPopup.vue'
import { useAppStore } from '../../utils/store'
import { formatDateLabel, formatMonthLabel, isSameMonth, parseDate, toMonthKey } from '../../utils/date'
import { formatCurrency } from '../../utils/money'
const store = useAppStore()
const editorVisible = ref(false)
const editingBill = ref(null)
const selectionMode = ref(false)
const selectedIds = ref([])
const themeClass = computed(() => (store.state.settings.theme === 'dark' ? 'theme-dark' : ''))
const filters = reactive({
type: 'all',
period: 'month',
month: toMonthKey(),
keyword: '',
categoryId: '',
accountId: '',
minAmount: '',
maxAmount: ''
})
const typeOptions = [
{ label: '全部', value: 'all' },
{ label: '支出', value: 'expense' },
{ label: '收入', value: 'income' }
]
const periodOptions = [
{ label: '本月', value: 'month' },
{ label: '近 7 天', value: '7d' },
{ label: '近 30 天', value: '30d' },
{ label: '全部', value: 'all' }
]
const sortedBills = computed(() => [...store.state.bills].sort((left, right) => right.createdAt - left.createdAt))
const categoryOptions = computed(() => {
const categories = filters.type === 'all'
? [...store.state.categories.expense, ...store.state.categories.income]
: store.state.categories[filters.type]
return [{ id: '', name: '全部分类' }, ...categories]
})
const accountOptions = computed(() => [{ id: '', name: '全部账户' }, ...store.state.accounts])
const categoryOptionNames = computed(() => categoryOptions.value.map((item) => item.name))
const accountOptionNames = computed(() => accountOptions.value.map((item) => item.name))
const selectedCategoryIndex = computed(() => Math.max(0, categoryOptions.value.findIndex((item) => item.id === filters.categoryId)))
const selectedAccountIndex = computed(() => Math.max(0, accountOptions.value.findIndex((item) => item.id === filters.accountId)))
const selectedCategoryName = computed(() => categoryOptions.value[selectedCategoryIndex.value]?.name || '全部分类')
const selectedAccountName = computed(() => accountOptions.value[selectedAccountIndex.value]?.name || '全部账户')
const selectedMonthLabel = computed(() => formatMonthLabel(filters.month))
const monthPickerValue = computed(() => filters.month)
function getDiffDays(dateKey) {
const today = new Date()
const current = parseDate(dateKey)
return (today.getTime() - current.getTime()) / (1000 * 60 * 60 * 24)
}
const filteredBills = computed(() => sortedBills.value.filter((bill) => {
if (filters.type !== 'all' && bill.type !== filters.type) {
return false
}
if (filters.period === 'month' && !isSameMonth(bill.date, filters.month)) {
return false
}
if (filters.period === '7d' && getDiffDays(bill.date) > 7) {
return false
}
if (filters.period === '30d' && getDiffDays(bill.date) > 30) {
return false
}
if (filters.categoryId && bill.categoryId !== filters.categoryId) {
return false
}
if (filters.accountId && bill.accountId !== filters.accountId) {
return false
}
if (filters.minAmount && Number(bill.amount) < Number(filters.minAmount)) {
return false
}
if (filters.maxAmount && Number(bill.amount) > Number(filters.maxAmount)) {
return false
}
if (filters.keyword) {
const categoryName = getCategory(bill).name || ''
const accountName = getAccount(bill).name || ''
const keyword = filters.keyword.trim().toLowerCase()
const target = `${bill.note || ''} ${categoryName} ${accountName}`.toLowerCase()
if (!target.includes(keyword)) {
return false
}
}
return true
}))
const summaryExpense = computed(() => filteredBills.value.filter((item) => item.type === 'expense').reduce((sum, item) => sum + Number(item.amount), 0))
const summaryIncome = computed(() => filteredBills.value.filter((item) => item.type === 'income').reduce((sum, item) => sum + Number(item.amount), 0))
function getCategory(bill) {
return (store.state.categories[bill.type] || []).find((item) => item.id === bill.categoryId) || {}
}
function getAccount(bill) {
return store.state.accounts.find((item) => item.id === bill.accountId) || {}
}
function onCategoryChange(event) {
filters.categoryId = categoryOptions.value[Number(event.detail.value)]?.id || ''
}
function onAccountChange(event) {
filters.accountId = accountOptions.value[Number(event.detail.value)]?.id || ''
}
function onMonthChange(event) {
filters.month = String(event.detail.value).slice(0, 7)
filters.period = 'month'
}
function clearFilters() {
filters.type = 'all'
filters.period = 'month'
filters.month = toMonthKey()
filters.keyword = ''
filters.categoryId = ''
filters.accountId = ''
filters.minAmount = ''
filters.maxAmount = ''
}
function openEditor(bill = null) {
editingBill.value = bill ? { ...bill } : null
editorVisible.value = true
}
function closeEditor() {
editorVisible.value = false
editingBill.value = null
}
function saveBill(payload) {
store.saveBill(payload)
uni.showToast({
title: payload.id ? '账单已更新' : '账单已保存',
icon: 'none'
})
}
function removeBill(bill) {
uni.showModal({
title: '删除账单',
content: `确认删除 ${getCategory(bill).name || '该账单'} 吗?`,
success: ({ confirm }) => {
if (confirm) {
store.deleteBill(bill.id)
}
}
})
}
function handleBillLongPress(bill) {
uni.showActionSheet({
itemList: ['编辑账单', '删除账单'],
success: ({ tapIndex }) => {
if (tapIndex === 0) {
openEditor(bill)
}
if (tapIndex === 1) {
removeBill(bill)
}
}
})
}
function toggleSelectionMode() {
selectionMode.value = !selectionMode.value
selectedIds.value = []
}
function toggleChecked(id) {
if (!selectionMode.value) {
return
}
selectedIds.value = selectedIds.value.includes(id)
? selectedIds.value.filter((item) => item !== id)
: [...selectedIds.value, id]
}
function selectAllVisible() {
selectedIds.value = filteredBills.value.map((item) => item.id)
}
function removeSelected() {
if (!selectedIds.value.length) {
uni.showToast({ title: '请选择账单', icon: 'none' })
return
}
uni.showModal({
title: '批量删除',
content: `确认删除 ${selectedIds.value.length} 笔账单吗?`,
success: ({ confirm }) => {
if (confirm) {
store.deleteBills(selectedIds.value)
selectedIds.value = []
selectionMode.value = false
}
}
})
}
onPullDownRefresh(() => {
setTimeout(() => {
uni.stopPullDownRefresh()
}, 200)
})
</script>
<style lang="scss" scoped>
.chip-row,
.action-row,
.picker-row,
.summary-row {
display: flex;
flex-wrap: wrap;
gap: 16rpx;
}
.compact-row {
margin-top: 16rpx;
}
.mini-pill {
padding: 16rpx 22rpx;
}
.form-grid {
display: flex;
flex-direction: column;
gap: 16rpx;
margin: 18rpx 0;
}
.double-grid,
.picker-row {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16rpx;
}
.picker-shell {
justify-content: space-between;
}
.action-row {
margin-top: 18rpx;
}
.action-row .ghost-button,
.action-row .primary-button {
flex: 1;
}
.section-link {
font-size: 24rpx;
color: var(--brand);
}
.summary-item {
flex: 1;
padding: 22rpx;
border-radius: 24rpx;
}
.summary-value {
margin-top: 10rpx;
font-size: 30rpx;
font-weight: 700;
color: var(--text-primary);
}
.bill-list {
display: flex;
flex-direction: column;
gap: 16rpx;
margin-top: 22rpx;
}
.bill-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
padding: 22rpx;
border-radius: 24rpx;
background: var(--surface-muted);
}
.row-main {
display: flex;
align-items: center;
gap: 16rpx;
flex: 1;
}
.check-box {
width: 30rpx;
height: 30rpx;
border-radius: 10rpx;
border: 2rpx solid var(--brand);
}
.check-box.checked {
background: var(--brand);
}
.bill-dot {
width: 16rpx;
height: 16rpx;
border-radius: 50%;
}
.bill-body {
flex: 1;
}
.bill-title,
.bill-amount {
font-size: 28rpx;
font-weight: 600;
color: var(--text-primary);
}
.bill-meta {
margin-top: 8rpx;
font-size: 22rpx;
color: var(--text-secondary);
line-height: 1.5;
}
.empty-card {
padding: 40rpx 0 10rpx;
text-align: center;
}
.batch-bar {
position: fixed;
left: 24rpx;
right: 24rpx;
bottom: 176rpx;
display: flex;
align-items: center;
gap: 16rpx;
padding: 20rpx 22rpx;
z-index: 18;
}
.batch-text {
flex: 1;
font-size: 26rpx;
color: var(--text-primary);
}
</style>