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 { Label } from '$lib/components/ui/label';
|
||||||
import { Badge } from '$lib/components/ui/badge';
|
import { Badge } from '$lib/components/ui/badge';
|
||||||
import * as Table from '$lib/components/ui/table';
|
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 { createManualBills, type ManualBillInput } from '$lib/api';
|
||||||
|
import { expenseCategories, incomeCategories } from '$lib/data/categories';
|
||||||
import Plus from '@lucide/svelte/icons/plus';
|
import Plus from '@lucide/svelte/icons/plus';
|
||||||
import Trash2 from '@lucide/svelte/icons/trash-2';
|
import Trash2 from '@lucide/svelte/icons/trash-2';
|
||||||
import Send from '@lucide/svelte/icons/send';
|
import Send from '@lucide/svelte/icons/send';
|
||||||
import Loader2 from '@lucide/svelte/icons/loader-2';
|
import Loader2 from '@lucide/svelte/icons/loader-2';
|
||||||
import CheckCircle from '@lucide/svelte/icons/check-circle';
|
import CheckCircle from '@lucide/svelte/icons/check-circle';
|
||||||
import AlertCircle from '@lucide/svelte/icons/alert-circle';
|
import AlertCircle from '@lucide/svelte/icons/alert-circle';
|
||||||
import Clock from '@lucide/svelte/icons/clock';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
categories: string[];
|
|
||||||
onSuccess?: () => void;
|
onSuccess?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { categories, onSuccess }: Props = $props();
|
let { onSuccess }: Props = $props();
|
||||||
|
|
||||||
// 获取当前日期时间(格式化为 YYYY-MM-DDTHH:mm)
|
// 获取当前日期时间(格式化为 YYYY-MM-DDTHH:mm)
|
||||||
function getCurrentDateTimeLocal(): string {
|
function getCurrentDateTimeLocal(): string {
|
||||||
@@ -43,8 +43,7 @@
|
|||||||
|
|
||||||
// 表单状态
|
// 表单状态
|
||||||
let formData = $state({
|
let formData = $state({
|
||||||
time: '',
|
time: getCurrentDateTime(),
|
||||||
timeLocal: getCurrentDateTimeLocal(), // 用于时间选择器的格式
|
|
||||||
category: '',
|
category: '',
|
||||||
merchant: '',
|
merchant: '',
|
||||||
description: '',
|
description: '',
|
||||||
@@ -55,6 +54,13 @@
|
|||||||
remark: '',
|
remark: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 根据收支类型动态计算分类列表
|
||||||
|
let currentCategories = $derived(
|
||||||
|
formData.income_expense === '收入'
|
||||||
|
? [...incomeCategories]
|
||||||
|
: [...expenseCategories]
|
||||||
|
);
|
||||||
|
|
||||||
// 待提交的账单列表
|
// 待提交的账单列表
|
||||||
let pendingBills = $state<ManualBillInput[]>([]);
|
let pendingBills = $state<ManualBillInput[]>([]);
|
||||||
|
|
||||||
@@ -74,6 +80,8 @@
|
|||||||
function handleIncomeExpenseChange(value: string | undefined) {
|
function handleIncomeExpenseChange(value: string | undefined) {
|
||||||
if (value !== undefined) {
|
if (value !== undefined) {
|
||||||
formData.income_expense = value;
|
formData.income_expense = value;
|
||||||
|
// 切换收支类型时清空已选分类(因为分类列表会变化)
|
||||||
|
formData.category = '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,19 +97,11 @@
|
|||||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 设置当前时间
|
// 处理日期时间变化
|
||||||
function setCurrentTime() {
|
function handleDateTimeChange(value: string) {
|
||||||
formData.timeLocal = getCurrentDateTimeLocal();
|
formData.time = value;
|
||||||
formData.time = convertDateTimeLocal(formData.timeLocal);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 监听时间选择器变化
|
|
||||||
$effect(() => {
|
|
||||||
if (formData.timeLocal) {
|
|
||||||
formData.time = convertDateTimeLocal(formData.timeLocal);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 验证表单
|
// 验证表单
|
||||||
function validateForm(): string | null {
|
function validateForm(): string | null {
|
||||||
if (!formData.time) return '请输入交易时间';
|
if (!formData.time) return '请输入交易时间';
|
||||||
@@ -137,8 +137,7 @@
|
|||||||
const savedCategory = formData.category;
|
const savedCategory = formData.category;
|
||||||
const savedIncomeExpense = formData.income_expense;
|
const savedIncomeExpense = formData.income_expense;
|
||||||
formData = {
|
formData = {
|
||||||
time: '',
|
time: getCurrentDateTime(),
|
||||||
timeLocal: getCurrentDateTimeLocal(),
|
|
||||||
category: savedCategory,
|
category: savedCategory,
|
||||||
merchant: '',
|
merchant: '',
|
||||||
description: '',
|
description: '',
|
||||||
@@ -197,9 +196,9 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</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.Header>
|
||||||
<Card.Title class="flex items-center gap-2">
|
<Card.Title class="flex items-center gap-2">
|
||||||
<Plus class="h-5 w-5" />
|
<Plus class="h-5 w-5" />
|
||||||
@@ -212,18 +211,11 @@
|
|||||||
<!-- 交易时间 -->
|
<!-- 交易时间 -->
|
||||||
<div class="space-y-2 sm:col-span-2">
|
<div class="space-y-2 sm:col-span-2">
|
||||||
<Label for="time">交易时间 *</Label>
|
<Label for="time">交易时间 *</Label>
|
||||||
<div class="flex gap-2">
|
<DateTimePicker
|
||||||
<input
|
bind:value={formData.time}
|
||||||
id="time"
|
onchange={handleDateTimeChange}
|
||||||
type="datetime-local"
|
placeholder="选择交易时间"
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 分类 -->
|
<!-- 分类 -->
|
||||||
@@ -235,7 +227,7 @@
|
|||||||
</Select.Trigger>
|
</Select.Trigger>
|
||||||
<Select.Portal>
|
<Select.Portal>
|
||||||
<Select.Content>
|
<Select.Content>
|
||||||
{#each categories as cat}
|
{#each currentCategories as cat}
|
||||||
<Select.Item value={cat}>{cat}</Select.Item>
|
<Select.Item value={cat}>{cat}</Select.Item>
|
||||||
{/each}
|
{/each}
|
||||||
</Select.Content>
|
</Select.Content>
|
||||||
@@ -284,15 +276,16 @@
|
|||||||
|
|
||||||
<!-- 商品说明 -->
|
<!-- 商品说明 -->
|
||||||
<div class="space-y-2 sm:col-span-2">
|
<div class="space-y-2 sm:col-span-2">
|
||||||
<Label for="description">商品说明</Label>
|
<Label for="description">{formData.income_expense === '收入' ? '收入说明' : '商品说明'}</Label>
|
||||||
<Input
|
<Input
|
||||||
id="description"
|
id="description"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="购买的商品或服务"
|
placeholder={formData.income_expense === '收入' ? '收入来源或说明' : '购买的商品或服务'}
|
||||||
bind:value={formData.description}
|
bind:value={formData.description}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if formData.income_expense === '支出'}
|
||||||
<!-- 支付方式 -->
|
<!-- 支付方式 -->
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label for="pay_method">支付方式</Label>
|
<Label for="pay_method">支付方式</Label>
|
||||||
@@ -314,6 +307,7 @@
|
|||||||
bind:value={formData.status}
|
bind:value={formData.status}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- 备注 -->
|
<!-- 备注 -->
|
||||||
<div class="space-y-2 sm:col-span-2">
|
<div class="space-y-2 sm:col-span-2">
|
||||||
@@ -336,7 +330,7 @@
|
|||||||
</Card.Root>
|
</Card.Root>
|
||||||
|
|
||||||
<!-- 右侧:待提交列表 -->
|
<!-- 右侧:待提交列表 -->
|
||||||
<Card.Root class="lg:col-span-1">
|
<Card.Root>
|
||||||
<Card.Header>
|
<Card.Header>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<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'}
|
{#if activeTab === 'manual'}
|
||||||
<ManualBillInput {categories} onSuccess={handleManualBillSuccess} />
|
<ManualBillInput onSuccess={handleManualBillSuccess} />
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user