2026-01-01 14:43:43 +08:00
|
|
|
|
<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>
|
2026-01-03 11:07:33 +08:00
|
|
|
|
import { ref, onMounted, watch } from 'vue'
|
2026-01-01 14:43:43 +08:00
|
|
|
|
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>
|