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:
YoVinchen
2025-12-03 21:54:41 +08:00
parent 1efa41082c
commit d304346514
+373 -137
View File
@@ -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>
); );