Files
billai/web/src/lib/components/bills/ManualBillInput.svelte
CHE LIANG ZHAO 6580a434ee feat(web): 新增 DateTimePicker 组件,优化手动添加账单表单
- 使用 shadcn-ui 的 Calendar + Popover 替换原生 datetime-local
- 根据收支类型动态切换分类选项(支出/收入分类)
- 切换收支类型时自动清空已选分类
- 收入模式下隐藏支付方式和交易状态输入框
- 调整表单布局为 1:1 两列
2026-01-13 13:23:06 +08:00

454 lines
15 KiB
Svelte
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.
<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>