fix
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 33s
Docker Build & Deploy / Deploy to Production (push) Successful in 7s
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 1s
Docker Build & Deploy / WeChat Notification (push) Successful in 1s

This commit is contained in:
SunCheng
2026-02-04 14:33:08 +08:00
parent 952c75bf08
commit 28c45e8e77
5 changed files with 1952 additions and 34 deletions

View File

@@ -0,0 +1,681 @@
<template>
<van-popup
v-model:show="visible"
position="bottom"
:style="{ height: 'auto', maxHeight: '85%', borderTopLeftRadius: '16px', borderTopRightRadius: '16px' }"
teleport="body"
@close="handleClose"
>
<div class="transaction-detail-sheet">
<!-- 头部 -->
<div class="sheet-header">
<div class="header-title">
交易详情
</div>
<van-icon
name="cross"
class="header-close"
@click="handleClose"
/>
</div>
<!-- 金额区域 -->
<div class="amount-section">
<div class="amount-label">
金额
</div>
<!-- 只读显示模式 -->
<div
v-if="!isEditingAmount"
class="amount-value"
@click="startEditAmount"
>
¥ {{ formatAmount(parseFloat(editForm.amount) || 0) }}
</div>
<!-- 编辑模式 -->
<div v-else class="amount-input-wrapper">
<span class="currency-symbol">¥</span>
<input
ref="amountInputRef"
v-model="editForm.amount"
type="number"
inputmode="decimal"
class="amount-input"
placeholder="0.00"
step="0.01"
min="0"
@blur="finishEditAmount"
>
</div>
</div>
<!-- 表单字段 -->
<div class="form-section">
<div class="form-row">
<div class="form-label">
时间
</div>
<div
class="form-value clickable"
@click="showDatePicker = true"
>
{{ formatDateTime(editForm.occurredAt) }}
</div>
</div>
<div class="form-row no-border">
<div class="form-label">
备注
</div>
<div class="form-value">
<input
v-model="editForm.reason"
type="text"
class="reason-input"
placeholder="请输入备注"
>
</div>
</div>
<div class="form-row">
<div class="form-label">
类型
</div>
<div class="form-value">
<van-radio-group
v-model="editForm.type"
direction="horizontal"
@change="handleTypeChange"
>
<van-radio
:name="0"
class="type-radio"
>
支出
</van-radio>
<van-radio
:name="1"
class="type-radio"
>
收入
</van-radio>
<van-radio
:name="2"
class="type-radio"
>
不计
</van-radio>
</van-radio-group>
</div>
</div>
<div class="form-row">
<div class="form-label">
分类
</div>
<div
class="form-value clickable"
@click="showClassifySelector = !showClassifySelector"
>
<span v-if="editForm.classify">{{ editForm.classify }}</span>
<span
v-else
class="placeholder"
>请选择分类</span>
</div>
</div>
</div>
<!-- 分类选择器展开/收起 -->
<div
v-if="showClassifySelector"
class="classify-section"
>
<ClassifySelector
v-model="editForm.classify"
:type="editForm.type"
:show-add="false"
:show-clear="false"
:show-all="false"
@change="handleClassifyChange"
/>
</div>
<!-- 操作按钮 -->
<div class="actions-section">
<van-button
class="delete-btn"
:loading="deleting"
@click="handleDelete"
>
删除
</van-button>
<van-button
class="save-btn"
type="primary"
:loading="saving"
@click="handleSave"
>
保存
</van-button>
</div>
</div>
<!-- 日期时间选择器 -->
<van-popup
v-model:show="showDatePicker"
position="bottom"
round
>
<van-datetime-picker
v-model="currentDateTime"
type="datetime"
title="选择日期时间"
:min-date="minDate"
:max-date="maxDate"
@confirm="handleDateTimeConfirm"
@cancel="showDatePicker = false"
/>
</van-popup>
</van-popup>
</template>
<script setup>
import { ref, reactive, watch, computed } from 'vue'
import { showToast, showDialog } from 'vant'
import dayjs from 'dayjs'
import ClassifySelector from '@/components/ClassifySelector.vue'
import {
updateTransaction,
deleteTransaction
} from '@/api/transactionRecord'
const props = defineProps({
show: {
type: Boolean,
default: false
},
transaction: {
type: Object,
default: null
}
})
const emit = defineEmits(['update:show', 'save', 'delete'])
const visible = ref(false)
const saving = ref(false)
const deleting = ref(false)
const showDatePicker = ref(false)
const showClassifySelector = ref(false)
const isEditingAmount = ref(false)
// 金额输入框引用
const amountInputRef = ref(null)
// 日期时间选择器配置
const minDate = new Date(2020, 0, 1)
const maxDate = new Date(2030, 11, 31)
const currentDateTime = ref(new Date())
// 编辑表单
const editForm = reactive({
id: 0,
amount: 0,
type: 0,
classify: '',
occurredAt: '',
reason: ''
})
// 监听 props 变化
watch(
() => props.show,
(newVal) => {
visible.value = newVal
}
)
watch(
() => props.transaction,
(newVal) => {
if (newVal) {
// 填充表单数据
editForm.id = newVal.id
editForm.amount = newVal.amount
editForm.type = newVal.type
editForm.classify = newVal.classify || ''
editForm.occurredAt = newVal.occurredAt
editForm.reason = newVal.reason || ''
// 初始化日期时间
if (newVal.occurredAt) {
currentDateTime.value = new Date(newVal.occurredAt)
}
// 收起分类选择器
showClassifySelector.value = false
}
}
)
watch(visible, (newVal) => {
emit('update:show', newVal)
if (!newVal) {
// 关闭时收起分类选择器
showClassifySelector.value = false
}
})
// 格式化金额显示
const formatAmount = (amount) => {
return Number(amount).toFixed(2)
}
// 开始编辑金额
const startEditAmount = () => {
isEditingAmount.value = true
// 自动聚焦输入框
setTimeout(() => {
amountInputRef.value?.focus()
// 选中所有文本,方便用户直接输入新值
amountInputRef.value?.select()
}, 100)
}
// 完成编辑金额
const finishEditAmount = () => {
// 验证并格式化金额
const parsed = parseFloat(editForm.amount)
editForm.amount = isNaN(parsed) || parsed < 0 ? 0 : parsed
isEditingAmount.value = false
}
// 格式化日期时间显示
const formatDateTime = (dateTime) => {
if (!dateTime) {return ''}
return dayjs(dateTime).format('YYYY-MM-DD HH:mm')
}
// 类型切换
const handleTypeChange = () => {
// 切换类型时清空分类,让用户重新选择
editForm.classify = ''
showClassifySelector.value = false
}
// 分类选择变化 - 自动保存
const handleClassifyChange = async () => {
if (editForm.id > 0 && editForm.classify) {
await handleSave()
}
}
// 日期时间确认
const handleDateTimeConfirm = (value) => {
editForm.occurredAt = dayjs(value).format('YYYY-MM-DDTHH:mm:ss')
currentDateTime.value = value
showDatePicker.value = false
}
// 保存修改
const handleSave = async () => {
try {
// 验证必填字段
if (!editForm.amount || editForm.amount <= 0) {
showToast('请输入有效金额')
return
}
if (!editForm.classify) {
showToast('请选择分类')
return
}
if (!editForm.occurredAt) {
showToast('请选择交易时间')
return
}
saving.value = true
const data = {
id: editForm.id,
amount: editForm.amount,
type: editForm.type,
classify: editForm.classify,
occurredAt: editForm.occurredAt,
reason: editForm.reason
}
const response = await updateTransaction(data)
if (response.success) {
showToast('保存成功')
emit('save', data)
visible.value = false
} else {
showToast(response.message || '保存失败')
}
} catch (error) {
console.error('保存出错:', error)
showToast('保存失败')
} finally {
saving.value = false
}
}
// 删除交易
const handleDelete = async () => {
showDialog({
title: '确认删除',
message: '确定要删除这条交易记录吗?删除后无法恢复。',
confirmButtonText: '删除',
cancelButtonText: '取消',
confirmButtonColor: '#EF4444'
}).then(async () => {
try {
deleting.value = true
const response = await deleteTransaction(editForm.id)
if (response.success) {
showToast('删除成功')
emit('delete', editForm.id)
visible.value = false
} else {
showToast(response.message || '删除失败')
}
} catch (error) {
console.error('删除出错:', error)
showToast('删除失败')
} finally {
deleting.value = false
}
}).catch(() => {
// 用户取消删除
})
}
// 关闭弹窗
const handleClose = () => {
visible.value = false
}
</script>
<style scoped lang="scss">
.transaction-detail-sheet {
background: #FFFFFF;
padding: 24px;
display: flex;
flex-direction: column;
gap: 24px;
.sheet-header {
display: flex;
justify-content: space-between;
align-items: center;
.header-title {
font-family: Inter, sans-serif;
font-size: 18px;
font-weight: 600;
color: #09090B;
}
.header-close {
font-size: 24px;
color: #71717A;
cursor: pointer;
}
}
.amount-section {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 16px 0;
.amount-label {
font-family: Inter, sans-serif;
font-size: 14px;
font-weight: normal;
color: #71717A;
}
.amount-value {
font-family: Inter, sans-serif;
font-size: 32px;
font-weight: 700;
color: #09090B;
cursor: pointer;
user-select: none;
transition: opacity 0.2s;
&:active {
opacity: 0.7;
}
}
.amount-input-wrapper {
display: flex;
align-items: center;
gap: 8px;
.currency-symbol {
font-size: 32px;
font-weight: 700;
color: #09090B;
}
.amount-input {
max-width: 200px;
font-size: 32px;
font-weight: 700;
color: #09090B;
border: none;
outline: none;
background: transparent;
text-align: center;
padding: 8px 0;
border-bottom: 2px solid #E4E4E7;
transition: border-color 0.3s;
&:focus {
border-bottom-color: #6366F1;
}
&::placeholder {
color: #A1A1AA;
}
// 移除 number 类型的上下箭头
&::-webkit-outer-spin-button,
&::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
// Firefox
&[type='number'] {
-moz-appearance: textfield;
}
}
}
}
.form-section {
display: flex;
flex-direction: column;
gap: 16px;
.form-row {
display: flex;
justify-content: space-between;
align-items: center;
height: 48px;
border-bottom: 1px solid #E4E4E7;
&.no-border {
border-bottom: none;
}
.form-label {
font-family: Inter, sans-serif;
font-size: 16px;
font-weight: normal;
color: #71717A;
}
.form-value {
font-family: Inter, sans-serif;
font-size: 16px;
font-weight: normal;
color: #09090B;
text-align: right;
flex: 1;
margin-left: 16px;
&.clickable {
cursor: pointer;
user-select: none;
transition: opacity 0.2s;
&:active {
opacity: 0.7;
}
}
.placeholder {
color: #A1A1AA;
}
.reason-input {
width: 100%;
border: none;
outline: none;
text-align: right;
font-family: Inter, sans-serif;
font-size: 16px;
color: #09090B;
background: transparent;
&::placeholder {
color: #A1A1AA;
}
}
:deep(.van-radio-group) {
display: flex;
gap: 16px;
justify-content: flex-end;
}
:deep(.van-radio) {
margin: 0;
}
:deep(.van-radio__label) {
margin-left: 4px;
}
}
}
}
.classify-section {
padding: 16px;
background: #F4F4F5;
border-radius: 8px;
margin-top: -8px;
}
.actions-section {
display: flex;
gap: 16px;
width: 100%;
.delete-btn {
flex: 1;
height: 48px;
border-radius: 8px;
border: 1px solid #EF4444;
background: transparent;
color: #EF4444;
font-family: Inter, sans-serif;
font-size: 16px;
font-weight: 500;
}
.save-btn {
flex: 1;
height: 48px;
border-radius: 8px;
background: #6366F1;
color: #FAFAFA;
font-family: Inter, sans-serif;
font-size: 16px;
font-weight: 500;
}
}
}
// 暗色模式
@media (prefers-color-scheme: dark) {
.transaction-detail-sheet {
background: #18181B;
.sheet-header {
.header-title {
color: #FAFAFA;
}
.header-close {
color: #A1A1AA;
}
}
.amount-section {
.amount-label {
color: #A1A1AA;
}
.amount-value {
color: #FAFAFA;
}
.amount-input-wrapper {
.currency-symbol {
color: #FAFAFA;
}
.amount-input {
color: #FAFAFA;
border-bottom-color: #27272A;
&:focus {
border-bottom-color: #6366F1;
}
}
}
}
.form-section {
.form-row {
border-bottom-color: #27272A;
.form-label {
color: #A1A1AA;
}
.form-value {
color: #FAFAFA;
.reason-input {
color: #FAFAFA;
}
}
}
}
.classify-section {
background: #27272A;
}
}
}
</style>