diff --git a/app/api/date-range/route.ts b/app/api/date-range/route.ts new file mode 100644 index 0000000..a1a37e5 --- /dev/null +++ b/app/api/date-range/route.ts @@ -0,0 +1,7 @@ +import { NextResponse } from "next/server"; +import { getDateRange } from "@/lib/queries"; + +export async function GET() { + const data = await getDateRange(); + return NextResponse.json(data); +} diff --git a/components/TimeRangeSelector.tsx b/components/TimeRangeSelector.tsx index b3b8980..b35315a 100644 --- a/components/TimeRangeSelector.tsx +++ b/components/TimeRangeSelector.tsx @@ -8,18 +8,14 @@ import { useTimeRange } from "@/lib/time-range-context"; import { useI18n } from "@/lib/i18n"; export function TimeRangeSelector() { - const { t, locale } = useI18n(); + const { t } = useI18n(); const { range, setRange, customStart, customEnd, setCustomStart, setCustomEnd } = useTimeRange(); const [showPopover, setShowPopover] = useState(false); - const [localStart, setLocalStart] = useState(customStart); - const [localEnd, setLocalEnd] = useState(customEnd); + const [localStart, setLocalStart] = useState(""); + const [localEnd, setLocalEnd] = useState(""); const popoverRef = useRef(null); const containerRef = useRef(null); - // Sync local state when context changes - useEffect(() => { setLocalStart(customStart); }, [customStart]); - useEffect(() => { setLocalEnd(customEnd); }, [customEnd]); - // Close popover on click outside useEffect(() => { if (!showPopover) return; @@ -48,7 +44,9 @@ export function TimeRangeSelector() { if (range === "custom" && showPopover) { setShowPopover(false); } else { - setRange("custom"); + // customStart/customEnd already reflects the current preset's dates + setLocalStart(customStart); + setLocalEnd(customEnd); setShowPopover(true); } } @@ -56,6 +54,7 @@ export function TimeRangeSelector() { function handleConfirm() { setCustomStart(localStart); setCustomEnd(localEnd); + setRange("custom"); setShowPopover(false); } @@ -126,7 +125,6 @@ export function TimeRangeSelector() { setLocalStart(e.target.value)} className="input-glass w-full rounded-md px-3 py-1.5 text-xs font-[family-name:var(--font-geist-mono)]" @@ -138,7 +136,6 @@ export function TimeRangeSelector() { setLocalEnd(e.target.value)} className="input-glass w-full rounded-md px-3 py-1.5 text-xs font-[family-name:var(--font-geist-mono)]" diff --git a/lib/queries.ts b/lib/queries.ts index 0f46513..4075a09 100644 --- a/lib/queries.ts +++ b/lib/queries.ts @@ -6,6 +6,18 @@ const REAL_MODEL = `COALESCE( THEN other::jsonb->>'upstream_model_name' END, model_name)`; +// ── 数据时间边界 ──────────────────────────────────────────────── + +export async function getDateRange(): Promise<{ minDate: string; maxDate: string }> { + const rows = await query( + `SELECT + ((MIN(to_timestamp(created_at)) AT TIME ZONE 'Asia/Shanghai')::date)::text as min_date, + ((MAX(to_timestamp(created_at)) AT TIME ZONE 'Asia/Shanghai')::date)::text as max_date + FROM logs WHERE type = 2` + ); + return { minDate: rows[0]?.min_date ?? "", maxDate: rows[0]?.max_date ?? "" }; +} + // 时间条件构建 function timeWhere( params: (string | number | boolean | null)[], @@ -19,7 +31,7 @@ function timeWhere( } if (endTs) { params.push(endTs); - where += ` AND created_at < $${params.length}`; + where += ` AND created_at <= $${params.length}`; } return where; } @@ -91,8 +103,8 @@ export async function getTrends( const truncExpr = granularity === "day" - ? `(to_timestamp(created_at) AT TIME ZONE 'Asia/Shanghai')::date` - : `date_trunc('${granularity}', to_timestamp(created_at) AT TIME ZONE 'Asia/Shanghai')::date`; + ? `((to_timestamp(created_at) AT TIME ZONE 'Asia/Shanghai')::date)::text` + : `(date_trunc('${granularity}', to_timestamp(created_at) AT TIME ZONE 'Asia/Shanghai')::date)::text`; const rows = await query( `SELECT @@ -107,7 +119,7 @@ export async function getTrends( ); return rows.map((r) => ({ - date: r.date instanceof Date ? r.date.toISOString().slice(0, 10) : String(r.date).slice(0, 10), + date: String(r.date).slice(0, 10), calls: Number(r.calls), prompt_tokens: Number(r.prompt_tokens), completion_tokens: Number(r.completion_tokens), diff --git a/lib/time-range-context.tsx b/lib/time-range-context.tsx index 0fdfbcc..ef8f4db 100644 --- a/lib/time-range-context.tsx +++ b/lib/time-range-context.tsx @@ -1,8 +1,8 @@ "use client"; -import { createContext, useContext, useState, useCallback, useSyncExternalStore, type ReactNode } from "react"; +import { createContext, useContext, useState, useCallback, useRef, useEffect, useSyncExternalStore, type ReactNode } from "react"; import dayjs from "dayjs"; -import { type TimeRange, getTimeRange } from "@/lib/utils"; +import { type TimeRange } from "@/lib/utils"; interface TimeRangeContextType { range: TimeRange; @@ -16,7 +16,7 @@ interface TimeRangeContextType { } const TimeRangeContext = createContext({ - range: "30d", + range: "7d", setRange: () => {}, customStart: "", customEnd: "", @@ -25,9 +25,8 @@ const TimeRangeContext = createContext({ getEffectiveRange: () => ({}), }); -// Use useSyncExternalStore to safely read localStorage without hydration mismatch const STORAGE_KEY = "time-range"; -const DEFAULT_RANGE = "30d"; +const DEFAULT_RANGE: TimeRange = "7d"; let listeners: Array<() => void> = []; function subscribe(cb: () => void) { @@ -50,6 +49,30 @@ function persist(range: TimeRange, customStart: string, customEnd: string) { emitChange(); } +/** Convert a preset to concrete date strings */ +function presetDates(preset: TimeRange): { start: string; end: string } { + const today = dayjs().format("YYYY-MM-DD"); + switch (preset) { + case "today": + return { start: today, end: today }; + case "7d": + return { start: dayjs().subtract(6, "day").format("YYYY-MM-DD"), end: today }; + case "30d": + return { start: dayjs().subtract(29, "day").format("YYYY-MM-DD"), end: today }; + default: + return { start: today, end: today }; + } +} + +function initDates(saved: { range?: TimeRange; customStart?: string; customEnd?: string } | null) { + if (saved?.customStart && saved?.customEnd) { + return { start: saved.customStart, end: saved.customEnd }; + } + const r = (saved?.range as TimeRange) ?? DEFAULT_RANGE; + if (r === "all") return { start: "", end: "" }; + return presetDates(r); +} + export function TimeRangeProvider({ children }: { children: ReactNode }) { const raw = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); @@ -58,46 +81,70 @@ export function TimeRangeProvider({ children }: { children: ReactNode }) { try { return JSON.parse(raw); } catch { return null; } })(); + const init = initDates(saved); const [range, setRangeState] = useState(saved?.range ?? DEFAULT_RANGE); - const [customStart, setCustomStartState] = useState( - saved?.customStart || dayjs().subtract(7, "day").format("YYYY-MM-DD") - ); - const [customEnd, setCustomEndState] = useState( - saved?.customEnd || dayjs().format("YYYY-MM-DD") - ); + const [customStart, setCustomStartState] = useState(init.start); + const [customEnd, setCustomEndState] = useState(init.end); + + const rangeRef = useRef(range); + const customStartRef = useRef(customStart); + const customEndRef = useRef(customEnd); + useEffect(() => { rangeRef.current = range; }, [range]); + useEffect(() => { customStartRef.current = customStart; }, [customStart]); + useEffect(() => { customEndRef.current = customEnd; }, [customEnd]); + + // Fetch actual date boundaries when "all" is selected (including on mount) + const fetchDateRange = useCallback(async () => { + try { + const res = await fetch("/api/date-range"); + const { minDate, maxDate } = await res.json(); + if (minDate && maxDate) { + setCustomStartState(minDate); + setCustomEndState(maxDate); + persist("all", minDate, maxDate); + } + } catch { /* ignore */ } + }, []); + + // On mount: if saved range is "all", fetch real boundaries + useEffect(() => { + if (range === "all") fetchDateRange(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); const setRange = useCallback((r: TimeRange) => { setRangeState(r); - setCustomStartState((prev: string) => { - setCustomEndState((end: string) => { persist(r, prev, end); return end; }); - return prev; - }); - }, []); + if (r === "all") { + // Query first, then backfill dates + fetchDateRange(); + } else if (r !== "custom") { + const { start, end } = presetDates(r); + setCustomStartState(start); + setCustomEndState(end); + persist(r, start, end); + } else { + persist(r, customStartRef.current, customEndRef.current); + } + }, [fetchDateRange]); const setCustomStart = useCallback((s: string) => { setCustomStartState(s); - setRangeState((r: TimeRange) => { - setCustomEndState((end: string) => { persist(r, s, end); return end; }); - return r; - }); + persist(rangeRef.current, s, customEndRef.current); }, []); const setCustomEnd = useCallback((e: string) => { setCustomEndState(e); - setRangeState((r: TimeRange) => { - setCustomStartState((start: string) => { persist(r, start, e); return start; }); - return r; - }); + persist(rangeRef.current, customStartRef.current, e); }, []); + // Always derive timestamps from customStart/customEnd — single source of truth const getEffectiveRange = useCallback(() => { - if (range === "custom") { - const result: { start?: number; end?: number } = {}; - if (customStart) result.start = dayjs(customStart).startOf("day").unix(); - if (customEnd) result.end = dayjs(customEnd).endOf("day").unix(); - return result; - } - return getTimeRange(range); + // "all" with dates not yet loaded → no filter + if (range === "all" && !customStart && !customEnd) return {}; + const result: { start?: number; end?: number } = {}; + if (customStart) result.start = dayjs(customStart).startOf("day").unix(); + if (customEnd) result.end = dayjs(customEnd).endOf("day").unix(); + return result; }, [range, customStart, customEnd]); return ( diff --git a/lib/utils.ts b/lib/utils.ts index 323d420..a80bc7d 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -25,23 +25,6 @@ export function formatDate(iso: string): string { // 预设时间范围 export type TimeRange = "today" | "7d" | "30d" | "all" | "custom"; -export function getTimeRange(range: TimeRange): { start?: number; end?: number } { - const now = dayjs(); - const end = now.endOf("day").unix(); - switch (range) { - case "today": - return { start: now.startOf("day").unix(), end }; - case "7d": - return { start: now.subtract(7, "day").startOf("day").unix(), end }; - case "30d": - return { start: now.subtract(30, "day").startOf("day").unix(), end }; - case "all": - return {}; - default: - return {}; - } -} - export function buildQuery( base: string, params: Record