feat(web): 新增 DateTimePicker 组件,优化手动添加账单表单

- 使用 shadcn-ui 的 Calendar + Popover 替换原生 datetime-local
- 根据收支类型动态切换分类选项(支出/收入分类)
- 切换收支类型时自动清空已选分类
- 收入模式下隐藏支付方式和交易状态输入框
- 调整表单布局为 1:1 两列
This commit is contained in:
CHE LIANG ZHAO
2026-01-13 13:23:06 +08:00
parent 0e41bbdf59
commit 6580a434ee
4 changed files with 171 additions and 37 deletions

View File

@@ -7,21 +7,21 @@
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';
import Clock from '@lucide/svelte/icons/clock';
interface Props {
categories: string[];
onSuccess?: () => void;
}
let { categories, onSuccess }: Props = $props();
let { onSuccess }: Props = $props();
// 获取当前日期时间(格式化为 YYYY-MM-DDTHH:mm
function getCurrentDateTimeLocal(): string {
@@ -43,8 +43,7 @@
// 表单状态
let formData = $state({
time: '',
timeLocal: getCurrentDateTimeLocal(), // 用于时间选择器的格式
time: getCurrentDateTime(),
category: '',
merchant: '',
description: '',
@@ -55,6 +54,13 @@
remark: '',
});
// 根据收支类型动态计算分类列表
let currentCategories = $derived(
formData.income_expense === '收入'
? [...incomeCategories]
: [...expenseCategories]
);
// 待提交的账单列表
let pendingBills = $state<ManualBillInput[]>([]);
@@ -74,6 +80,8 @@
function handleIncomeExpenseChange(value: string | undefined) {
if (value !== undefined) {
formData.income_expense = value;
// 切换收支类型时清空已选分类(因为分类列表会变化)
formData.category = '';
}
}
@@ -89,19 +97,11 @@
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
}
// 设置当前时间
function setCurrentTime() {
formData.timeLocal = getCurrentDateTimeLocal();
formData.time = convertDateTimeLocal(formData.timeLocal);
// 处理日期时间变化
function handleDateTimeChange(value: string) {
formData.time = value;
}
// 监听时间选择器变化
$effect(() => {
if (formData.timeLocal) {
formData.time = convertDateTimeLocal(formData.timeLocal);
}
});
// 验证表单
function validateForm(): string | null {
if (!formData.time) return '请输入交易时间';
@@ -137,8 +137,7 @@
const savedCategory = formData.category;
const savedIncomeExpense = formData.income_expense;
formData = {
time: '',
timeLocal: getCurrentDateTimeLocal(),
time: getCurrentDateTime(),
category: savedCategory,
merchant: '',
description: '',
@@ -197,9 +196,9 @@
}
</script>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- 左侧:输入表单 -->
<Card.Root class="lg:col-span-2">
<Card.Root>
<Card.Header>
<Card.Title class="flex items-center gap-2">
<Plus class="h-5 w-5" />
@@ -212,18 +211,11 @@
<!-- 交易时间 -->
<div class="space-y-2 sm:col-span-2">
<Label for="time">交易时间 *</Label>
<div class="flex gap-2">
<input
id="time"
type="datetime-local"
bind:value={formData.timeLocal}
class="flex-1 h-10 rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
/>
<Button variant="outline" size="icon" onclick={setCurrentTime} title="设置为当前时间">
<Clock class="h-4 w-4" />
</Button>
</div>
<p class="text-xs text-muted-foreground">已选择:{formData.time || '未选择'}</p>
<DateTimePicker
bind:value={formData.time}
onchange={handleDateTimeChange}
placeholder="选择交易时间"
/>
</div>
<!-- 分类 -->
@@ -235,7 +227,7 @@
</Select.Trigger>
<Select.Portal>
<Select.Content>
{#each categories as cat}
{#each currentCategories as cat}
<Select.Item value={cat}>{cat}</Select.Item>
{/each}
</Select.Content>
@@ -284,15 +276,16 @@
<!-- 商品说明 -->
<div class="space-y-2 sm:col-span-2">
<Label for="description">商品说明</Label>
<Label for="description">{formData.income_expense === '收入' ? '收入说明' : '商品说明'}</Label>
<Input
id="description"
type="text"
placeholder="购买的商品或服务"
placeholder={formData.income_expense === '收入' ? '收入来源或说明' : '购买的商品或服务'}
bind:value={formData.description}
/>
</div>
{#if formData.income_expense === '支出'}
<!-- 支付方式 -->
<div class="space-y-2">
<Label for="pay_method">支付方式</Label>
@@ -314,6 +307,7 @@
bind:value={formData.status}
/>
</div>
{/if}
<!-- 备注 -->
<div class="space-y-2 sm:col-span-2">
@@ -336,7 +330,7 @@
</Card.Root>
<!-- 右侧:待提交列表 -->
<Card.Root class="lg:col-span-1">
<Card.Root>
<Card.Header>
<div class="flex items-center justify-between">
<div>

View File

@@ -0,0 +1,137 @@
<script lang="ts">
import { Calendar } from "$lib/components/ui/calendar";
import * as Popover from "$lib/components/ui/popover";
import { Button } from "$lib/components/ui/button";
import { Input } from "$lib/components/ui/input";
import { cn } from "$lib/utils";
import CalendarIcon from "@lucide/svelte/icons/calendar";
import Clock from "@lucide/svelte/icons/clock";
import ChevronDown from "@lucide/svelte/icons/chevron-down";
import { CalendarDate, type DateValue } from "@internationalized/date";
interface Props {
value?: string; // 格式: YYYY-MM-DD HH:mm:ss
placeholder?: string;
class?: string;
onchange?: (value: string) => void;
}
let { value = $bindable(""), placeholder = "选择日期和时间", class: className, onchange }: Props = $props();
let open = $state(false);
let selectedDate = $state<DateValue | undefined>(undefined);
let timeValue = $state("00:00");
// 从 value 初始化日期和时间
$effect(() => {
if (value) {
const [datePart, timePart] = value.split(" ");
if (datePart) {
const [year, month, day] = datePart.split("-").map(Number);
if (year && month && day) {
selectedDate = new CalendarDate(year, month, day);
}
}
if (timePart) {
timeValue = timePart.slice(0, 5); // HH:mm
}
}
});
// 格式化显示文本
function formatDisplayText(): string {
if (!selectedDate) return "";
const year = selectedDate.year;
const month = String(selectedDate.month).padStart(2, "0");
const day = String(selectedDate.day).padStart(2, "0");
return `${year}-${month}-${day} ${timeValue}`;
}
// 更新输出值
function updateValue() {
if (selectedDate) {
const year = selectedDate.year;
const month = String(selectedDate.month).padStart(2, "0");
const day = String(selectedDate.day).padStart(2, "0");
const newValue = `${year}-${month}-${day} ${timeValue}:00`;
value = newValue;
onchange?.(newValue);
}
}
// 日期选择处理
function handleDateSelect(date: DateValue | undefined) {
selectedDate = date;
updateValue();
}
// 时间变化处理
function handleTimeChange(e: Event) {
const target = e.target as HTMLInputElement;
timeValue = target.value;
updateValue();
}
// 设置当前时间
function setNow() {
const now = new Date();
selectedDate = new CalendarDate(now.getFullYear(), now.getMonth() + 1, now.getDate());
timeValue = `${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`;
updateValue();
}
const displayText = $derived(formatDisplayText());
</script>
<Popover.Root bind:open>
<Popover.Trigger>
{#snippet child({ props })}
<Button
variant="outline"
class={cn(
"w-full justify-start text-left font-normal",
!selectedDate && "text-muted-foreground",
className
)}
{...props}
>
<CalendarIcon class="mr-2 h-4 w-4" />
{displayText || placeholder}
<ChevronDown class="ml-auto h-4 w-4 opacity-50" />
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Portal>
<Popover.Content class="w-auto p-0" align="start">
<div class="p-3 space-y-3">
<Calendar
type="single"
value={selectedDate}
onValueChange={handleDateSelect}
captionLayout="dropdown"
locale="zh-CN"
/>
<div class="border-t pt-3 px-1">
<div class="flex items-center gap-2">
<Clock class="h-4 w-4 text-muted-foreground" />
<span class="text-sm text-muted-foreground">时间</span>
<Input
type="time"
value={timeValue}
onchange={handleTimeChange}
class="flex-1 h-9"
/>
<Button variant="outline" size="sm" onclick={setNow}>
现在
</Button>
</div>
</div>
<div class="border-t pt-3 flex justify-end">
<Button size="sm" onclick={() => (open = false)}>
确定
</Button>
</div>
</div>
</Popover.Content>
</Popover.Portal>
</Popover.Root>

View File

@@ -0,0 +1,3 @@
import DateTimePicker from "./date-time-picker.svelte";
export { DateTimePicker };

View File

@@ -483,6 +483,6 @@
<!-- 手动添加视图 -->
{#if activeTab === 'manual'}
<ManualBillInput {categories} onSuccess={handleManualBillSuccess} />
<ManualBillInput onSuccess={handleManualBillSuccess} />
{/if}
</div>