import { App, Button, Input, Modal, Select } from "antd"; import { IconButton } from "@/components/ui/base/buttons"; import type { ColumnsType } from "antd/es/table"; import { Download, Eye, Play, Search } from "lucide-react"; import { saveAs } from "file-saver"; import { useEffect, useRef, useState } from "react"; import { useSearchParams } from "react-router"; import { PaginationBar } from "@/components/layout/workspace-page"; import { MediaPreview } from "@/components/media-preview"; import { formatCredits } from "@/constant/credits"; import { useDebouncedValue } from "@/hooks/use-debounced-value"; import { exportAdminApiLogs, listAdminApiLogs, type ApiCallLog } from "@/services/api/auth"; import { ApiLogDetailDrawer } from "../components/api-log-detail-drawer"; import { AdminPageFrame } from "../components/admin-shell"; import { AdminBatchBar, AdminDataTable, AdminExportButton, AdminFilterChip, AdminStatusBadge, AdminTableEmpty } from "../components/admin-ui"; export default function LogsPage() { const { message } = App.useApp(); const [searchParams, setSearchParams] = useSearchParams(); const keyword = searchParams.get("filter") || ""; const status = normalizeStatus(searchParams.get("status")); const recordType = searchParams.get("recordType") === "download" ? "download" : searchParams.get("recordType") === "all" ? "all" : "request"; const page = positiveInt(searchParams.get("page"), 1); const pageSize = normalizePageSize(searchParams.get("pageSize")); const debouncedKeyword = useDebouncedValue(keyword); const [logs, setLogs] = useState([]); const [total, setTotal] = useState(0); const [loading, setLoading] = useState(true); const [selectedIds, setSelectedIds] = useState([]); const [detailLogId, setDetailLogId] = useState(null); const [mediaPreview, setMediaPreview] = useState<{ url: string; kind: "image" | "video"; title: string } | null>(null); const requestSequence = useRef(0); const hasFilters = Boolean(keyword || status !== "all" || recordType !== "request"); const updateUrl = (patch: Record, replace = false) => { const next = new URLSearchParams(searchParams); Object.entries(patch).forEach(([key, value]) => { const isDefault = (key === "filter" && value === "") || (key === "status" && value === "all") || (key === "page" && value === 1) || (key === "pageSize" && value === 20); if (isDefault) next.delete(key); else next.set(key, String(value)); }); setSearchParams(next, { replace }); }; useEffect(() => { const sequence = ++requestSequence.current; setLoading(true); void listAdminApiLogs({ recordType, keyword: debouncedKeyword || undefined, status: status === "all" ? undefined : status, page, pageSize }) .then((result) => { if (sequence !== requestSequence.current) return; setLogs(result.logs); setTotal(result.total); setSelectedIds([]); if (result.total > 0 && result.logs.length === 0 && page > 1) updateUrl({ page: 1 }, true); }) .catch((error) => sequence === requestSequence.current && message.error(error instanceof Error ? error.message : "读取请求明细失败")) .finally(() => sequence === requestSequence.current && setLoading(false)); }, [debouncedKeyword, status, recordType, page, pageSize]); const columns: ColumnsType = [ { title: "时间", width: 168, render: (_, log) => formatTime(log.startedAt || log.createdAt) }, { title: "用户", width: 180, render: (_, log) => (
{log.userDisplayName || log.userAccount || "未知用户"}
{log.userAccount ? `@${log.userAccount}` : "账号未记录"}
), }, { title: "渠道 / 模型", width: 230, render: (_, log) => (
{log.channelName || "未记录渠道"}
{log.model || "未识别模型"}
), }, { title: "能力", dataIndex: "capability", width: 88, render: capabilityText }, { title: "结果", width: 118, render: (_, log) => setMediaPreview({ url, kind, title: `${capabilityText(log.capability)}结果` })} /> }, { title: "请求阶段 / 状态", width: 160, render: (_, log) => }, { title: "错误信息", width: 260, render: (_, log) => log.status === "failed" || log.error || log.errorCode ? (
{log.errorCode || `HTTP ${log.statusCode || "失败"}`}
{log.error || "上游未返回错误详情"}
) : ( -- ), }, { title: "耗时", dataIndex: "durationMs", width: 112, render: (value) => {formatDuration(value)} }, { title: "积分计费", width: 145, render: (_, log) => }, { title: "Tokens", width: 166, render: (_, log) => log.usageAvailable ? (
输入 {log.inputTokens.toLocaleString()}
输出 {log.outputTokens.toLocaleString()}
{log.cachedTokens > 0 ? (
缓存 {log.cachedTokens.toLocaleString()}
) : null}
) : ( 未返回 ), }, ]; return ( exportAdminApiLogs({ recordType, keyword: debouncedKeyword || undefined, status: status === "all" ? undefined : status })} fileName={() => `请求明细-${new Date().toISOString().slice(0, 10)}.csv`} label="导出当前筛选" successMessage="已按当前筛选导出请求明细" errorMessage="导出请求明细失败" /> } > } value={keyword} placeholder="搜索用户、渠道、模型、路径或请求号" onChange={(event) => updateUrl({ filter: event.target.value, page: 1 }, true)} /> } toolbarActiveFilters={ <> {keyword ? updateUrl({ filter: "", page: 1 })} /> : null} {status !== "all" ? updateUrl({ status: "all", page: 1 })} /> : null} } toolbarFilters={ <> updateUrl({ status: value, page: 1 })} options={[ { label: "全部结果", value: "all" }, { label: "成功", value: "succeeded" }, { label: "失败", value: "failed" }, ]} /> } toolbarActive={hasFilters} onReset={() => updateUrl({ filter: "", status: "all", recordType: "request", page: 1 })} batchActions={ setSelectedIds([])}> exportAdminApiLogs({ recordType, ids: selectedIds })} fileName={() => `请求明细-已选${selectedIds.length}条.csv`} label="导出已选" successMessage={`已导出选中的 ${selectedIds.length} 条请求明细`} errorMessage="导出请求明细失败" /> } skeletonColumns={10} table={{ className: "app-data-table", size: "small", rowKey: "id", loading, rowSelection: { selectedRowKeys: selectedIds, preserveSelectedRowKeys: false, onChange: (keys) => setSelectedIds(keys.map(String)) }, onRow: (log) => ({ onClick: (event) => { if ((event.target as HTMLElement).closest("button,a,input,.ant-checkbox-wrapper")) return; setDetailLogId(log.id); }, className: "admin-table-clickable-row", }), columns, dataSource: logs, pagination: false, scroll: { x: 1600 }, }} empty={} footer={ updateUrl({ page: nextSize !== pageSize ? 1 : nextPage, pageSize: nextSize })} />} /> setDetailLogId(null)} onLogUpdated={(next) => setLogs((items) => items.map((item) => (item.id === next.id ? next : item)))} /> setMediaPreview(null)} footer={ mediaPreview ? ( ) : null } destroyOnHidden > {mediaPreview ? ( ) : null} ); } function positiveInt(value: string | null, fallback: number) { const parsed = Number(value); return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback; } function normalizePageSize(value: string | null) { const parsed = positiveInt(value, 20); return [20, 50, 100].includes(parsed) ? parsed : 20; } function normalizeStatus(value: string | null): "all" | "succeeded" | "failed" { return value === "succeeded" || value === "failed" ? value : "all"; } function formatTime(value?: string) { return value ? new Date(value).toLocaleString("zh-CN", { hour12: false }) : "--"; } function capabilityText(value: string) { return ({ text: "文本", image: "图片", video: "视频", audio: "音频" } as Record)[value] || "未知"; } function formatDuration(value: number) { if (value < 1_000) return `${value} ms`; if (value < 60_000) return `${(value / 1_000).toFixed(value < 10_000 ? 1 : 0)} 秒`; const minutes = Math.floor(value / 60_000); const seconds = Math.round((value % 60_000) / 1_000); return `${minutes} 分 ${seconds} 秒`; } function BillingSummary({ log }: { log: ApiCallLog }) { if (!log.billable) return 不计费; if (!log.billingAvailable) return 未扣积分; const status = log.billingStatus || "reserved"; const statusLabel = ({ settled: "已结算", refunded: "已退回", uncertain: "待核对", running: "运行中", reserved: "已预授权" } as const)[status]; return (
{formatCredits(log.billingAmountMicrocredits)} 积分
{statusLabel}
); } function MediaResult({ log, onPreview }: { log: ApiCallLog; onPreview: (url: string, kind: "image" | "video") => void }) { const url = log.mediaPreviewUrl; const kind = log.mediaPreviewKind; const [unavailableUrl, setUnavailableUrl] = useState(""); if (!url || (kind !== "image" && kind !== "video")) return --; const previewUnavailable = unavailableUrl === url; return (
downloadMedia(url, kind)} title="下载原文件" aria-label="下载原文件" />
); } function downloadMedia(url: string, kind: "image" | "video") { saveAs(url, `api-call-${kind}.${kind === "video" ? "mp4" : "png"}`); } function CallStatus({ log }: { log: ApiCallLog }) { const providerStatus = log.providerStatus?.toLowerCase(); const processing = ["queued", "pending", "processing", "running", "in_progress"].includes(providerStatus || ""); const failed = log.status === "failed" || ["failed", "cancelled", "expired"].includes(providerStatus || ""); return (
{requestKindText(log.requestKind)}
{log.capability === "video" ?
已轮询 {log.pollCount || 0} 次
: null}
); } function requestKindText(value: ApiCallLog["requestKind"]) { const labels: Partial> = { create: "模型生成", poll: "状态查询", download: "结果下载", repair: "结果修复" }; return labels[value] || "上游请求"; }