mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-08-04 11:43:57 +08:00
feat(usage): redesign calendar date range picker with auto-switch and simplified layout
This commit is contained in:
@@ -0,0 +1,415 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { CalendarDays, ChevronLeft, ChevronRight } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/components/ui/popover";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { resolveUsageRange } from "@/lib/usageRange";
|
||||||
|
import { getLocaleFromLanguage } from "./format";
|
||||||
|
import type { UsageRangeSelection } from "@/types/usage";
|
||||||
|
|
||||||
|
type DraftField = "start" | "end";
|
||||||
|
|
||||||
|
interface UsageDateRangePickerProps {
|
||||||
|
selection: UsageRangeSelection;
|
||||||
|
onApply: (selection: UsageRangeSelection) => void;
|
||||||
|
triggerLabel: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── helpers ── */
|
||||||
|
|
||||||
|
function startOfDay(d: Date): Date {
|
||||||
|
return new Date(d.getFullYear(), d.getMonth(), d.getDate());
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSameDay(a: Date, b: Date): boolean {
|
||||||
|
return (
|
||||||
|
a.getFullYear() === b.getFullYear() &&
|
||||||
|
a.getMonth() === b.getMonth() &&
|
||||||
|
a.getDate() === b.getDate()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toTs(d: Date): number {
|
||||||
|
return Math.floor(d.getTime() / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fromTs(ts: number): Date {
|
||||||
|
return new Date(ts * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDate(ts: number): string {
|
||||||
|
const d = fromTs(ts);
|
||||||
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtTime(ts: number): string {
|
||||||
|
const d = fromTs(ts);
|
||||||
|
return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDateInput(ts: number, value: string): number {
|
||||||
|
const [y, m, d] = value.split("-").map(Number);
|
||||||
|
if (!Number.isFinite(y) || !Number.isFinite(m) || !Number.isFinite(d))
|
||||||
|
return ts;
|
||||||
|
const base = fromTs(ts);
|
||||||
|
return toTs(new Date(y, m - 1, d, base.getHours(), base.getMinutes()));
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTimeInput(ts: number, value: string): number {
|
||||||
|
const [h, min] = value.split(":").map(Number);
|
||||||
|
if (!Number.isFinite(h) || !Number.isFinite(min)) return ts;
|
||||||
|
const base = fromTs(ts);
|
||||||
|
return toTs(
|
||||||
|
new Date(base.getFullYear(), base.getMonth(), base.getDate(), h, min),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setDateKeepTime(ts: number, day: Date): number {
|
||||||
|
const base = fromTs(ts);
|
||||||
|
return toTs(
|
||||||
|
new Date(
|
||||||
|
day.getFullYear(),
|
||||||
|
day.getMonth(),
|
||||||
|
day.getDate(),
|
||||||
|
base.getHours(),
|
||||||
|
base.getMinutes(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCalendarDays(month: Date): Date[] {
|
||||||
|
const first = new Date(month.getFullYear(), month.getMonth(), 1);
|
||||||
|
const gridStart = new Date(first);
|
||||||
|
gridStart.setDate(first.getDate() - first.getDay());
|
||||||
|
return Array.from({ length: 42 }, (_, i) => {
|
||||||
|
const d = new Date(gridStart);
|
||||||
|
d.setDate(gridStart.getDate() + i);
|
||||||
|
return d;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── component ── */
|
||||||
|
|
||||||
|
export function UsageDateRangePicker({
|
||||||
|
selection,
|
||||||
|
onApply,
|
||||||
|
triggerLabel,
|
||||||
|
}: UsageDateRangePickerProps) {
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [activeField, setActiveField] = useState<DraftField>("start");
|
||||||
|
const resolvedRange = useMemo(
|
||||||
|
() => resolveUsageRange(selection),
|
||||||
|
[selection],
|
||||||
|
);
|
||||||
|
const [draftStart, setDraftStart] = useState(resolvedRange.startDate);
|
||||||
|
const [draftEnd, setDraftEnd] = useState(resolvedRange.endDate);
|
||||||
|
const [displayMonth, setDisplayMonth] = useState(
|
||||||
|
() =>
|
||||||
|
new Date(
|
||||||
|
fromTs(resolvedRange.startDate).getFullYear(),
|
||||||
|
fromTs(resolvedRange.startDate).getMonth(),
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const language = i18n.resolvedLanguage || i18n.language || "en";
|
||||||
|
const locale = getLocaleFromLanguage(language);
|
||||||
|
|
||||||
|
// Reset draft when popover opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const r = resolveUsageRange(selection);
|
||||||
|
setDraftStart(r.startDate);
|
||||||
|
setDraftEnd(r.endDate);
|
||||||
|
setDisplayMonth(
|
||||||
|
new Date(
|
||||||
|
fromTs(r.startDate).getFullYear(),
|
||||||
|
fromTs(r.startDate).getMonth(),
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
setActiveField("start");
|
||||||
|
setError(null);
|
||||||
|
}, [open, selection]);
|
||||||
|
|
||||||
|
const calendarDays = useMemo(
|
||||||
|
() => getCalendarDays(displayMonth),
|
||||||
|
[displayMonth],
|
||||||
|
);
|
||||||
|
|
||||||
|
const weekdayLabels = useMemo(
|
||||||
|
() =>
|
||||||
|
Array.from({ length: 7 }, (_, i) =>
|
||||||
|
new Intl.DateTimeFormat(locale, { weekday: "narrow" }).format(
|
||||||
|
new Date(2024, 0, 7 + i),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
[locale],
|
||||||
|
);
|
||||||
|
|
||||||
|
const startDay = fromTs(draftStart);
|
||||||
|
const endDay = fromTs(draftEnd);
|
||||||
|
const today = new Date();
|
||||||
|
|
||||||
|
/* Pick a date from the calendar */
|
||||||
|
const handleDatePick = (day: Date) => {
|
||||||
|
setError(null);
|
||||||
|
const nextTs = setDateKeepTime(
|
||||||
|
activeField === "start" ? draftStart : draftEnd,
|
||||||
|
day,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (activeField === "start") {
|
||||||
|
setDraftStart(nextTs);
|
||||||
|
// Auto-swap if start > end
|
||||||
|
if (nextTs > draftEnd) {
|
||||||
|
setDraftEnd(nextTs);
|
||||||
|
}
|
||||||
|
// Auto-advance to end field
|
||||||
|
setActiveField("end");
|
||||||
|
} else {
|
||||||
|
// If picked end < start, treat as new start and auto-advance
|
||||||
|
if (nextTs < draftStart) {
|
||||||
|
setDraftStart(nextTs);
|
||||||
|
setActiveField("end");
|
||||||
|
} else {
|
||||||
|
setDraftEnd(nextTs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigate calendar if the day is outside the displayed month
|
||||||
|
if (
|
||||||
|
day.getMonth() !== displayMonth.getMonth() ||
|
||||||
|
day.getFullYear() !== displayMonth.getFullYear()
|
||||||
|
) {
|
||||||
|
setDisplayMonth(new Date(day.getFullYear(), day.getMonth(), 1));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleApply = () => {
|
||||||
|
setError(null);
|
||||||
|
if (draftStart > draftEnd) {
|
||||||
|
setError(t("usage.invalidTimeRangeOrder", "开始时间不能晚于结束时间"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onApply({
|
||||||
|
preset: "custom",
|
||||||
|
customStartDate: draftStart,
|
||||||
|
customEndDate: draftEnd,
|
||||||
|
});
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const goToToday = () => {
|
||||||
|
setDisplayMonth(new Date(today.getFullYear(), today.getMonth(), 1));
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ── Field card (start / end) ── */
|
||||||
|
const renderField = (field: DraftField) => {
|
||||||
|
const isActive = activeField === field;
|
||||||
|
const ts = field === "start" ? draftStart : draftEnd;
|
||||||
|
const setTs = field === "start" ? setDraftStart : setDraftEnd;
|
||||||
|
const label =
|
||||||
|
field === "start"
|
||||||
|
? t("usage.startTime", "开始时间")
|
||||||
|
: t("usage.endTime", "结束时间");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"rounded-lg border px-3 py-2 cursor-pointer transition-all",
|
||||||
|
isActive
|
||||||
|
? "border-primary ring-1 ring-primary/30 bg-primary/5"
|
||||||
|
: "border-border/50 hover:border-border",
|
||||||
|
)}
|
||||||
|
onClick={() => setActiveField(field)}
|
||||||
|
>
|
||||||
|
<div className="mb-1.5 text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
className="h-7 flex-1 border-0 bg-transparent p-0 text-sm shadow-none focus-visible:ring-0"
|
||||||
|
value={fmtDate(ts)}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = parseDateInput(ts, e.target.value);
|
||||||
|
setTs(next);
|
||||||
|
const d = fromTs(next);
|
||||||
|
setDisplayMonth(new Date(d.getFullYear(), d.getMonth(), 1));
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
|
onFocus={() => setActiveField(field)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="time"
|
||||||
|
step={60}
|
||||||
|
className="h-7 w-[90px] flex-none border-0 bg-transparent p-0 text-sm shadow-none focus-visible:ring-0"
|
||||||
|
value={fmtTime(ts)}
|
||||||
|
onChange={(e) => {
|
||||||
|
setTs(parseTimeInput(ts, e.target.value));
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
|
onFocus={() => setActiveField(field)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={selection.preset === "custom" ? "default" : "outline"}
|
||||||
|
className="justify-start gap-2"
|
||||||
|
>
|
||||||
|
<CalendarDays className="h-4 w-4" />
|
||||||
|
<span className="truncate">{triggerLabel}</span>
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent
|
||||||
|
className="w-[340px] max-w-[calc(100vw-2rem)] p-3 sm:w-[620px]"
|
||||||
|
align="end"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row">
|
||||||
|
{/* Left: date fields */}
|
||||||
|
<div className="space-y-2 sm:w-[250px] sm:flex-none">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t("usage.customRangeHint", "支持日期与时间,最长 30 天")}
|
||||||
|
</p>
|
||||||
|
{renderField("start")}
|
||||||
|
{renderField("end")}
|
||||||
|
|
||||||
|
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||||
|
|
||||||
|
<div className="flex gap-2 pt-1">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="flex-1"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
>
|
||||||
|
{t("common.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
className="flex-1"
|
||||||
|
onClick={handleApply}
|
||||||
|
>
|
||||||
|
{t("common.confirm")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right: calendar */}
|
||||||
|
<div className="rounded-lg border border-border/50 bg-muted/30 p-2.5 sm:min-w-0 sm:flex-1">
|
||||||
|
{/* Month navigation */}
|
||||||
|
<div className="flex items-center justify-between mb-1.5">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-7 w-7"
|
||||||
|
onClick={() =>
|
||||||
|
setDisplayMonth(
|
||||||
|
new Date(
|
||||||
|
displayMonth.getFullYear(),
|
||||||
|
displayMonth.getMonth() - 1,
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-sm font-medium hover:text-primary transition-colors"
|
||||||
|
onClick={goToToday}
|
||||||
|
title={t("usage.presetToday", { defaultValue: "当天" })}
|
||||||
|
>
|
||||||
|
{displayMonth.toLocaleDateString(locale, {
|
||||||
|
year: "numeric",
|
||||||
|
month: "long",
|
||||||
|
})}
|
||||||
|
</button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-7 w-7"
|
||||||
|
onClick={() =>
|
||||||
|
setDisplayMonth(
|
||||||
|
new Date(
|
||||||
|
displayMonth.getFullYear(),
|
||||||
|
displayMonth.getMonth() + 1,
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Weekday headers */}
|
||||||
|
<div className="grid grid-cols-7 text-center text-[11px] text-muted-foreground mb-0.5">
|
||||||
|
{weekdayLabels.map((label, i) => (
|
||||||
|
<div key={i} className="py-0.5">
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Day grid */}
|
||||||
|
<div className="grid grid-cols-7 gap-px">
|
||||||
|
{calendarDays.map((day) => {
|
||||||
|
const isCurrentMonth =
|
||||||
|
day.getMonth() === displayMonth.getMonth();
|
||||||
|
const isToday = isSameDay(day, today);
|
||||||
|
const isStart = isSameDay(day, startDay);
|
||||||
|
const isEnd = isSameDay(day, endDay);
|
||||||
|
const dayStart = startOfDay(day);
|
||||||
|
const inRange =
|
||||||
|
dayStart >= startOfDay(startDay) &&
|
||||||
|
dayStart <= startOfDay(endDay);
|
||||||
|
const isEndpoint = isStart || isEnd;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={day.toISOString()}
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
"relative h-7 rounded text-xs transition-colors",
|
||||||
|
!isCurrentMonth && "text-muted-foreground/30",
|
||||||
|
isCurrentMonth && !inRange && "hover:bg-muted",
|
||||||
|
inRange && !isEndpoint && "bg-primary/10 text-primary",
|
||||||
|
isEndpoint &&
|
||||||
|
"bg-primary text-primary-foreground font-medium",
|
||||||
|
isToday && !isEndpoint && "ring-1 ring-primary/40",
|
||||||
|
)}
|
||||||
|
onClick={() => handleDatePick(day)}
|
||||||
|
>
|
||||||
|
{day.getDate()}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import type { UsageRangePreset, UsageRangeSelection } from "@/types/usage";
|
||||||
|
|
||||||
|
const DAY_SECONDS = 24 * 60 * 60;
|
||||||
|
const DAY_MS = DAY_SECONDS * 1000;
|
||||||
|
|
||||||
|
export const MAX_CUSTOM_USAGE_RANGE_SECONDS = 30 * DAY_SECONDS;
|
||||||
|
|
||||||
|
export interface ResolvedUsageRange {
|
||||||
|
startDate: number;
|
||||||
|
endDate: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStartOfLocalDayDate(nowMs: number): Date {
|
||||||
|
const date = new Date(nowMs);
|
||||||
|
return new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPresetLookbackStart(
|
||||||
|
preset: Exclude<UsageRangePreset, "today" | "1d" | "custom">,
|
||||||
|
nowMs: number,
|
||||||
|
): number {
|
||||||
|
const dayCount = preset === "7d" ? 7 : preset === "14d" ? 14 : 30;
|
||||||
|
return Math.floor(
|
||||||
|
getStartOfLocalDayDate(nowMs - (dayCount - 1) * DAY_MS).getTime() / 1000,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveUsageRange(
|
||||||
|
selection: UsageRangeSelection,
|
||||||
|
nowMs: number = Date.now(),
|
||||||
|
): ResolvedUsageRange {
|
||||||
|
const endDate = Math.floor(nowMs / 1000);
|
||||||
|
|
||||||
|
switch (selection.preset) {
|
||||||
|
case "today":
|
||||||
|
return {
|
||||||
|
startDate: Math.floor(getStartOfLocalDayDate(nowMs).getTime() / 1000),
|
||||||
|
endDate,
|
||||||
|
};
|
||||||
|
case "1d":
|
||||||
|
return {
|
||||||
|
startDate: endDate - DAY_SECONDS,
|
||||||
|
endDate,
|
||||||
|
};
|
||||||
|
case "7d":
|
||||||
|
case "14d":
|
||||||
|
case "30d":
|
||||||
|
return {
|
||||||
|
startDate: getPresetLookbackStart(selection.preset, nowMs),
|
||||||
|
endDate,
|
||||||
|
};
|
||||||
|
case "custom": {
|
||||||
|
const startDate = selection.customStartDate ?? endDate - DAY_SECONDS;
|
||||||
|
const customEndDate = selection.customEndDate ?? endDate;
|
||||||
|
return {
|
||||||
|
startDate,
|
||||||
|
endDate: customEndDate,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function timestampToLocalDatetime(timestamp: number): string {
|
||||||
|
const date = new Date(timestamp * 1000);
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||||
|
const day = String(date.getDate()).padStart(2, "0");
|
||||||
|
const hours = String(date.getHours()).padStart(2, "0");
|
||||||
|
const minutes = String(date.getMinutes()).padStart(2, "0");
|
||||||
|
return `${year}-${month}-${day}T${hours}:${minutes}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function localDatetimeToTimestamp(datetime: string): number | undefined {
|
||||||
|
if (!datetime || datetime.length < 16) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timestamp = new Date(datetime).getTime();
|
||||||
|
if (Number.isNaN(timestamp)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.floor(timestamp / 1000);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user