Files
EmailBill/Web/src/components/ReasonGroupList.vue
孙诚 82bb13c385
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 19s
Docker Build & Deploy / Deploy to Production (push) Successful in 7s
统一组件
2026-01-03 11:07:33 +08:00

759 lines
19 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 class="amount-text" v-if="group.totalAmount">
¥{{ 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="85%"
>
<template #header-actions>
<van-button
type="primary"
size="small"
class="batch-classify-btn"
@click.stop="handleBatchClassify(selectedGroup)"
>
批量分类
</van-button>
</template>
<TransactionList
:transactions="groupTransactions"
:loading="transactionLoading"
:finished="transactionFinished"
@load="loadGroupTransactions"
@click="handleTransactionClick"
@delete="handleGroupTransactionDelete"
/>
</PopupContainer>
<!-- 账单详情弹窗 -->
<TransactionDetail
v-model:show="showTransactionDetail"
:transaction="selectedTransaction"
@save="handleTransactionSaved"
/>
<!-- 批量设置对话框 -->
<van-dialog
v-model:show="showBatchDialog"
title="批量设置分类"
:show-cancel-button="true"
@confirm="handleConfirmBatchUpdate"
@cancel="resetBatchForm"
>
<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>
<!-- 分类按钮网格 -->
<div class="classify-buttons">
<van-button
type="success"
size="small"
class="classify-btn"
@click="showAddClassify = true"
>
+ 新增
</van-button>
<van-button
v-for="item in classifyOptions"
:key="item.id"
:type="batchForm.classify === item.text ? 'primary' : 'default'"
size="small"
class="classify-btn"
@click="selectClassify(item.text)"
>
{{ item.text }}
</van-button>
<van-button
v-if="batchForm.classify"
type="warning"
size="small"
class="classify-btn"
@click="clearClassify"
>
清空
</van-button>
</div>
</van-cell-group>
</van-form>
</van-dialog>
<!-- 新增分类对话框 -->
<van-dialog
v-model:show="showAddClassify"
title="新增交易分类"
show-cancel-button
@confirm="addNewClassify"
>
<van-field v-model="newClassify" placeholder="请输入新的交易分类" />
</van-dialog>
</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 { getCategoryList, createCategory } from '@/api/transactionCategory'
import TransactionList from './TransactionList.vue'
import TransactionDetail from './TransactionDetail.vue'
import PopupContainer from './PopupContainer.vue'
const props = defineProps({
// 是否支持多选
selectable: {
type: Boolean,
default: false
},
// 每页数量
pageSize: {
type: Number,
default: 3 // TODO 测试写小一点
}
})
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 categories = ref([])
// 弹窗状态
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 showAddClassify = ref(false)
const batchFormRef = ref(null)
const batchGroup = ref(null)
const newClassify = ref('')
const batchForm = ref({
type: null,
typeName: '',
classify: ''
})
// 根据选中的类型过滤分类选项
const classifyOptions = computed(() => {
if (batchForm.value.type === null) return []
return categories.value
.filter(c => c.type === batchForm.value.type)
.map(c => ({ text: c.name, value: c.name, id: c.id }))
})
// 监听交易类型变化,重新加载分类
watch(() => batchForm.value.type, (newVal) => {
batchForm.value.classify = ''
if (newVal !== null) {
loadCategories(newVal)
}
})
// 获取类型名称
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 || ''
}
// 加载对应类型的分类列表
loadCategories(group.sampleType)
showBatchDialog.value = true
}
// 获取所有分类
const loadCategories = async (type = null) => {
try {
const res = await getCategoryList(type)
if (res.success) {
categories.value = res.data || []
}
} catch (error) {
console.error('获取分类列表失败:', error)
}
}
// 选择分类
const selectClassify = (classify) => {
batchForm.value.classify = classify
}
// 新增分类
const addNewClassify = async () => {
if (!newClassify.value.trim()) {
showToast('请输入分类名称')
return
}
if (batchForm.value.type === null) {
showToast('请先选择交易类型')
return
}
try {
const categoryName = newClassify.value.trim()
// 调用API创建分类
const response = await createCategory({
name: categoryName,
type: batchForm.value.type
})
if (response.success) {
showToast('分类创建成功')
// 重新加载分类列表
await loadCategories(batchForm.value.type)
batchForm.value.classify = categoryName
} else {
showToast(response.message || '创建分类失败')
}
} catch (error) {
console.error('创建分类出错:', error)
showToast('创建分类失败')
} finally {
newClassify.value = ''
showAddClassify.value = false
}
}
// 清空分类
const clearClassify = () => {
batchForm.value.classify = ''
showToast('已清空分类')
}
// 确认批量更新
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;
}
.group-info {
display: flex;
align-items: center;
gap: 8px;
margin-top: 4px;
flex-wrap: wrap;
}
.count-text {
font-size: 13px;
color: #969799;
}
.amount-text {
font-size: 14px;
font-weight: 500;
color: #ff976a;
}
:deep(.van-cell-group--inset) {
margin: 16px;
}
/* 批量分类表单 */
.setting-form {
padding: 16px 0;
}
.classify-buttons {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 12px 16px;
max-height: 300px;
overflow-y: auto;
}
.classify-btn {
flex: 0 0 auto;
min-width: 70px;
border-radius: 16px;
}
/* 交易列表弹窗 - 自定义样式 */
</style>