Files
EmailBill/Web/src/components/Bill/BillListComponent.vue
SunCheng 045158730f
Some checks failed
Docker Build & Deploy / Build Docker Image (push) Failing after 4m47s
Docker Build & Deploy / Deploy to Production (push) Has been skipped
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 2s
Docker Build & Deploy / WeChat Notification (push) Successful in 2s
refactor: 整理组件目录结构
- TransactionDetail, CategoryBillPopup 移入 Transaction/
- BudgetTypeTabs 移入 Budget/
- GlassBottomNav, ModernEmpty 移入 Global/
- Icon, IconSelector, ClassifySelector 等 8 个通用组件移入 Common/
- 更新所有相关引用路径
2026-02-21 10:10:16 +08:00

900 lines
21 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>
<div class="bill-list-component">
<!-- 4.1 筛选栏 UI -->
<div
v-if="enableFilter"
class="filter-bar"
>
<van-dropdown-menu active-color="#1989fa">
<!-- 4.2 类型筛选 -->
<van-dropdown-item
v-model="selectedType"
:options="typeOptions"
/>
<!-- 4.3 分类筛选 -->
<van-dropdown-item
v-model="selectedCategory"
:options="categoryOptions"
/>
<!-- 4.4 日期范围筛选 -->
<van-dropdown-item
ref="dateDropdown"
title="日期"
>
<van-cell-group inset>
<van-cell
:title="dateRangeText"
is-link
@click="showCalendar = true"
/>
</van-cell-group>
<div style="padding: 16px">
<van-button
block
type="primary"
@click="closeDateDropdown"
>
确定
</van-button>
</div>
</van-dropdown-item>
<!-- 4.5 排序功能 -->
<van-dropdown-item
v-model="sortBy"
:options="sortOptions"
/>
</van-dropdown-menu>
<!-- 4.6 重置按钮 -->
<van-button
size="small"
type="default"
style="margin-top: 8px"
@click="resetFilters"
>
重置筛选
</van-button>
</div>
<!-- 4.4 日期选择弹出层 -->
<van-calendar
v-model:show="showCalendar"
type="range"
:min-date="new Date(2020, 0, 1)"
:max-date="new Date(2030, 11, 31)"
@confirm="onDateConfirm"
/>
<!-- 账单列表 -->
<van-list
:loading="loading"
:finished="finished"
finished-text="没有更多了"
@load="onLoad"
>
<van-cell-group
v-if="displayTransactions && displayTransactions.length"
inset
style="margin-top: 10px"
>
<van-swipe-cell
v-for="transaction in displayTransactions"
:key="transaction.id"
class="bill-item"
>
<div class="bill-row">
<!-- 多选框 -->
<van-checkbox
v-if="showCheckbox"
:model-value="isSelected(transaction.id)"
class="checkbox-col"
@update:model-value="toggleSelection(transaction)"
/>
<!-- 账单卡片 -->
<div
class="bill-card"
@click="handleClick(transaction)"
>
<!-- 5.1 左侧图标 -->
<div
class="card-icon"
:style="{ backgroundColor: getIconBg(transaction.type) }"
>
<!-- 使用 Iconify 图标格式collection:name -->
<Icon
v-if="isIconifyFormat(getIconByClassify(transaction.classify))"
:icon-identifier="getIconByClassify(transaction.classify)"
:color="getIconColor(transaction.type)"
size="20"
/>
<!-- 降级使用 Vant 图标 -->
<van-icon
v-else
:name="getIconByClassify(transaction.classify)"
:color="getIconColor(transaction.type)"
size="20"
/>
</div>
<!-- 5.1 中间内容 -->
<div class="card-content">
<div class="card-title">
<span class="reason">{{ transaction.reason || '(无摘要)' }}</span>
</div>
<div class="card-info">
<!-- 5.6 时间格式化 -->
<span class="time">{{ formatTime(transaction.occurredAt) }}</span>
<!-- 5.5 分类标签 -->
<span
v-if="transaction.classify"
class="classify-tag"
:class="getClassifyTagClass(transaction.type)"
>
{{ transaction.classify }}
</span>
</div>
</div>
<!-- 5.1 右侧金额 -->
<div class="card-right">
<div
class="amount"
:class="getAmountClass(transaction.type)"
>
{{ formatAmount(transaction.amount, transaction.type) }}
</div>
<!-- 5.5 类型标签 -->
<van-tag
:type="getTypeTagType(transaction.type)"
size="small"
class="type-tag"
>
{{ getTypeName(transaction.type) }}
</van-tag>
</div>
</div>
</div>
<!-- 删除按钮 -->
<template
v-if="showDelete"
#right
>
<van-button
square
type="danger"
text="删除"
class="delete-button"
@click="handleDeleteClick(transaction)"
/>
</template>
</van-swipe-cell>
</van-cell-group>
<!-- 空状态 -->
<van-empty
v-if="!loading && !(displayTransactions && displayTransactions.length)"
description="暂无交易记录"
/>
</van-list>
</div>
</template>
<!--
/**
* BillListComponent - 统一的账单列表组件
*
* @component
* @description
* 高内聚的账单列表组件,基于 v2 风格设计,支持筛选、排序、分页、左滑删除、多选等功能。
* 可用于替代项目中的旧版 TransactionList 组件。
*
* @example
* ```vue
* <BillListComponent
* dataSource="api"
* :apiParams="{ type: 0, dateRange: ['2026-01-01', '2026-01-31'] }"
* :showDelete="true"
* :enableFilter="true"
* @click="handleBillClick"
* @delete="handleBillDelete"
* />
* ```
*
* @props
* - dataSource?: 'api' | 'custom' - 数据源模式,默认 'api'
* - apiParams?: { dateRange?, category?, type? } - API 模式的筛选参数
* - transactions?: Array - 自定义数据源dataSource='custom' 时使用)
* - showDelete?: Boolean - 是否显示左滑删除,默认 true
* - showCheckbox?: Boolean - 是否显示多选框,默认 false
* - enableFilter?: Boolean - 是否启用筛选栏,默认 true
* - enableSort?: Boolean - 是否启用排序,默认 true
* - compact?: Boolean - 是否使用紧凑模式,默认 true
* - selectedIds?: Set - 已选中的账单 ID 集合
*
* @emits
* - load - 触发分页加载
* - click - 点击账单卡片,参数: transaction
* - delete - 删除账单成功,参数: id
* - update:selectedIds - 多选状态变更,参数: Set<id>
*
* @author AI Assistant
* @since 2026-02-15
*/
-->
<script setup>
import { ref, computed, watch, onMounted } from 'vue'
import { showConfirmDialog, showToast } from 'vant'
import { getTransactionList, deleteTransaction } from '@/api/transactionRecord'
import { getCategoryList } from '@/api/transactionCategory'
import Icon from '@/components/Common/Icon.vue'
/**
* @typedef {Object} Transaction
* @property {number|string} id - 账单 ID
* @property {string} reason - 摘要
* @property {number} amount - 金额
* @property {0|1|2} type - 类型0=支出, 1=收入, 2=不计入
* @property {string} [classify] - 分类
* @property {string} occurredAt - 交易时间
* @property {number} [balance] - 余额
* @property {string} [importFrom] - 导入来源
* @property {number} [upsetedType] - 修改后类型
* @property {string} [upsetedClassify] - 修改后分类
*/
// Props 定义
const props = defineProps({
dataSource: {
type: String,
default: 'api',
validator: (value) => ['api', 'custom'].includes(value)
},
apiParams: {
type: Object,
default: () => ({})
},
transactions: {
type: Array,
default: () => []
},
showDelete: {
type: Boolean,
default: true
},
showCheckbox: {
type: Boolean,
default: false
},
enableFilter: {
type: Boolean,
default: true
},
enableSort: {
type: Boolean,
default: true
},
compact: {
type: Boolean,
default: true
},
selectedIds: {
type: Set,
default: () => new Set()
}
})
// Emits 定义
const emit = defineEmits(['load', 'click', 'delete', 'update:selectedIds'])
// 响应式数据状态
const rawTransactions = ref([]) // API 或 custom 数据的原始数据
const loading = ref(false)
const finished = ref(false)
const page = ref(1)
const pageSize = ref(20)
// 分类列表及图标映射
const categories = ref([]) // 所有分类列表
const categoryIconMap = ref({}) // 分类名称 -> 图标的映射
// 筛选状态管理
const selectedType = ref(null) // null=全部, 0=支出, 1=收入, 2=不计入
const selectedCategory = ref(null) // null=全部
const dateRange = ref(null)
const sortBy = ref('time-desc')
// 4.1-4.5 筛选选项数据
const typeOptions = [
{ text: '全部类型', value: null },
{ text: '支出', value: 0 },
{ text: '收入', value: 1 },
{ text: '不计入收支', value: 2 }
]
const categoryOptions = ref([
{ text: '全部分类', value: null },
{ text: '餐饮', value: '餐饮' },
{ text: '购物', value: '购物' },
{ text: '交通', value: '交通' },
{ text: '娱乐', value: '娱乐' },
{ text: '医疗', value: '医疗' },
{ text: '工资', value: '工资' },
{ text: '红包', value: '红包' },
{ text: '其他', value: '其他' }
])
const sortOptions = [
{ text: '时间降序', value: 'time-desc' },
{ text: '时间升序', value: 'time-asc' },
{ text: '金额降序', value: 'amount-desc' },
{ text: '金额升序', value: 'amount-asc' }
]
// 4.4 日期选择相关
const showCalendar = ref(false)
const dateDropdown = ref(null)
const dateRangeText = computed(() => {
if (!dateRange.value) {
return '选择日期范围'
}
return `${dateRange.value[0]}${dateRange.value[1]}`
})
const onDateConfirm = (values) => {
const [start, end] = values
dateRange.value = [formatDateKey(start), formatDateKey(end)]
showCalendar.value = false
}
const closeDateDropdown = () => {
dateDropdown.value?.toggle()
}
const formatDateKey = (date) => {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
// 4.6 重置筛选
const resetFilters = () => {
selectedType.value = null
selectedCategory.value = null
dateRange.value = null
sortBy.value = 'time-desc'
}
// 多选状态管理(本地状态,与 prop 同步)
const localSelectedIds = ref(new Set())
// 监听 props.selectedIds 变化,同步到本地状态
watch(
() => props.selectedIds,
(newIds) => {
localSelectedIds.value = new Set(newIds)
},
{ immediate: true }
)
// 数据源模式切换逻辑
const displayTransactions = computed(() => {
let data = []
// 2.1 根据 dataSource 选择数据源
if (props.dataSource === 'custom') {
data = props.transactions || []
} else {
data = rawTransactions.value
}
// 2.5 应用筛选逻辑
let filtered = data
// 类型筛选
if (selectedType.value !== null) {
filtered = filtered.filter((t) => t.type === selectedType.value)
}
// 分类筛选
if (selectedCategory.value) {
filtered = filtered.filter((t) => t.classify === selectedCategory.value)
}
// 日期范围筛选
if (dateRange.value) {
const [start, end] = dateRange.value
filtered = filtered.filter((t) => {
const date = new Date(t.occurredAt).toISOString().split('T')[0]
return date >= start && date <= end
})
}
// 2.5 应用排序逻辑
const sorted = [...filtered]
switch (sortBy.value) {
case 'amount-desc':
sorted.sort((a, b) => b.amount - a.amount)
break
case 'amount-asc':
sorted.sort((a, b) => a.amount - b.amount)
break
case 'time-desc':
sorted.sort((a, b) => new Date(b.occurredAt).getTime() - new Date(a.occurredAt).getTime())
break
case 'time-asc':
sorted.sort((a, b) => new Date(a.occurredAt).getTime() - new Date(b.occurredAt).getTime())
break
}
return sorted
})
// ========== 格式化和样式方法 ==========
// 5.3 根据分类获取图标
const getIconByClassify = (classify) => {
// 优先使用从API加载的分类图标
if (categoryIconMap.value[classify]) {
return categoryIconMap.value[classify]
}
// 降级:使用本地映射(向后兼容)
const iconMap = {
餐饮: 'food-o',
购物: 'shopping-cart-o',
交通: 'logistics',
娱乐: 'music-o',
医疗: 'hospital-o',
工资: 'balance-o',
红包: 'envelop-o',
其他: 'star-o'
}
return iconMap[classify || ''] || 'star-o'
}
// 判断是否为 Iconify 格式collection:name
const isIconifyFormat = (icon) => {
return icon && icon.includes(':')
}
// 5.3 根据类型获取图标背景色
const getIconBg = (type) => {
if (type === 0) {
return '#FEE2E2'
} // 支出 - 浅红色
if (type === 1) {
return '#D1FAE5'
} // 收入 - 浅绿色
return '#E5E7EB' // 不计入 - 灰色
}
// 5.3 根据类型获取图标颜色
const getIconColor = (type) => {
if (type === 0) {
return '#EF4444'
} // 支出 - 红色
if (type === 1) {
return '#10B981'
} // 收入 - 绿色
return '#6B7280' // 不计入 - 灰色
}
// 5.4 格式化金额
const formatAmount = (amount, type) => {
const formatted = `¥${Number(amount).toFixed(2)}`
if (type === 0) {
return `- ${formatted}`
}
if (type === 1) {
return `+ ${formatted}`
}
return formatted
}
// 5.4 获取金额样式类
const getAmountClass = (type) => {
if (type === 0) {
return 'amount-expense'
}
if (type === 1) {
return 'amount-income'
}
return 'amount-neutral'
}
// 5.5 获取类型名称
const getTypeName = (type) => {
const typeMap = {
0: '支出',
1: '收入',
2: '不计入'
}
return typeMap[type] || '未知'
}
// 5.5 获取类型标签类型
const getTypeTagType = (type) => {
if (type === 0) {
return 'danger'
}
if (type === 1) {
return 'success'
}
return 'default'
}
// 5.5 获取分类标签样式类
const getClassifyTagClass = (type) => {
if (type === 0) {
return 'tag-expense'
}
if (type === 1) {
return 'tag-income'
}
return 'tag-neutral'
}
// 5.6 格式化时间
const formatTime = (dateString) => {
if (!dateString) {
return ''
}
const date = new Date(dateString)
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
return `${month}-${day} ${hours}:${minutes}`
}
// ========== API 数据加载 ==========
// 3.2 初始加载逻辑
const fetchTransactions = async () => {
if (props.dataSource !== 'api') {
return
}
try {
loading.value = true
const params = {
latestId:
page.value === 1 ? undefined : rawTransactions.value[rawTransactions.value.length - 1]?.id
}
// 应用 apiParams 筛选
if (props.apiParams?.type !== undefined) {
selectedType.value = props.apiParams.type
}
if (props.apiParams?.category) {
selectedCategory.value = props.apiParams.category
}
if (props.apiParams?.dateRange) {
dateRange.value = props.apiParams.dateRange
}
const response = await getTransactionList(params)
if (response && response.success) {
const newData = response.data || []
if (page.value === 1) {
rawTransactions.value = newData
} else {
rawTransactions.value = [...rawTransactions.value, ...newData]
}
// 判断是否加载完成
finished.value = newData.length < pageSize.value
} else {
showToast(response?.message || '加载失败')
finished.value = true
}
} catch (error) {
console.error('加载交易记录失败:', error)
showToast('加载失败,请稍后重试')
finished.value = true
} finally {
loading.value = false
}
}
// 3.3 分页加载逻辑
const onLoad = () => {
if (props.dataSource === 'api') {
page.value++
fetchTransactions()
}
emit('load')
}
// 3.4 筛选条件变更时的数据重载逻辑
const resetAndReload = () => {
if (props.dataSource !== 'api') {
return
}
page.value = 1
rawTransactions.value = []
finished.value = false
fetchTransactions()
}
// 监听筛选条件变化
watch([selectedType, selectedCategory, dateRange, sortBy], () => {
resetAndReload()
})
// 监听 apiParams 变化
watch(
() => props.apiParams,
() => {
resetAndReload()
},
{ deep: true }
)
// 加载分类列表及图标映射
const loadCategories = async () => {
try {
const response = await getCategoryList()
if (response && response.success) {
categories.value = response.data || []
// 构建分类名称 -> 图标的映射
const iconMap = {}
categories.value.forEach(category => {
if (category.name && category.icon) {
iconMap[category.name] = category.icon
}
})
categoryIconMap.value = iconMap
}
} catch (error) {
console.error('加载分类列表失败:', error)
// 静默失败,使用降级图标
}
}
// 组件挂载时初始加载
onMounted(() => {
// 加载分类列表(用于图标映射)
loadCategories()
if (props.dataSource === 'api') {
fetchTransactions()
}
})
// 临时实现:点击处理
const handleClick = (transaction) => {
emit('click', transaction)
}
// 6.3-6.8 删除处理(完整实现)
const handleDeleteClick = async (transaction) => {
try {
await showConfirmDialog({
title: '提示',
message: '确定要删除这条交易记录吗?'
})
loading.value = true
const response = await deleteTransaction(transaction.id)
if (response && response.success) {
showToast('删除成功')
// 6.5 删除成功后更新本地列表
rawTransactions.value = rawTransactions.value.filter((t) => t.id !== transaction.id)
// 6.7 派发全局事件
try {
window.dispatchEvent(new CustomEvent('transaction-deleted', { detail: transaction.id }))
} catch (e) {
console.log('非浏览器环境,跳过派发 transaction-deleted 事件', e)
}
// 6.8 触发父组件事件
emit('delete', transaction.id)
} else {
showToast(response?.message || '删除失败')
}
} catch (err) {
if (err !== 'cancel') {
console.error('删除出错:', err)
showToast('删除失败')
}
} finally {
loading.value = false
}
}
// 临时实现:多选相关
const isSelected = (id) => {
return localSelectedIds.value.has(id)
}
const toggleSelection = (transaction) => {
const newSelectedIds = new Set(localSelectedIds.value)
if (newSelectedIds.has(transaction.id)) {
newSelectedIds.delete(transaction.id)
} else {
newSelectedIds.add(transaction.id)
}
localSelectedIds.value = newSelectedIds
emit('update:selectedIds', newSelectedIds)
}
</script>
<style scoped lang="scss">
.bill-list-component {
width: 100%;
}
.filter-bar {
padding: 12px 16px;
background-color: var(--van-background-2);
}
.bill-row {
display: flex;
align-items: center;
width: 100%;
overflow: hidden;
}
.checkbox-col {
padding: 12px 8px;
flex-shrink: 0;
}
// 5.1-5.2 账单卡片布局(紧凑模式)
.bill-card {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
cursor: pointer;
transition: background-color 0.3s;
flex: 1;
min-width: 0;
overflow: hidden;
margin-top: 6px; // 5.2 紧凑间距
}
.bill-card:active {
background-color: var(--van-active-color);
}
// 5.3 左侧图标
.card-icon {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 50%;
flex-shrink: 0;
}
// 5.1 中间内容
.card-content {
display: flex;
flex-direction: column;
gap: 4px;
flex: 1;
min-width: 0;
overflow: hidden;
}
.card-title {
display: flex;
align-items: center;
gap: 8px;
}
.reason {
font-size: 15px;
font-weight: 500;
color: var(--van-text-color);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.card-info {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: var(--van-text-color-2);
}
.time {
flex-shrink: 0;
}
// 5.5 分类标签
.classify-tag {
padding: 2px 8px;
font-size: 11px;
border-radius: 12px;
flex-shrink: 0;
}
.tag-expense {
background-color: rgba(239, 68, 68, 0.1);
color: #ef4444;
}
.tag-income {
background-color: rgba(16, 185, 129, 0.1);
color: #10b981;
}
.tag-neutral {
background-color: rgba(107, 114, 128, 0.1);
color: #6b7280;
}
// 5.1 右侧金额区域
.card-right {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 4px;
flex-shrink: 0;
min-width: 90px;
}
// 5.4 金额样式
.amount {
font-size: 16px;
font-weight: bold;
white-space: nowrap;
}
.amount-expense {
color: var(--van-danger-color);
}
.amount-income {
color: var(--van-success-color);
}
.amount-neutral {
color: var(--van-text-color-2);
}
// 5.5 类型标签
.type-tag {
font-size: 11px;
}
.delete-button {
height: 100%;
}
// 9.4 根据 compact prop 调整样式
.bill-list-component.comfortable {
.bill-card {
padding: 16px;
margin-top: 10px;
}
.card-icon {
width: 44px;
height: 44px;
}
}
</style>