- 使用 shadcn-ui 的 Calendar + Popover 替换原生 datetime-local - 根据收支类型动态切换分类选项(支出/收入分类) - 切换收支类型时自动清空已选分类 - 收入模式下隐藏支付方式和交易状态输入框 - 调整表单布局为 1:1 两列
454 lines
15 KiB
Svelte
454 lines
15 KiB
Svelte
<script lang="ts">
|
||
import * as Card from '$lib/components/ui/card';
|
||
import * as Dialog from '$lib/components/ui/dialog';
|
||
import * as Select from '$lib/components/ui/select';
|
||
import { Button } from '$lib/components/ui/button';
|
||
import { Input } from '$lib/components/ui/input';
|
||
import { Label } from '$lib/components/ui/label';
|
||
import { Badge } from '$lib/components/ui/badge';
|
||
import * as Table from '$lib/components/ui/table';
|
||
import { DateTimePicker } from '$lib/components/ui/date-time-picker';
|
||
import { createManualBills, type ManualBillInput } from '$lib/api';
|
||
import { expenseCategories, incomeCategories } from '$lib/data/categories';
|
||
import Plus from '@lucide/svelte/icons/plus';
|
||
import Trash2 from '@lucide/svelte/icons/trash-2';
|
||
import Send from '@lucide/svelte/icons/send';
|
||
import Loader2 from '@lucide/svelte/icons/loader-2';
|
||
import CheckCircle from '@lucide/svelte/icons/check-circle';
|
||
import AlertCircle from '@lucide/svelte/icons/alert-circle';
|
||
|
||
interface Props {
|
||
onSuccess?: () => void;
|
||
}
|
||
|
||
let { onSuccess }: Props = $props();
|
||
|
||
// 获取当前日期时间(格式化为 YYYY-MM-DDTHH:mm)
|
||
function getCurrentDateTimeLocal(): string {
|
||
const now = new Date();
|
||
const year = now.getFullYear();
|
||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||
const day = String(now.getDate()).padStart(2, '0');
|
||
const hours = String(now.getHours()).padStart(2, '0');
|
||
const minutes = String(now.getMinutes()).padStart(2, '0');
|
||
return `${year}-${month}-${day}T${hours}:${minutes}`;
|
||
}
|
||
|
||
// 将 datetime-local 格式转换为标准格式 YYYY-MM-DD HH:mm:ss
|
||
function convertDateTimeLocal(dateTimeLocal: string): string {
|
||
if (!dateTimeLocal) return '';
|
||
const [date, time] = dateTimeLocal.split('T');
|
||
return `${date} ${time}:00`;
|
||
}
|
||
|
||
// 表单状态
|
||
let formData = $state({
|
||
time: getCurrentDateTime(),
|
||
category: '',
|
||
merchant: '',
|
||
description: '',
|
||
income_expense: '支出',
|
||
amount: '',
|
||
pay_method: '',
|
||
status: '交易成功',
|
||
remark: '',
|
||
});
|
||
|
||
// 根据收支类型动态计算分类列表
|
||
let currentCategories = $derived(
|
||
formData.income_expense === '收入'
|
||
? [...incomeCategories]
|
||
: [...expenseCategories]
|
||
);
|
||
|
||
// 待提交的账单列表
|
||
let pendingBills = $state<ManualBillInput[]>([]);
|
||
|
||
// 提交状态
|
||
let isSubmitting = $state(false);
|
||
let submitResult = $state<{ success: number; failed: number; duplicates: number } | null>(null);
|
||
let submitError = $state('');
|
||
let showResult = $state(false);
|
||
|
||
// Select 变化处理
|
||
function handleCategoryChange(value: string | undefined) {
|
||
if (value !== undefined) {
|
||
formData.category = value;
|
||
}
|
||
}
|
||
|
||
function handleIncomeExpenseChange(value: string | undefined) {
|
||
if (value !== undefined) {
|
||
formData.income_expense = value;
|
||
// 切换收支类型时清空已选分类(因为分类列表会变化)
|
||
formData.category = '';
|
||
}
|
||
}
|
||
|
||
// 获取当前日期时间(格式化为 YYYY-MM-DD HH:mm:ss)
|
||
function getCurrentDateTime(): string {
|
||
const now = new Date();
|
||
const year = now.getFullYear();
|
||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||
const day = String(now.getDate()).padStart(2, '0');
|
||
const hours = String(now.getHours()).padStart(2, '0');
|
||
const minutes = String(now.getMinutes()).padStart(2, '0');
|
||
const seconds = String(now.getSeconds()).padStart(2, '0');
|
||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
||
}
|
||
|
||
// 处理日期时间变化
|
||
function handleDateTimeChange(value: string) {
|
||
formData.time = value;
|
||
}
|
||
|
||
// 验证表单
|
||
function validateForm(): string | null {
|
||
if (!formData.time) return '请输入交易时间';
|
||
if (!formData.category) return '请选择分类';
|
||
if (!formData.income_expense) return '请选择收支类型';
|
||
if (!formData.amount || parseFloat(formData.amount) <= 0) return '请输入有效金额';
|
||
return null;
|
||
}
|
||
|
||
// 添加到待提交列表
|
||
function addBill() {
|
||
const error = validateForm();
|
||
if (error) {
|
||
alert(error);
|
||
return;
|
||
}
|
||
|
||
const bill: ManualBillInput = {
|
||
time: formData.time,
|
||
category: formData.category,
|
||
merchant: formData.merchant,
|
||
description: formData.description || undefined,
|
||
income_expense: formData.income_expense,
|
||
amount: parseFloat(formData.amount),
|
||
pay_method: formData.pay_method || undefined,
|
||
status: formData.status || undefined,
|
||
remark: formData.remark || undefined,
|
||
};
|
||
|
||
pendingBills = [...pendingBills, bill];
|
||
|
||
// 清空表单(保留分类和收支类型)
|
||
const savedCategory = formData.category;
|
||
const savedIncomeExpense = formData.income_expense;
|
||
formData = {
|
||
time: getCurrentDateTime(),
|
||
category: savedCategory,
|
||
merchant: '',
|
||
description: '',
|
||
income_expense: savedIncomeExpense,
|
||
amount: '',
|
||
pay_method: '',
|
||
status: '交易成功',
|
||
remark: '',
|
||
};
|
||
}
|
||
|
||
// 删除待提交的账单
|
||
function removeBill(index: number) {
|
||
pendingBills = pendingBills.filter((_, i) => i !== index);
|
||
}
|
||
|
||
// 提交所有账单
|
||
async function submitAllBills() {
|
||
if (pendingBills.length === 0) {
|
||
alert('请至少添加一条账单');
|
||
return;
|
||
}
|
||
|
||
isSubmitting = true;
|
||
submitError = '';
|
||
submitResult = null;
|
||
|
||
try {
|
||
const response = await createManualBills(pendingBills);
|
||
if (response.result && response.data) {
|
||
submitResult = response.data;
|
||
showResult = true;
|
||
|
||
// 清空待提交列表
|
||
pendingBills = [];
|
||
|
||
// 调用成功回调
|
||
if (onSuccess) {
|
||
onSuccess();
|
||
}
|
||
} else {
|
||
submitError = response.message || '提交失败';
|
||
}
|
||
} catch (err) {
|
||
submitError = err instanceof Error ? err.message : '提交失败';
|
||
} finally {
|
||
isSubmitting = false;
|
||
}
|
||
}
|
||
|
||
// 清空所有
|
||
function clearAll() {
|
||
if (confirm('确定要清空所有待提交的账单吗?')) {
|
||
pendingBills = [];
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||
<!-- 左侧:输入表单 -->
|
||
<Card.Root>
|
||
<Card.Header>
|
||
<Card.Title class="flex items-center gap-2">
|
||
<Plus class="h-5 w-5" />
|
||
手动添加账单
|
||
</Card.Title>
|
||
<Card.Description>填写账单信息,可以连续添加多条</Card.Description>
|
||
</Card.Header>
|
||
<Card.Content>
|
||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||
<!-- 交易时间 -->
|
||
<div class="space-y-2 sm:col-span-2">
|
||
<Label for="time">交易时间 *</Label>
|
||
<DateTimePicker
|
||
bind:value={formData.time}
|
||
onchange={handleDateTimeChange}
|
||
placeholder="选择交易时间"
|
||
/>
|
||
</div>
|
||
|
||
<!-- 分类 -->
|
||
<div class="space-y-2">
|
||
<Label for="category">分类 *</Label>
|
||
<Select.Root type="single" value={formData.category} onValueChange={handleCategoryChange}>
|
||
<Select.Trigger class="w-full">
|
||
<span>{formData.category || '请选择分类'}</span>
|
||
</Select.Trigger>
|
||
<Select.Portal>
|
||
<Select.Content>
|
||
{#each currentCategories as cat}
|
||
<Select.Item value={cat}>{cat}</Select.Item>
|
||
{/each}
|
||
</Select.Content>
|
||
</Select.Portal>
|
||
</Select.Root>
|
||
</div>
|
||
|
||
<!-- 收支类型 -->
|
||
<div class="space-y-2">
|
||
<Label for="income_expense">收/支 *</Label>
|
||
<Select.Root type="single" value={formData.income_expense} onValueChange={handleIncomeExpenseChange}>
|
||
<Select.Trigger class="w-full">
|
||
<span>{formData.income_expense}</span>
|
||
</Select.Trigger>
|
||
<Select.Portal>
|
||
<Select.Content>
|
||
<Select.Item value="支出">支出</Select.Item>
|
||
<Select.Item value="收入">收入</Select.Item>
|
||
</Select.Content>
|
||
</Select.Portal>
|
||
</Select.Root>
|
||
</div>
|
||
|
||
<!-- 交易对方 -->
|
||
<div class="space-y-2">
|
||
<Label for="merchant">交易对方</Label>
|
||
<Input
|
||
id="merchant"
|
||
type="text"
|
||
placeholder="商家名称(可选)"
|
||
bind:value={formData.merchant}
|
||
/>
|
||
</div>
|
||
|
||
<!-- 金额 -->
|
||
<div class="space-y-2">
|
||
<Label for="amount">金额 *</Label>
|
||
<Input
|
||
id="amount"
|
||
type="number"
|
||
step="0.01"
|
||
placeholder="0.00"
|
||
bind:value={formData.amount}
|
||
/>
|
||
</div>
|
||
|
||
<!-- 商品说明 -->
|
||
<div class="space-y-2 sm:col-span-2">
|
||
<Label for="description">{formData.income_expense === '收入' ? '收入说明' : '商品说明'}</Label>
|
||
<Input
|
||
id="description"
|
||
type="text"
|
||
placeholder={formData.income_expense === '收入' ? '收入来源或说明' : '购买的商品或服务'}
|
||
bind:value={formData.description}
|
||
/>
|
||
</div>
|
||
|
||
{#if formData.income_expense === '支出'}
|
||
<!-- 支付方式 -->
|
||
<div class="space-y-2">
|
||
<Label for="pay_method">支付方式</Label>
|
||
<Input
|
||
id="pay_method"
|
||
type="text"
|
||
placeholder="现金/银行卡/支付宝等"
|
||
bind:value={formData.pay_method}
|
||
/>
|
||
</div>
|
||
|
||
<!-- 交易状态 -->
|
||
<div class="space-y-2">
|
||
<Label for="status">交易状态</Label>
|
||
<Input
|
||
id="status"
|
||
type="text"
|
||
placeholder="交易成功"
|
||
bind:value={formData.status}
|
||
/>
|
||
</div>
|
||
{/if}
|
||
|
||
<!-- 备注 -->
|
||
<div class="space-y-2 sm:col-span-2">
|
||
<Label for="remark">备注</Label>
|
||
<Input
|
||
id="remark"
|
||
type="text"
|
||
placeholder="其他说明"
|
||
bind:value={formData.remark}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</Card.Content>
|
||
<Card.Footer>
|
||
<Button class="w-full" onclick={addBill}>
|
||
<Plus class="mr-2 h-4 w-4" />
|
||
添加到列表
|
||
</Button>
|
||
</Card.Footer>
|
||
</Card.Root>
|
||
|
||
<!-- 右侧:待提交列表 -->
|
||
<Card.Root>
|
||
<Card.Header>
|
||
<div class="flex items-center justify-between">
|
||
<div>
|
||
<Card.Title>待提交列表</Card.Title>
|
||
<Card.Description>
|
||
{pendingBills.length} 条账单
|
||
</Card.Description>
|
||
</div>
|
||
{#if pendingBills.length > 0}
|
||
<Button variant="ghost" size="sm" onclick={clearAll}>
|
||
清空
|
||
</Button>
|
||
{/if}
|
||
</div>
|
||
</Card.Header>
|
||
<Card.Content>
|
||
{#if pendingBills.length === 0}
|
||
<div class="flex flex-col items-center justify-center py-8 text-center text-muted-foreground">
|
||
<Plus class="h-12 w-12 mb-2 opacity-30" />
|
||
<p class="text-sm">暂无账单</p>
|
||
</div>
|
||
{:else}
|
||
<div class="space-y-2 max-h-[500px] overflow-y-auto">
|
||
{#each pendingBills as bill, index}
|
||
<div class="border rounded-lg p-3 space-y-1">
|
||
<div class="flex items-start justify-between gap-2">
|
||
<div class="flex-1 min-w-0">
|
||
<div class="flex items-center gap-2 mb-1">
|
||
<Badge variant="outline" class="text-xs">
|
||
{bill.category}
|
||
</Badge>
|
||
<Badge
|
||
variant={bill.income_expense === '支出' ? 'destructive' : 'default'}
|
||
class="text-xs"
|
||
>
|
||
{bill.income_expense}
|
||
</Badge>
|
||
</div>
|
||
<p class="font-medium truncate">{bill.merchant}</p>
|
||
{#if bill.description}
|
||
<p class="text-xs text-muted-foreground truncate">{bill.description}</p>
|
||
{/if}
|
||
<div class="flex items-center justify-between mt-1">
|
||
<p class="text-sm text-muted-foreground">{bill.time}</p>
|
||
<p class="font-mono font-semibold">¥{bill.amount.toFixed(2)}</p>
|
||
</div>
|
||
</div>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
class="h-8 w-8"
|
||
onclick={() => removeBill(index)}
|
||
>
|
||
<Trash2 class="h-4 w-4 text-destructive" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
{/each}
|
||
</div>
|
||
{/if}
|
||
|
||
{#if submitError}
|
||
<div class="mt-4 flex items-center gap-2 rounded-lg border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
|
||
<AlertCircle class="h-4 w-4 flex-shrink-0" />
|
||
{submitError}
|
||
</div>
|
||
{/if}
|
||
</Card.Content>
|
||
<Card.Footer>
|
||
<Button
|
||
class="w-full"
|
||
disabled={pendingBills.length === 0 || isSubmitting}
|
||
onclick={submitAllBills}
|
||
>
|
||
{#if isSubmitting}
|
||
<Loader2 class="mr-2 h-4 w-4 animate-spin" />
|
||
提交中...
|
||
{:else}
|
||
<Send class="mr-2 h-4 w-4" />
|
||
提交全部 ({pendingBills.length})
|
||
{/if}
|
||
</Button>
|
||
</Card.Footer>
|
||
</Card.Root>
|
||
</div>
|
||
|
||
<!-- 提交结果对话框 -->
|
||
<Dialog.Root bind:open={showResult}>
|
||
<Dialog.Content>
|
||
<Dialog.Header>
|
||
<Dialog.Title class="flex items-center gap-2">
|
||
<CheckCircle class="h-5 w-5 text-green-500" />
|
||
提交完成
|
||
</Dialog.Title>
|
||
</Dialog.Header>
|
||
{#if submitResult}
|
||
<div class="space-y-3 py-4">
|
||
<div class="flex items-center justify-between p-3 rounded-lg bg-green-50 dark:bg-green-950">
|
||
<span class="text-sm">成功创建</span>
|
||
<span class="font-bold text-green-600 dark:text-green-400">{submitResult.success} 条</span>
|
||
</div>
|
||
{#if submitResult.duplicates > 0}
|
||
<div class="flex items-center justify-between p-3 rounded-lg bg-yellow-50 dark:bg-yellow-950">
|
||
<span class="text-sm">重复跳过</span>
|
||
<span class="font-bold text-yellow-600 dark:text-yellow-400">{submitResult.duplicates} 条</span>
|
||
</div>
|
||
{/if}
|
||
{#if submitResult.failed > 0}
|
||
<div class="flex items-center justify-between p-3 rounded-lg bg-red-50 dark:bg-red-950">
|
||
<span class="text-sm">失败</span>
|
||
<span class="font-bold text-red-600 dark:text-red-400">{submitResult.failed} 条</span>
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
{/if}
|
||
<Dialog.Footer>
|
||
<Button onclick={() => showResult = false}>确定</Button>
|
||
</Dialog.Footer>
|
||
</Dialog.Content>
|
||
</Dialog.Root>
|