import * as DialogPrimitive from "@radix-ui/react-dialog"; import { CalendarDays, ChevronLeft, ChevronRight } from "lucide-react"; import { useEffect, useId, useRef, useState } from "react"; import { Button } from "@/shared/ui/button"; import { cn } from "@/shared/ui/cn"; import { Dialog, DialogPortal, DialogTitle, DialogTrigger } from "@/shared/ui/dialog"; import { FieldSelect } from "@/shared/ui/field-select"; import { Input } from "@/shared/ui/input"; export interface DateTimeFieldProps { label: string; value: string; onChange: (value: string) => void; disabled?: boolean; } interface DateTimeParts { year: number; month: number; day: number; hour: number; minute: number; } const MONTH_OPTIONS = Array.from({ length: 12 }, (_, index) => ({ value: String(index + 1), label: `${index + 1}月` })); const HOUR_OPTIONS = Array.from({ length: 24 }, (_, hour) => ({ value: pad(hour), label: pad(hour) })); const MINUTE_OPTIONS = Array.from({ length: 60 }, (_, minute) => ({ value: pad(minute), label: pad(minute) })); const WEEKDAYS = ["一", "二", "三", "四", "五", "六", "日"]; function pad(value: number) { return String(value).padStart(2, "0"); } function daysInMonth(year: number, month: number) { const date = new Date(0); date.setHours(12, 0, 0, 0); date.setFullYear(year, month, 0); return date.getDate(); } function calendarDate(year: number, monthIndex: number, day: number) { const date = new Date(0); date.setHours(12, 0, 0, 0); date.setFullYear(year, monthIndex, day); return date; } function isValidParts(parts: DateTimeParts) { return ( Number.isInteger(parts.year) && parts.year >= 1 && parts.year <= 9999 && parts.month >= 1 && parts.month <= 12 && parts.day >= 1 && parts.day <= daysInMonth(parts.year, parts.month) && parts.hour >= 0 && parts.hour <= 23 && parts.minute >= 0 && parts.minute <= 59 ); } function parseDateTimeValue(value: string): DateTimeParts | null { const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})$/.exec(value); if (!match) return null; const parts = { year: Number(match[1]), month: Number(match[2]), day: Number(match[3]), hour: Number(match[4]), minute: Number(match[5]) }; return isValidParts(parts) ? parts : null; } function formatValue(parts: DateTimeParts) { return `${String(parts.year).padStart(4, "0")}-${pad(parts.month)}-${pad(parts.day)}T${pad(parts.hour)}:${pad(parts.minute)}`; } function todayParts(): DateTimeParts { const date = new Date(); return { year: date.getFullYear(), month: date.getMonth() + 1, day: date.getDate(), hour: date.getHours(), minute: date.getMinutes() }; } function displayValue(value: string) { const parts = parseDateTimeValue(value); return parts ? `${String(parts.year).padStart(4, "0")}-${pad(parts.month)}-${pad(parts.day)} ${pad(parts.hour)}:${pad(parts.minute)}` : value || "请选择日期和时间"; } function sameDay(parts: DateTimeParts, date: Date) { return ( parts.year === date.getFullYear() && parts.month === date.getMonth() + 1 && parts.day === date.getDate() ); } function moveDay(parts: DateTimeParts, amount: number): DateTimeParts { const date = calendarDate(parts.year, parts.month - 1, parts.day + amount); const year = date.getFullYear(); if (year < 1 || year > 9999) return parts; return { ...parts, year, month: date.getMonth() + 1, day: date.getDate() }; } export function DateTimeField({ label, value, onChange, disabled = false }: DateTimeFieldProps) { const id = useId(); const [open, setOpen] = useState(false); const [draft, setDraft] = useState( () => parseDateTimeValue(value) ?? todayParts() ); const [yearText, setYearText] = useState(String(draft.year)); const [sourceInvalid, setSourceInvalid] = useState(Boolean(value && !parseDateTimeValue(value))); const selectedDayRef = useRef(null); const focusDayAfterNavigation = useRef(false); const validYear = /^\d{1,4}$/.test(yearText) && Number(yearText) >= 1; const canConfirm = !disabled && !sourceInvalid && validYear && isValidParts(draft); const resetDraft = (nextValue: string) => { const parsed = parseDateTimeValue(nextValue); const next = parsed ?? todayParts(); setDraft(next); setYearText(String(next.year)); setSourceInvalid(Boolean(nextValue && !parsed)); }; useEffect(() => { if (open) resetDraft(value); }, [open, value]); useEffect(() => { if (!disabled) return; setOpen(false); }, [disabled]); useEffect(() => { if (!focusDayAfterNavigation.current) return; focusDayAfterNavigation.current = false; selectedDayRef.current?.focus(); }, [draft.year, draft.month, draft.day]); const updateDraft = (next: DateTimeParts) => { setDraft(next); setYearText(String(next.year)); setSourceInvalid(false); }; const changeMonth = (amount: number) => { if ( (amount < 0 && draft.year === 1 && draft.month === 1) || (amount > 0 && draft.year === 9999 && draft.month === 12) ) return; const date = calendarDate(draft.year, draft.month - 1 + amount, 1); const nextYear = date.getFullYear(); const nextMonth = date.getMonth() + 1; updateDraft({ ...draft, year: nextYear, month: nextMonth, day: Math.min(draft.day, daysInMonth(nextYear, nextMonth)) }); }; const firstWeekday = (calendarDate(draft.year, draft.month - 1, 1).getDay() + 6) % 7; const dayCount = daysInMonth(draft.year, draft.month); const today = new Date(); return (
{ if (disabled && nextOpen) return; setOpen(nextOpen); }} > { if (sourceInvalid) return; event.preventDefault(); requestAnimationFrame(() => selectedDayRef.current?.focus()); }} > 选择{label}
{ const nextMonth = Number(month); updateDraft({ ...draft, month: nextMonth, day: Math.min(draft.day, daysInMonth(draft.year, nextMonth)) }); }} />
{yearText || "----"}年 {draft.month}月
{Array.from({ length: firstWeekday }, (_, index) => ( ))} {Array.from({ length: dayCount }, (_, index) => { const day = index + 1; const selected = day === draft.day; const isToday = sameDay({ ...draft, day }, today); return ( ); })}
{ setDraft({ ...draft, hour: Number(hour) }); setSourceInvalid(false); }} /> { setDraft({ ...draft, minute: Number(minute) }); setSourceInvalid(false); }} />
{sourceInvalid ? (

当前值格式无效,请重新选择日期和时间。

) : null}
); }