Files
EmailBill/Web/src/views/TransactionsRecord.vue
孙诚 c1aa4df4f3
Some checks failed
Docker Build & Deploy / Build Docker Image (push) Failing after 6s
Docker Build & Deploy / Deploy to Production (push) Has been skipped
fix
2026-01-01 11:58:21 +08:00

571 lines
15 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="page-container-flex">
<!-- 下拉刷新区域 -->
<van-pull-refresh v-model="refreshing" @refresh="onRefresh">
<!-- 加载提示 -->
<van-loading v-if="loading && !(transactionList && transactionList.length)" vertical style="padding: 50px 0">
加载中...
</van-loading>
<!-- 交易记录列表 -->
<TransactionList
:transactions="transactionList"
:loading="loading"
:finished="finished"
:show-delete="true"
@load="onLoad"
@click="viewDetail"
@delete="(id) => {
// 从当前的交易列表中移除该交易
transactionList.value = transactionList.value.filter(t => t.id !== id)
}"
/>
<!-- 底部安全距离 -->
<div style="height: calc(50px + env(safe-area-inset-bottom, 0px))"></div>
</van-pull-refresh>
<!-- 详情/编辑弹出层 -->
<TransactionDetail
v-model:show="detailVisible"
:transaction="currentTransaction"
@save="onDetailSave"
/>
<!-- 新增交易记录弹出层 -->
<PopupContainer
v-model="addDialogVisible"
title="手动录账单"
height="85%"
>
<van-form @submit="onAddSubmit">
<van-cell-group inset title="基本信息">
<van-field
v-model="addForm.occurredAt"
is-link
readonly
name="occurredAt"
label="交易时间"
placeholder="请选择交易时间"
@click="showDateTimePicker = true"
:rules="[{ required: true, message: '请选择交易时间' }]"
/>
</van-cell-group>
<van-cell-group inset title="交易明细">
<van-field
v-model="addForm.reason"
name="reason"
label="交易摘要"
placeholder="请输入交易摘要"
type="textarea"
rows="2"
autosize
maxlength="200"
show-word-limit
/>
<van-field
v-model="addForm.amount"
name="amount"
label="交易金额"
placeholder="请输入交易金额"
type="number"
:rules="[{ required: true, message: '请输入交易金额' }]"
/>
<van-field
v-model="addForm.typeText"
is-link
readonly
name="type"
label="交易类型"
placeholder="请选择交易类型"
@click="showAddTypePicker = true"
:rules="[{ required: true, message: '请选择交易类型' }]"
/>
<van-field
v-model="addForm.classify"
is-link
readonly
name="classify"
label="交易分类"
placeholder="请选择或输入交易分类"
@click="showAddClassifyPicker = true"
/>
</van-cell-group>
<div style="margin: 16px;">
<van-button round block type="primary" native-type="submit" :loading="addSubmitting">
确认添加
</van-button>
</div>
</van-form>
</PopupContainer>
<!-- 新增交易 - 日期时间选择器 -->
<van-popup v-model:show="showDateTimePicker" position="bottom" round>
<van-date-picker
v-model="dateTimeValue"
title="选择日期时间"
:min-date="new Date(2020, 0, 1)"
:max-date="new Date()"
@confirm="onDateTimeConfirm"
@cancel="showDateTimePicker = false"
/>
</van-popup>
<!-- 新增交易 - 交易类型选择器 -->
<van-popup v-model:show="showAddTypePicker" position="bottom" round>
<van-picker
show-toolbar
:columns="typeColumns"
@confirm="onAddTypeConfirm"
@cancel="showAddTypePicker = false"
/>
</van-popup>
<!-- 新增交易 - 交易分类选择器 -->
<van-popup v-model:show="showAddClassifyPicker" position="bottom" round>
<van-picker
ref="addClassifyPickerRef"
:columns="classifyColumns"
@confirm="onAddClassifyConfirm"
@cancel="showAddClassifyPicker = false"
>
<template #toolbar>
<div class="picker-toolbar">
<van-button class="toolbar-cancel" size="small" @click="clearAddClassify">清空</van-button>
<van-button class="toolbar-add" size="small" type="primary" @click="showAddClassify = true">新增</van-button>
<van-button class="toolbar-confirm" size="small" type="primary" @click="confirmAddClassify">确认</van-button>
</div>
</template>
</van-picker>
</van-popup>
<!-- 新增分类对话框 -->
<van-dialog
v-model:show="showAddClassify"
title="新增交易分类"
show-cancel-button
@confirm="addNewClassify"
>
<van-field v-model="newClassify" placeholder="请输入新的交易分类" />
</van-dialog>
<!-- 底部浮动搜索框 -->
<div class="floating-search">
<van-search
v-model="searchKeyword"
placeholder="搜索交易摘要、来源、卡号、分类"
@update:model-value="onSearchChange"
@clear="onSearchClear"
shape="round"
/>
</div>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, onBeforeUnmount } from 'vue'
import { showToast } from 'vant'
import {
getTransactionList,
getTransactionDetail,
createTransaction,
deleteTransaction
} from '@/api/transactionRecord'
import { getCategoryList, createCategory } from '@/api/transactionCategory'
import TransactionList from '@/components/TransactionList.vue'
import TransactionDetail from '@/components/TransactionDetail.vue'
import PopupContainer from '@/components/PopupContainer.vue'
const transactionList = ref([])
const loading = ref(false)
const refreshing = ref(false)
const finished = ref(false)
const pageIndex = ref(1)
const pageSize = 20
const total = ref(0)
const detailVisible = ref(false)
const currentTransaction = ref(null)
// 搜索相关
const searchKeyword = ref('')
let searchTimer = null
// 新增交易弹窗相关
const addDialogVisible = ref(false)
const addSubmitting = ref(false)
const showDateTimePicker = ref(false)
const dateTimeValue = ref([new Date().getFullYear(), new Date().getMonth() + 1, new Date().getDate()])
const showAddTypePicker = ref(false)
const showAddClassifyPicker = ref(false)
const addClassifyPickerRef = ref(null)
const showAddClassify = ref(false)
const newClassify = ref('')
// 交易类型
const typeColumns = [
{ text: '支出', value: 0 },
{ text: '收入', value: 1 },
{ text: '不计入收支', value: 2 }
]
// 分类相关
const classifyColumns = ref([])
// 新增表单
const addForm = reactive({
occurredAt: '',
reason: '',
amount: '',
type: 0,
typeText: '',
classify: ''
})
// 加载分类列表
const loadClassifyList = async (type = null) => {
try {
const response = await getCategoryList(type)
if (response.success) {
classifyColumns.value = (response.data || []).map(item => ({
text: item.name,
value: item.name,
id: item.id
}))
}
} catch (error) {
console.error('加载分类列表出错:', error)
}
}
// 加载数据
const loadData = async (isRefresh = false) => {
if (isRefresh) {
pageIndex.value = 1
transactionList.value = []
finished.value = false
}
loading.value = true
try {
const params = {
pageIndex: pageIndex.value,
pageSize: pageSize
}
// 添加搜索关键词
if (searchKeyword.value) {
params.searchKeyword = searchKeyword.value
}
const response = await getTransactionList(params)
if (response.success) {
const newList = response.data || []
total.value = response.total || 0
if (isRefresh) {
transactionList.value = newList
} else {
transactionList.value = [...(transactionList.value || []), ...newList]
}
if (newList.length === 0 || newList.length < pageSize) {
finished.value = true
} else {
finished.value = false
pageIndex.value++
}
} else {
showToast(response.message || '加载数据失败')
finished.value = true
}
} catch (error) {
console.error('加载数据出错:', error)
showToast('加载数据出错: ' + (error.message || '未知错误'))
finished.value = true
} finally {
loading.value = false
refreshing.value = false
}
}
// 下拉刷新
const onRefresh = () => {
finished.value = false
transactionList.value = []
loadData(true)
}
// 搜索相关方法
const onSearchChange = () => {
// 防抖处理用户停止输入500ms后自动搜索
if (searchTimer) {
clearTimeout(searchTimer)
}
searchTimer = setTimeout(() => {
onSearch()
}, 500)
}
const onSearch = () => {
// 重置分页状态并刷新数据
transactionList.value = []
finished.value = false
loadData(true)
}
const onSearchClear = () => {
searchKeyword.value = ''
onSearch()
}
// 加载更多
const onLoad = () => {
loadData(false)
}
// 查看详情
const viewDetail = async (transaction) => {
try {
const response = await getTransactionDetail(transaction.id)
if (response.success) {
currentTransaction.value = response.data
detailVisible.value = true
} else {
showToast(response.message || '获取详情失败')
}
} catch (error) {
console.error('获取详情出错:', error)
showToast('获取详情失败')
}
}
// 详情保存后的回调
const onDetailSave = async () => {
loadData(true)
// 重新加载分类列表
await loadClassifyList()
}
// 删除功能由 TransactionList 组件内部处理,组件通过 :show-delete 启用
// 打开新增弹窗
const openAddDialog = () => {
// 重置表单
addForm.occurredAt = ''
addForm.reason = ''
addForm.amount = ''
addForm.type = 0
addForm.typeText = ''
addForm.classify = ''
// 设置默认日期时间为当前时间
const now = new Date()
dateTimeValue.value = [now.getFullYear(), now.getMonth() + 1, now.getDate()]
addForm.occurredAt = formatDateForSubmit(now)
addDialogVisible.value = true
}
// 格式化日期用于提交
const formatDateForSubmit = (date) => {
const year = date.getFullYear()
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')
const seconds = String(date.getSeconds()).padStart(2, '0')
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
}
// 日期时间选择确认
const onDateTimeConfirm = ({ selectedValues }) => {
const date = new Date(selectedValues[0], selectedValues[1] - 1, selectedValues[2])
addForm.occurredAt = formatDateForSubmit(date)
showDateTimePicker.value = false
}
// 新增交易 - 交易类型选择确认
const onAddTypeConfirm = ({ selectedValues, selectedOptions }) => {
addForm.type = selectedValues[0]
addForm.typeText = selectedOptions[0].text
showAddTypePicker.value = false
}
// 新增交易 - 交易分类选择确认
const onAddClassifyConfirm = ({ selectedOptions }) => {
if (selectedOptions && selectedOptions[0]) {
addForm.classify = selectedOptions[0].text
}
showAddClassifyPicker.value = false
}
// 新增交易 - 清空分类
const clearAddClassify = () => {
addForm.classify = ''
showAddClassifyPicker.value = false
showToast('已清空分类')
}
// 新增交易 - 确认分类(从 picker 中获取选中值)
const confirmAddClassify = () => {
if (addClassifyPickerRef.value) {
const selectedValues = addClassifyPickerRef.value.getSelectedOptions()
if (selectedValues && selectedValues[0]) {
addForm.classify = selectedValues[0].text
}
}
showAddClassifyPicker.value = false
}
// 新增分类
const addNewClassify = async () => {
if (!newClassify.value.trim()) {
showToast('请输入分类名称')
return
}
try {
const response = await createCategory({
name: newClassify.value.trim(),
type: addForm.type
})
if (response.success) {
showToast('新增分类成功')
newClassify.value = ''
// 重新加载分类列表
await loadClassifyList(addForm.type)
// 设置为新增的分类
addForm.classify = response.data.name
} else {
showToast(response.message || '新增分类失败')
}
} catch (error) {
console.error('新增分类失败:', error)
showToast('新增分类失败')
}
}
// 提交新增交易
const onAddSubmit = async () => {
try {
addSubmitting.value = true
const data = {
occurredAt: addForm.occurredAt,
reason: addForm.reason,
amount: parseFloat(addForm.amount),
type: addForm.type,
classify: addForm.classify || null
}
const response = await createTransaction(data)
if (response.success) {
showToast('添加成功')
addDialogVisible.value = false
loadData(true)
try { window.dispatchEvent(new CustomEvent('transactions-changed', { detail: response.data })) } catch(e) {}
// 重新加载分类列表
await loadClassifyList()
} else {
showToast(response.message || '添加失败')
}
} catch (error) {
console.error('添加出错:', error)
showToast('添加失败: ' + (error.message || '未知错误'))
} finally {
addSubmitting.value = false
}
}
onMounted(async () => {
await loadClassifyList()
// 不需要手动调用 loadDatavan-list 会自动触发 onLoad
})
// 监听全局删除事件,保持页面一致性
const onGlobalTransactionDeleted = (e) => {
// 如果在此页面,重新刷新当前列表以保持数据一致
transactionList.value = []
pageIndex.value = 1
finished.value = false
loadData(true)
}
window.addEventListener && window.addEventListener('transaction-deleted', onGlobalTransactionDeleted)
onBeforeUnmount(() => {
window.removeEventListener && window.removeEventListener('transaction-deleted', onGlobalTransactionDeleted)
})
// 外部新增/修改/批量更新时的刷新监听
const onGlobalTransactionsChanged = (e) => {
transactionList.value = []
pageIndex.value = 1
finished.value = false
loadData(true)
}
window.addEventListener && window.addEventListener('transactions-changed', onGlobalTransactionsChanged)
onBeforeUnmount(() => {
window.removeEventListener && window.removeEventListener('transactions-changed', onGlobalTransactionsChanged)
})
// 暴露给父级方法调用
defineExpose({
openAddDialog
})
</script>
<style scoped>
:deep(.van-pull-refresh) {
flex: 1;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
.floating-search {
position: fixed;
bottom: 90px;
left: 0;
right: 0;
z-index: 999;
padding: 8px 16px;
background: transparent;
pointer-events: none;
}
.floating-search :deep(.van-search) {
pointer-events: auto;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
border-radius: 20px;
border: none;
}
.picker-toolbar {
display: flex;
width: 100%;
align-items: center;
padding: 5px 10px;
border-bottom: 1px solid #ebedf0;
}
.toolbar-cancel {
margin-right: auto;
}
.toolbar-confirm {
margin-left: auto;
}
/* 设置页面容器背景色 */
:deep(.van-nav-bar) {
background: transparent !important;
}
</style>