mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-08-04 19:45:34 +08:00
refactor(ui): enhance RequestLogTable with filtering and pagination
UI improvements: - Add filter bar with app type, provider name, model, status selectors - Add date range picker (startDate/endDate) - Add search/reset/refresh buttons Pagination: - Implement proper page-based pagination with page info display - Show total count and current page range - Add prev/next navigation buttons Features: - Default to last 24 hours filter - Streamlined table columns layout - Query invalidation on refresh
This commit is contained in:
@@ -8,156 +8,392 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
import { useRequestLogs } from "@/lib/query/usage";
|
import { Button } from "@/components/ui/button";
|
||||||
import { RequestDetailPanel } from "./RequestDetailPanel";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { useRequestLogs, usageKeys } from "@/lib/query/usage";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import type { LogFilters } from "@/types/usage";
|
import type { LogFilters } from "@/types/usage";
|
||||||
|
import { ChevronLeft, ChevronRight, RefreshCw, Search, X } from "lucide-react";
|
||||||
|
|
||||||
export function RequestLogTable() {
|
export function RequestLogTable() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [filters] = useState<LogFilters>({});
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
// 默认时间范围:过去24小时
|
||||||
|
const getDefaultFilters = (): LogFilters => {
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const oneDayAgo = now - 24 * 60 * 60;
|
||||||
|
return { startDate: oneDayAgo, endDate: now };
|
||||||
|
};
|
||||||
|
|
||||||
|
const [filters, setFilters] = useState<LogFilters>(getDefaultFilters);
|
||||||
|
const [tempFilters, setTempFilters] = useState<LogFilters>(getDefaultFilters);
|
||||||
const [page, setPage] = useState(0);
|
const [page, setPage] = useState(0);
|
||||||
const [selectedRequestId, setSelectedRequestId] = useState<string | null>(
|
const pageSize = 20;
|
||||||
null,
|
|
||||||
);
|
|
||||||
const limit = 20;
|
|
||||||
|
|
||||||
const { data: logs, isLoading } = useRequestLogs(
|
const { data: result, isLoading } = useRequestLogs(filters, page, pageSize);
|
||||||
filters,
|
|
||||||
limit,
|
|
||||||
page * limit,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (isLoading) {
|
const logs = result?.data ?? [];
|
||||||
return <div className="h-[400px] animate-pulse rounded bg-gray-100" />;
|
const total = result?.total ?? 0;
|
||||||
}
|
const totalPages = Math.ceil(total / pageSize);
|
||||||
|
|
||||||
|
const handleSearch = () => {
|
||||||
|
setFilters(tempFilters);
|
||||||
|
setPage(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
const defaults = getDefaultFilters();
|
||||||
|
setTempFilters(defaults);
|
||||||
|
setFilters(defaults);
|
||||||
|
setPage(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRefresh = () => {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: usageKeys.logs(filters, page, pageSize),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="rounded-md bg-card/60 shadow-sm">
|
{/* 筛选栏 */}
|
||||||
<Table>
|
<div className="flex flex-wrap items-center gap-2 rounded-md bg-card/60 p-3 shadow-sm">
|
||||||
<TableHeader>
|
<Select
|
||||||
<TableRow>
|
value={tempFilters.appType || "all"}
|
||||||
<TableHead>{t("usage.time", "时间")}</TableHead>
|
onValueChange={(v) =>
|
||||||
<TableHead>{t("usage.provider", "供应商")}</TableHead>
|
setTempFilters({
|
||||||
<TableHead>{t("usage.billingModel", "计费模型")}</TableHead>
|
...tempFilters,
|
||||||
<TableHead className="text-right">
|
appType: v === "all" ? undefined : v,
|
||||||
{t("usage.inputTokens", "输入")}
|
})
|
||||||
</TableHead>
|
}
|
||||||
<TableHead className="text-right">
|
>
|
||||||
{t("usage.outputTokens", "输出")}
|
<SelectTrigger className="w-[120px]">
|
||||||
</TableHead>
|
<SelectValue placeholder={t("usage.endpoint", "端点")} />
|
||||||
<TableHead className="text-right">
|
</SelectTrigger>
|
||||||
{t("usage.cacheCreationTokens", "缓存写入")}
|
<SelectContent>
|
||||||
</TableHead>
|
<SelectItem value="all">{t("common.all", "全部")}</SelectItem>
|
||||||
<TableHead className="text-right">
|
<SelectItem value="claude">Claude</SelectItem>
|
||||||
{t("usage.cacheReadTokens", "缓存读取")}
|
<SelectItem value="codex">Codex</SelectItem>
|
||||||
</TableHead>
|
<SelectItem value="gemini">Gemini</SelectItem>
|
||||||
<TableHead className="text-right">
|
</SelectContent>
|
||||||
{t("usage.totalCost", "成本")}
|
</Select>
|
||||||
</TableHead>
|
|
||||||
<TableHead className="text-right">
|
<Select
|
||||||
{t("usage.latency", "耗时")}
|
value={tempFilters.statusCode?.toString() || "all"}
|
||||||
</TableHead>
|
onValueChange={(v) =>
|
||||||
<TableHead>{t("usage.status", "状态")}</TableHead>
|
setTempFilters({
|
||||||
</TableRow>
|
...tempFilters,
|
||||||
</TableHeader>
|
statusCode: v === "all" ? undefined : parseInt(v),
|
||||||
<TableBody>
|
})
|
||||||
{logs?.length === 0 ? (
|
}
|
||||||
<TableRow>
|
>
|
||||||
<TableCell
|
<SelectTrigger className="w-[120px]">
|
||||||
colSpan={7}
|
<SelectValue placeholder={t("usage.status", "状态码")} />
|
||||||
className="text-center text-muted-foreground"
|
</SelectTrigger>
|
||||||
>
|
<SelectContent>
|
||||||
{t("usage.noData", "暂无数据")}
|
<SelectItem value="all">{t("common.all", "全部")}</SelectItem>
|
||||||
</TableCell>
|
<SelectItem value="200">200</SelectItem>
|
||||||
</TableRow>
|
<SelectItem value="400">400</SelectItem>
|
||||||
) : (
|
<SelectItem value="401">401</SelectItem>
|
||||||
logs?.map((log) => (
|
<SelectItem value="429">429</SelectItem>
|
||||||
<TableRow
|
<SelectItem value="500">500</SelectItem>
|
||||||
key={log.requestId}
|
</SelectContent>
|
||||||
className="cursor-pointer hover:bg-muted/50"
|
</Select>
|
||||||
onClick={() => setSelectedRequestId(log.requestId)}
|
|
||||||
>
|
<Input
|
||||||
<TableCell>
|
placeholder={t("usage.provider", "供应商名称")}
|
||||||
{new Date(log.createdAt * 1000).toLocaleString("zh-CN")}
|
className="w-[140px]"
|
||||||
</TableCell>
|
value={tempFilters.providerName || ""}
|
||||||
<TableCell>
|
onChange={(e) =>
|
||||||
<div className="flex flex-col text-sm leading-tight">
|
setTempFilters({
|
||||||
<span className="font-medium">
|
...tempFilters,
|
||||||
{log.providerName ||
|
providerName: e.target.value || undefined,
|
||||||
t("usage.unknownProvider", "未知供应商")}
|
})
|
||||||
</span>
|
}
|
||||||
<span className="font-mono text-xs text-muted-foreground">
|
/>
|
||||||
{log.providerId}
|
|
||||||
</span>
|
<Input
|
||||||
</div>
|
placeholder={t("usage.model", "模型名称")}
|
||||||
</TableCell>
|
className="w-[140px]"
|
||||||
<TableCell className="font-mono text-sm">
|
value={tempFilters.model || ""}
|
||||||
{log.model}
|
onChange={(e) =>
|
||||||
</TableCell>
|
setTempFilters({
|
||||||
<TableCell className="text-right">
|
...tempFilters,
|
||||||
{log.inputTokens.toLocaleString()}
|
model: e.target.value || undefined,
|
||||||
</TableCell>
|
})
|
||||||
<TableCell className="text-right">
|
}
|
||||||
{log.outputTokens.toLocaleString()}
|
/>
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right">
|
<Input
|
||||||
{log.cacheCreationTokens.toLocaleString()}
|
type="datetime-local"
|
||||||
</TableCell>
|
className="w-[180px]"
|
||||||
<TableCell className="text-right">
|
value={
|
||||||
{log.cacheReadTokens.toLocaleString()}
|
tempFilters.startDate
|
||||||
</TableCell>
|
? new Date(tempFilters.startDate * 1000)
|
||||||
<TableCell className="text-right">
|
.toISOString()
|
||||||
${parseFloat(log.totalCostUsd).toFixed(6)}
|
.slice(0, 16)
|
||||||
</TableCell>
|
: ""
|
||||||
<TableCell className="text-right">
|
}
|
||||||
{log.latencyMs}ms
|
onChange={(e) =>
|
||||||
</TableCell>
|
setTempFilters({
|
||||||
<TableCell>
|
...tempFilters,
|
||||||
<span
|
startDate: e.target.value
|
||||||
className={`inline-flex rounded-full px-2 py-1 text-xs ${
|
? Math.floor(new Date(e.target.value).getTime() / 1000)
|
||||||
log.statusCode >= 200 && log.statusCode < 300
|
: undefined,
|
||||||
? "bg-green-100 text-green-800"
|
})
|
||||||
: "bg-red-100 text-red-800"
|
}
|
||||||
}`}
|
/>
|
||||||
>
|
|
||||||
{log.statusCode}
|
<Input
|
||||||
</span>
|
type="datetime-local"
|
||||||
</TableCell>
|
className="w-[180px]"
|
||||||
</TableRow>
|
value={
|
||||||
))
|
tempFilters.endDate
|
||||||
)}
|
? new Date(tempFilters.endDate * 1000).toISOString().slice(0, 16)
|
||||||
</TableBody>
|
: ""
|
||||||
</Table>
|
}
|
||||||
|
onChange={(e) =>
|
||||||
|
setTempFilters({
|
||||||
|
...tempFilters,
|
||||||
|
endDate: e.target.value
|
||||||
|
? Math.floor(new Date(e.target.value).getTime() / 1000)
|
||||||
|
: undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="ml-auto flex gap-2">
|
||||||
|
<Button size="sm" onClick={handleSearch}>
|
||||||
|
<Search className="mr-1 h-4 w-4" />
|
||||||
|
{t("common.search", "查询")}
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="outline" onClick={handleReset}>
|
||||||
|
<X className="mr-1 h-4 w-4" />
|
||||||
|
{t("common.reset", "重置")}
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="outline" onClick={handleRefresh}>
|
||||||
|
<RefreshCw className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{logs && logs.length >= limit && (
|
{isLoading ? (
|
||||||
<div className="flex justify-center gap-2">
|
<div className="h-[400px] animate-pulse rounded bg-gray-100" />
|
||||||
<button
|
) : (
|
||||||
onClick={() => setPage(Math.max(0, page - 1))}
|
<>
|
||||||
disabled={page === 0}
|
<div className="rounded-md bg-card/60 shadow-sm overflow-x-auto">
|
||||||
className="rounded border px-3 py-1 disabled:opacity-50"
|
<Table>
|
||||||
>
|
<TableHeader>
|
||||||
{t("common.previous", "上一页")}
|
<TableRow>
|
||||||
</button>
|
<TableHead>{t("usage.time", "时间")}</TableHead>
|
||||||
<span className="px-3 py-1">
|
<TableHead>{t("usage.provider", "供应商")}</TableHead>
|
||||||
{t("common.page", "第")} {page + 1} {t("common.pageUnit", "页")}
|
<TableHead className="min-w-[280px]">
|
||||||
</span>
|
{t("usage.billingModel", "计费模型")}
|
||||||
<button
|
</TableHead>
|
||||||
onClick={() => setPage(page + 1)}
|
<TableHead className="text-right">
|
||||||
disabled={logs.length < limit}
|
{t("usage.inputTokens", "输入")}
|
||||||
className="rounded border px-3 py-1 disabled:opacity-50"
|
</TableHead>
|
||||||
>
|
<TableHead className="text-right">
|
||||||
{t("common.next", "下一页")}
|
{t("usage.outputTokens", "输出")}
|
||||||
</button>
|
</TableHead>
|
||||||
</div>
|
<TableHead className="text-right min-w-[90px]">
|
||||||
)}
|
{t("usage.cacheCreationTokens", "缓存写入")}
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="text-right min-w-[90px]">
|
||||||
|
{t("usage.cacheReadTokens", "缓存读取")}
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="text-right">
|
||||||
|
{t("usage.totalCost", "成本")}
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="text-center min-w-[140px]">
|
||||||
|
{t("usage.timingInfo", "用时/首字")}
|
||||||
|
</TableHead>
|
||||||
|
<TableHead>{t("usage.status", "状态")}</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{logs.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell
|
||||||
|
colSpan={10}
|
||||||
|
className="text-center text-muted-foreground"
|
||||||
|
>
|
||||||
|
{t("usage.noData", "暂无数据")}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
logs.map((log) => (
|
||||||
|
<TableRow key={log.requestId}>
|
||||||
|
<TableCell>
|
||||||
|
{new Date(log.createdAt * 1000).toLocaleString("zh-CN")}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{log.providerName ||
|
||||||
|
t("usage.unknownProvider", "未知供应商")}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell
|
||||||
|
className="font-mono text-sm max-w-[280px] truncate"
|
||||||
|
title={log.model}
|
||||||
|
>
|
||||||
|
{log.model}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
{log.inputTokens.toLocaleString()}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
{log.outputTokens.toLocaleString()}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
{log.cacheCreationTokens.toLocaleString()}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
{log.cacheReadTokens.toLocaleString()}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
${parseFloat(log.totalCostUsd).toFixed(6)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center justify-center gap-1">
|
||||||
|
{(() => {
|
||||||
|
const durationSec =
|
||||||
|
(log.durationMs ?? log.latencyMs) / 1000;
|
||||||
|
const durationColor =
|
||||||
|
durationSec <= 5
|
||||||
|
? "bg-green-100 text-green-800"
|
||||||
|
: durationSec <= 120
|
||||||
|
? "bg-orange-100 text-orange-800"
|
||||||
|
: "bg-red-200 text-red-900";
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center justify-center rounded-full px-2 py-0.5 text-xs ${durationColor}`}
|
||||||
|
>
|
||||||
|
{Math.round(durationSec)}s
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
{log.isStreaming &&
|
||||||
|
log.firstTokenMs != null &&
|
||||||
|
(() => {
|
||||||
|
const firstSec = log.firstTokenMs / 1000;
|
||||||
|
const firstColor =
|
||||||
|
firstSec <= 5
|
||||||
|
? "bg-green-100 text-green-800"
|
||||||
|
: firstSec <= 120
|
||||||
|
? "bg-orange-100 text-orange-800"
|
||||||
|
: "bg-red-200 text-red-900";
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center justify-center rounded-full px-2 py-0.5 text-xs ${firstColor}`}
|
||||||
|
>
|
||||||
|
{firstSec.toFixed(1)}s
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center justify-center rounded-full px-2 py-0.5 text-xs ${
|
||||||
|
log.isStreaming
|
||||||
|
? "bg-blue-100 text-blue-800"
|
||||||
|
: "bg-purple-100 text-purple-800"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{log.isStreaming
|
||||||
|
? t("usage.stream", "流")
|
||||||
|
: t("usage.nonStream", "非流")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<span
|
||||||
|
className={`inline-flex rounded-full px-2 py-1 text-xs ${
|
||||||
|
log.statusCode >= 200 && log.statusCode < 300
|
||||||
|
? "bg-green-100 text-green-800"
|
||||||
|
: "bg-red-100 text-red-800"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{log.statusCode}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
{selectedRequestId && (
|
{/* 分页控件 */}
|
||||||
<RequestDetailPanel
|
{total > 0 && (
|
||||||
requestId={selectedRequestId}
|
<div className="flex items-center justify-between px-2">
|
||||||
onClose={() => setSelectedRequestId(null)}
|
<span className="text-sm text-muted-foreground">
|
||||||
/>
|
{t("usage.totalRecords", "共 {{total}} 条记录", { total })}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setPage(Math.max(0, page - 1))}
|
||||||
|
disabled={page === 0}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
{/* 页码按钮 */}
|
||||||
|
{(() => {
|
||||||
|
const pages: (number | string)[] = [];
|
||||||
|
if (totalPages <= 7) {
|
||||||
|
for (let i = 0; i < totalPages; i++) pages.push(i);
|
||||||
|
} else {
|
||||||
|
pages.push(0);
|
||||||
|
if (page > 2) pages.push("...");
|
||||||
|
for (
|
||||||
|
let i = Math.max(1, page - 1);
|
||||||
|
i <= Math.min(totalPages - 2, page + 1);
|
||||||
|
i++
|
||||||
|
) {
|
||||||
|
pages.push(i);
|
||||||
|
}
|
||||||
|
if (page < totalPages - 3) pages.push("...");
|
||||||
|
pages.push(totalPages - 1);
|
||||||
|
}
|
||||||
|
return pages.map((p, idx) =>
|
||||||
|
typeof p === "string" ? (
|
||||||
|
<span
|
||||||
|
key={`ellipsis-${idx}`}
|
||||||
|
className="px-2 text-muted-foreground"
|
||||||
|
>
|
||||||
|
...
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
key={p}
|
||||||
|
variant={p === page ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
onClick={() => setPage(p)}
|
||||||
|
>
|
||||||
|
{p + 1}
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setPage(page + 1)}
|
||||||
|
disabled={page >= totalPages - 1}
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user