feat(admin): 管理后台 - 完善配置运营页面并修复导航、筛选与积分计费
This commit is contained in:
@@ -7,6 +7,7 @@ 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";
|
||||
@@ -57,46 +58,216 @@ export default function LogsPage() {
|
||||
|
||||
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: "用户",
|
||||
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: "错误信息",
|
||||
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: 130, render: (_, log) => log.costAvailable ? <div><div className="tabular-nums">{formatCost(log)}</div><div className="text-xs text-foreground/40">估算费用</div></div> : <span className="text-foreground/35">未配置</span> },
|
||||
{ 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> },
|
||||
{ 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({ keyword: debouncedKeyword || undefined, status: status === "all" ? undefined : status })} fileName={() => `请求明细-${new Date().toISOString().slice(0, 10)}.csv`} label="导出当前筛选" successMessage="已按当前筛选导出请求明细" errorMessage="导出请求明细失败" />}>
|
||||
<AdminPageFrame
|
||||
title="请求明细"
|
||||
description="上游调用与积分计费"
|
||||
actions={
|
||||
<AdminExportButton
|
||||
exportFile={() => exportAdminApiLogs({ 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 className="w-32" value={status} onChange={(value) => updateUrl({ status: value, page: 1 })} options={[{ label: "全部结果", value: "all" }, { label: "成功", value: "succeeded" }, { label: "失败", value: "failed" }]} />}
|
||||
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
|
||||
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", page: 1 })}
|
||||
batchActions={<AdminBatchBar count={selectedIds.length} onClear={() => setSelectedIds([])}><AdminExportButton type="primary" size="small" exportFile={() => exportAdminApiLogs({ ids: selectedIds })} fileName={() => `请求明细-已选${selectedIds.length}条.csv`} label="导出已选" successMessage={`已导出选中的 ${selectedIds.length} 条请求明细`} errorMessage="导出请求明细失败" /></AdminBatchBar>}
|
||||
batchActions={
|
||||
<AdminBatchBar count={selectedIds.length} onClear={() => setSelectedIds([])}>
|
||||
<AdminExportButton
|
||||
type="primary"
|
||||
size="small"
|
||||
exportFile={() => exportAdminApiLogs({ 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 } }}
|
||||
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}
|
||||
<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 formatCost(log: ApiCallLog) { return `${log.currency || "USD"} ${(log.estimatedCostMicros / 1_000_000).toFixed(6)}`; }
|
||||
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.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;
|
||||
@@ -104,14 +275,27 @@ function MediaResult({ log, onPreview }: { log: ApiCallLog; onPreview: (url: str
|
||||
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>
|
||||
<Button type="text" size="small" className="!size-7 !min-w-7 !p-0" icon={<Download className="size-3.5" />} onClick={() => downloadMedia(url, kind)} title="下载原文件" aria-label="下载原文件" />
|
||||
</div>;
|
||||
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>
|
||||
<Button type="text" size="small" className="!size-7 !min-w-7 !p-0" icon={<Download className="size-3.5" />} onClick={() => downloadMedia(url, kind)} title="下载原文件" aria-label="下载原文件" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function downloadMedia(url: string, kind: "image" | "video") {
|
||||
@@ -122,5 +306,10 @@ 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><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>;
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user