diff --git a/CLAUDE.md b/CLAUDE.md index 43c994c..e81620e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1 +1,63 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + @AGENTS.md + +## Commands + +```bash +npm run dev # Start dev server (localhost:3000) +npm run build # Production build (standalone output) +npm run start # Start production server +npm run lint # ESLint (eslint-config-next/core-web-vitals + typescript) +``` + +Docker deployment: port 8019, `docker-compose up` with `.env.production`, external network `sinobridge`. + +## Architecture + +Next.js 16 App Router analytics dashboard for an API gateway. React 19, TypeScript strict mode, Tailwind CSS 4, Recharts 3, PostgreSQL. + +### Three Global Contexts (wrapped in `components/ClientProviders.tsx`) + +1. **ThemeProvider** (`lib/theme.tsx`) — light/dark/system, localStorage `theme`, supports iframe embedding via `?theme=` query param and postMessage sync +2. **I18nProvider** (`lib/i18n.tsx`) — zh/en, localStorage `locale`, all translation keys in a single `translations` object +3. **TimeRangeProvider** (`lib/time-range-context.tsx`) — today/7d/30d/all/custom, localStorage `time-range`, exposes `getEffectiveRange()` returning `{ start?, end? }` unix timestamps (seconds) + +Pages consume these via `useTheme()`, `useI18n()`, `useTimeRange()`. The `TimeRangeSelector` component reads from context (no props). + +### Data Flow + +Pages call `getEffectiveRange()` → `buildQuery("/api/...", { start, end })` → API route → `lib/queries.ts` SQL → PostgreSQL → JSON response → Recharts/tables. + +### Database Layer (`lib/db.ts`, `lib/queries.ts`) + +- `pg` pool (max 10 connections), env var `PG_CONNECTION_STRING` +- All queries filter `type = 2`, timezone `Asia/Shanghai` +- Real model name resolution: `COALESCE(other::jsonb->>'upstream_model_name', model_name)` +- Quota to USD: `quota / 500000` +- Parameterized queries with `$N` positional params + +### API Routes (all GET, query-param driven) + +| Route | Key Params | Returns | +|-------|-----------|---------| +| `/api/overview` | start, end | Aggregate stats (calls, tokens, users, models, channels) | +| `/api/trends` | start, end, granularity (day/week/month) | Time-series array | +| `/api/rankings` | start, end, type (user/model/channel), limit | Ranked items | +| `/api/detail/[type]/[id]` | start, end | Stats + breakdown (models or users) | +| `/api/logs` | start, end, page, page_size, username, model, token_name | Paginated logs | +| `/api/aggregation` | start, end | All users aggregated (top 500) | + +### Styling + +CSS variables in `globals.css` — dark theme default (cyan `#00e5ff` accent), light theme (blue `#0284c7`). Key classes: `.glass` (glassmorphic card), `.gradient-text`, `.btn-accent`, `.input-glass`, `.row-glow`. Portal page (`/portal`) has its own extensive animation system. + +### Path Alias + +`@/*` maps to project root (tsconfig). + +### Fonts + +Outfit (sans, `--font-geist-sans`) and JetBrains Mono (mono, `--font-geist-mono`) via `next/font/google`. diff --git a/app/aggregation/page.tsx b/app/aggregation/page.tsx index d8e8da7..db033e6 100644 --- a/app/aggregation/page.tsx +++ b/app/aggregation/page.tsx @@ -1,8 +1,9 @@ "use client"; -import { useEffect, useState, useCallback } from "react"; +import { useEffect, useState, useCallback, useRef } from "react"; +import { createPortal } from "react-dom"; import { motion } from "motion/react"; -import { Users, Calendar, Hash, Zap, ArrowUpDown, ArrowDown, ArrowUp } from "lucide-react"; +import { Users, Calendar, Hash, Zap, ArrowUpDown, ArrowDown, ArrowUp, HelpCircle } from "lucide-react"; import { TimeRangeSelector } from "@/components/TimeRangeSelector"; import { buildQuery, formatNumber, formatTokens } from "@/lib/utils"; import { useTimeRange } from "@/lib/time-range-context"; @@ -13,7 +14,66 @@ interface AggItem { prompt_tokens: number; completion_tokens: number; total_tokens: number; } -type SortKey = "total_tokens" | "calls" | "prompt_tokens" | "completion_tokens"; +type SortKey = "total_tokens" | "calls" | "prompt_tokens" | "completion_tokens" | "ratio"; + +function RatioTooltip({ text }: { text: string }) { + const [show, setShow] = useState(false); + const [pos, setPos] = useState({ x: 0, y: 0 }); + const iconRef = useRef(null); + const [mounted, setMounted] = useState(false); + + useEffect(() => { setMounted(true); }, []); + + function handleEnter() { + if (iconRef.current) { + const rect = iconRef.current.getBoundingClientRect(); + setPos({ x: rect.right, y: rect.top }); + } + setShow(true); + } + + return ( + <> + e.stopPropagation()} + onMouseEnter={handleEnter} + onMouseLeave={() => setShow(false)} + > + + + {mounted && show && createPortal( +
+ {text} + +
, + document.body + )} + + ); +} export default function AggregationPage() { const { t } = useI18n(); @@ -34,7 +94,11 @@ export default function AggregationPage() { useEffect(() => { fetchData(); }, [fetchData]); const sorted = [...data].sort((a, b) => { - const diff = (a[sortKey] as number) - (b[sortKey] as number); + const getVal = (item: AggItem) => + sortKey === "ratio" + ? (item.prompt_tokens > 0 ? item.completion_tokens / item.prompt_tokens : 0) + : (item[sortKey] as number); + const diff = getVal(a) - getVal(b); return sortAsc ? diff : -diff; }); @@ -104,14 +168,23 @@ export default function AggregationPage() { {h.label} ))} + handleSort("ratio")}> + + {t("agg.ratio")} + + + + {t("common.share")} {loading ? ( -
+
) : sorted.map((item, i) => { const pct = totals.tokens > 0 ? (item.total_tokens / totals.tokens * 100) : 0; + const ratio = item.prompt_tokens > 0 ? (item.completion_tokens / item.prompt_tokens) : 0; return ( {i + 1} @@ -120,6 +193,7 @@ export default function AggregationPage() { {formatTokens(item.prompt_tokens)} {formatTokens(item.completion_tokens)} {formatTokens(item.total_tokens)} + = 1 ? "var(--accent)" : "var(--text-muted)" }}>{ratio.toFixed(2)}
diff --git a/lib/i18n.tsx b/lib/i18n.tsx index 8ca1973..77b8487 100644 --- a/lib/i18n.tsx +++ b/lib/i18n.tsx @@ -68,6 +68,8 @@ const translations = { "agg.userCount": "用户数", "agg.totalCalls": "总调用", "agg.totalToken": "总 Token", + "agg.ratio": "转换率", + "agg.ratioTip": "输出Token / 输入Token,反映每次请求的生成效率。>1 表示输出多于输入(如生成、写作),<1 表示输入多于输出(如分析、摘要)", // logs "logs.title": "日志明细", "logs.filterUser": "用户名", @@ -148,6 +150,8 @@ const translations = { "agg.userCount": "Users", "agg.totalCalls": "Total Calls", "agg.totalToken": "Total Token", + "agg.ratio": "Out/In Ratio", + "agg.ratioTip": "Completion tokens / Prompt tokens. >1 means more output than input (generation, writing); <1 means more input than output (analysis, summarization)", "logs.title": "Log Details", "logs.filterUser": "Username", "logs.filterModel": "Model", diff --git a/lib/time-range-context.tsx b/lib/time-range-context.tsx index c3b64c5..ba56ea0 100644 --- a/lib/time-range-context.tsx +++ b/lib/time-range-context.tsx @@ -1,6 +1,6 @@ "use client"; -import { createContext, useContext, useState, useCallback, type ReactNode } from "react"; +import { createContext, useContext, useState, useCallback, useSyncExternalStore, type ReactNode } from "react"; import dayjs from "dayjs"; import { type TimeRange, getTimeRange } from "@/lib/utils"; @@ -25,45 +25,70 @@ const TimeRangeContext = createContext({ 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; - } +// Use useSyncExternalStore to safely read localStorage without hydration mismatch +const STORAGE_KEY = "time-range"; +const DEFAULT_RANGE = "30d"; + +let listeners: Array<() => void> = []; +function subscribe(cb: () => void) { + listeners = [...listeners, cb]; + return () => { listeners = listeners.filter(l => l !== cb); }; +} +function emitChange() { + for (const l of listeners) l(); +} + +function getSnapshot(): string { + return localStorage.getItem(STORAGE_KEY) ?? ""; +} +function getServerSnapshot(): string { + return ""; } function persist(range: TimeRange, customStart: string, customEnd: string) { - localStorage.setItem("time-range", JSON.stringify({ range, customStart, customEnd })); + localStorage.setItem(STORAGE_KEY, JSON.stringify({ range, customStart, customEnd })); + emitChange(); } 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 raw = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); + + const saved = (() => { + if (!raw) return null; + try { return JSON.parse(raw); } catch { return null; } + })(); + + 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 setRange = useCallback((r: TimeRange) => { setRangeState(r); - setCustomStartState(prev => { persist(r, prev, customEnd); return prev; }); - }, [customEnd]); + setCustomStartState(prev => { + setCustomEndState(end => { persist(r, prev, end); return end; }); + return prev; + }); + }, []); const setCustomStart = useCallback((s: string) => { setCustomStartState(s); - persist(range, s, customEnd); - }, [range, customEnd]); + setRangeState(r => { + setCustomEndState(end => { persist(r, s, end); return end; }); + return r; + }); + }, []); const setCustomEnd = useCallback((e: string) => { setCustomEndState(e); - persist(range, customStart, e); - }, [range, customStart]); + setRangeState(r => { + setCustomStartState(start => { persist(r, start, e); return start; }); + return r; + }); + }, []); const getEffectiveRange = useCallback(() => { if (range === "custom") {