feat(admin): add draw management features including create, update, delete, and batch delete functionalities

- Implemented API functions for creating, updating, and deleting draws.
- Enhanced the admin draws console with UI components for managing draws.
- Added internationalization support for new draw management actions and messages.
This commit is contained in:
2026-05-25 18:00:43 +08:00
parent eb02252431
commit f080e6ba8e
15 changed files with 948 additions and 24 deletions

View File

@@ -0,0 +1,184 @@
"use client";
import * as React from "react";
import { format, parse } from "date-fns";
import { enUS } from "date-fns/locale";
import { CalendarIcon, Clock } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button, buttonVariants } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { cn } from "@/lib/utils";
/** `yyyy-MM-dd HH:mm:ss` */
export type AdminDateTimeValue = string;
function splitDateTime(value: AdminDateTimeValue): { date: string; time: string } {
const trimmed = value.trim();
const match = /^(\d{4}-\d{2}-\d{2})(?: (\d{2}:\d{2}(?::\d{2})?))?$/.exec(trimmed);
if (!match) {
return { date: "", time: "" };
}
const time = match[2] ?? "";
if (/^\d{2}:\d{2}$/.test(time)) {
return { date: match[1], time: `${time}:00` };
}
return { date: match[1], time };
}
function normalizeTimeForInput(time: string): string {
if (!time) {
return "";
}
if (/^\d{2}:\d{2}:\d{2}$/.test(time)) {
return time;
}
if (/^\d{2}:\d{2}$/.test(time)) {
return `${time}:00`;
}
return "";
}
function normalizeTimeForApi(time: string): string {
if (!time) {
return "00:00:00";
}
if (/^\d{2}:\d{2}$/.test(time)) {
return `${time}:00`;
}
if (/^\d{2}:\d{2}:\d{2}$/.test(time)) {
return time;
}
return "00:00:00";
}
function joinDateTime(date: string, time: string): AdminDateTimeValue {
if (!date) {
return "";
}
return `${date} ${normalizeTimeForApi(time)}`;
}
export function AdminDateTimeField({
id,
label,
value,
onChange,
optional = false,
required = false,
}: {
id: string;
label: string;
/** `yyyy-MM-dd HH:mm:ss` or empty */
value: AdminDateTimeValue;
onChange: (next: AdminDateTimeValue) => void;
/** 允许留空(开始/封盘等可选字段) */
optional?: boolean;
required?: boolean;
}) {
const { t } = useTranslation(["common"]);
const [open, setOpen] = React.useState(false);
const { date, time } = splitDateTime(value);
const parsedDate = React.useMemo(() => {
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
return undefined;
}
const d = parse(date, "yyyy-MM-dd", new Date());
return Number.isNaN(d.getTime()) ? undefined : d;
}, [date]);
const dateSummary = parsedDate
? format(parsedDate, "yyyy-MM-dd", { locale: enUS })
: t("date.placeholder", { defaultValue: "Select date" });
const timeInputId = `${id}-time`;
return (
<div className="grid gap-1.5">
<Label htmlFor={id}>
{label}
{required ? <span className="text-destructive"> *</span> : null}
</Label>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<Popover
modal={false}
open={open}
onOpenChange={setOpen}
>
<PopoverTrigger
type="button"
id={id}
className={cn(
buttonVariants({ variant: "outline", size: "default" }),
"h-8 w-full justify-start gap-2 px-2.5 font-normal sm:flex-1 md:text-sm",
!parsedDate && "text-muted-foreground",
)}
>
<CalendarIcon className="pointer-events-none size-4 shrink-0 opacity-70" aria-hidden />
<span className="min-w-0 flex-1 truncate text-left">{dateSummary}</span>
</PopoverTrigger>
<PopoverContent align="start" sideOffset={6} className="w-auto min-w-fit p-0">
<Calendar
mode="single"
locale={enUS}
captionLayout="dropdown"
selected={parsedDate}
defaultMonth={parsedDate}
onSelect={(d) => {
if (!d) {
onChange("");
return;
}
onChange(joinDateTime(format(d, "yyyy-MM-dd"), time));
setOpen(false);
}}
/>
{optional ? (
<div className="flex justify-end border-t px-2 py-1">
<Button
type="button"
variant="ghost"
size="xs"
className="h-7 px-2"
onClick={() => {
onChange("");
setOpen(false);
}}
>
{t("actions.clear", { defaultValue: "Clear" })}
</Button>
</div>
) : null}
</PopoverContent>
</Popover>
<div className="relative w-full sm:w-[9.5rem] sm:shrink-0">
<Clock
className="pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2 opacity-70"
aria-hidden
/>
<Input
id={timeInputId}
type="time"
step={1}
disabled={!date}
value={normalizeTimeForInput(time)}
className="h-8 pl-8 font-mono text-sm tabular-nums"
onChange={(e) => {
onChange(joinDateTime(date, e.target.value));
}}
/>
</div>
</div>
{optional ? (
<p className="text-xs text-muted-foreground">
{t("datetime.optionalHint", { defaultValue: "Leave empty to auto-fill from server config." })}
</p>
) : null}
</div>
);
}