feat: Refactor transaction handling and add new features
- Updated ReasonGroupList.vue to modify classify button behavior for adding new classifications. - Refactored TransactionDetail.vue to integrate PopupContainer and enhance transaction detail display. - Improved TransactionDetailDialog.vue with updated classify button functionality. - Simplified BalanceView.vue by removing manual entry button. - Enhanced PeriodicRecord.vue to update classify button interactions. - Removed unused add transaction dialog from TransactionsRecord.vue. - Added new API endpoints in TransactionRecordController for parsing transactions and handling offsets. - Introduced BillForm.vue and ManualBillAdd.vue for streamlined bill entry. - Implemented OneLineBillAdd.vue for intelligent transaction parsing. - Created GlobalAddBill.vue for a unified bill addition interface.
This commit is contained in:
367
Web/src/components/Bill/BillForm.vue
Normal file
367
Web/src/components/Bill/BillForm.vue
Normal file
@@ -0,0 +1,367 @@
|
||||
<template>
|
||||
<div class="bill-form">
|
||||
<van-form @submit="handleSubmit">
|
||||
<van-cell-group inset>
|
||||
<!-- 日期时间 -->
|
||||
<van-field label="时间">
|
||||
<template #input>
|
||||
<div style="display: flex; gap: 16px">
|
||||
<div @click="showDatePicker = true">{{ form.date }}</div>
|
||||
<div @click="showTimePicker = true">{{ form.time }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</van-field>
|
||||
|
||||
<!-- 金额 -->
|
||||
<van-field
|
||||
v-model="form.amount"
|
||||
name="amount"
|
||||
label="金额"
|
||||
type="number"
|
||||
placeholder="0.00"
|
||||
:rules="[{ required: true, message: '请输入金额' }]"
|
||||
/>
|
||||
|
||||
<!-- 备注 -->
|
||||
<van-field
|
||||
v-model="form.note"
|
||||
name="note"
|
||||
label="摘要"
|
||||
placeholder="摘要信息"
|
||||
rows="2"
|
||||
autosize
|
||||
type="textarea"
|
||||
/>
|
||||
|
||||
<!-- 交易类型 -->
|
||||
<van-field name="type" label="类型">
|
||||
<template #input>
|
||||
<van-radio-group v-model="form.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="category" label="分类">
|
||||
<template #input>
|
||||
<span v-if="!categoryName" style="color: #c8c9cc;">请选择分类</span>
|
||||
<span v-else>{{ categoryName }}</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 categoryList"
|
||||
:key="item.id"
|
||||
:type="categoryName === item.name ? 'primary' : 'default'"
|
||||
size="small"
|
||||
class="classify-btn"
|
||||
@click="selectClassify(item)"
|
||||
>
|
||||
{{ item.name }}
|
||||
</van-button>
|
||||
</div>
|
||||
</van-cell-group>
|
||||
|
||||
<div class="actions">
|
||||
<van-button round block type="primary" native-type="submit" :loading="loading">
|
||||
{{ submitText }}
|
||||
</van-button>
|
||||
<slot name="actions"></slot>
|
||||
</div>
|
||||
</van-form>
|
||||
|
||||
<!-- 新增分类对话框 -->
|
||||
<van-dialog
|
||||
v-model:show="showAddClassify"
|
||||
title="新增交易分类"
|
||||
show-cancel-button
|
||||
@confirm="addNewClassify"
|
||||
>
|
||||
<van-field v-model="newClassify" placeholder="请输入新的交易分类" />
|
||||
</van-dialog>
|
||||
|
||||
<!-- 日期选择弹窗 -->
|
||||
<van-popup v-model:show="showDatePicker" position="bottom" round>
|
||||
<van-date-picker
|
||||
v-model="currentDate"
|
||||
title="选择日期"
|
||||
@confirm="onConfirmDate"
|
||||
@cancel="showDatePicker = false"
|
||||
/>
|
||||
</van-popup>
|
||||
|
||||
<!-- 时间选择弹窗 -->
|
||||
<van-popup v-model:show="showTimePicker" position="bottom" round>
|
||||
<van-time-picker
|
||||
v-model="currentTime"
|
||||
title="选择时间"
|
||||
@confirm="onConfirmTime"
|
||||
@cancel="showTimePicker = false"
|
||||
/>
|
||||
</van-popup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, watch, toRefs } from 'vue'
|
||||
import { showToast } from 'vant'
|
||||
import dayjs from 'dayjs'
|
||||
import { getCategoryList, createCategory } from '@/api/transactionCategory'
|
||||
|
||||
const props = defineProps({
|
||||
initialData: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
submitText: {
|
||||
type: String,
|
||||
default: '保存'
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['submit'])
|
||||
|
||||
// 表单数据
|
||||
const form = ref({
|
||||
type: 0, // 0: 支出, 1: 收入, 2: 不计
|
||||
amount: '',
|
||||
categoryId: null,
|
||||
date: dayjs().format('YYYY-MM-DD'),
|
||||
time: dayjs().format('HH:mm'),
|
||||
note: ''
|
||||
})
|
||||
|
||||
const categoryName = ref('')
|
||||
|
||||
// 弹窗控制
|
||||
const showDatePicker = ref(false)
|
||||
const showTimePicker = ref(false)
|
||||
const showAddClassify = ref(false)
|
||||
const newClassify = ref('')
|
||||
|
||||
// 选择器数据
|
||||
const categoryList = ref([])
|
||||
|
||||
// 日期时间临时变量 (Vant DatePicker 需要数组或特定格式)
|
||||
const currentDate = ref(dayjs().format('YYYY-MM-DD').split('-'))
|
||||
const currentTime = ref(dayjs().format('HH:mm').split(':'))
|
||||
|
||||
// 初始化数据
|
||||
const initForm = async () => {
|
||||
if (props.initialData) {
|
||||
const { occurredAt, amount, reason, type, classify } = props.initialData
|
||||
|
||||
if (occurredAt) {
|
||||
const dt = dayjs(occurredAt)
|
||||
form.value.date = dt.format('YYYY-MM-DD')
|
||||
form.value.time = dt.format('HH:mm')
|
||||
currentDate.value = form.value.date.split('-')
|
||||
currentTime.value = form.value.time.split(':')
|
||||
}
|
||||
|
||||
if (amount !== undefined) form.value.amount = amount
|
||||
if (reason !== undefined) form.value.note = reason
|
||||
if (type !== undefined) form.value.type = type
|
||||
|
||||
// 加载分类列表
|
||||
await loadClassifyList(form.value.type)
|
||||
|
||||
// 如果有传入分类名称,尝试匹配
|
||||
if (classify) {
|
||||
const found = categoryList.value.find(c => c.name === classify)
|
||||
if (found) {
|
||||
selectClassify(found)
|
||||
} else {
|
||||
// 如果没找到对应分类,但有分类名称,可能需要特殊处理或者就显示名称但不关联ID?
|
||||
// 这里暂时只显示名称,ID为空,或者需要自动创建?
|
||||
// 按照原有逻辑,后端需要分类名称,所以这里只要设置 categoryName 即可
|
||||
// 但是 ManualBillAdd 原逻辑是需要 categoryId 的。
|
||||
// 不过 createTransaction 接口传的是 classify (name)。
|
||||
// 让我们看 ManualBillAdd 的 handleSave:
|
||||
// classify: categoryName.value
|
||||
// 所以只要 categoryName 有值就行。
|
||||
categoryName.value = classify
|
||||
}
|
||||
}
|
||||
} else {
|
||||
await loadClassifyList(form.value.type)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
initForm()
|
||||
})
|
||||
|
||||
// 监听 initialData 变化 (例如重新解析后)
|
||||
watch(() => props.initialData, () => {
|
||||
initForm()
|
||||
}, { deep: true })
|
||||
|
||||
// 监听交易类型变化,重新加载分类
|
||||
watch(() => form.value.type, (newVal) => {
|
||||
// 如果是初始化过程中导致的类型变化,可能不需要清空分类(如果已经匹配好了)
|
||||
// 但如果是用户手动切换,应该清空
|
||||
// 这里简单处理:如果是用户切换,通常需要重新选择。
|
||||
// 为了避免初始化时的冲突,可以在 initForm 里处理好。
|
||||
// 这里主要响应用户操作。
|
||||
|
||||
// 只有当当前分类不属于新类型时才清空?或者总是清空?
|
||||
// 原逻辑是总是清空。
|
||||
// 但是如果是 initForm 引起的,我们不希望清空。
|
||||
// 暂时保持原逻辑,但在 initForm 里调用 loadClassifyList 后再设置 categoryName
|
||||
|
||||
// 简单的做法:在 watch 内部判断是否正在初始化?比较麻烦。
|
||||
// 或者:只在用户点击 radio 时触发?
|
||||
// watch 是最稳妥的,但要注意 initForm 里的顺序。
|
||||
// 在 initForm 里,我们先设置 type,这会触发 watch。
|
||||
// 所以 initForm 里的 loadClassifyList 可能会被 watch 里的覆盖。
|
||||
// 让我们调整一下策略:
|
||||
// 不在 watch 里加载,而是由 radio change 事件触发?
|
||||
// 或者在 watch 里判断,如果 categoryName 已经有值且符合当前类型(这个很难判断),就不清空。
|
||||
|
||||
// 实际上,initForm 里设置 type 后,watch 会执行。
|
||||
// watch 会清空 categoryName。
|
||||
// 所以 initForm 里设置 type 后,再设置 categoryName 是没用的,除非 watch 是异步的或者我们在 nextTick 设置。
|
||||
|
||||
// 让我们修改 watch 逻辑,或者在 initForm 里处理。
|
||||
// 更好的方式:
|
||||
// 移除 watch,改为在 radio group 上 @change="handleTypeChange"
|
||||
})
|
||||
|
||||
const handleTypeChange = (newType) => {
|
||||
categoryName.value = ''
|
||||
form.value.categoryId = null
|
||||
loadClassifyList(newType)
|
||||
}
|
||||
|
||||
const loadClassifyList = async (type = null) => {
|
||||
try {
|
||||
const response = await getCategoryList(type)
|
||||
if (response.success) {
|
||||
categoryList.value = response.data || []
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载分类列表出错:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const selectClassify = (item) => {
|
||||
categoryName.value = item.name
|
||||
form.value.categoryId = item.id
|
||||
}
|
||||
|
||||
const addNewClassify = async () => {
|
||||
if (!newClassify.value.trim()) {
|
||||
showToast('请输入分类名称')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const name = newClassify.value.trim()
|
||||
|
||||
// 调用API创建分类
|
||||
const response = await createCategory({
|
||||
name: name,
|
||||
type: form.value.type
|
||||
})
|
||||
|
||||
if (response.success) {
|
||||
showToast('分类创建成功')
|
||||
const newId = response.data
|
||||
// 重新加载分类列表
|
||||
await loadClassifyList(form.value.type)
|
||||
|
||||
// 选中新创建的分类
|
||||
categoryName.value = name
|
||||
form.value.categoryId = newId
|
||||
} else {
|
||||
showToast(response.message || '创建分类失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('创建分类出错:', error)
|
||||
showToast('创建分类失败')
|
||||
} finally {
|
||||
newClassify.value = ''
|
||||
showAddClassify.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const onConfirmDate = ({ selectedValues }) => {
|
||||
form.value.date = selectedValues.join('-')
|
||||
showDatePicker.value = false
|
||||
}
|
||||
|
||||
const onConfirmTime = ({ selectedValues }) => {
|
||||
form.value.time = selectedValues.join(':')
|
||||
showTimePicker.value = false
|
||||
}
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!form.value.amount) {
|
||||
showToast('请输入金额')
|
||||
return
|
||||
}
|
||||
if (!categoryName.value) {
|
||||
showToast('请选择分类')
|
||||
return
|
||||
}
|
||||
|
||||
const fullDateTime = `${form.value.date}T${form.value.time}:00`
|
||||
|
||||
const payload = {
|
||||
occurredAt: fullDateTime,
|
||||
classify: categoryName.value,
|
||||
amount: parseFloat(form.value.amount),
|
||||
reason: form.value.note || '',
|
||||
type: form.value.type
|
||||
}
|
||||
|
||||
emit('submit', payload)
|
||||
}
|
||||
|
||||
// 暴露重置方法给父组件
|
||||
const reset = () => {
|
||||
form.value.amount = ''
|
||||
form.value.note = ''
|
||||
// 保留日期和类型
|
||||
}
|
||||
|
||||
defineExpose({ reset })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.bill-form {
|
||||
padding-top: 10px;
|
||||
}
|
||||
.actions {
|
||||
margin: 20px 16px;
|
||||
}
|
||||
.classify-buttons {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
.classify-btn {
|
||||
flex: 0 0 auto;
|
||||
min-width: 70px;
|
||||
border-radius: 16px;
|
||||
}
|
||||
</style>
|
||||
50
Web/src/components/Bill/ManualBillAdd.vue
Normal file
50
Web/src/components/Bill/ManualBillAdd.vue
Normal file
@@ -0,0 +1,50 @@
|
||||
<template>
|
||||
<div class="manual-bill-add">
|
||||
<BillForm
|
||||
ref="billFormRef"
|
||||
:loading="saving"
|
||||
@submit="handleSave"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { showToast } from 'vant'
|
||||
import { createTransaction } from '@/api/transactionRecord'
|
||||
import BillForm from './BillForm.vue'
|
||||
|
||||
const emit = defineEmits(['success'])
|
||||
|
||||
const saving = ref(false)
|
||||
const billFormRef = ref(null)
|
||||
|
||||
const handleSave = async (payload) => {
|
||||
saving.value = true
|
||||
try {
|
||||
const res = await createTransaction(payload)
|
||||
|
||||
if (!res.success) {
|
||||
throw new Error(res.message || '保存失败')
|
||||
}
|
||||
|
||||
showToast('保存成功')
|
||||
// 重置表单
|
||||
if (billFormRef.value) {
|
||||
billFormRef.value.reset()
|
||||
}
|
||||
emit('success')
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
showToast('保存失败: ' + err.message)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.manual-bill-add {
|
||||
/* padding-top: 10px; */
|
||||
}
|
||||
</style>
|
||||
126
Web/src/components/Bill/OneLineBillAdd.vue
Normal file
126
Web/src/components/Bill/OneLineBillAdd.vue
Normal file
@@ -0,0 +1,126 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="input-section" v-if="!parseResult" style="margin: 12px 12px 0 16px;">
|
||||
<van-field
|
||||
v-model="text"
|
||||
type="textarea"
|
||||
rows="4"
|
||||
placeholder="例如:1月3日 晚餐 45.5 美团"
|
||||
class="bill-input"
|
||||
:disabled="parsing || saving"
|
||||
/>
|
||||
<div class="actions">
|
||||
<van-button
|
||||
type="primary"
|
||||
round
|
||||
block
|
||||
@click="handleParse"
|
||||
:loading="parsing"
|
||||
:disabled="!text.trim()"
|
||||
>
|
||||
智能解析
|
||||
</van-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="parseResult" class="result-section">
|
||||
<BillForm
|
||||
:initial-data="parseResult"
|
||||
:loading="saving"
|
||||
submit-text="确认保存"
|
||||
@submit="handleSave"
|
||||
>
|
||||
<template #actions>
|
||||
<van-button
|
||||
plain
|
||||
round
|
||||
block
|
||||
@click="parseResult = null"
|
||||
class="mt-2"
|
||||
>
|
||||
重新输入
|
||||
</van-button>
|
||||
</template>
|
||||
</BillForm>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { showToast } from 'vant'
|
||||
import BillForm from './BillForm.vue'
|
||||
import { createTransaction, parseOneLine } from '@/api/transactionRecord'
|
||||
|
||||
const emit = defineEmits(['success'])
|
||||
|
||||
const text = ref('')
|
||||
const parsing = ref(false)
|
||||
const saving = ref(false)
|
||||
const parseResult = ref(null)
|
||||
|
||||
const handleParse = async () => {
|
||||
if (!text.value.trim()) return
|
||||
|
||||
parsing.value = true
|
||||
parseResult.value = null
|
||||
|
||||
try {
|
||||
const res = await parseOneLine(text.value)
|
||||
if(!res.success){
|
||||
throw new Error(res.message || '解析失败')
|
||||
}
|
||||
|
||||
parseResult.value = res.data
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
showToast('解析失败:' + err.message)
|
||||
} finally {
|
||||
parsing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async (payload) => {
|
||||
saving.value = true
|
||||
try {
|
||||
const res = await createTransaction(payload)
|
||||
|
||||
if (!res.success) {
|
||||
throw new Error(res.message || '保存失败')
|
||||
}
|
||||
|
||||
showToast('保存成功')
|
||||
text.value = ''
|
||||
parseResult.value = null
|
||||
emit('success')
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
showToast('保存失败:' + err.message)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.bill-input {
|
||||
background-color: var(--van-background-2);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.mt-2 {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.preview-card {
|
||||
margin-bottom: 16px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
border: 1px solid #ebedf0;
|
||||
}
|
||||
</style>
|
||||
80
Web/src/components/Global/GlobalAddBill.vue
Normal file
80
Web/src/components/Global/GlobalAddBill.vue
Normal file
@@ -0,0 +1,80 @@
|
||||
<template>
|
||||
<div class="global-add-bill">
|
||||
<!-- Floating Add Bill Button -->
|
||||
<div class="floating-add" @click="openAddBill">
|
||||
<van-icon name="plus" />
|
||||
</div>
|
||||
|
||||
<!-- Add Bill Modal -->
|
||||
<PopupContainer
|
||||
v-model="showAddBill"
|
||||
title="记一笔"
|
||||
height="85%"
|
||||
>
|
||||
<van-tabs v-model:active="activeTab" shrink>
|
||||
<van-tab title="一句话录账" name="one">
|
||||
<OneLineBillAdd @success="handleSuccess" />
|
||||
</van-tab>
|
||||
<van-tab title="手动录账" name="manual">
|
||||
<ManualBillAdd @success="handleSuccess" />
|
||||
</van-tab>
|
||||
</van-tabs>
|
||||
</PopupContainer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, defineEmits } from 'vue'
|
||||
import PopupContainer from '@/components/PopupContainer.vue'
|
||||
import OneLineBillAdd from '@/components/Bill/OneLineBillAdd.vue'
|
||||
import ManualBillAdd from '@/components/Bill/ManualBillAdd.vue'
|
||||
|
||||
const emit = defineEmits(['success'])
|
||||
|
||||
const showAddBill = ref(false)
|
||||
const activeTab = ref('one')
|
||||
|
||||
const openAddBill = () => {
|
||||
showAddBill.value = true
|
||||
// Reset to default tab if needed, or keep last used
|
||||
// activeTab.value = 'one'
|
||||
}
|
||||
|
||||
const handleSuccess = () => {
|
||||
showAddBill.value = false
|
||||
|
||||
emit('success')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.floating-add {
|
||||
position: fixed;
|
||||
bottom: 80px; /* Above tabbar */
|
||||
right: 20px;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
background-color: var(--van-primary-color);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: 24px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
z-index: 999;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.floating-add:active {
|
||||
transform: scale(0.9);
|
||||
}
|
||||
|
||||
:deep(.van-tabs__wrap) {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
background-color: #fff;
|
||||
}
|
||||
</style>
|
||||
@@ -9,13 +9,20 @@
|
||||
<div class="popup-container">
|
||||
<!-- 头部区域 -->
|
||||
<div class="popup-header-fixed">
|
||||
<h3 class="popup-title">{{ title }}</h3>
|
||||
<!-- 标题行 (无子标题且有操作时使用 Grid 布局) -->
|
||||
<div class="header-title-row" :class="{ 'has-actions': !subtitle && hasActions }">
|
||||
<h3 class="popup-title">{{ title }}</h3>
|
||||
<!-- 无子标题时,操作按钮与标题同行 -->
|
||||
<div v-if="!subtitle && hasActions" class="header-actions-inline">
|
||||
<slot name="header-actions"></slot>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 子标题/统计信息 -->
|
||||
<div v-if="subtitle || hasActions" class="header-stats">
|
||||
<span v-if="subtitle" class="stats-text" v-html="subtitle" />
|
||||
<div v-if="subtitle" class="header-stats">
|
||||
<span class="stats-text" v-html="subtitle" />
|
||||
<!-- 额外操作插槽 -->
|
||||
<slot name="header-actions"></slot>
|
||||
<slot v-if="hasActions" name="header-actions"></slot>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -84,6 +91,24 @@ const hasActions = computed(() => !!slots['header-actions'])
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.header-title-row.has-actions {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.header-title-row.has-actions .popup-title {
|
||||
grid-column: 2;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.header-actions-inline {
|
||||
grid-column: 3;
|
||||
justify-self: end;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.popup-title {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
|
||||
@@ -134,6 +134,14 @@
|
||||
|
||||
<!-- 分类按钮网格 -->
|
||||
<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"
|
||||
@@ -144,14 +152,6 @@
|
||||
>
|
||||
{{ item.text }}
|
||||
</van-button>
|
||||
<van-button
|
||||
type="success"
|
||||
size="small"
|
||||
class="classify-btn"
|
||||
@click="showAddClassify = true"
|
||||
>
|
||||
+ 新增
|
||||
</van-button>
|
||||
<van-button
|
||||
v-if="batchForm.classify"
|
||||
type="warning"
|
||||
|
||||
@@ -1,18 +1,15 @@
|
||||
<template>
|
||||
<van-popup
|
||||
v-model:show="visible"
|
||||
position="bottom"
|
||||
:style="{ height: '85%' }"
|
||||
round
|
||||
closeable
|
||||
@update:show="handleVisibleChange"
|
||||
<PopupContainer
|
||||
v-model="visible"
|
||||
title="交易详情"
|
||||
height="85%"
|
||||
:closeable="false"
|
||||
>
|
||||
<div class="popup-container" v-if="transaction">
|
||||
<div class="popup-header-fixed">
|
||||
<h3>交易详情</h3>
|
||||
</div>
|
||||
|
||||
<div class="popup-scroll-content">
|
||||
<template #header-actions>
|
||||
<van-button size="small" type="primary" plain @click="handleOffsetClick">抵账</van-button>
|
||||
</template>
|
||||
|
||||
<div v-if="transaction">
|
||||
<van-form @submit="onSubmit" style="margin-top: 12px;">
|
||||
<van-cell-group inset>
|
||||
<van-cell title="卡号" :value="transaction.card" />
|
||||
@@ -67,6 +64,14 @@
|
||||
|
||||
<!-- 分类按钮网格 -->
|
||||
<div class="classify-buttons">
|
||||
<van-button
|
||||
type="success"
|
||||
size="small"
|
||||
class="classify-btn"
|
||||
@click="showAddClassify = true"
|
||||
>
|
||||
+ 新增
|
||||
</van-button>
|
||||
<van-button
|
||||
v-for="item in classifyColumns"
|
||||
:key="item.id"
|
||||
@@ -77,14 +82,6 @@
|
||||
>
|
||||
{{ item.text }}
|
||||
</van-button>
|
||||
<van-button
|
||||
type="success"
|
||||
size="small"
|
||||
class="classify-btn"
|
||||
@click="showAddClassify = true"
|
||||
>
|
||||
+ 新增
|
||||
</van-button>
|
||||
<van-button
|
||||
v-if="editForm.classify"
|
||||
type="warning"
|
||||
@@ -103,9 +100,8 @@
|
||||
</van-button>
|
||||
</div>
|
||||
</van-form>
|
||||
</div>
|
||||
</div>
|
||||
</van-popup>
|
||||
</PopupContainer>
|
||||
|
||||
<!-- 交易类型选择器 -->
|
||||
<van-popup v-model:show="showTypePicker" position="bottom" round>
|
||||
@@ -126,12 +122,33 @@
|
||||
>
|
||||
<van-field v-model="newClassify" placeholder="请输入新的交易分类" />
|
||||
</van-dialog>
|
||||
|
||||
<!-- 抵账候选列表弹窗 -->
|
||||
<PopupContainer
|
||||
v-model="showOffsetPopup"
|
||||
title="选择抵账交易"
|
||||
height="70%"
|
||||
>
|
||||
<van-list>
|
||||
<van-cell
|
||||
v-for="item in offsetCandidates"
|
||||
:key="item.id"
|
||||
:title="item.reason"
|
||||
:label="formatDate(item.occurredAt)"
|
||||
:value="item.amount"
|
||||
is-link
|
||||
@click="handleCandidateSelect(item)"
|
||||
/>
|
||||
<van-empty v-if="offsetCandidates.length === 0" description="暂无匹配的抵账交易" />
|
||||
</van-list>
|
||||
</PopupContainer>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, watch, defineProps, defineEmits } from 'vue'
|
||||
import { showToast } from 'vant'
|
||||
import { updateTransaction } from '@/api/transactionRecord'
|
||||
import { showToast, showConfirmDialog } from 'vant'
|
||||
import PopupContainer from '@/components/PopupContainer.vue'
|
||||
import { updateTransaction, getCandidatesForOffset, offsetTransactions } from '@/api/transactionRecord'
|
||||
import { getCategoryList, createCategory } from '@/api/transactionCategory'
|
||||
|
||||
const props = defineProps({
|
||||
@@ -207,10 +224,6 @@ watch(() => editForm.type, (newVal) => {
|
||||
loadClassifyList(newVal)
|
||||
})
|
||||
|
||||
const handleVisibleChange = (newVal) => {
|
||||
emit('update:show', newVal)
|
||||
}
|
||||
|
||||
// 加载分类列表
|
||||
const loadClassifyList = async (type = null) => {
|
||||
try {
|
||||
@@ -332,6 +345,51 @@ const formatDate = (dateString) => {
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
// 抵账相关
|
||||
const showOffsetPopup = ref(false)
|
||||
const offsetCandidates = ref([])
|
||||
|
||||
const handleOffsetClick = async () => {
|
||||
try {
|
||||
const res = await getCandidatesForOffset(editForm.id)
|
||||
if (res.success) {
|
||||
offsetCandidates.value = res.data || []
|
||||
showOffsetPopup.value = true
|
||||
} else {
|
||||
showToast(res.message || '获取抵账列表失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取抵账列表出错:', error)
|
||||
showToast('获取抵账列表失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleCandidateSelect = (candidate) => {
|
||||
showConfirmDialog({
|
||||
title: '确认抵账',
|
||||
message: `确认将当前交易与 "${candidate.reason}" (${candidate.amount}) 互相抵消吗?\n抵消后两笔交易将被删除。`,
|
||||
})
|
||||
.then(async () => {
|
||||
try {
|
||||
const res = await offsetTransactions(editForm.id, candidate.id)
|
||||
if (res.success) {
|
||||
showToast('抵账成功')
|
||||
showOffsetPopup.value = false
|
||||
visible.value = false
|
||||
emit('save') // 触发列表刷新
|
||||
} else {
|
||||
showToast(res.message || '抵账失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('抵账出错:', error)
|
||||
showToast('抵账失败')
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// on cancel
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -80,6 +80,14 @@
|
||||
|
||||
<!-- 分类按钮网格 -->
|
||||
<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"
|
||||
@@ -90,14 +98,6 @@
|
||||
>
|
||||
{{ item.name }}
|
||||
</van-button>
|
||||
<van-button
|
||||
type="success"
|
||||
size="small"
|
||||
class="classify-btn"
|
||||
@click="showAddClassify = true"
|
||||
>
|
||||
+ 新增
|
||||
</van-button>
|
||||
<van-button
|
||||
v-if="formData.classify"
|
||||
type="warning"
|
||||
|
||||
Reference in New Issue
Block a user