328 lines
18 KiB
TypeScript
328 lines
18 KiB
TypeScript
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<ApiCallLog[]>([]);
|
|
const [total, setTotal] = useState(0);
|
|
const [loading, setLoading] = useState(true);
|
|
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
|
const [detailLogId, setDetailLogId] = useState<string | null>(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<string, string | number>, 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<ApiCallLog> = [
|
|
{ title: "时间", width: 168, render: (_, log) => formatTime(log.startedAt || log.createdAt) },
|
|
{
|
|
title: "用户",
|
|
width: 180,
|
|
render: (_, log) => (
|
|
<div className="min-w-0">
|
|
<div className="truncate font-medium text-foreground/85">{log.userDisplayName || log.userAccount || "未知用户"}</div>
|
|
<div className="truncate text-xs text-foreground/45">{log.userAccount ? `@${log.userAccount}` : "账号未记录"}</div>
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
title: "渠道 / 模型",
|
|
width: 230,
|
|
render: (_, log) => (
|
|
<div className="min-w-0">
|
|
<div className="truncate text-foreground/78">{log.channelName || "未记录渠道"}</div>
|
|
<div className="truncate text-xs text-foreground/45" title={log.model}>
|
|
{log.model || "未识别模型"}
|
|
</div>
|
|
</div>
|
|
),
|
|
},
|
|
{ title: "能力", dataIndex: "capability", width: 88, render: capabilityText },
|
|
{ title: "结果", width: 118, render: (_, log) => <MediaResult log={log} onPreview={(url, kind) => setMediaPreview({ url, kind, title: `${capabilityText(log.capability)}结果` })} /> },
|
|
{ title: "请求阶段 / 状态", width: 160, render: (_, log) => <CallStatus log={log} /> },
|
|
{
|
|
title: "错误信息",
|
|
width: 260,
|
|
render: (_, log) =>
|
|
log.status === "failed" || log.error || log.errorCode ? (
|
|
<div className="min-w-0" title={[log.errorCode, log.error].filter(Boolean).join(" · ")}>
|
|
<div className="truncate text-xs font-medium text-red-500">{log.errorCode || `HTTP ${log.statusCode || "失败"}`}</div>
|
|
<div className="line-clamp-2 text-xs leading-5 text-foreground/55">{log.error || "上游未返回错误详情"}</div>
|
|
</div>
|
|
) : (
|
|
<span className="text-foreground/30">--</span>
|
|
),
|
|
},
|
|
{ title: "耗时", dataIndex: "durationMs", width: 112, render: (value) => <span className="tabular-nums">{formatDuration(value)}</span> },
|
|
{ title: "积分计费", width: 145, render: (_, log) => <BillingSummary log={log} /> },
|
|
{
|
|
title: "Tokens",
|
|
width: 166,
|
|
render: (_, log) =>
|
|
log.usageAvailable ? (
|
|
<div className="space-y-0.5 text-xs tabular-nums">
|
|
<div>
|
|
<span className="text-foreground/40">输入</span> {log.inputTokens.toLocaleString()}
|
|
</div>
|
|
<div>
|
|
<span className="text-foreground/40">输出</span> {log.outputTokens.toLocaleString()}
|
|
</div>
|
|
{log.cachedTokens > 0 ? (
|
|
<div>
|
|
<span className="text-foreground/40">缓存</span> {log.cachedTokens.toLocaleString()}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
) : (
|
|
<span className="text-foreground/35">未返回</span>
|
|
),
|
|
},
|
|
];
|
|
|
|
return (
|
|
<AdminPageFrame
|
|
title="请求明细"
|
|
description="模型生成与结果下载记录;仅计费调用扣除积分"
|
|
actions={
|
|
<AdminExportButton
|
|
exportFile={() => exportAdminApiLogs({ recordType, keyword: debouncedKeyword || undefined, status: status === "all" ? undefined : status })}
|
|
fileName={() => `请求明细-${new Date().toISOString().slice(0, 10)}.csv`}
|
|
label="导出当前筛选"
|
|
successMessage="已按当前筛选导出请求明细"
|
|
errorMessage="导出请求明细失败"
|
|
/>
|
|
}
|
|
>
|
|
<AdminDataTable
|
|
toolbar={
|
|
<Input
|
|
allowClear
|
|
className="app-list-search"
|
|
prefix={<Search className="size-4 text-foreground/40" />}
|
|
value={keyword}
|
|
placeholder="搜索用户、渠道、模型、路径或请求号"
|
|
onChange={(event) => updateUrl({ filter: event.target.value, page: 1 }, true)}
|
|
/>
|
|
}
|
|
toolbarActiveFilters={
|
|
<>
|
|
{keyword ? <AdminFilterChip label={`搜索:${keyword}`} onRemove={() => updateUrl({ filter: "", page: 1 })} /> : null}
|
|
{status !== "all" ? <AdminFilterChip label={`结果:${status === "succeeded" ? "成功" : "失败"}`} onRemove={() => updateUrl({ status: "all", page: 1 })} /> : null}
|
|
</>
|
|
}
|
|
toolbarFilters={
|
|
<>
|
|
<Select aria-label="明细类型" className="w-32" value={recordType} onChange={(value) => updateUrl({ recordType: value, page: 1 })} options={[{ label: "仅请求", value: "request" }, { label: "仅下载", value: "download" }, { label: "全部明细", value: "all" }]} />
|
|
<Select
|
|
className="w-32"
|
|
value={status}
|
|
onChange={(value) => 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={
|
|
<AdminBatchBar count={selectedIds.length} onClear={() => setSelectedIds([])}>
|
|
<AdminExportButton
|
|
type="primary"
|
|
size="small"
|
|
exportFile={() => exportAdminApiLogs({ recordType, ids: selectedIds })}
|
|
fileName={() => `请求明细-已选${selectedIds.length}条.csv`}
|
|
label="导出已选"
|
|
successMessage={`已导出选中的 ${selectedIds.length} 条请求明细`}
|
|
errorMessage="导出请求明细失败"
|
|
/>
|
|
</AdminBatchBar>
|
|
}
|
|
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={<AdminTableEmpty filtered={hasFilters} />}
|
|
footer={<PaginationBar alwaysShow current={page} pageSize={pageSize} total={total} onChange={(nextPage, nextSize) => updateUrl({ page: nextSize !== pageSize ? 1 : nextPage, pageSize: nextSize })} />}
|
|
/>
|
|
<ApiLogDetailDrawer logId={detailLogId} onClose={() => setDetailLogId(null)} onLogUpdated={(next) => setLogs((items) => items.map((item) => (item.id === next.id ? next : item)))} />
|
|
<Modal
|
|
title={mediaPreview?.title || "媒体预览"}
|
|
open={Boolean(mediaPreview)}
|
|
width={880}
|
|
onCancel={() => setMediaPreview(null)}
|
|
footer={
|
|
mediaPreview ? (
|
|
<Button icon={<Download className="size-4" />} onClick={() => downloadMedia(mediaPreview.url, mediaPreview.kind)}>
|
|
下载原文件
|
|
</Button>
|
|
) : null
|
|
}
|
|
destroyOnHidden
|
|
>
|
|
{mediaPreview ? (
|
|
<MediaPreview
|
|
src={mediaPreview.url}
|
|
kind={mediaPreview.kind}
|
|
alt={mediaPreview.title}
|
|
controls={mediaPreview.kind === "video"}
|
|
className="max-h-[72vh] w-full bg-black object-contain"
|
|
fallbackClassName="min-h-[360px] rounded-lg bg-black/90 text-white/55"
|
|
/>
|
|
) : null}
|
|
</Modal>
|
|
</AdminPageFrame>
|
|
);
|
|
}
|
|
|
|
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<string, string>)[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 <span className="text-foreground/45">不计费</span>;
|
|
if (!log.billingAvailable) return <span className="text-foreground/35">未扣积分</span>;
|
|
const status = log.billingStatus || "reserved";
|
|
const statusLabel = ({ settled: "已结算", refunded: "已退回", uncertain: "待核对", running: "运行中", reserved: "已预授权" } as const)[status];
|
|
return (
|
|
<div>
|
|
<div className="tabular-nums">{formatCredits(log.billingAmountMicrocredits)} 积分</div>
|
|
<div className="text-xs text-foreground/40">{statusLabel}</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 <span className="text-foreground/30">--</span>;
|
|
const previewUnavailable = unavailableUrl === url;
|
|
return (
|
|
<div className="flex w-[90px] items-center gap-1.5">
|
|
<button
|
|
type="button"
|
|
title={previewUnavailable ? "预览不可用,素材可能已删除" : `预览${kind === "video" ? "视频" : "图片"}`}
|
|
aria-label={previewUnavailable ? "预览不可用,素材可能已删除" : `预览${kind === "video" ? "视频" : "图片"}`}
|
|
disabled={previewUnavailable}
|
|
className="group relative h-11 w-16 shrink-0 overflow-hidden rounded border border-border/75 bg-black/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
onClick={() => onPreview(url, kind)}
|
|
>
|
|
<MediaPreview src={url} kind={kind} alt="生成结果" loading="lazy" className="size-full object-cover" fallbackClassName="text-white/55" fallbackLabel="预览不可用" onUnavailable={() => setUnavailableUrl(url)} />
|
|
{!previewUnavailable ? (
|
|
<span className="absolute inset-0 grid place-items-center bg-black/0 text-white opacity-0 transition group-hover:bg-black/35 group-hover:opacity-100 group-focus-visible:bg-black/35 group-focus-visible:opacity-100">
|
|
{kind === "video" ? <Play className="size-4 fill-current" /> : <Eye className="size-4" />}
|
|
</span>
|
|
) : null}
|
|
{!previewUnavailable && log.mediaCount > 1 ? <span className="absolute bottom-0.5 right-0.5 rounded-sm bg-black/65 px-1 text-[var(--fs-micro)] leading-4 text-white">{log.mediaCount}</span> : null}
|
|
</button>
|
|
<IconButton size="sm" variant="ghost" icon={Download} onClick={() => downloadMedia(url, kind)} title="下载原文件" aria-label="下载原文件" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<div>
|
|
<div className="mb-1 text-xs font-medium text-foreground/70">{requestKindText(log.requestKind)}</div>
|
|
<AdminStatusBadge label={failed ? "失败" : processing ? "处理中" : "成功"} tone={failed ? "error" : processing ? "warning" : "success"} />
|
|
{log.capability === "video" ? <div className="mt-1 text-xs tabular-nums text-foreground/45">已轮询 {log.pollCount || 0} 次</div> : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function requestKindText(value: ApiCallLog["requestKind"]) {
|
|
const labels: Partial<Record<ApiCallLog["requestKind"], string>> = { create: "模型生成", poll: "状态查询", download: "结果下载", repair: "结果修复" };
|
|
return labels[value] || "上游请求";
|
|
}
|