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)}
+
{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;
|