diff --git a/app/aggregation/page.tsx b/app/aggregation/page.tsx index 0d54257..1f4e3fd 100644 --- a/app/aggregation/page.tsx +++ b/app/aggregation/page.tsx @@ -3,18 +3,19 @@ import { useEffect, useState, useCallback, useRef, startTransition } from "react"; import { createPortal } from "react-dom"; import { motion } from "motion/react"; -import { Users, Calendar, Hash, Zap, ArrowUpDown, ArrowDown, ArrowUp, HelpCircle } from "lucide-react"; +import { Users, Calendar, Hash, Zap, DollarSign, ArrowUpDown, ArrowDown, ArrowUp, HelpCircle } from "lucide-react"; import { TimeRangeSelector } from "@/components/TimeRangeSelector"; -import { buildQuery, formatNumber, formatTokens } from "@/lib/utils"; +import { buildQuery, formatNumber, formatTokens, formatUSD } from "@/lib/utils"; import { useTimeRange } from "@/lib/time-range-context"; import { useI18n } from "@/lib/i18n"; interface AggItem { rank: number; name: string; calls: number; prompt_tokens: number; completion_tokens: number; cache_creation_tokens: number; cache_read_tokens: number; total_tokens: number; + quota: number; quota_usd: number; } -type SortKey = "total_tokens" | "calls" | "prompt_tokens" | "completion_tokens" | "cache_creation_tokens" | "cache_read_tokens" | "ratio"; +type SortKey = "total_tokens" | "calls" | "prompt_tokens" | "completion_tokens" | "cache_creation_tokens" | "cache_read_tokens" | "quota_usd" | "ratio"; function RatioTooltip({ text }: { text: string }) { const [show, setShow] = useState(false); @@ -103,8 +104,8 @@ export default function AggregationPage() { }); const totals = data.reduce( - (acc, d) => ({ calls: acc.calls + d.calls, tokens: acc.tokens + d.total_tokens }), - { calls: 0, tokens: 0 } + (acc, d) => ({ calls: acc.calls + d.calls, tokens: acc.tokens + d.total_tokens, cost: acc.cost + d.quota_usd }), + { calls: 0, tokens: 0, cost: 0 } ); function handleSort(key: SortKey) { @@ -126,6 +127,7 @@ export default function AggregationPage() { { key: "cache_read_tokens", label: t("th.cacheRead") }, { key: "completion_tokens", label: t("th.output") }, { key: "total_tokens", label: t("th.totalToken") }, + { key: "quota_usd", label: t("th.cost") }, ]; return ( @@ -155,6 +157,11 @@ export default function AggregationPage() { {t("agg.totalToken")} {formatTokens(totals.tokens)} +
+ + {t("agg.totalCost")} + {formatUSD(totals.cost)} +
)} @@ -183,7 +190,7 @@ export default function AggregationPage() { {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; @@ -197,6 +204,7 @@ export default function AggregationPage() { {formatTokens(item.cache_read_tokens)} {formatTokens(item.completion_tokens)} {formatTokens(item.total_tokens)} + {formatUSD(item.quota_usd)} = 1 ? "var(--accent)" : "var(--text-muted)" }}>{ratio.toFixed(2)}
diff --git a/app/api/trends/route.ts b/app/api/trends/route.ts index c37ca77..9dac7eb 100644 --- a/app/api/trends/route.ts +++ b/app/api/trends/route.ts @@ -1,12 +1,22 @@ import { NextRequest, NextResponse } from "next/server"; -import { getTrends } from "@/lib/queries"; +import { getTrends, type TrendGranularity } from "@/lib/queries"; + +const GRANULARITIES: TrendGranularity[] = ["hour", "day", "week", "month"]; export async function GET(req: NextRequest) { const sp = req.nextUrl.searchParams; - const granularity = (sp.get("granularity") || "day") as "day" | "week" | "month"; + const requestedGranularity = sp.get("granularity"); + const granularity = GRANULARITIES.includes(requestedGranularity as TrendGranularity) + ? requestedGranularity as TrendGranularity + : "day"; const startTs = sp.get("start") ? Number(sp.get("start")) : undefined; const endTs = sp.get("end") ? Number(sp.get("end")) : undefined; + const channelId = sp.get("channel_id") ? Number(sp.get("channel_id")) : undefined; - const data = await getTrends(granularity, startTs, endTs); + const data = await getTrends(granularity, startTs, endTs, { + username: sp.get("username") || undefined, + model: sp.get("model") || undefined, + channelId: Number.isFinite(channelId) ? channelId : undefined, + }); return NextResponse.json(data); } diff --git a/app/detail/[...slug]/page.tsx b/app/detail/[...slug]/page.tsx index e84c082..ce74c51 100644 --- a/app/detail/[...slug]/page.tsx +++ b/app/detail/[...slug]/page.tsx @@ -3,12 +3,12 @@ import { useEffect, useState, useCallback, startTransition } from "react"; import { useParams } from "next/navigation"; import { motion } from "motion/react"; -import { ArrowLeft, Hash, Zap, MessageSquare, DatabaseZap, BookOpen } from "lucide-react"; +import { ArrowLeft, Hash, Zap, DollarSign, MessageSquare, DatabaseZap, BookOpen } from "lucide-react"; import Link from "next/link"; import { StatsCard } from "@/components/StatsCard"; import { TimeRangeSelector } from "@/components/TimeRangeSelector"; import { TrendChart } from "@/components/charts/TrendChart"; -import { buildQuery, formatNumber, formatTokens } from "@/lib/utils"; +import { buildQuery, formatNumber, formatTokens, formatUSD } from "@/lib/utils"; import { useTimeRange } from "@/lib/time-range-context"; import { useI18n } from "@/lib/i18n"; @@ -75,12 +75,13 @@ export default function DetailPage() {
) : data ? ( <> -
+
- - - + + + +
@@ -97,6 +98,7 @@ export default function DetailPage() { {t("th.name")} {t("th.calls")} {t("th.totalToken")} + {t("th.cost")} @@ -105,6 +107,7 @@ export default function DetailPage() { {item.name} {formatNumber(item.calls)} {formatTokens(item.total_tokens)} + {formatUSD(item.quota / 500000)} ))} diff --git a/app/page.tsx b/app/page.tsx index 27bd4f1..64599d1 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -2,7 +2,7 @@ import { useEffect, useState, useCallback, startTransition } from "react"; import { motion } from "motion/react"; -import { Zap, Hash, Users, Cpu, TrendingUp, BarChart3 } from "lucide-react"; +import { Zap, Hash, Users, Cpu, DollarSign, TrendingUp, BarChart3 } from "lucide-react"; import { StatsCard } from "@/components/StatsCard"; import { TimeRangeSelector } from "@/components/TimeRangeSelector"; import { TrendChart } from "@/components/charts/TrendChart"; @@ -14,6 +14,7 @@ import { useI18n } from "@/lib/i18n"; interface OverviewData { total_calls: number; total_tokens: number; + total_quota: number; active_users: number; active_models: number; } @@ -24,6 +25,7 @@ interface TrendPoint { total_tokens: number; prompt_tokens: number; completion_tokens: number; + quota: number; } interface RankItem { @@ -35,8 +37,8 @@ interface RankItem { export default function DashboardPage() { const { t } = useI18n(); const { getEffectiveRange } = useTimeRange(); - const [granularity, setGranularity] = useState<"day" | "week" | "month">("day"); - const [trendMetric, setTrendMetric] = useState<"total_tokens" | "calls">("total_tokens"); + const [granularity, setGranularity] = useState<"hour" | "day" | "week" | "month">("day"); + const [trendMetric, setTrendMetric] = useState<"total_tokens" | "calls" | "quota">("total_tokens"); const [overview, setOverview] = useState(null); const [trends, setTrends] = useState([]); const [userRank, setUserRank] = useState([]); @@ -61,6 +63,7 @@ export default function DashboardPage() { useEffect(() => { fetchData(); }, [fetchData]); const grans = [ + { key: "hour" as const, label: t("gran.hour") }, { key: "day" as const, label: t("gran.day") }, { key: "week" as const, label: t("gran.week") }, { key: "month" as const, label: t("gran.month") }, @@ -76,11 +79,12 @@ export default function DashboardPage() {
{overview && ( -
+
- - + + +
)} @@ -104,7 +108,7 @@ export default function DashboardPage() { ))}
- {([["total_tokens", t("metric.token")], ["calls", t("metric.calls")]] as const).map(([k, l]) => ( + {([["total_tokens", t("metric.token")], ["calls", t("metric.calls")], ["quota", t("metric.cost")]] as const).map(([k, l]) => ( {/* Status */} diff --git a/components/StatsCard.tsx b/components/StatsCard.tsx index fee7408..dcfb9ef 100644 --- a/components/StatsCard.tsx +++ b/components/StatsCard.tsx @@ -1,19 +1,19 @@ "use client"; import { motion } from "motion/react"; -import { formatNumber, formatTokens } from "@/lib/utils"; +import { formatNumber, formatTokens, formatUSD } from "@/lib/utils"; import { type LucideIcon } from "lucide-react"; interface StatsCardProps { title: string; value: number; - format?: "number" | "tokens"; + format?: "number" | "tokens" | "usd"; icon: LucideIcon; delay?: number; } export function StatsCard({ title, value, format = "number", icon: Icon, delay = 0 }: StatsCardProps) { - const display = format === "tokens" ? formatTokens(value) : formatNumber(value); + const display = format === "tokens" ? formatTokens(value) : format === "usd" ? formatUSD(value) : formatNumber(value); return ( {t("common.noData")}
; - // 本地化日期格式 + const parseTrendDate = (dateStr: string) => { + const [datePart, timePart] = dateStr.replace("T", " ").split(" "); + const [year, month, day] = datePart.split("-").map(Number); + const hour = timePart ? timePart.slice(0, 5) : ""; + return { year, month, day, hour }; + }; + const formatDateLabel = (dateStr: string) => { - const d = new Date(dateStr); + const { year, month, day, hour } = parseTrendDate(dateStr); + const d = new Date(year, month - 1, day); if (locale === "zh") { - return `${d.getMonth() + 1}/${d.getDate()}`; + return hour ? `${month}/${day} ${hour}` : `${month}/${day}`; } - return d.toLocaleDateString("en-US", { month: "short", day: "numeric" }); + const dateLabel = d.toLocaleDateString("en-US", { month: "short", day: "numeric" }); + return hour ? `${dateLabel}, ${hour}` : dateLabel; + }; + + const formatTooltipLabel = (label: string) => { + const { year, month, day, hour } = parseTrendDate(label); + const d = new Date(year, month - 1, day); + if (locale === "zh") { + return hour ? `${year}/${month}/${day} ${hour}` : `${year}/${month}/${day}`; + } + const dateLabel = d.toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" }); + return hour ? `${dateLabel}, ${hour}` : dateLabel; }; const tooltipStyle = { @@ -66,12 +84,7 @@ export function TrendChart({ data, metric = "total_tokens" }: { data: TrendPoint /> { - const d = new Date(String(label)); - return locale === "zh" - ? `${d.getFullYear()}/${d.getMonth() + 1}/${d.getDate()}` - : d.toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" }); - }} + labelFormatter={(label) => formatTooltipLabel(String(label))} formatter={(value, name) => [ formatTokens(Number(value)), name === t("th.input") ? t("th.input") : t("th.output"), @@ -85,6 +98,26 @@ export function TrendChart({ data, metric = "total_tokens" }: { data: TrendPoint ); } + // 金额模式:单 Y 轴 + if (metric === "quota") { + return ( + + + + + formatUSD(v / 500000)} tick={{ fontSize: 11 }} stroke="var(--chart-grid)" /> + formatTooltipLabel(String(label))} + formatter={(value) => [formatUSD(Number(value) / 500000), t("th.cost")]} + /> + + + + + ); + } + // 调用量模式:单 Y 轴 return ( @@ -94,12 +127,7 @@ export function TrendChart({ data, metric = "total_tokens" }: { data: TrendPoint formatTokens(v)} tick={{ fontSize: 11 }} stroke="var(--chart-grid)" /> { - const d = new Date(String(label)); - return locale === "zh" - ? `${d.getFullYear()}/${d.getMonth() + 1}/${d.getDate()}` - : d.toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" }); - }} + labelFormatter={(label) => formatTooltipLabel(String(label))} formatter={(value, name) => [ formatTokens(Number(value)), name === t("th.calls") ? t("th.calls") : String(name), diff --git a/lib/i18n.tsx b/lib/i18n.tsx index 3d1deb6..284425b 100644 --- a/lib/i18n.tsx +++ b/lib/i18n.tsx @@ -31,11 +31,13 @@ const translations = { "time.endDate": "结束日期", "time.confirm": "确认", // granularity + "gran.hour": "小时", "gran.day": "日", "gran.week": "周", "gran.month": "月", // metrics "metric.token": "Token", + "metric.cost": "金额", "metric.calls": "调用量", // dashboard "dash.title": "仪表盘", @@ -45,6 +47,7 @@ const translations = { "dash.activeModels": "活跃模型", "dash.trend": "使用趋势", "dash.userTop10": "用户 Top 10 — Token 消耗", + "dash.totalCost": "消费金额", "dash.modelTop10": "模型 Top 10 — Token 消耗", // table headers "th.rank": "#", @@ -55,6 +58,7 @@ const translations = { "th.output": "输出", "th.cacheCreation": "缓存创建", "th.cacheRead": "缓存读取", + "th.cost": "金额", "th.totalToken": "总 Token", "th.time": "时间", "th.realModel": "真实模型", @@ -69,6 +73,7 @@ const translations = { "agg.title": "用户聚合", "agg.userCount": "用户数", "agg.totalCalls": "总调用", + "agg.totalCost": "总金额", "agg.totalToken": "总 Token", "agg.ratio": "转换率", "agg.ratioTip": "输出Token / 输入Token,反映每次请求的生成效率。>1 表示输出多于输入(如生成、写作),<1 表示输入多于输出(如分析、摘要)", @@ -154,10 +159,12 @@ const translations = { "time.startDate": "Start", "time.endDate": "End", "time.confirm": "Confirm", + "gran.hour": "Hour", "gran.day": "Day", "gran.week": "Week", "gran.month": "Month", "metric.token": "Token", + "metric.cost": "Cost", "metric.calls": "Calls", "dash.title": "Dashboard", "dash.totalCalls": "Total Calls", @@ -166,6 +173,7 @@ const translations = { "dash.activeModels": "Active Models", "dash.trend": "Usage Trend", "dash.userTop10": "User Top 10 — Token Usage", + "dash.totalCost": "Total Cost", "dash.modelTop10": "Model Top 10 — Token Usage", "th.rank": "#", "th.name": "Name", @@ -175,6 +183,7 @@ const translations = { "th.output": "Output", "th.cacheCreation": "Cache Write", "th.cacheRead": "Cache Read", + "th.cost": "Cost", "th.totalToken": "Total Token", "th.time": "Time", "th.realModel": "Real Model", @@ -187,6 +196,7 @@ const translations = { "agg.title": "User Aggregation", "agg.userCount": "Users", "agg.totalCalls": "Total Calls", + "agg.totalCost": "Total Cost", "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)", diff --git a/lib/queries.test.ts b/lib/queries.test.ts new file mode 100644 index 0000000..8bc138a --- /dev/null +++ b/lib/queries.test.ts @@ -0,0 +1,46 @@ +import { beforeEach, describe, expect, mock, test } from "bun:test"; + +const queryMock = mock(async () => [ + { + date: "2026-04-01 13:00:00", + calls: 1, + prompt_tokens: 10, + completion_tokens: 20, + cache_creation_tokens: 3, + cache_read_tokens: 4, + quota: 100, + }, +]); + +mock.module("./db", () => ({ + query: queryMock, +})); + +const { getTrends } = await import("./queries"); + +describe("getTrends", () => { + beforeEach(() => { + queryMock.mockClear(); + }); + + test("adds optional detail filters to the trend query", async () => { + await getTrends("day", 101, 201, { username: "alice" }); + expect(queryMock.mock.calls[0][0]).toContain("username = $3"); + expect(queryMock.mock.calls[0][1]).toEqual([101, 201, "alice"]); + + await getTrends("day", 102, 202, { model: "gpt-4.1" }); + expect(queryMock.mock.calls[1][0]).toContain("= $3"); + expect(queryMock.mock.calls[1][1]).toEqual([102, 202, "gpt-4.1"]); + + await getTrends("day", 103, 203, { channelId: 7 }); + expect(queryMock.mock.calls[2][0]).toContain("channel_id = $3"); + expect(queryMock.mock.calls[2][1]).toEqual([103, 203, 7]); + }); + + test("keeps the hour in hourly trend buckets", async () => { + const trends = await getTrends("hour", 104, 204); + + expect(queryMock.mock.calls[0][0]).toContain("date_trunc('hour'"); + expect(trends[0].date).toBe("2026-04-01 13:00"); + }); +}); diff --git a/lib/queries.ts b/lib/queries.ts index 98adfdd..f87399e 100644 --- a/lib/queries.ts +++ b/lib/queries.ts @@ -130,19 +130,50 @@ export interface TrendPoint { quota: number; } +export type TrendGranularity = "hour" | "day" | "week" | "month"; + +export interface TrendFilters { + username?: string; + model?: string; + channelId?: number; +} + +function appendTrendFilters( + where: string, + params: (string | number | boolean | null)[], + filters: TrendFilters = {} +): string { + if (filters.username) { + params.push(filters.username); + where += ` AND username = $${params.length}`; + } + if (filters.model) { + params.push(filters.model); + where += ` AND ${REAL_MODEL} = $${params.length}`; + } + if (filters.channelId !== undefined) { + params.push(filters.channelId); + where += ` AND channel_id = $${params.length}`; + } + return where; +} + export function getTrends( - granularity: "day" | "week" | "month" = "day", + granularity: TrendGranularity = "day", startTs?: number, - endTs?: number + endTs?: number, + filters: TrendFilters = {} ): Promise { - return cached(cacheKey("trends", granularity, startTs, endTs), async () => { + return cached(cacheKey("trends", granularity, startTs, endTs, filters.username, filters.model, filters.channelId), async () => { const params: (string | number | boolean | null)[] = []; - const where = timeWhere(params, startTs, endTs); + const where = appendTrendFilters(timeWhere(params, startTs, endTs), params, filters); const truncExpr = - granularity === "day" - ? `((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`; + granularity === "hour" + ? `to_char(date_trunc('hour', to_timestamp(created_at) AT TIME ZONE 'Asia/Shanghai'), 'YYYY-MM-DD HH24:00')` + : granularity === "day" + ? `((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 @@ -159,7 +190,7 @@ export function getTrends( ); return rows.map((r) => ({ - date: String(r.date).slice(0, 10), + date: String(r.date).slice(0, granularity === "hour" ? 16 : 10), calls: Number(r.calls), prompt_tokens: Number(r.prompt_tokens), completion_tokens: Number(r.completion_tokens), diff --git a/next.config.ts b/next.config.ts index 68a6c64..a303ae5 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,7 +1,14 @@ import type { NextConfig } from "next"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const projectRoot = path.dirname(fileURLToPath(import.meta.url)); const nextConfig: NextConfig = { output: "standalone", + turbopack: { + root: projectRoot, + }, }; export default nextConfig;