493 lines
13 KiB
Vue
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>
|