feat(web): 新增 DateTimePicker 组件,优化手动添加账单表单
- 使用 shadcn-ui 的 Calendar + Popover 替换原生 datetime-local - 根据收支类型动态切换分类选项(支出/收入分类) - 切换收支类型时自动清空已选分类 - 收入模式下隐藏支付方式和交易状态输入框 - 调整表单布局为 1:1 两列
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
3
web/src/lib/components/ui/date-time-picker/index.ts
Normal file
3
web/src/lib/components/ui/date-time-picker/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import DateTimePicker from "./date-time-picker.svelte";
|
||||
|
||||
export { DateTimePicker };
|
||||
@@ -483,6 +483,6 @@
|
||||
|
||||
<!-- 手动添加视图 -->
|
||||
{#if activeTab === 'manual'}
|
||||
<ManualBillInput {categories} onSuccess={handleManualBillSuccess} />
|
||||
<ManualBillInput onSuccess={handleManualBillSuccess} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user