171 lines
6.4 KiB
Vue
171 lines
6.4 KiB
Vue
<script setup lang="ts">
|
||
import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
|
||
import Icon from './Icon.vue'
|
||
import { getLedgerCategories, setLedgerCategories } from '../db/categories'
|
||
|
||
const props = defineProps<{
|
||
open: boolean
|
||
title: string
|
||
initial: { type: string; category: string; amountYuan: string; date: string; note: string; reimbursable: number; reimbursed: number }
|
||
}>()
|
||
|
||
const emit = defineEmits<{
|
||
(e: 'cancel'): void
|
||
(e: 'save', payload: { type: string; category: string; amount: number; date: string; note: string; reimbursable: number; reimbursed: number }): void
|
||
}>()
|
||
|
||
const fType = ref<'expense' | 'income'>('expense')
|
||
const fCategory = ref('')
|
||
const fAmount = ref('')
|
||
const fDate = ref('')
|
||
const fNote = ref('')
|
||
const fReimbursable = ref(false)
|
||
const fReimbursed = ref(false)
|
||
const fError = ref('')
|
||
const amountInput = ref<HTMLInputElement | null>(null)
|
||
|
||
function onKey(e: KeyboardEvent) {
|
||
if (!props.open) return
|
||
if (e.key === 'Escape') emit('cancel')
|
||
}
|
||
watch(() => props.open, async v => {
|
||
if (v) {
|
||
await loadCats()
|
||
fType.value = (props.initial.type === 'income' ? 'income' : 'expense')
|
||
fCategory.value = props.initial.category
|
||
fAmount.value = props.initial.amountYuan
|
||
fDate.value = props.initial.date
|
||
fNote.value = props.initial.note
|
||
fReimbursable.value = props.initial.reimbursable === 1
|
||
fReimbursed.value = props.initial.reimbursed === 1
|
||
fError.value = ''
|
||
window.addEventListener('keydown', onKey)
|
||
await nextTick()
|
||
amountInput.value?.focus()
|
||
} else {
|
||
window.removeEventListener('keydown', onKey)
|
||
}
|
||
})
|
||
onUnmounted(() => window.removeEventListener('keydown', onKey))
|
||
|
||
// 切换收支类型后:重新拉取对应分类列表,并回落到第一项
|
||
watch(fType, async () => {
|
||
await loadCats()
|
||
fCategory.value = categoryOptions.value[0] || ''
|
||
})
|
||
|
||
const categoryOptions = ref<string[]>([])
|
||
async function loadCats() {
|
||
categoryOptions.value = await getLedgerCategories(fType.value)
|
||
}
|
||
const newCat = ref('')
|
||
async function addCategory() {
|
||
const name = newCat.value.trim()
|
||
if (!name) return
|
||
if (categoryOptions.value.includes(name)) { newCat.value = ''; return }
|
||
categoryOptions.value.push(name)
|
||
await setLedgerCategories(fType.value, categoryOptions.value)
|
||
fCategory.value = name
|
||
newCat.value = ''
|
||
}
|
||
function computedCategory() {
|
||
return (fCategory.value && categoryOptions.value.includes(fCategory.value)) ? fCategory.value : categoryOptions.value[0]
|
||
}
|
||
|
||
function save() {
|
||
const amountText = String(fAmount.value ?? '').trim()
|
||
const v = Number(amountText)
|
||
if (!amountText || !isFinite(v) || v <= 0) {
|
||
fError.value = '请填写大于 0 的金额'
|
||
return
|
||
}
|
||
if (!fDate.value) {
|
||
fError.value = '请选择日期'
|
||
return
|
||
}
|
||
const reimbursable = fType.value === 'expense' && fReimbursable.value ? 1 : 0
|
||
emit('save', {
|
||
type: fType.value,
|
||
category: computedCategory(),
|
||
amount: Math.round(v * 100),
|
||
date: fDate.value,
|
||
note: String(fNote.value ?? '').trim(),
|
||
reimbursable,
|
||
reimbursed: reimbursable && fReimbursed.value ? 1 : 0
|
||
})
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<teleport to="body">
|
||
<transition name="modal">
|
||
<div v-if="open" class="mask" @click.self="emit('cancel')">
|
||
<div class="dialog" role="dialog" :aria-label="title">
|
||
<div class="dialog-head">
|
||
<span class="dialog-title">{{ title }}</span>
|
||
<button class="icon-btn" title="关闭" @click="emit('cancel')">
|
||
<Icon name="close" :size="16" />
|
||
</button>
|
||
</div>
|
||
<div class="dialog-body">
|
||
<div class="form-grid">
|
||
<div class="field span2">
|
||
<label>类型</label>
|
||
<div class="seg">
|
||
<button class="seg-btn" :class="{ active: fType === 'expense' }" @click="fType = 'expense'">支出</button>
|
||
<button class="seg-btn" :class="{ active: fType === 'income' }" @click="fType = 'income'">收入</button>
|
||
</div>
|
||
</div>
|
||
<div class="field">
|
||
<label>金额(元,必填)</label>
|
||
<input ref="amountInput" class="input" :class="{ invalid: fError }" v-model="fAmount" type="number" min="0" step="0.01" placeholder="0.00" />
|
||
</div>
|
||
<div class="field">
|
||
<label>日期(必填)</label>
|
||
<input class="input" type="date" v-model="fDate" />
|
||
</div>
|
||
<div class="field">
|
||
<label>分类(可直接新增)</label>
|
||
<select class="select" v-model="fCategory">
|
||
<option v-for="c in categoryOptions" :key="c" :value="c">{{ c }}</option>
|
||
</select>
|
||
<div class="add-cat">
|
||
<input class="input add-cat-input" v-model="newCat" placeholder="新增分类名" @keydown.enter.prevent="addCategory" />
|
||
<button class="btn btn-xs" type="button" @click="addCategory">添加</button>
|
||
</div>
|
||
</div>
|
||
<div class="field" v-if="fType === 'expense'">
|
||
<label>报销状态</label>
|
||
<div class="seg">
|
||
<button class="seg-btn" :class="{ active: !fReimbursable }" @click="fReimbursable = false; fReimbursed = false">无需报销</button>
|
||
<button class="seg-btn" :class="{ active: fReimbursable && !fReimbursed }" @click="fReimbursable = true; fReimbursed = false">待报销</button>
|
||
<button class="seg-btn" :class="{ active: fReimbursable && fReimbursed }" @click="fReimbursable = true; fReimbursed = true">已报销</button>
|
||
</div>
|
||
</div>
|
||
<div class="field span2">
|
||
<label>备注</label>
|
||
<textarea class="textarea" v-model="fNote" placeholder="选填"></textarea>
|
||
</div>
|
||
</div>
|
||
<div v-if="fError" class="form-error">{{ fError }}</div>
|
||
</div>
|
||
<div class="dialog-btns">
|
||
<button class="btn btn-sm" @click="emit('cancel')">取消</button>
|
||
<button class="btn btn-primary btn-sm" @click="save">保存</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</transition>
|
||
</teleport>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.dialog { width: 480px; }
|
||
.add-cat { display: flex; gap: 6px; margin-top: 6px; }
|
||
.add-cat-input { flex: 1; height: 28px; font-size: 12px; }
|
||
@media (max-width: 720px) {
|
||
.add-cat-input { height: 34px; }
|
||
.add-cat .btn { height: 34px; }
|
||
}
|
||
</style>
|