Files
new-api-analytics/app/logs/page.tsx
shangzy c5c91cc157 feat: 添加缓存 Token 独立展示(cache_creation / cache_read)
从 logs 表 other JSON 字段提取 cache_creation_tokens 和 cache_tokens,
在排名、日志、聚合、详情页分别展示,total_tokens 包含缓存部分。
2026-04-20 19:55:09 +08:00

117 lines
7.2 KiB
TypeScript

"use client";
import { useEffect, useState, useCallback, startTransition } from "react";
import { motion } from "motion/react";
import { ScrollText, Search, ChevronLeft, ChevronRight, Zap } from "lucide-react";
import { TimeRangeSelector } from "@/components/TimeRangeSelector";
import { buildQuery, formatNumber, formatDate } from "@/lib/utils";
import { useTimeRange } from "@/lib/time-range-context";
import { useI18n } from "@/lib/i18n";
interface LogEntry {
id: number; created_at: string; display_name: string;
real_model: string; channel_name: string; prompt_tokens: number;
completion_tokens: number; cache_creation_tokens: number; cache_read_tokens: number;
total_tokens: number; use_time: number; is_stream: boolean;
}
export default function LogsPage() {
const { t } = useI18n();
const { getEffectiveRange } = useTimeRange();
const [page, setPage] = useState(1);
const [logs, setLogs] = useState<LogEntry[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [filters, setFilters] = useState({ username: "", model: "", token_name: "" });
const pageSize = 100;
const fetchData = useCallback(async () => {
startTransition(() => setLoading(true));
const { start, end } = getEffectiveRange();
const res = await fetch(buildQuery("/api/logs", { start, end, page, page_size: pageSize, ...filters }));
const data = await res.json();
startTransition(() => { setLogs(data.logs); setTotal(data.total); setLoading(false); });
}, [page, filters, getEffectiveRange]);
useEffect(() => { fetchData(); }, [fetchData]);
useEffect(() => { startTransition(() => 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.cacheCreation"), t("th.cacheRead"), t("th.output"), t("th.totalToken"), t("th.latency"), ""];
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<motion.div initial={{ opacity: 0, x: -20 }} animate={{ opacity: 1, x: 0 }} className="flex items-center gap-3">
<ScrollText className="h-5 w-5" style={{ color: "var(--accent)", opacity: 0.6 }} />
<h1 className="text-2xl font-bold gradient-text">{t("logs.title")}</h1>
</motion.div>
<TimeRangeSelector />
</div>
<motion.div initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} className="flex items-center gap-3">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-3.5 w-3.5" style={{ color: "var(--text-muted)" }} />
<input type="text" placeholder={t("logs.filterUser")} value={filters.username}
onChange={(e) => setFilters({ ...filters, username: e.target.value })}
className="input-glass rounded-lg pl-9 pr-3 py-2 text-sm w-36" />
</div>
<input type="text" placeholder={t("logs.filterModel")} value={filters.model}
onChange={(e) => setFilters({ ...filters, model: e.target.value })}
className="input-glass rounded-lg px-3 py-2 text-sm w-36" />
<input type="text" placeholder={t("logs.filterToken")} value={filters.token_name}
onChange={(e) => setFilters({ ...filters, token_name: e.target.value })}
className="input-glass rounded-lg px-3 py-2 text-sm w-36" />
<span className="text-xs font-[family-name:var(--font-geist-mono)]" style={{ color: "var(--text-muted)" }}>
{formatNumber(total)} {t("common.records")}
</span>
</motion.div>
<motion.div initial={{ opacity: 0, y: 16 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.1 }} className="glass overflow-auto">
<table className="w-full text-sm whitespace-nowrap">
<thead>
<tr style={{ borderBottom: "1px solid var(--surface-border)" }}>
{headers.map((h, i) => (
<th key={i} className={`px-3 py-3 text-xs font-medium uppercase tracking-wider ${i >= 4 ? "text-right" : "text-left"}`} style={{ color: "var(--text-muted)" }}>{h}</th>
))}
</tr>
</thead>
<tbody>
{loading ? (
<tr><td colSpan={11} className="px-3 py-16 text-center"><div className="inline-block h-5 w-5 animate-spin rounded-full spinner" /></td></tr>
) : logs.map((log) => (
<tr key={log.id} className="row-glow transition-colors" style={{ borderBottom: "1px solid var(--surface-border)" }}>
<td className="px-3 py-2.5 text-xs font-[family-name:var(--font-geist-mono)]" style={{ color: "var(--text-muted)" }}>{formatDate(log.created_at)}</td>
<td className="px-3 py-2.5" style={{ color: "var(--text-secondary)" }}>{log.display_name}</td>
<td className="px-3 py-2.5 font-[family-name:var(--font-geist-mono)] text-xs" style={{ color: "var(--text-accent)", opacity: 0.7 }}>{log.real_model}</td>
<td className="px-3 py-2.5" style={{ color: "var(--text-muted)" }}>{log.channel_name}</td>
<td className="px-3 py-2.5 text-right tabular-nums font-[family-name:var(--font-geist-mono)] text-xs" style={{ color: "var(--text-muted)" }}>{formatNumber(log.prompt_tokens)}</td>
<td className="px-3 py-2.5 text-right tabular-nums font-[family-name:var(--font-geist-mono)] text-xs" style={{ color: "var(--text-muted)" }}>{formatNumber(log.cache_creation_tokens)}</td>
<td className="px-3 py-2.5 text-right tabular-nums font-[family-name:var(--font-geist-mono)] text-xs" style={{ color: "var(--text-muted)" }}>{formatNumber(log.cache_read_tokens)}</td>
<td className="px-3 py-2.5 text-right tabular-nums font-[family-name:var(--font-geist-mono)] text-xs" style={{ color: "var(--text-muted)" }}>{formatNumber(log.completion_tokens)}</td>
<td className="px-3 py-2.5 text-right tabular-nums font-medium font-[family-name:var(--font-geist-mono)] text-xs" style={{ color: "var(--text-primary)" }}>{formatNumber(log.total_tokens)}</td>
<td className="px-3 py-2.5 text-right tabular-nums text-xs" style={{ color: "var(--text-muted)" }}>{log.use_time}ms</td>
<td className="px-3 py-2.5 text-center">{log.is_stream ? <Zap className="inline h-3 w-3 text-yellow-500/60" /> : ""}</td>
</tr>
))}
</tbody>
</table>
</motion.div>
{totalPages > 1 && (
<div className="flex items-center justify-center gap-3">
<button onClick={() => setPage(Math.max(1, page - 1))} disabled={page === 1}
className="btn-accent rounded-lg px-3 py-1.5 text-xs disabled:opacity-20 flex items-center gap-1">
<ChevronLeft className="h-3 w-3" /> {t("common.prevPage")}
</button>
<span className="text-xs font-[family-name:var(--font-geist-mono)]" style={{ color: "var(--text-muted)" }}>{page} / {totalPages}</span>
<button onClick={() => setPage(Math.min(totalPages, page + 1))} disabled={page === totalPages}
className="btn-accent rounded-lg px-3 py-1.5 text-xs disabled:opacity-20 flex items-center gap-1">
{t("common.nextPage")} <ChevronRight className="h-3 w-3" />
</button>
</div>
)}
</div>
);
}