diff --git a/app/api/channels/route.ts b/app/api/channels/route.ts new file mode 100644 index 0000000..e586dd9 --- /dev/null +++ b/app/api/channels/route.ts @@ -0,0 +1,161 @@ +import { NextResponse } from "next/server"; +import { and, sql, gte, lte } from "drizzle-orm"; +import type { SQL } from "drizzle-orm"; +import { db } from "@/lib/db/client"; +import { usageRecords, modelPrices } from "@/lib/db/schema"; +import { estimateCost, priceMap } from "@/lib/usage"; + +type ChannelAggRow = { + channel: string | null; + requests: number; + tokens: number; + inputTokens: number; + outputTokens: number; + reasoningTokens: number; + cachedTokens: number; + errorCount: number; +}; + +type ChannelModelAggRow = { + channel: string | null; + model: string; + requests: number; + inputTokens: number; + outputTokens: number; + reasoningTokens: number; + cachedTokens: number; +}; + +type PriceRow = typeof modelPrices.$inferSelect; + +function toNumber(value: unknown): number { + const num = Number(value ?? 0); + return Number.isFinite(num) ? num : 0; +} + +function parseDateInput(value?: string | Date | null) { + if (!value) return null; + const date = value instanceof Date ? value : new Date(value); + return Number.isFinite(date.getTime()) ? date : null; +} + +function withDayStart(date: Date) { + const d = new Date(date); + d.setHours(0, 0, 0, 0); + return d; +} + +function withDayEnd(date: Date) { + const d = new Date(date); + d.setHours(23, 59, 59, 999); + return d; +} + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + const startParam = searchParams.get("start"); + const endParam = searchParams.get("end"); + const daysParam = searchParams.get("days"); + + const startDate = parseDateInput(startParam); + const endDate = parseDateInput(endParam); + const hasCustomRange = startDate && endDate && endDate >= startDate; + + const DAY_MS = 24 * 60 * 60 * 1000; + const days = hasCustomRange + ? Math.max(1, Math.round((withDayEnd(endDate).getTime() - withDayStart(startDate).getTime()) / DAY_MS) + 1) + : Math.min(Math.max(Math.floor(Number(daysParam) || 14), 1), 90); + + const since = hasCustomRange ? withDayStart(startDate!) : new Date(Date.now() - days * DAY_MS); + const until = hasCustomRange ? withDayEnd(endDate!) : undefined; + + const whereParts: SQL[] = [gte(usageRecords.occurredAt, since)]; + if (until) whereParts.push(lte(usageRecords.occurredAt, until)); + const whereClause = whereParts.length ? and(...whereParts) : undefined; + + try { + // Fetch aggregated channel statistics + const channelAggRows: ChannelAggRow[] = await db + .select({ + channel: usageRecords.channel, + requests: sql`count(*)`, + tokens: sql`sum(${usageRecords.totalTokens})`, + inputTokens: sql`sum(${usageRecords.inputTokens})`, + outputTokens: sql`sum(${usageRecords.outputTokens})`, + reasoningTokens: sql`coalesce(sum(${usageRecords.reasoningTokens}), 0)`, + cachedTokens: sql`coalesce(sum(${usageRecords.cachedTokens}), 0)`, + errorCount: sql`sum(case when ${usageRecords.isError} then 1 else 0 end)` + }) + .from(usageRecords) + .where(whereClause) + .groupBy(usageRecords.channel) + .orderBy(sql`count(*) desc`); + + // Fetch channel-model breakdown for cost calculation + const channelModelAggRows: ChannelModelAggRow[] = await db + .select({ + channel: usageRecords.channel, + model: usageRecords.model, + requests: sql`count(*)`, + inputTokens: sql`sum(${usageRecords.inputTokens})`, + outputTokens: sql`sum(${usageRecords.outputTokens})`, + reasoningTokens: sql`coalesce(sum(${usageRecords.reasoningTokens}), 0)`, + cachedTokens: sql`coalesce(sum(${usageRecords.cachedTokens}), 0)` + }) + .from(usageRecords) + .where(whereClause) + .groupBy(usageRecords.channel, usageRecords.model); + + // Fetch pricing information + const priceRows: PriceRow[] = await db.select().from(modelPrices); + const prices = priceMap( + priceRows.map((p: PriceRow) => ({ + model: p.model, + inputPricePer1M: Number(p.inputPricePer1M), + cachedInputPricePer1M: Number(p.cachedInputPricePer1M), + outputPricePer1M: Number(p.outputPricePer1M) + })) + ); + + // Calculate costs per channel + const channelCostMap = new Map(); + for (const row of channelModelAggRows) { + const channelKey = row.channel ?? "未知渠道"; + const cost = estimateCost( + { + inputTokens: toNumber(row.inputTokens), + cachedTokens: toNumber(row.cachedTokens), + outputTokens: toNumber(row.outputTokens), + reasoningTokens: toNumber(row.reasoningTokens) + }, + row.model, + prices + ); + channelCostMap.set(channelKey, (channelCostMap.get(channelKey) ?? 0) + cost); + } + + // Build response + const channels = channelAggRows.map((row) => { + const channelKey = row.channel ?? "未知渠道"; + return { + channel: channelKey, + requests: toNumber(row.requests), + totalTokens: toNumber(row.tokens), + inputTokens: toNumber(row.inputTokens), + outputTokens: toNumber(row.outputTokens), + reasoningTokens: toNumber(row.reasoningTokens), + cachedTokens: toNumber(row.cachedTokens), + errorCount: toNumber(row.errorCount), + cost: Number((channelCostMap.get(channelKey) ?? 0).toFixed(4)) + }; + }); + + return NextResponse.json({ channels, days }); + } catch (error) { + console.error("Error fetching channel statistics:", error); + return NextResponse.json( + { error: "Internal Server Error" }, + { status: 500 } + ); + } +} diff --git a/app/api/records/route.ts b/app/api/records/route.ts index 8b12d72..15e5923 100644 --- a/app/api/records/route.ts +++ b/app/api/records/route.ts @@ -30,6 +30,7 @@ export async function GET(request: Request) { const cursor = searchParams.get("cursor"); const model = searchParams.get("model"); const route = searchParams.get("route"); + const channel = searchParams.get("channel"); const start = searchParams.get("start"); const end = searchParams.get("end"); const includeFilters = searchParams.get("includeFilters") === "1"; @@ -43,6 +44,7 @@ export async function GET(request: Request) { cursor, model: model || undefined, route: route || undefined, + channel: channel || undefined, start, end, includeFilters diff --git a/app/api/sync/route.ts b/app/api/sync/route.ts index 9e12cea..bba3c1c 100644 --- a/app/api/sync/route.ts +++ b/app/api/sync/route.ts @@ -47,6 +47,114 @@ async function isAuthorized(request: Request) { return false; } +async function fetchAuthIndexMapping(): Promise> { + // 从 CLIProxyAPI 的 /auth-files 接口获取 auth_index -> 渠道名的映射 + try { + const baseUrl = config.cliproxy.baseUrl.replace(/\/$/, ""); + const url = baseUrl.endsWith("/v0/management") + ? `${baseUrl}/auth-files` + : `${baseUrl}/v0/management/auth-files`; + + const res = await fetch(url, { + headers: { + Authorization: `Bearer ${config.cliproxy.apiKey}`, + "Content-Type": "application/json" + }, + cache: "no-store" + }); + + const map = new Map(); + if (!res.ok) { + console.warn(`Failed to fetch auth files mapping: ${res.status} ${res.statusText}`); + return map; + } + + const data = (await res.json()) as { files?: Array<{ auth_index?: string; provider?: string; name?: string; email?: string }> }; + for (const file of data.files ?? []) { + if (file.auth_index) { + // 用 "provider/name" 作为可读的渠道名 + const channelName = [file.provider, file.name] + .filter(Boolean) + .join("/"); + map.set(String(file.auth_index), channelName || file.auth_index); + } + } + return map; + } catch (error) { + console.warn("Error fetching auth index mapping:", error); + return new Map(); + } +} + +async function fetchApiKeyChannelMapping(): Promise> { + // 从 4 个 API key 端点获取 api-key -> 渠道名的映射 + // 当 auth_index 无法通过 /auth-files 匹配时,用 source 匹配这些 api-key + try { + const baseUrl = config.cliproxy.baseUrl.replace(/\/$/, ""); + const prefix = baseUrl.endsWith("/v0/management") ? baseUrl : `${baseUrl}/v0/management`; + const headers = { + Authorization: `Bearer ${config.cliproxy.apiKey}`, + "Content-Type": "application/json" + }; + + const endpoints = [ + "openai-compatibility", + "claude-api-key", + "codex-api-key", + "gemini-api-key" + ] as const; + + const results = await Promise.allSettled( + endpoints.map((ep) => + fetch(`${prefix}/${ep}`, { headers, cache: "no-store" }).then((r) => + r.ok ? r.json() : null + ) + ) + ); + + const map = new Map(); + + for (let i = 0; i < endpoints.length; i++) { + const result = results[i]; + if (result.status !== "fulfilled" || !result.value) continue; + const data = result.value; + const ep = endpoints[i]; + + if (ep === "openai-compatibility") { + // 结构: { "openai-compatibility": [{ name, api-key-entries: [{ api-key }] }] } + const entries = data["openai-compatibility"] as Array<{ + name?: string; + "api-key-entries"?: Array<{ "api-key"?: string }>; + }> | undefined; + for (const entry of entries ?? []) { + const channelName = entry.name || ep; + for (const keyEntry of entry["api-key-entries"] ?? []) { + if (keyEntry["api-key"] && !map.has(keyEntry["api-key"])) { + map.set(keyEntry["api-key"], channelName); + } + } + } + } else { + // 结构: { "": [{ api-key, base-url }] } + const entries = data[ep] as Array<{ + "api-key"?: string; + "base-url"?: string; + }> | undefined; + for (const entry of entries ?? []) { + if (entry["api-key"] && !map.has(entry["api-key"])) { + map.set(entry["api-key"], entry["base-url"] || ep); + } + } + } + } + + return map; + } catch (error) { + console.warn("Error fetching API key channel mapping:", error); + return new Map(); + } +} + async function performSync(request: Request) { if (!config.password && !config.cronSecret && !PASSWORD) return missingPassword(); if (!(await isAuthorized(request))) return unauthorized(); @@ -87,7 +195,13 @@ async function performSync(request: Request) { ); } - const rows = toUsageRecords(payload, pulledAt); + // 获取 auth_index 到渠道名的映射(并行请求) + const [authMap, apiKeyMap] = await Promise.all([ + fetchAuthIndexMapping(), + fetchApiKeyChannelMapping() + ]); + + const rows = toUsageRecords(payload, pulledAt, authMap, apiKeyMap); if (rows.length === 0) { return NextResponse.json({ status: "ok", inserted: 0, message: "No usage data" }); diff --git a/app/channels/page.tsx b/app/channels/page.tsx new file mode 100644 index 0000000..ffcd25e --- /dev/null +++ b/app/channels/page.tsx @@ -0,0 +1,472 @@ +"use client"; + +import { useEffect, useState, useCallback, useMemo } from "react"; +import { formatCurrency, formatNumberWithCommas } from "@/lib/utils"; +import { Activity, RefreshCw, ChevronDown, ChevronRight, Key, Users } from "lucide-react"; + +type ChannelStat = { + channel: string; + requests: number; + totalTokens: number; + inputTokens: number; + outputTokens: number; + reasoningTokens: number; + cachedTokens: number; + errorCount: number; + cost: number; +}; + +type ChannelAPIResponse = { + channels: ChannelStat[]; + days: number; +}; + +type ChannelGroup = { + name: string; + type: "auth" | "apikey"; + channels: ChannelStat[]; + total: Omit; +}; + +function aggregateStats(channels: ChannelStat[]): Omit { + return channels.reduce( + (acc, ch) => ({ + requests: acc.requests + ch.requests, + totalTokens: acc.totalTokens + ch.totalTokens, + inputTokens: acc.inputTokens + ch.inputTokens, + outputTokens: acc.outputTokens + ch.outputTokens, + reasoningTokens: acc.reasoningTokens + ch.reasoningTokens, + cachedTokens: acc.cachedTokens + ch.cachedTokens, + errorCount: acc.errorCount + ch.errorCount, + cost: acc.cost + ch.cost + }), + { requests: 0, totalTokens: 0, inputTokens: 0, outputTokens: 0, reasoningTokens: 0, cachedTokens: 0, errorCount: 0, cost: 0 } + ); +} + +function groupChannels(channels: ChannelStat[]): ChannelGroup[] { + const authGroups = new Map(); + const apiKeyChannels: ChannelStat[] = []; + + for (const ch of channels) { + const name = ch.channel; + const slashIdx = name.indexOf("/"); + const looksLikeUrl = name.startsWith("http://") || name.startsWith("https://"); + const looksLikeHex = /^[0-9a-f]{8,}$/i.test(name); + + if (slashIdx > 0 && !looksLikeUrl && !looksLikeHex) { + const provider = name.slice(0, slashIdx); + const existing = authGroups.get(provider) || []; + existing.push(ch); + authGroups.set(provider, existing); + } else { + apiKeyChannels.push(ch); + } + } + + const groups: ChannelGroup[] = []; + + const authEntries = [...authGroups.entries()] + .map(([name, chs]) => ({ name, channels: chs, total: aggregateStats(chs) })) + .sort((a, b) => b.total.requests - a.total.requests); + + for (const entry of authEntries) { + groups.push({ name: entry.name, type: "auth", channels: entry.channels, total: entry.total }); + } + + for (const ch of apiKeyChannels) { + groups.push({ + name: ch.channel, + type: "apikey", + channels: [ch], + total: { requests: ch.requests, totalTokens: ch.totalTokens, inputTokens: ch.inputTokens, outputTokens: ch.outputTokens, reasoningTokens: ch.reasoningTokens, cachedTokens: ch.cachedTokens, errorCount: ch.errorCount, cost: ch.cost } + }); + } + + return groups; +} + +function fmtRate(requests: number, errorCount: number): string { + if (requests === 0) return "-"; + const rate = ((requests - errorCount) / requests) * 100; + if (rate === 100) return "100%"; + return rate.toFixed(1) + "%"; +} + +function rateColor(requests: number, errorCount: number): string { + if (requests === 0) return "text-slate-500"; + const rate = ((requests - errorCount) / requests) * 100; + if (rate >= 99) return "text-emerald-400"; + if (rate >= 95) return "text-amber-400"; + return "text-red-400"; +} + +function StatCard({ label, value, color }: { label: string; value: string; color?: string }) { + return ( +
+
{label}
+
+ {value} +
+
+ ); +} + +function TokenBar({ input, output, reasoning, cached, total }: { input: number; output: number; reasoning: number; cached: number; total: number }) { + if (total === 0) return null; + const segments = [ + { value: input - cached, color: "bg-rose-400", label: "输入" }, + { value: cached, color: "bg-purple-400", label: "缓存" }, + { value: output, color: "bg-emerald-400", label: "输出" }, + { value: reasoning, color: "bg-amber-400", label: "思考" }, + ].filter(s => s.value > 0); + + return ( +
+ {segments.map((seg, i) => ( +
+ ))} +
+ ); +} + +function TokenLegend() { + return ( +
+ 输入 + 缓存 + 输出 + 思考 +
+ ); +} + +/* Fixed column widths for alignment across all rows */ +const COL = { + arrow: "w-5 shrink-0", + icon: "w-9 shrink-0", + requests: "w-[72px] text-right shrink-0", + tokens: "w-[90px] text-right shrink-0", + cost: "w-[80px] text-right shrink-0", + rate: "w-[56px] text-right shrink-0", +}; + +export default function ChannelsPage() { + const [channels, setChannels] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [days, setDays] = useState(14); + const [expandedGroups, setExpandedGroups] = useState>(new Set()); + const [sortBy, setSortBy] = useState<"requests" | "totalTokens" | "cost">("requests"); + + const fetchChannels = useCallback(async () => { + setLoading(true); + setError(null); + try { + const response = await fetch(`/api/channels?days=${days}`); + if (!response.ok) throw new Error(`Failed to fetch: ${response.statusText}`); + const data: ChannelAPIResponse = await response.json(); + setChannels(data.channels || []); + } catch (err) { + setError(err instanceof Error ? err.message : "Unknown error"); + } finally { + setLoading(false); + } + }, [days]); + + useEffect(() => { + fetchChannels(); + }, [fetchChannels]); + + const toggleGroup = (name: string) => { + setExpandedGroups(prev => { + const next = new Set(prev); + if (next.has(name)) next.delete(name); + else next.add(name); + return next; + }); + }; + + const groups = useMemo(() => { + const g = groupChannels(channels); + return g.sort((a, b) => b.total[sortBy] - a.total[sortBy]); + }, [channels, sortBy]); + + const totalStats = useMemo(() => aggregateStats(channels), [channels]); + + return ( +
+ {/* Header */} +
+
+

+ + 渠道统计 +

+

按认证渠道查看用量和费用统计

+
+ +
+ + {/* Time Range */} +
+ 时间范围 + {[1, 7, 14, 30, 90].map((d) => ( + + ))} +
+ + {/* Error */} + {error && ( +
+
+

加载失败

+

{error}

+
+
+ )} + + {/* Stat Cards */} +
+ {loading ? ( + <> + {[...Array(4)].map((_, i) => ( +
+ ))} + + ) : ( + <> + + + +
+
总费用
+
{formatCurrency(totalStats.cost)}
+
+ + )} +
+ + {/* Sort Options + Token Legend */} + {!loading && channels.length > 0 && ( +
+
+ 排序 + {([["requests", "请求数"], ["totalTokens", "Token"], ["cost", "费用"]] as const).map(([key, label]) => ( + + ))} +
+ +
+ )} + + {/* Column Headers */} + {!loading && channels.length > 0 && ( +
+
+
+
+
+
请求
+
Tokens
+
费用
+
成功率
+
+
+ )} + + {/* Channel Groups */} +
+ {loading ? ( +
+ +

加载中...

+
+ ) : channels.length === 0 ? ( +
+

暂无数据

+
+ ) : ( + groups.map((group) => { + const isAuth = group.type === "auth"; + const isExpanded = expandedGroups.has(group.name); + const hasMultiple = group.channels.length > 1; + const canExpand = isAuth && hasMultiple; + + return ( +
+ {/* Group Header */} +
canExpand && toggleGroup(group.name)} + > + {/* Arrow - always occupies space for alignment */} +
+ {canExpand && ( + isExpanded + ? + : + )} +
+ + {/* Icon */} +
+ {isAuth ? : } +
+ + {/* Name & Badge & Token Bar */} +
+
+ {group.name} + {canExpand && ( + + {group.channels.length} 账号 + + )} +
+ +
+ + {/* Stats - Desktop */} +
+
+
{formatNumberWithCommas(group.total.requests)}
+
+
+
{formatNumberWithCommas(group.total.totalTokens)}
+
+
+
{formatCurrency(group.total.cost)}
+
+
+
+ {fmtRate(group.total.requests, group.total.errorCount)} +
+
+
+ + {/* Stats - Mobile */} +
+
{formatNumberWithCommas(group.total.requests)} 次
+
{formatCurrency(group.total.cost)}
+
+
+ + {/* Expanded Sub-channels */} + {canExpand && isExpanded && ( +
+ {group.channels + .sort((a, b) => b.requests - a.requests) + .map((ch, idx) => { + const accountName = ch.channel.includes("/") + ? ch.channel.slice(ch.channel.indexOf("/") + 1) + : ch.channel; + return ( +
+ {/* Arrow placeholder */} +
+ {/* Icon placeholder */} +
+
+ {accountName} + +
+ {/* Stats - Desktop */} +
+
+
{formatNumberWithCommas(ch.requests)}
+
+
+
{formatNumberWithCommas(ch.totalTokens)}
+
+
+
{formatCurrency(ch.cost)}
+
+
+
+ {fmtRate(ch.requests, ch.errorCount)} +
+
+
+ {/* Stats - Mobile */} +
+
{formatNumberWithCommas(ch.requests)} 次
+
{formatCurrency(ch.cost)}
+
+
+ ); + })} +
+ )} +
+ ); + }) + )} +
+
+ ); +} diff --git a/app/components/Sidebar.tsx b/app/components/Sidebar.tsx index 30efa89..e744abb 100644 --- a/app/components/Sidebar.tsx +++ b/app/components/Sidebar.tsx @@ -2,13 +2,14 @@ import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; -import { BarChart3, FileText, Activity, LogOut, Github, ExternalLink, Table } from "lucide-react"; +import { BarChart3, FileText, Activity, LogOut, Github, ExternalLink, Table, Menu, X, Radio } from "lucide-react"; import { useEffect, useState, useCallback } from "react"; import { Modal } from "./Modal"; const links = [ { href: "/", label: "仪表盘", icon: BarChart3 }, { href: "/explore", label: "数据探索", icon: Activity }, + { href: "/channels", label: "渠道统计", icon: Radio }, { href: "/records", label: "调用记录", icon: Table }, { href: "/logs", label: "日志", icon: FileText } ]; diff --git a/app/records/page.tsx b/app/records/page.tsx index f55064b..4c74460 100644 --- a/app/records/page.tsx +++ b/app/records/page.tsx @@ -11,6 +11,7 @@ type UsageRecord = { id: number; occurredAt: string; route: string; + channel: string | null; model: string; totalTokens: number; inputTokens: number; @@ -24,13 +25,14 @@ type UsageRecord = { type RecordsResponse = { items: UsageRecord[]; nextCursor: string | null; - filters?: { models: string[]; routes: string[] }; + filters?: { models: string[]; routes: string[]; channels: string[] }; }; type SortField = | "occurredAt" | "model" | "route" + | "channel" | "totalTokens" | "inputTokens" | "outputTokens" @@ -138,8 +140,10 @@ export default function RecordsPage() { const [models, setModels] = useState([]); const [routes, setRoutes] = useState([]); + const [channels, setChannels] = useState([]); const [modelInput, setModelInput] = useState(""); const [routeInput, setRouteInput] = useState(""); + const [channelInput, setChannelInput] = useState(""); const [startInput, setStartInput] = useState(""); const [endInput, setEndInput] = useState(""); const [rangePickerOpen, setRangePickerOpen] = useState(false); @@ -151,6 +155,7 @@ export default function RecordsPage() { const [appliedModel, setAppliedModel] = useState(""); const [appliedRoute, setAppliedRoute] = useState(""); + const [appliedChannel, setAppliedChannel] = useState(""); const [appliedStart, setAppliedStart] = useState(""); const [appliedEnd, setAppliedEnd] = useState(""); @@ -172,12 +177,13 @@ export default function RecordsPage() { if (cursorValue) params.set("cursor", cursorValue); if (appliedModel) params.set("model", appliedModel); if (appliedRoute) params.set("route", appliedRoute); + if (appliedChannel) params.set("channel", appliedChannel); if (appliedStart) params.set("start", new Date(appliedStart).toISOString()); if (appliedEnd) params.set("end", new Date(appliedEnd).toISOString()); if (includeFilters) params.set("includeFilters", "1"); return params; }, - [sortField, sortOrder, appliedModel, appliedRoute, appliedStart, appliedEnd] + [sortField, sortOrder, appliedModel, appliedRoute, appliedChannel, appliedStart, appliedEnd] ); const fetchRecords = useCallback( @@ -201,6 +207,9 @@ export default function RecordsPage() { if (data.filters?.routes?.length) { setRoutes(data.filters.routes); } + if (data.filters?.channels?.length) { + setChannels(data.filters.channels); + } } catch (err) { setError((err as Error).message || "加载失败"); } finally { @@ -315,13 +324,15 @@ export default function RecordsPage() { resetAndFetch(false); }, [sortField, sortOrder, resetAndFetch]); - const applyFilters = (overrides?: { model?: string; route?: string; start?: string; end?: string }) => { + const applyFilters = (overrides?: { model?: string; route?: string; channel?: string; start?: string; end?: string }) => { const nextModel = (overrides?.model ?? modelInput).trim(); const nextRoute = (overrides?.route ?? routeInput).trim(); + const nextChannel = (overrides?.channel ?? channelInput).trim(); const nextStart = overrides?.start ?? startInput; const nextEnd = overrides?.end ?? endInput; setAppliedModel(nextModel); setAppliedRoute(nextRoute); + setAppliedChannel(nextChannel); setAppliedStart(nextStart); setAppliedEnd(nextEnd); }; @@ -336,9 +347,14 @@ export default function RecordsPage() { setAppliedRoute(val.trim()); }; + const applyChannelOption = (val: string) => { + setChannelInput(val); + setAppliedChannel(val.trim()); + }; + useEffect(() => { resetAndFetch(false); - }, [appliedModel, appliedRoute, appliedStart, appliedEnd, resetAndFetch]); + }, [appliedModel, appliedRoute, appliedChannel, appliedStart, appliedEnd, resetAndFetch]); const costTone = useCallback((cost: number) => { if (cost >= 5) return "bg-red-500/20 text-red-300 ring-1 ring-red-500/40"; @@ -357,13 +373,14 @@ export default function RecordsPage() { const parts: string[] = []; if (appliedModel) parts.push(`模型: ${appliedModel}`); if (appliedRoute) parts.push(`密钥: ${appliedRoute}`); + if (appliedChannel) parts.push(`渠道: ${appliedChannel}`); if (appliedStart || appliedEnd) { const startLabel = appliedStart ? formatDateTimeDisplay(appliedStart) : "-"; const endLabel = appliedEnd ? formatDateTimeDisplay(appliedEnd) : "-"; parts.push(`时间: ${startLabel} ~ ${endLabel}`); } return parts.length ? parts.join(" / ") : "暂无筛选"; - }, [appliedModel, appliedRoute, appliedStart, appliedEnd]); + }, [appliedModel, appliedRoute, appliedChannel, appliedStart, appliedEnd]); const rangeLabel = useMemo(() => { if (!startInput && !endInput) return "选择时间范围"; @@ -481,6 +498,18 @@ export default function RecordsPage() { setAppliedRoute(""); }} /> + { + setChannelInput(""); + setAppliedChannel(""); + }} + />