first commot
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 8s
Docker Build & Deploy / Deploy to Production (push) Successful in 7s

This commit is contained in:
孙诚
2025-12-25 11:20:56 +08:00
commit 4526cc6396
104 changed files with 11070 additions and 0 deletions

View File

@@ -0,0 +1,607 @@
<template>
<div class="transaction-record-container">
<!-- 顶部导航栏 -->
<van-nav-bar title="交易记录" fixed placeholder style="z-index: 9999;">
<template #right>
<van-button type="primary" size="small" @click="openAddDialog">
手动录账
</van-button>
</template>
</van-nav-bar>
<!-- 下拉刷新区域 -->
<van-pull-refresh v-model="refreshing" @refresh="onRefresh" class="refresh-wrapper">
<!-- 加载提示 -->
<van-loading v-if="loading && !(transactionList && transactionList.length)" vertical style="padding: 50px 0">
加载中...
</van-loading>
<!-- 交易记录列表 -->
<TransactionList
:transactions="transactionList"
:loading="loading"
:finished="finished"
@load="onLoad"
@click="viewDetail"
@delete="handleDelete"
/>
</van-pull-refresh>
<!-- 详情/编辑弹出层 -->
<TransactionDetail
v-model:show="detailVisible"
:transaction="currentTransaction"
@save="onDetailSave"
/>
<!-- 新增交易记录弹出层 -->
<van-popup
v-model:show="addDialogVisible"
position="bottom"
:style="{ height: '85%' }"
round
closeable
>
<div class="transaction-detail">
<div class="detail-header" style="padding-top: 10px; padding-left: 10px;">
<h3>手动录账单</h3>
</div>
<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-field
v-model="addForm.subClassify"
is-link
readonly
name="subClassify"
label="交易子分类"
placeholder="请选择或输入交易子分类"
@click="showAddSubClassifyPicker = true"
/>
</van-cell-group>
<div style="margin: 16px;">
<van-button round block type="primary" native-type="submit" :loading="addSubmitting">
确认添加
</van-button>
</div>
</van-form>
</div>
</van-popup>
<!-- 新增交易 - 日期时间选择器 -->
<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-popup v-model:show="showAddSubClassifyPicker" position="bottom" round>
<van-picker
ref="addSubClassifyPickerRef"
:columns="subClassifyColumns"
@confirm="onAddSubClassifyConfirm"
@cancel="showAddSubClassifyPicker = false"
>
<template #toolbar>
<div class="picker-toolbar">
<van-button class="toolbar-cancel" size="small" @click="clearAddSubClassify">清空</van-button>
<van-button class="toolbar-add" size="small" type="primary" @click="showAddSubClassify = true">新增</van-button>
<van-button class="toolbar-confirm" size="small" type="primary" @click="confirmAddSubClassify">确认</van-button>
</div>
</template>
</van-picker>
</van-popup>
<!-- 底部浮动搜索框 -->
<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 } from 'vue'
import { showToast, showConfirmDialog } from 'vant'
import {
getTransactionList,
getTransactionDetail,
createTransaction,
deleteTransaction
} from '@/api/transactionRecord'
import { getCategoryTree } from '@/api/transactionCategory'
import TransactionList from '@/components/TransactionList.vue'
import TransactionDetail from '@/components/TransactionDetail.vue'
const transactionList = ref([])
const loading = ref(false)
const refreshing = ref(false)
const finished = ref(false)
const lastId = ref(null)
const lastTime = ref(null)
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 showAddSubClassifyPicker = ref(false)
const addClassifyPickerRef = ref(null)
const addSubClassifyPickerRef = ref(null)
// 交易类型
const typeColumns = [
{ text: '支出', value: 0 },
{ text: '收入', value: 1 },
{ text: '不计入收支', value: 2 }
]
// 分类相关
const classifyColumns = ref([])
const subClassifyColumns = ref([])
// 新增表单
const addForm = reactive({
occurredAt: '',
reason: '',
amount: '',
type: 0,
typeText: '',
classify: '',
subClassify: ''
})
// 加载分类列表(从分类树中提取)
const loadClassifyList = async (type = null) => {
try {
const response = await getCategoryTree(type)
if (response.success) {
// 从树形结构中提取分类名称Level 2
classifyColumns.value = (response.data || []).map(item => ({
text: item.name,
value: item.name,
id: item.id,
children: item.children || []
}))
}
} catch (error) {
console.error('加载分类列表出错:', error)
}
}
// 加载子分类列表(根据选中的分类)
const loadSubClassifyList = async (classifyName) => {
try {
// 从已加载的分类树中查找对应的子分类
const classifyItem = classifyColumns.value.find(item => item.value === classifyName)
if (classifyItem && classifyItem.children) {
subClassifyColumns.value = classifyItem.children.map(child => ({
text: child.name,
value: child.name,
id: child.id
}))
} else {
subClassifyColumns.value = []
}
} catch (error) {
console.error('加载子分类列表出错:', error)
}
}
// 加载数据
const loadData = async (isRefresh = false) => {
if (isRefresh) {
lastId.value = null
lastTime.value = null
transactionList.value = []
finished.value = false
}
loading.value = true
try {
const params = {}
if (lastTime.value && lastId.value) {
params.lastOccurredAt = lastTime.value
params.lastId = lastId.value
}
// 添加搜索关键词
if (searchKeyword.value) {
params.searchKeyword = searchKeyword.value
}
const response = await getTransactionList(params)
if (response.success) {
const newList = response.data || []
total.value = response.total || 0
const newLastId = response.lastId || 0
const newLastTime = response.lastTime
if (isRefresh) {
transactionList.value = newList
} else {
transactionList.value = [...(transactionList.value || []), ...newList]
}
if (newLastId > 0 && newLastTime) {
lastId.value = newLastId
lastTime.value = newLastTime
}
if (newList.length === 0 || newList.length < 20) {
finished.value = true
} else {
finished.value = false
}
} 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
lastId.value = null
transactionList.value = []
loadData(false)
}
// 搜索相关方法
const onSearchChange = () => {
// 防抖处理用户停止输入500ms后自动搜索
if (searchTimer) {
clearTimeout(searchTimer)
}
searchTimer = setTimeout(() => {
onSearch()
}, 500)
}
const onSearch = () => {
// 重置分页状态并刷新数据
lastId.value = null
lastTime.value = null
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()
}
// 删除
const handleDelete = async (transaction) => {
try {
await showConfirmDialog({
title: '提示',
message: '确定要删除这条交易记录吗?',
})
const response = await deleteTransaction(transaction.id)
if (response.success) {
showToast('删除成功')
loadData(true)
} else {
showToast(response.message || '删除失败')
}
} catch (error) {
if (error !== 'cancel') {
console.error('删除出错:', error)
showToast('删除失败')
}
}
}
// 打开新增弹窗
const openAddDialog = () => {
// 重置表单
addForm.occurredAt = ''
addForm.reason = ''
addForm.amount = ''
addForm.type = 0
addForm.typeText = ''
addForm.classify = ''
addForm.subClassify = ''
// 设置默认日期时间为当前时间
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 = async ({ selectedOptions }) => {
if (selectedOptions && selectedOptions[0]) {
addForm.classify = selectedOptions[0].text
// 加载对应的子分类
await loadSubClassifyList(selectedOptions[0].value)
}
showAddClassifyPicker.value = false
}
// 新增交易 - 交易子分类选择确认
const onAddSubClassifyConfirm = ({ selectedOptions }) => {
if (selectedOptions && selectedOptions[0]) {
addForm.subClassify = selectedOptions[0].text
}
showAddSubClassifyPicker.value = false
}
// 新增交易 - 清空分类
const clearAddClassify = () => {
addForm.classify = ''
showAddClassifyPicker.value = false
showToast('已清空分类')
}
// 新增交易 - 清空子分类
const clearAddSubClassify = () => {
addForm.subClassify = ''
showAddSubClassifyPicker.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
}
// 新增交易 - 确认子分类(从 picker 中获取选中值)
const confirmAddSubClassify = () => {
if (addSubClassifyPickerRef.value) {
const selectedValues = addSubClassifyPickerRef.value.getSelectedOptions()
if (selectedValues && selectedValues[0]) {
addForm.subClassify = selectedValues[0].text
}
}
showAddSubClassifyPicker.value = false
}
// 提交新增交易
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,
subClassify: addForm.subClassify || null
}
const response = await createTransaction(data)
if (response.success) {
showToast('添加成功')
addDialogVisible.value = false
loadData(true)
// 重新加载分类列表
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
})
</script>
<style scoped>
@import '@/styles/common.css';
.floating-search {
position: fixed;
bottom: 50px;
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;
}
</style>