Files
test/uniapp/components/BillEditorPopup.vue
T
2026-06-11 09:53:11 +08:00

343 lines
6.8 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>
<view v-if="visible" class="popup-shell" @touchmove.stop.prevent="">
<view class="popup-mask" @click="emit('close')"></view>
<view class="surface-card popup-panel">
<view class="popup-head">
<view>
<text class="section-title">{{ form.id ? '编辑账单' : '新增账单' }}</text>
<text class="section-subtitle">3 步完成记录所有数据仅保存在本机</text>
</view>
<text class="close-text" @click="emit('close')">关闭</text>
</view>
<scroll-view scroll-y class="popup-body">
<view class="field-block">
<text class="field-label">收支类型</text>
<view class="pill-row">
<view
v-for="item in typeOptions"
:key="item.value"
class="pill-button"
:class="{ active: form.type === item.value }"
@click="form.type = item.value"
>
{{ item.label }}
</view>
</view>
</view>
<view class="field-block">
<text class="field-label">金额</text>
<view class="input-shell amount-shell">
<text class="prefix-text">¥</text>
<input v-model="form.amount" type="digit" placeholder="输入金额" />
</view>
</view>
<view class="field-block">
<text class="field-label">分类</text>
<view class="chip-grid">
<view
v-for="item in currentCategories"
:key="item.id"
class="chip-item"
:class="{ active: form.categoryId === item.id }"
@click="form.categoryId = item.id"
>
<view class="chip-dot" :style="{ background: item.color }"></view>
<text>{{ item.name }}</text>
</view>
</view>
</view>
<view class="field-block">
<text class="field-label">账户</text>
<view class="chip-grid">
<view
v-for="item in accounts"
:key="item.id"
class="chip-item"
:class="{ active: form.accountId === item.id }"
@click="form.accountId = item.id"
>
<view class="chip-dot" :style="{ background: item.color }"></view>
<text>{{ item.name }}</text>
</view>
</view>
</view>
<view class="field-block">
<text class="field-label">日期</text>
<picker mode="date" :value="form.date" @change="onDateChange">
<view class="input-shell picker-shell">
<text>{{ form.date }}</text>
<text class="tiny-text">选择</text>
</view>
</picker>
</view>
<view class="field-block">
<text class="field-label">备注</text>
<view class="input-shell textarea-shell">
<textarea
v-model="form.note"
maxlength="40"
placeholder="补充说明,便于后续搜索"
></textarea>
</view>
</view>
</scroll-view>
<view class="popup-foot">
<view class="ghost-button" @click="emit('close')">取消</view>
<view class="primary-button save-button" @click="handleSave">保存账单</view>
</view>
</view>
</view>
</template>
<script setup>
import { computed, reactive, watch } from 'vue'
import { toDateKey } from '../utils/date'
const props = defineProps({
visible: {
type: Boolean,
default: false
},
entry: {
type: Object,
default: null
},
categories: {
type: Object,
required: true
},
accounts: {
type: Array,
required: true
},
defaultType: {
type: String,
default: 'expense'
},
initialCategoryId: {
type: String,
default: ''
}
})
const emit = defineEmits(['close', 'save'])
const typeOptions = [
{ label: '支出', value: 'expense' },
{ label: '收入', value: 'income' }
]
const form = reactive({
id: '',
type: 'expense',
amount: '',
categoryId: '',
accountId: '',
date: toDateKey(),
note: '',
createdAt: 0
})
const currentCategories = computed(() => props.categories[form.type] || [])
function hydrateForm() {
const source = props.entry || {}
const nextType = source.type || props.defaultType || 'expense'
const defaultCategory = props.initialCategoryId && (props.categories[nextType] || []).some((item) => item.id === props.initialCategoryId)
? props.initialCategoryId
: (props.categories[nextType]?.[0]?.id || '')
form.id = source.id || ''
form.type = nextType
form.amount = source.amount ? String(source.amount) : ''
form.categoryId = source.categoryId || defaultCategory
form.accountId = source.accountId || props.accounts[0]?.id || ''
form.date = source.date || toDateKey()
form.note = source.note || ''
form.createdAt = source.createdAt || 0
}
watch(
() => props.visible,
(visible) => {
if (visible) {
hydrateForm()
}
},
{ immediate: true }
)
watch(
() => form.type,
(nextType) => {
const availableIds = (props.categories[nextType] || []).map((item) => item.id)
if (!availableIds.includes(form.categoryId)) {
form.categoryId = availableIds[0] || ''
}
}
)
function onDateChange(event) {
form.date = event.detail.value
}
function handleSave() {
if (!Number(form.amount)) {
uni.showToast({
title: '请输入有效金额',
icon: 'none'
})
return
}
if (!form.categoryId || !form.accountId) {
uni.showToast({
title: '请选择分类和账户',
icon: 'none'
})
return
}
emit('save', {
id: form.id,
type: form.type,
amount: Number(form.amount),
categoryId: form.categoryId,
accountId: form.accountId,
date: form.date,
note: form.note.trim(),
createdAt: form.createdAt
})
emit('close')
}
</script>
<style lang="scss" scoped>
.popup-shell {
position: fixed;
inset: 0;
z-index: 50;
}
.popup-mask {
position: absolute;
inset: 0;
background: rgba(3, 12, 21, 0.42);
}
.popup-panel {
position: absolute;
left: 16rpx;
right: 16rpx;
bottom: 16rpx;
max-height: 84vh;
padding: 28rpx 28rpx 32rpx;
display: flex;
flex-direction: column;
gap: 24rpx;
}
.popup-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16rpx;
}
.close-text {
padding: 10rpx 0;
font-size: 24rpx;
color: var(--text-secondary);
}
.popup-body {
max-height: 58vh;
}
.field-block {
display: flex;
flex-direction: column;
gap: 18rpx;
margin-bottom: 24rpx;
}
.field-label {
font-size: 26rpx;
font-weight: 600;
color: var(--text-primary);
}
.pill-row,
.chip-grid {
display: flex;
flex-wrap: wrap;
gap: 16rpx;
}
.chip-item {
display: flex;
align-items: center;
gap: 10rpx;
padding: 16rpx 20rpx;
border-radius: 22rpx;
background: var(--surface-muted);
color: var(--text-secondary);
font-size: 24rpx;
}
.chip-item.active {
background: var(--brand-soft);
color: var(--text-primary);
}
.chip-dot {
width: 14rpx;
height: 14rpx;
border-radius: 50%;
}
.amount-shell {
gap: 12rpx;
}
.prefix-text {
font-size: 36rpx;
font-weight: 600;
color: var(--text-primary);
}
.picker-shell {
justify-content: space-between;
}
.textarea-shell {
padding: 20rpx 24rpx;
min-height: 160rpx;
align-items: flex-start;
}
.textarea-shell textarea {
min-height: 120rpx;
}
.popup-foot {
display: flex;
align-items: center;
gap: 16rpx;
}
.popup-foot .ghost-button {
flex: 0 0 180rpx;
}
.save-button {
flex: 1;
}
</style>