feat: 添加日期处理库和日历选择器,更新管理员抽奖模块

This commit is contained in:
2026-05-09 17:40:35 +08:00
parent f19cdb48ad
commit ac3f28459b
28 changed files with 2186 additions and 117 deletions

View File

@@ -0,0 +1,92 @@
"use client";
import * as React from "react";
import { format, parse } from "date-fns";
import { zhCN } from "date-fns/locale";
import { CalendarIcon } from "lucide-react";
import { Button, buttonVariants } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
import { Label } from "@/components/ui/label";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { cn } from "@/lib/utils";
export function AdminDateField({
id,
label,
value,
onChange,
placeholder = "选择日期",
}: {
id: string;
label: string;
/** `yyyy-MM-dd` 或空 */
value: string;
onChange: (next: string) => void;
placeholder?: string;
}) {
const [open, setOpen] = React.useState(false);
const parsed = React.useMemo(() => {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
return undefined;
}
const d = parse(value, "yyyy-MM-dd", new Date());
return Number.isNaN(d.getTime()) ? undefined : d;
}, [value]);
const summary = parsed ? format(parsed, "yyyy年M月d日", { locale: zhCN }) : placeholder;
return (
<div className="grid gap-1.5">
<Label htmlFor={id}>{label}</Label>
<Popover
modal={false}
open={open}
onOpenChange={(next) => {
setOpen(next);
}}
>
<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 md:text-sm",
!parsed && "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">{summary}</span>
</PopoverTrigger>
<PopoverContent align="start" sideOffset={6} className="w-auto min-w-fit p-0">
<Calendar
mode="single"
locale={zhCN}
captionLayout="dropdown"
selected={parsed}
defaultMonth={parsed}
onSelect={(d) => {
onChange(d ? format(d, "yyyy-MM-dd") : "");
setOpen(false);
}}
/>
<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);
}}
>
</Button>
</div>
</PopoverContent>
</Popover>
</div>
);
}

View File

@@ -0,0 +1,154 @@
"use client";
import * as React from "react";
import type { DateRange } from "react-day-picker";
import { format, parse } from "date-fns";
import { zhCN } from "date-fns/locale";
import { CalendarRange } from "lucide-react";
import { Button, buttonVariants } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
import { Label } from "@/components/ui/label";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { useIsMobile } from "@/hooks/use-mobile";
import { cn } from "@/lib/utils";
function parseYmd(value: string): Date | undefined {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
return undefined;
}
const d = parse(value, "yyyy-MM-dd", new Date());
return Number.isNaN(d.getTime()) ? undefined : d;
}
function formatYmd(d: Date): string {
return format(d, "yyyy-MM-dd");
}
function summarize(from: string, to: string, placeholder: string): string {
const df = parseYmd(from);
const dt = parseYmd(to);
if (!df && !dt) {
return placeholder;
}
const a = df ? format(df, "yyyy年M月d日", { locale: zhCN }) : "…";
const b = dt ? format(dt, "yyyy年M月d日", { locale: zhCN }) : "…";
return `${a}${b}`;
}
/** shadcn Popover + Calendar `mode="range"`;输出与原先两个 `yyyy-MM-dd` 筛选字段兼容 */
export function AdminDateRangeField({
id,
label,
from: fromProp,
to: toProp,
onRangeChange,
placeholder = "选择日期范围",
}: {
id: string;
label?: string;
from: string;
to: string;
onRangeChange: (next: { from: string; to: string }) => void;
placeholder?: string;
}) {
const [open, setOpen] = React.useState(false);
const isMobile = useIsMobile();
const selected = React.useMemo((): DateRange | undefined => {
const df = parseYmd(fromProp);
const dt = parseYmd(toProp);
if (!df && !dt) {
return undefined;
}
if (df && !dt) {
return { from: df };
}
if (df && dt) {
return { from: df, to: dt };
}
return dt ? { from: dt } : undefined;
}, [fromProp, toProp]);
const hasSelection = Boolean(parseYmd(fromProp) || parseYmd(toProp));
const defaultMonth = selected?.from ?? selected?.to ?? new Date();
return (
<div className="grid gap-1.5">
{label ? (
<Label htmlFor={id} className="leading-none">
{label}
</Label>
) : null}
<Popover
modal={false}
open={open}
onOpenChange={(next) => {
setOpen(next);
}}
>
<PopoverTrigger
type="button"
id={id}
className={cn(
buttonVariants({ variant: "outline", size: "default" }),
"h-8 min-h-8 w-full justify-start gap-2 px-2.5 font-normal md:text-sm",
!hasSelection && "text-muted-foreground",
)}
>
<CalendarRange className="pointer-events-none size-4 shrink-0 opacity-70" aria-hidden />
<span className="min-w-0 flex-1 truncate text-left">
{summarize(fromProp, toProp, placeholder)}
</span>
</PopoverTrigger>
<PopoverContent align="start" sideOffset={6} className="w-auto max-w-[calc(100vw-2rem)] min-w-fit p-0">
<p className="text-muted-foreground border-b px-3 py-2 text-xs leading-relaxed">
</p>
<Calendar
mode="range"
locale={zhCN}
captionLayout="dropdown"
selected={selected}
defaultMonth={defaultMonth}
numberOfMonths={isMobile ? 1 : 2}
onSelect={(range) => {
if (!range?.from && !range?.to) {
onRangeChange({ from: "", to: "" });
return;
}
const nf = range.from ? formatYmd(range.from) : "";
const nt = range.to ? formatYmd(range.to) : "";
onRangeChange({ from: nf, to: nt });
}}
/>
<div className="flex items-center justify-end gap-2 border-t px-2 py-1.5">
<Button
type="button"
variant="ghost"
size="xs"
className="h-7 px-2"
onClick={() => {
onRangeChange({ from: "", to: "" });
setOpen(false);
}}
>
</Button>
<Button
type="button"
variant="secondary"
size="xs"
className="h-7 px-2"
onClick={() => {
setOpen(false);
}}
>
</Button>
</div>
</PopoverContent>
</Popover>
</div>
);
}

View File

@@ -0,0 +1,198 @@
"use client";
import { Label } from "@/components/ui/label";
import {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from "@/components/ui/pagination";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { cn } from "@/lib/utils";
export const ADMIN_LIST_PER_PAGE_OPTIONS = [10, 20, 25, 50, 100] as const;
/** 服务端分页页码序列(省略号);与 shadcn Pagination 拼装用 */
export function adminListPageSlices(
current: number,
last: number,
delta = 1,
): (number | "ellipsis")[] {
if (last <= 1) {
return last === 1 ? [1] : [];
}
const cap = delta * 2 + 3;
if (last <= cap) {
return Array.from({ length: last }, (_, i) => i + 1);
}
const set = new Set<number>([1, last]);
for (let p = current - delta; p <= current + delta; p++) {
if (p >= 1 && p <= last) {
set.add(p);
}
}
const sorted = [...set].sort((a, b) => a - b);
const out: (number | "ellipsis")[] = [];
let prev = 0;
for (const p of sorted) {
if (prev && p - prev > 1) {
out.push("ellipsis");
}
out.push(p);
prev = p;
}
return out;
}
export function AdminPerPagePicker({
selectId,
perPage,
onChange,
}: {
/** 表单控件 id · 区分同页多块列表 */
selectId: string;
perPage: number;
onChange: (next: number) => void;
}) {
return (
<div className="flex items-center gap-2">
<Label htmlFor={selectId} className="text-sm leading-none whitespace-nowrap">
</Label>
<Select
modal={false}
value={String(perPage)}
onValueChange={(v) => {
if (v == null || v === "") {
return;
}
onChange(Number(v));
}}
>
<SelectTrigger id={selectId} size="sm" className="w-[6.75rem]">
<SelectValue placeholder="请选择" />
</SelectTrigger>
<SelectContent align="start" sideOffset={6}>
{ADMIN_LIST_PER_PAGE_OPTIONS.map((n) => (
<SelectItem key={n} value={String(n)}>
{n}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}
/** 表格底栏:左统计 + 右「每页条数」与 shadcn Pagination与 Data Table pagination 一节一致) */
export function AdminListPaginationFooter({
selectId,
total,
page,
lastPage,
perPage,
loading,
onPerPageChange,
onPageChange,
}: {
selectId: string;
total: number;
page: number;
lastPage: number;
perPage: number;
loading: boolean;
onPerPageChange: (nextPerPage: number) => void;
onPageChange: (nextPage: number) => void;
}) {
return (
<div className="flex flex-col gap-4 border-t border-border pt-4 sm:flex-row sm:items-center sm:justify-between">
<p className="text-muted-foreground text-sm tabular-nums">
{total} {page} / {lastPage}
</p>
<div className="flex w-full flex-col items-stretch gap-4 sm:w-auto sm:flex-row sm:flex-wrap sm:items-center sm:justify-end lg:gap-6">
<AdminPerPagePicker
selectId={selectId}
perPage={perPage}
onChange={(next) => {
onPerPageChange(next);
}}
/>
{lastPage > 1 ? (
<Pagination className="mx-0 flex w-full max-w-none justify-center sm:w-auto sm:justify-end md:justify-end">
<PaginationContent className="flex-wrap justify-center md:justify-end">
<PaginationItem>
<PaginationPrevious
href="#"
text="上一页"
aria-disabled={page <= 1 || loading}
className={cn(
(page <= 1 || loading) && "pointer-events-none opacity-50",
)}
onClick={(e) => {
e.preventDefault();
if (page <= 1 || loading) {
return;
}
onPageChange(Math.max(1, page - 1));
}}
/>
</PaginationItem>
{adminListPageSlices(page, lastPage).map((item, idx) =>
item === "ellipsis" ? (
<PaginationItem key={`${selectId}-e-${idx}`}>
<PaginationEllipsis />
</PaginationItem>
) : (
<PaginationItem key={`${selectId}-${item}`}>
<PaginationLink
href="#"
size="sm"
isActive={item === page}
className="min-w-8 tabular-nums"
aria-disabled={loading}
onClick={(e) => {
e.preventDefault();
if (loading) {
return;
}
onPageChange(item);
}}
>
{item}
</PaginationLink>
</PaginationItem>
),
)}
<PaginationItem>
<PaginationNext
href="#"
text="下一页"
aria-disabled={page >= lastPage || loading}
className={cn(
(page >= lastPage || loading) && "pointer-events-none opacity-50",
)}
onClick={(e) => {
e.preventDefault();
if (page >= lastPage || loading) {
return;
}
onPageChange(Math.min(lastPage, page + 1));
}}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
) : null}
</div>
</div>
);
}