diff --git a/app/aggregation/page.tsx b/app/aggregation/page.tsx index 7c66c2f..d8e8da7 100644 --- a/app/aggregation/page.tsx +++ b/app/aggregation/page.tsx @@ -4,7 +4,8 @@ import { useEffect, useState, useCallback } from "react"; import { motion } from "motion/react"; import { Users, Calendar, Hash, Zap, ArrowUpDown, ArrowDown, ArrowUp } from "lucide-react"; import { TimeRangeSelector } from "@/components/TimeRangeSelector"; -import { type TimeRange, getTimeRange, buildQuery, formatNumber, formatTokens } from "@/lib/utils"; +import { buildQuery, formatNumber, formatTokens } from "@/lib/utils"; +import { useTimeRange } from "@/lib/time-range-context"; import { useI18n } from "@/lib/i18n"; interface AggItem { @@ -16,7 +17,7 @@ type SortKey = "total_tokens" | "calls" | "prompt_tokens" | "completion_tokens"; export default function AggregationPage() { const { t } = useI18n(); - const [range, setRange] = useState("30d"); + const { getEffectiveRange } = useTimeRange(); const [data, setData] = useState([]); const [loading, setLoading] = useState(true); const [sortKey, setSortKey] = useState("total_tokens"); @@ -24,11 +25,11 @@ export default function AggregationPage() { const fetchData = useCallback(async () => { setLoading(true); - const { start, end } = getTimeRange(range); + const { start, end } = getEffectiveRange(); const res = await fetch(buildQuery("/api/aggregation", { start, end })); setData(await res.json()); setLoading(false); - }, [range]); + }, [getEffectiveRange]); useEffect(() => { fetchData(); }, [fetchData]); @@ -68,7 +69,7 @@ export default function AggregationPage() {

{t("agg.title")}

- + {!loading && data.length > 0 && ( diff --git a/app/detail/[...slug]/page.tsx b/app/detail/[...slug]/page.tsx index f6fa2ea..624711a 100644 --- a/app/detail/[...slug]/page.tsx +++ b/app/detail/[...slug]/page.tsx @@ -8,7 +8,8 @@ import Link from "next/link"; import { StatsCard } from "@/components/StatsCard"; import { TimeRangeSelector } from "@/components/TimeRangeSelector"; import { TrendChart } from "@/components/charts/TrendChart"; -import { type TimeRange, getTimeRange, buildQuery, formatNumber, formatTokens } from "@/lib/utils"; +import { buildQuery, formatNumber, formatTokens } from "@/lib/utils"; +import { useTimeRange } from "@/lib/time-range-context"; import { useI18n } from "@/lib/i18n"; interface DetailData { @@ -27,14 +28,14 @@ export default function DetailPage() { const id = segments[1] || ""; const decodedId = decodeURIComponent(id); - const [range, setRange] = useState("30d"); + const { getEffectiveRange } = useTimeRange(); const [data, setData] = useState(null); const [trends, setTrends] = useState([]); const [loading, setLoading] = useState(true); const fetchData = useCallback(async () => { setLoading(true); - const { start, end } = getTimeRange(range); + const { start, end } = getEffectiveRange(); const tp = { start, end }; const [detail, tr] = await Promise.all([ fetch(buildQuery(`/api/detail/${type}/${encodeURIComponent(decodedId)}`, tp)).then(r => r.json()), @@ -45,7 +46,7 @@ export default function DetailPage() { })).then(r => r.json()), ]); setData(detail); setTrends(tr); setLoading(false); - }, [range, type, decodedId]); + }, [type, decodedId, getEffectiveRange]); useEffect(() => { fetchData(); }, [fetchData]); @@ -66,7 +67,7 @@ export default function DetailPage() {

{title}

- + {loading ? ( diff --git a/app/logs/page.tsx b/app/logs/page.tsx index 30c2c38..8bedd7a 100644 --- a/app/logs/page.tsx +++ b/app/logs/page.tsx @@ -4,7 +4,8 @@ import { useEffect, useState, useCallback } from "react"; import { motion } from "motion/react"; import { ScrollText, Search, ChevronLeft, ChevronRight, Zap } from "lucide-react"; import { TimeRangeSelector } from "@/components/TimeRangeSelector"; -import { type TimeRange, getTimeRange, buildQuery, formatNumber, formatTokens, formatDate } from "@/lib/utils"; +import { buildQuery, formatNumber, formatTokens, formatDate } from "@/lib/utils"; +import { useTimeRange } from "@/lib/time-range-context"; import { useI18n } from "@/lib/i18n"; interface LogEntry { @@ -16,7 +17,7 @@ interface LogEntry { export default function LogsPage() { const { t } = useI18n(); - const [range, setRange] = useState("7d"); + const { getEffectiveRange } = useTimeRange(); const [page, setPage] = useState(1); const [logs, setLogs] = useState([]); const [total, setTotal] = useState(0); @@ -26,14 +27,14 @@ export default function LogsPage() { const fetchData = useCallback(async () => { setLoading(true); - const { start, end } = getTimeRange(range); + const { start, end } = getEffectiveRange(); const res = await fetch(buildQuery("/api/logs", { start, end, page, page_size: pageSize, ...filters })); const data = await res.json(); setLogs(data.logs); setTotal(data.total); setLoading(false); - }, [range, page, filters]); + }, [page, filters, getEffectiveRange]); useEffect(() => { fetchData(); }, [fetchData]); - useEffect(() => { setPage(1); }, [range, filters]); + useEffect(() => { setPage(1); }, [getEffectiveRange, filters]); const totalPages = Math.ceil(total / pageSize); const headers = [t("th.time"), t("th.user"), t("th.realModel"), t("th.channel"), t("th.input"), t("th.output"), t("th.totalToken"), t("th.latency"), ""]; @@ -45,7 +46,7 @@ export default function LogsPage() {

{t("logs.title")}

- + diff --git a/app/page.tsx b/app/page.tsx index cc3e820..b77c1ff 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -7,12 +7,13 @@ import { StatsCard } from "@/components/StatsCard"; import { TimeRangeSelector } from "@/components/TimeRangeSelector"; import { TrendChart } from "@/components/charts/TrendChart"; import { RankingBar } from "@/components/charts/RankingBar"; -import { type TimeRange, getTimeRange, buildQuery } from "@/lib/utils"; +import { buildQuery } from "@/lib/utils"; +import { useTimeRange } from "@/lib/time-range-context"; import { useI18n } from "@/lib/i18n"; export default function DashboardPage() { const { t } = useI18n(); - const [range, setRange] = useState("30d"); + const { getEffectiveRange } = useTimeRange(); const [granularity, setGranularity] = useState<"day" | "week" | "month">("day"); const [trendMetric, setTrendMetric] = useState<"total_tokens" | "calls">("total_tokens"); const [overview, setOverview] = useState(null); @@ -23,7 +24,7 @@ export default function DashboardPage() { const fetchData = useCallback(async () => { setLoading(true); - const { start, end } = getTimeRange(range); + const { start, end } = getEffectiveRange(); const tp = { start, end }; const [ov, tr, ur, mr] = await Promise.all([ fetch(buildQuery("/api/overview", tp)).then(r => r.json()), @@ -32,7 +33,7 @@ export default function DashboardPage() { fetch(buildQuery("/api/rankings", { ...tp, type: "model", limit: 10 })).then(r => r.json()), ]); setOverview(ov); setTrends(tr); setUserRank(ur); setModelRank(mr); setLoading(false); - }, [range, granularity]); + }, [granularity, getEffectiveRange]); useEffect(() => { fetchData(); }, [fetchData]); @@ -48,7 +49,7 @@ export default function DashboardPage() { {t("dash.title")} - + {overview && ( diff --git a/app/rankings/page.tsx b/app/rankings/page.tsx index 40c02fd..56bb263 100644 --- a/app/rankings/page.tsx +++ b/app/rankings/page.tsx @@ -5,7 +5,8 @@ import Link from "next/link"; import { motion } from "motion/react"; import { Trophy, Users, Cpu, Radio, ArrowUpDown, ArrowDown, ArrowUp } from "lucide-react"; import { TimeRangeSelector } from "@/components/TimeRangeSelector"; -import { type TimeRange, getTimeRange, buildQuery, formatNumber, formatTokens } from "@/lib/utils"; +import { buildQuery, formatNumber, formatTokens } from "@/lib/utils"; +import { useTimeRange } from "@/lib/time-range-context"; import { useI18n } from "@/lib/i18n"; type Tab = "user" | "model" | "channel"; @@ -18,7 +19,7 @@ interface RankItem { export default function RankingsPage() { const { t } = useI18n(); - const [range, setRange] = useState("30d"); + const { getEffectiveRange } = useTimeRange(); const [tab, setTab] = useState("user"); const [data, setData] = useState([]); const [loading, setLoading] = useState(true); @@ -33,11 +34,11 @@ export default function RankingsPage() { const fetchData = useCallback(async () => { setLoading(true); - const { start, end } = getTimeRange(range); + const { start, end } = getEffectiveRange(); const res = await fetch(buildQuery("/api/rankings", { start, end, type: tab, limit: 100 })); setData(await res.json()); setLoading(false); - }, [range, tab]); + }, [tab, getEffectiveRange]); useEffect(() => { fetchData(); }, [fetchData]); @@ -80,7 +81,7 @@ export default function RankingsPage() {

{t("rank.title")}

- +
diff --git a/components/ClientProviders.tsx b/components/ClientProviders.tsx index 84559d3..7ffd405 100644 --- a/components/ClientProviders.tsx +++ b/components/ClientProviders.tsx @@ -4,6 +4,7 @@ import { type ReactNode } from "react"; import { usePathname } from "next/navigation"; import { I18nProvider } from "@/lib/i18n"; import { ThemeProvider } from "@/lib/theme"; +import { TimeRangeProvider } from "@/lib/time-range-context"; import { Sidebar } from "@/components/Sidebar"; export function ClientProviders({ children }: { children: ReactNode }) { @@ -13,14 +14,16 @@ export function ClientProviders({ children }: { children: ReactNode }) { return ( - {isPortal ? ( - <>{children} - ) : ( - <> - -
{children}
- - )} + + {isPortal ? ( + <>{children} + ) : ( + <> + +
{children}
+ + )} +
); diff --git a/components/TimeRangeSelector.tsx b/components/TimeRangeSelector.tsx index 9b87e13..0159a55 100644 --- a/components/TimeRangeSelector.tsx +++ b/components/TimeRangeSelector.tsx @@ -1,41 +1,156 @@ "use client"; +import { useState, useRef, useEffect } from "react"; +import { motion, AnimatePresence } from "motion/react"; +import { Calendar } from "lucide-react"; import { type TimeRange } from "@/lib/utils"; +import { useTimeRange } from "@/lib/time-range-context"; import { useI18n } from "@/lib/i18n"; -import { motion } from "motion/react"; -export function TimeRangeSelector({ - value, - onChange, -}: { - value: TimeRange; - onChange: (v: TimeRange) => void; -}) { +export function TimeRangeSelector() { const { t } = useI18n(); - const ranges: { label: string; value: TimeRange }[] = [ + const { range, setRange, customStart, customEnd, setCustomStart, setCustomEnd } = useTimeRange(); + const [showPopover, setShowPopover] = useState(false); + const [localStart, setLocalStart] = useState(customStart); + const [localEnd, setLocalEnd] = useState(customEnd); + 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; + const handler = (e: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + setShowPopover(false); + } + }; + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); + }, [showPopover]); + + const presets: { label: string; value: TimeRange }[] = [ { label: t("time.today"), value: "today" }, { label: t("time.7d"), value: "7d" }, { label: t("time.30d"), value: "30d" }, { label: t("time.all"), value: "all" }, ]; + function handlePreset(v: TimeRange) { + setRange(v); + setShowPopover(false); + } + + function handleCustomClick() { + if (range === "custom" && showPopover) { + setShowPopover(false); + } else { + setRange("custom"); + setShowPopover(true); + } + } + + function handleConfirm() { + setCustomStart(localStart); + setCustomEnd(localEnd); + setShowPopover(false); + } + return ( -
- {ranges.map((r) => ( - + ))} + + {/* Custom button */} + - ))} +
+ + {/* Popover */} + + {showPopover && ( + +
+ + 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)]" + /> +
+
+ + 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/i18n.tsx b/lib/i18n.tsx index 3f66ba6..8ca1973 100644 --- a/lib/i18n.tsx +++ b/lib/i18n.tsx @@ -26,6 +26,10 @@ const translations = { "time.7d": "7 天", "time.30d": "30 天", "time.all": "全部", + "time.custom": "自定义", + "time.startDate": "开始日期", + "time.endDate": "结束日期", + "time.confirm": "确认", // granularity "gran.day": "日", "gran.week": "周", @@ -108,6 +112,10 @@ const translations = { "time.7d": "7 Days", "time.30d": "30 Days", "time.all": "All", + "time.custom": "Custom", + "time.startDate": "Start", + "time.endDate": "End", + "time.confirm": "Confirm", "gran.day": "Day", "gran.week": "Week", "gran.month": "Month", diff --git a/lib/time-range-context.tsx b/lib/time-range-context.tsx new file mode 100644 index 0000000..c3b64c5 --- /dev/null +++ b/lib/time-range-context.tsx @@ -0,0 +1,93 @@ +"use client"; + +import { createContext, useContext, useState, useCallback, type ReactNode } from "react"; +import dayjs from "dayjs"; +import { type TimeRange, getTimeRange } from "@/lib/utils"; + +interface TimeRangeContextType { + range: TimeRange; + setRange: (r: TimeRange) => void; + customStart: string; + customEnd: string; + setCustomStart: (s: string) => void; + setCustomEnd: (s: string) => void; + /** Returns { start?, end? } unix timestamps (seconds) ready for API calls */ + getEffectiveRange: () => { start?: number; end?: number }; +} + +const TimeRangeContext = createContext({ + range: "30d", + setRange: () => {}, + customStart: "", + customEnd: "", + setCustomStart: () => {}, + setCustomEnd: () => {}, + getEffectiveRange: () => ({}), +}); + +function loadSaved(): { range: TimeRange; customStart: string; customEnd: string } | null { + if (typeof window === "undefined") return null; + try { + const raw = localStorage.getItem("time-range"); + if (!raw) return null; + const parsed = JSON.parse(raw); + return { + range: parsed.range || "30d", + customStart: parsed.customStart || "", + customEnd: parsed.customEnd || "", + }; + } catch { + return null; + } +} + +function persist(range: TimeRange, customStart: string, customEnd: string) { + localStorage.setItem("time-range", JSON.stringify({ range, customStart, customEnd })); +} + +export function TimeRangeProvider({ children }: { children: ReactNode }) { + const [range, setRangeState] = useState(() => loadSaved()?.range ?? "30d"); + const [customStart, setCustomStartState] = useState(() => loadSaved()?.customStart || dayjs().subtract(7, "day").format("YYYY-MM-DD")); + const [customEnd, setCustomEndState] = useState(() => loadSaved()?.customEnd || dayjs().format("YYYY-MM-DD")); + + const setRange = useCallback((r: TimeRange) => { + setRangeState(r); + setCustomStartState(prev => { persist(r, prev, customEnd); return prev; }); + }, [customEnd]); + + const setCustomStart = useCallback((s: string) => { + setCustomStartState(s); + persist(range, s, customEnd); + }, [range, customEnd]); + + const setCustomEnd = useCallback((e: string) => { + setCustomEndState(e); + persist(range, customStart, e); + }, [range, customStart]); + + 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); + }, [range, customStart, customEnd]); + + return ( + + {children} + + ); +} + +export function useTimeRange() { + return useContext(TimeRangeContext); +} diff --git a/lib/utils.ts b/lib/utils.ts index 90fe37f..323d420 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -27,13 +27,14 @@ 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: now.unix() }; + return { start: now.startOf("day").unix(), end }; case "7d": - return { start: now.subtract(7, "day").startOf("day").unix(), end: now.unix() }; + return { start: now.subtract(7, "day").startOf("day").unix(), end }; case "30d": - return { start: now.subtract(30, "day").startOf("day").unix(), end: now.unix() }; + return { start: now.subtract(30, "day").startOf("day").unix(), end }; case "all": return {}; default: