Files
EmailBill/Web/src/components/ReasonGroupList.vue
SunCheng e51a3edd50 refactor: 统一账单列表组件,封装 BillListComponent
- 创建 BillListComponent 组件(基于 v2 风格,紧凑布局)
  - 支持筛选(类型、分类、日期范围)和排序(金额、时间)
  - 支持分页加载、左滑删除、点击详情、多选模式
  - 支持 API 自动加载和 Custom 自定义数据两种模式
- 迁移 6 个页面/组件到新组件:
  - TransactionsRecord.vue
  - EmailRecord.vue
  - ClassificationNLP.vue
  - UnconfirmedClassification.vue
  - BudgetCard.vue
  - ReasonGroupList.vue
- 删除旧版 TransactionList 组件
- 保留 CalendarV2 的特殊版本(有专用功能)
- 添加完整的使用文档和 JSDoc 注释
2026-02-15 10:08:14 +08:00

685 lines
16 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="reason-group-list-v2">
<van-empty
v-if="groups.length === 0 && !loading"
description="暂无数据"
/>
<van-cell-group
v-else
inset
>
<van-cell
v-for="group in groups"
:key="group.reason"
clickable
@click="handleGroupClick(group)"
@long-press="handleLongPress(group)"
>
<template #title>
<div class="group-header">
<van-checkbox
v-if="selectable"
:model-value="isSelected(group.reason)"
@click.stop="handleToggleSelection(group.reason)"
/>
<div class="group-title">
{{ group.reason }}
</div>
</div>
</template>
<template #label>
<div class="group-info">
<van-tag
:type="getTypeColor(group.sampleType)"
size="medium"
style="margin-right: 8px"
>
{{ getTypeName(group.sampleType) }}
</van-tag>
<van-tag
v-if="group.sampleClassify"
type="primary"
size="medium"
style="margin-right: 8px"
>
{{ group.sampleClassify }}
</van-tag>
<span class="count-text">{{ group.count }} </span>
<span
v-if="group.totalAmount"
class="amount-text"
>
¥{{ Math.abs(group.totalAmount).toFixed(2) }}
</span>
</div>
</template>
<template #right-icon>
<van-icon name="arrow" />
</template>
</van-cell>
</van-cell-group>
<!-- 账单列表弹窗 -->
<PopupContainer
v-model="showTransactionList"
:title="selectedGroup?.reason || '交易记录'"
:subtitle="groupTransactionsTotal ? `共 ${groupTransactionsTotal} 笔交易` : ''"
height="75%"
>
<template #header-actions>
<van-button
type="primary"
size="small"
class="batch-classify-btn"
@click.stop="handleBatchClassify(selectedGroup)"
>
批量分类
</van-button>
</template>
<BillListComponent
data-source="custom"
:transactions="groupTransactions"
:loading="transactionLoading"
:finished="transactionFinished"
:enable-filter="false"
@load="loadGroupTransactions"
@click="handleTransactionClick"
@delete="handleGroupTransactionDelete"
/>
</PopupContainer>
<!-- 账单详情弹窗 -->
<TransactionDetail
v-model:show="showTransactionDetail"
:transaction="selectedTransaction"
@save="handleTransactionSaved"
/>
<!-- 批量设置对话框 -->
<PopupContainer
v-model="showBatchDialog"
title="批量设置分类"
height="60%"
>
<van-form
ref="batchFormRef"
class="setting-form"
>
<van-cell-group inset>
<!-- 显示选中的摘要 -->
<van-field
:model-value="batchGroup?.reason"
label="交易摘要"
readonly
input-align="left"
/>
<!-- 显示记录数量 -->
<van-field
:model-value="`${batchGroup?.count || 0} `"
label="记录数量"
readonly
input-align="left"
/>
<!-- 交易类型 -->
<van-field
name="type"
label="交易类型"
>
<template #input>
<van-radio-group
v-model="batchForm.type"
direction="horizontal"
>
<van-radio :name="0">
支出
</van-radio>
<van-radio :name="1">
收入
</van-radio>
<van-radio :name="2">
不计
</van-radio>
</van-radio-group>
</template>
</van-field>
<!-- 分类选择 -->
<van-field
name="classify"
label="分类"
>
<template #input>
<span
v-if="!batchForm.classify"
style="opacity: 0.4"
>请选择分类</span>
<span v-else>{{ batchForm.classify }}</span>
</template>
</van-field>
<!-- 分类选择组件 -->
<ClassifySelector
v-model="batchForm.classify"
:type="batchForm.type"
/>
</van-cell-group>
</van-form>
<template #footer>
<van-button
round
block
type="primary"
@click="handleConfirmBatchUpdate"
>
确定
</van-button>
</template>
</PopupContainer>
</div>
</template>
<script setup>
import { ref, computed, watch, onBeforeUnmount } from 'vue'
import { showToast, showSuccessToast, showLoadingToast, closeToast, showConfirmDialog } from 'vant'
import { getReasonGroups, batchUpdateByReason, getTransactionList } from '@/api/transactionRecord'
import ClassifySelector from './ClassifySelector.vue'
import BillListComponent from './Bill/BillListComponent.vue'
import TransactionDetail from './TransactionDetail.vue'
import PopupContainer from './PopupContainer.vue'
const props = defineProps({
// 是否支持多选
selectable: {
type: Boolean,
default: false
},
// 每页数量
pageSize: {
type: Number,
default: 20
}
})
const emit = defineEmits(['long-press', 'data-loaded', 'data-changed'])
// 数据状态
const groups = ref([])
const loading = ref(false)
const selectedReasons = ref(new Set())
const pageIndex = ref(1)
const finished = ref(false)
const total = ref(0)
// 弹窗状态
const showTransactionList = ref(false)
const showTransactionDetail = ref(false)
const selectedGroup = ref(null)
const selectedTransaction = ref(null)
// 交易记录列表状态
const groupTransactions = ref([])
const groupTransactionsTotal = ref(0)
const transactionLoading = ref(false)
const transactionFinished = ref(false)
const transactionPageIndex = ref(1)
const transactionPageSize = ref(20)
// 批量分类相关状态
const showBatchDialog = ref(false)
const batchFormRef = ref(null)
const batchGroup = ref(null)
const batchForm = ref({
type: null,
typeName: '',
classify: ''
})
// 监听交易类型变化,重新加载分类
watch(
() => batchForm.value.type,
(newVal) => {
batchForm.value.classify = ''
}
)
// 获取类型名称
const getTypeName = (type) => {
const typeMap = {
0: '支出',
1: '收入',
2: '不计收支'
}
return typeMap[type] || '未知'
}
// 获取类型对应的标签颜色
const getTypeColor = (type) => {
const colorMap = {
0: 'danger',
1: 'success',
2: 'default'
}
return colorMap[type] || 'default'
}
// 判断是否选中
const isSelected = (reason) => {
return selectedReasons.value.has(reason)
}
// 处理分组点击 - 显示账单列表
const handleGroupClick = async (group) => {
selectedGroup.value = group
// 重置状态
groupTransactions.value = []
groupTransactionsTotal.value = 0
transactionPageIndex.value = 1
transactionFinished.value = false
showTransactionList.value = true
// 加载第一页数据
await loadGroupTransactions()
}
// 加载分组的交易记录
const loadGroupTransactions = async () => {
if (transactionFinished.value || !selectedGroup.value) {
return
}
transactionLoading.value = true
try {
const res = await getTransactionList({
reason: selectedGroup.value.reason,
pageIndex: transactionPageIndex.value,
pageSize: transactionPageSize.value
})
if (res.success) {
const newData = res.data || []
groupTransactions.value = [...groupTransactions.value, ...newData]
groupTransactionsTotal.value = res.total || 0
// 判断是否还有更多数据
if (newData.length < transactionPageSize.value) {
transactionFinished.value = true
}
transactionPageIndex.value++
} else {
showToast(res.message || '获取交易记录失败')
}
} catch (error) {
console.error('加载交易记录失败:', error)
showToast('加载交易记录失败')
} finally {
transactionLoading.value = false
}
}
// 处理长按 - 触发父组件的长按事件
const handleLongPress = (group) => {
emit('long-press', group)
}
// 处理切换选择
// 处理切换选择
const handleToggleSelection = (reason) => {
if (selectedReasons.value.has(reason)) {
selectedReasons.value.delete(reason)
} else {
selectedReasons.value.add(reason)
}
// 触发响应式更新
selectedReasons.value = new Set(selectedReasons.value)
}
// ========== 批量分类相关方法 ==========
// 处理批量分类按钮点击
const handleBatchClassify = (group) => {
batchGroup.value = group
batchForm.value = {
type: group.sampleType,
typeName: getTypeName(group.sampleType),
classify: group.sampleClassify || ''
}
showBatchDialog.value = true
}
// 确认批量更新
const handleConfirmBatchUpdate = async () => {
try {
// 表单验证
await batchFormRef.value?.validate()
// 二次确认
await showConfirmDialog({
title: '确认批量设置',
message: `确定要将「${batchGroup.value.reason}」的 ${batchGroup.value.count} 条记录设置为「${batchForm.value.typeName} - ${batchForm.value.classify}」吗?`
})
showLoadingToast({
message: '更新中...',
forbidClick: true,
duration: 0
})
const res = await batchUpdateByReason({
reason: batchGroup.value.reason,
type: batchForm.value.type,
classify: batchForm.value.classify
})
closeToast()
if (res.success) {
showSuccessToast(res.message || `成功更新 ${res.data} 条记录`)
showBatchDialog.value = false
resetBatchForm()
// 刷新列表数据
await refresh()
// 通知父组件数据已更改
emit('data-changed')
try {
window.dispatchEvent(
new CustomEvent('transactions-changed', {
detail: {
reason: batchGroup.value.reason
}
})
)
} catch (e) {
console.error('触发全局 transactions-changed 事件失败:', e)
}
// 关闭弹窗
showTransactionList.value = false
} else {
showToast(res.message || '批量更新失败')
}
} catch (error) {
closeToast()
if (error !== 'cancel') {
console.error('批量更新失败:', error)
showToast(error.message || '批量更新失败')
}
}
}
// 重置批量表单
const resetBatchForm = () => {
batchGroup.value = null
batchForm.value = {
type: null,
typeName: '',
classify: ''
}
batchFormRef.value?.resetValidation()
}
// 处理账单项点击 - 显示账单详情
const handleTransactionClick = (transaction) => {
selectedTransaction.value = transaction
showTransactionDetail.value = true
}
// 处理分组中的删除事件
const handleGroupTransactionDelete = async (transactionId) => {
groupTransactions.value = groupTransactions.value.filter((t) => t.id !== transactionId)
groupTransactionsTotal.value = Math.max(0, (groupTransactionsTotal.value || 0) - 1)
if (groupTransactions.value.length === 0 && !transactionFinished.value) {
// 如果当前页数据为空且未加载完,则尝试加载下一页
await loadGroupTransactions()
}
if (groupTransactions.value.length === 0) {
// 如果删除后当前分组没有交易了,关闭弹窗
showTransactionList.value = false
groups.value = groups.value.filter((g) => g.reason !== selectedGroup.value.reason)
selectedGroup.value = null
total.value--
}
// 重新加载当前页统计(如果需要)并通知父组件数据已更改
emit('data-changed')
}
// 全局删除事件监听,刷新当前分组交易或分组数据
const onGlobalTransactionDeleted = () => {
// 如果当前弹窗打开并存在 selectedGroup则重新加载分组交易
if (showTransactionList.value && selectedGroup.value) {
// 重新加载从第一页开始
groupTransactions.value = []
transactionPageIndex.value = 1
transactionFinished.value = false
loadGroupTransactions()
} else {
// 否则刷新分组列表
refresh()
}
}
window.addEventListener &&
window.addEventListener('transaction-deleted', onGlobalTransactionDeleted)
onBeforeUnmount(() => {
window.removeEventListener &&
window.removeEventListener('transaction-deleted', onGlobalTransactionDeleted)
})
// 当有交易新增/修改/批量更新时的刷新监听
const onGlobalTransactionsChanged = () => {
if (showTransactionList.value && selectedGroup.value) {
groupTransactions.value = []
transactionPageIndex.value = 1
transactionFinished.value = false
loadGroupTransactions()
} else {
refresh()
}
}
window.addEventListener &&
window.addEventListener('transactions-changed', onGlobalTransactionsChanged)
onBeforeUnmount(() => {
window.removeEventListener &&
window.removeEventListener('transactions-changed', onGlobalTransactionsChanged)
})
// 处理账单保存后的回调
const handleTransactionSaved = async () => {
// 通知父组件数据已更改
emit('data-changed')
// 重新加载当前页数据
await refresh()
}
// ========== 公开方法 ==========
/**
* 加载数据(支持分页)
*/
const loadData = async () => {
if (finished.value) {
return
}
loading.value = true
try {
const res = await getReasonGroups(pageIndex.value, props.pageSize)
if (res.success) {
const newData = res.data || []
groups.value = [...groups.value, ...newData]
total.value = res.total || 0
// 判断是否还有更多数据
if (groups.value.length >= total.value) {
finished.value = true
}
pageIndex.value++
emit('data-loaded', {
groups: groups.value,
total: total.value,
finished: finished.value
})
} else {
showToast(res.message || '获取数据失败')
}
} catch (error) {
console.error('加载数据失败:', error)
showToast('加载数据失败')
} finally {
loading.value = false
}
}
/**
* 刷新数据(重新加载)
*/
const refresh = async () => {
groups.value = []
pageIndex.value = 1
finished.value = false
selectedReasons.value = new Set()
await loadData()
}
/**
* 获取列表数据
* @param {boolean} onlySelected - 是否只获取已选中的
* @returns {Array} 分组列表
*/
const getList = (onlySelected = false) => {
if (onlySelected && props.selectable) {
return groups.value.filter((g) => selectedReasons.value.has(g.reason))
}
return [...groups.value]
}
/**
* 设置列表数据
* @param {Array} newGroups - 新的分组列表
*/
const setList = (newGroups) => {
groups.value = newGroups || []
total.value = groups.value.length
emit('data-changed')
}
/**
* 获取已选中的分组reason集合
* @returns {Set} 已选中的reason集合
*/
const getSelectedReasons = () => {
return new Set(selectedReasons.value)
}
/**
* 设置已选中的分组
* @param {Array|Set} reasons - reason数组或Set
*/
const setSelectedReasons = (reasons) => {
selectedReasons.value = new Set(reasons)
}
/**
* 清空选择
*/
const clearSelection = () => {
selectedReasons.value = new Set()
}
/**
* 全选
*/
const selectAll = () => {
selectedReasons.value = new Set(groups.value.map((g) => g.reason))
}
// 暴露方法给父组件
defineExpose({
loadData,
refresh,
getList,
setList,
getSelectedReasons,
setSelectedReasons,
clearSelection,
selectAll
})
</script>
<style scoped>
.reason-group-list-v2 {
width: 100%;
}
/* 分组头部 */
.group-header {
display: flex;
align-items: center;
gap: 8px;
}
.group-title {
flex: 1;
font-size: 15px;
font-weight: 500;
line-height: 1.4;
}
.batch-classify-btn {
flex-shrink: 0;
border-radius: 12px;
padding: 0 12px;
height: 28px;
}
.popup-actions {
display: flex;
gap: 8px;
align-items: center;
}
.popup-actions .van-button {
flex: 1;
min-width: auto;
}
.group-info {
display: flex;
align-items: center;
gap: 8px;
margin-top: 4px;
flex-wrap: wrap;
}
.count-text {
font-size: 13px;
color: var(--van-text-color-2);
}
.amount-text {
font-size: 14px;
font-weight: 500;
color: var(--van-orange);
}
:deep(.van-cell-group--inset) {
margin: 16px;
}
/* 批量分类表单 */
.setting-form {
padding: 16px 0;
}
/* 交易列表弹窗 - 自定义样式 */
</style>