feat(web): 角色与音频工作台 - 完善素材选择、生成参数与同步交互

This commit is contained in:
ddcat666
2026-10-01 09:43:02 +08:00
parent 3aa874a70c
commit 12125e18f6
77 changed files with 3641 additions and 721 deletions

View File

@@ -1,25 +1,40 @@
import { useEffect, useState } from "react";
import { App, Button, Descriptions, Drawer, Skeleton, Tabs, Typography } from "antd";
import { AdminEmpty } from "@/pages/admin/components/admin-ui";
import { RefreshCw } from "lucide-react";
import { useEffect, useState, type ReactNode } from "react";
import { Alert, App, Button, Drawer, Input, Skeleton, Tabs, Typography } from "antd";
import { AdminEmpty, AdminStatusBadge } from "@/pages/admin/components/admin-ui";
import { Activity, Clipboard, Link2, RefreshCw, Save, Video } from "lucide-react";
import { formatCredits } from "@/constant/credits";
import { mediaDeliverySummary } from "@/lib/generation-task-display";
import { getAdminApiLog, queryAdminApiLogTask, type ApiCallLog } from "@/services/api/auth";
import { AdminStatusBadge } from "./admin-ui";
import { getAdminApiLog, queryAdminApiLogTask, recoverAdminApiLogVideoByURL, type ApiCallLog } from "@/services/api/auth";
export function ApiLogDetailDrawer({ logId, onClose, onLogUpdated }: { logId: string | null; onClose: () => void; onLogUpdated?: (log: ApiCallLog) => void }) {
const { message } = App.useApp();
const [log, setLog] = useState<ApiCallLog | null>(null);
const [loading, setLoading] = useState(false);
const [querying, setQuerying] = useState(false);
const [providerRequestId, setProviderRequestId] = useState("");
const [videoURL, setVideoURL] = useState("");
const [resultText, setResultText] = useState("");
const refresh = async (id: string) => {
const refreshed = await getAdminApiLog(id);
setLog(refreshed.log);
setProviderRequestId(refreshed.log.providerRequestId || "");
onLogUpdated?.(refreshed.log);
return refreshed.log;
};
useEffect(() => {
if (!logId) return;
let active = true;
setLoading(true);
setLog(null);
setResultText("");
setVideoURL("");
void getAdminApiLog(logId)
.then((result) => active && setLog(result.log))
.then((result) => {
if (!active) return;
setLog(result.log);
setProviderRequestId(result.log.providerRequestId || "");
})
.catch((error) => active && message.error(error instanceof Error ? error.message : "读取请求详情失败"))
.finally(() => active && setLoading(false));
return () => {
@@ -27,127 +42,194 @@ export function ApiLogDetailDrawer({ logId, onClose, onLogUpdated }: { logId: st
};
}, [logId, message]);
const queryProviderTask = async () => {
const runQuery = async () => {
if (!log) return;
setQuerying(true);
setResultText("");
try {
const result = await queryAdminApiLogTask(log.id);
const refreshed = await getAdminApiLog(log.id);
setLog(refreshed.log);
onLogUpdated?.(refreshed.log);
const result = await queryAdminApiLogTask(log.id, providerRequestId.trim() || undefined);
await refresh(log.id);
if (result.recovered) {
window.dispatchEvent(new CustomEvent("wallet:updated"));
if (result.billingSettled) message.success("已获取上游视频,任务已恢复并完成结算");
else message.warning("已获取上游视频,任务已恢复,计费状态待核对");
setResultText(result.billingSettled ? "查询成功,视频已转存并完成结算。" : "视频已转存,计费状态待核对。");
message.success("视频任务恢复成功");
} else {
message.info(`上游任务仍在处理中${result.providerStatus ? `(${result.providerStatus})` : ""}`);
setResultText(`上游仍在生成中${result.providerStatus ? `(${result.providerStatus})` : ""},暂不扣费。`);
}
} catch (error) {
message.error(error instanceof Error ? error.message : "查询上游任务失败");
setResultText(error instanceof Error ? error.message : "查询上游任务失败");
} finally {
setQuerying(false);
}
};
const runURLRecovery = async () => {
if (!log || !videoURL.trim()) return;
setQuerying(true);
setResultText("");
try {
const result = await recoverAdminApiLogVideoByURL(log.id, videoURL.trim(), providerRequestId.trim() || undefined);
await refresh(log.id);
window.dispatchEvent(new CustomEvent("wallet:updated"));
setResultText(result.billingSettled ? "视频已转存并完成结算。" : "视频已转存,计费状态待核对。");
message.success("视频转存成功");
} catch (error) {
setResultText(error instanceof Error ? error.message : "视频转存失败");
} finally {
setQuerying(false);
}
};
const title = log ? (
<div className="flex min-w-0 items-center gap-3">
<div className="grid size-9 shrink-0 place-items-center rounded-lg bg-blue-500/12 text-blue-500">
<Activity className="size-[18px]" aria-hidden="true" />
</div>
<div className="min-w-0">
<div className="truncate text-[15px] font-semibold text-foreground">请求日志详情</div>
<div className="mt-0.5 truncate text-xs font-normal text-foreground/50">
{capabilityText(log.capability)} · {log.model || "未识别模型"} · {formatTime(log.startedAt || log.createdAt)}
</div>
</div>
</div>
) : "请求日志详情";
return (
<Drawer title="请求详情" open={Boolean(logId)} onClose={onClose} width="min(1200px, 90vw)" destroyOnHidden rootClassName="admin-drawer">
{loading ? <Skeleton active paragraph={{ rows: 12 }} /> : log ? <LogDetail log={log} querying={querying} onQueryProviderTask={queryProviderTask} /> : <AdminEmpty size="compact" title="没有请求详情" />}
<Drawer title={title} open={Boolean(logId)} onClose={onClose} size="min(1120px, 66vw)" destroyOnHidden className="api-log-detail-drawer-panel" rootClassName="admin-drawer api-log-detail-drawer">
{loading ? <Skeleton active paragraph={{ rows: 10 }} /> : log ? <LogDetail log={log} providerRequestId={providerRequestId} videoURL={videoURL} resultText={resultText} querying={querying} onProviderRequestIdChange={setProviderRequestId} onVideoURLChange={setVideoURL} onQuery={runQuery} onRecoverURL={runURLRecovery} /> : <AdminEmpty size="compact" title="没有请求详情" />}
</Drawer>
);
}
function LogDetail({ log, querying, onQueryProviderTask }: { log: ApiCallLog; querying: boolean; onQueryProviderTask: () => void }) {
type LogDetailProps = {
log: ApiCallLog;
providerRequestId: string;
videoURL: string;
resultText: string;
querying: boolean;
onProviderRequestIdChange: (value: string) => void;
onVideoURLChange: (value: string) => void;
onQuery: () => void;
onRecoverURL: () => void;
};
function LogDetail({ log, providerRequestId, videoURL, resultText, querying, onProviderRequestIdChange, onVideoURLChange, onQuery, onRecoverURL }: LogDetailProps) {
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 || "");
const items = [
["时间", new Date(log.startedAt || log.createdAt).toLocaleString("zh-CN", { hour12: false })],
["状态", <AdminStatusBadge label={failed ? "失败" : processing ? "处理中" : "成功"} tone={failed ? "error" : processing ? "warning" : "success"} />],
[
"用户",
<div>
<div className="font-medium text-foreground/90">{log.userDisplayName || log.userAccount || "未知用户"}</div>
{log.userAccount ? <div className="mt-0.5 text-xs text-foreground/50">@{log.userAccount}</div> : null}
</div>,
],
[
"渠道 / 模型",
<div>
<div className="text-foreground/85">{log.channelName || "未记录渠道"}</div>
<div className="mt-0.5 font-mono text-xs text-foreground/55" title={log.model}>
{log.model || "未识别模型"}
</div>
</div>,
],
["能力", capabilityText(log.capability)],
["请求阶段", requestKindText(log.requestKind)],
["作品交付", mediaDeliverySummary(log.taskStatus, log.mediaStage) || "未记录保存阶段"],
["计费属性", log.billable ? "计费调用" : "不计费"],
["总耗时", <span className="tabular-nums">{formatDuration(log.durationMs)}</span>],
["视频轮询", log.capability === "video" ? <span className="tabular-nums">{log.pollCount || 0} 次</span> : <span className="text-foreground/35">--</span>],
[
"Token 用量",
log.usageAvailable ? (
<div className="space-y-1 font-mono text-sm tabular-nums">
<div>
<span className="text-foreground/55">输入</span> <span className="ml-2 text-foreground/85">{log.inputTokens.toLocaleString()}</span>
</div>
<div>
<span className="text-foreground/55">输出</span> <span className="ml-2 text-foreground/85">{log.outputTokens.toLocaleString()}</span>
</div>
{log.cachedTokens > 0 ? (
<div>
<span className="text-foreground/55">缓存</span> <span className="ml-2 text-foreground/85">{log.cachedTokens.toLocaleString()}</span>
</div>
) : null}
</div>
) : (
<span className="text-foreground/35">未返回</span>
),
],
["销售价格(积分)", billingText(log)],
["成本价格(积分)", log.creditCostMicrocredits !== undefined ? `${formatCredits(log.creditCostMicrocredits)} 积分` : log.creditCostConfigured ? "待核算" : "未配置"],
["上游成本", log.costAvailable ? <span className="font-mono tabular-nums">{log.currency || "USD"} {(log.estimatedCostMicros / 1_000_000).toFixed(6)}</span> : <span className="text-foreground/35">未配置成本</span>],
[
"错误信息",
[log.errorCode, log.error].filter(Boolean).length > 0 ? (
<div>
{log.errorCode ? <div className="mb-1 font-mono text-sm font-medium text-red-500">{log.errorCode}</div> : null}
{log.error ? <div className="text-sm text-foreground/75">{log.error}</div> : null}
</div>
) : (
<span className="text-foreground/35">--</span>
),
],
["方法与路径", <code className="text-sm text-foreground/80">{log.method} {log.path}</code>],
["请求 Content-Type", log.requestContentType ? <code className="text-sm text-foreground/70">{log.requestContentType}</code> : <span className="text-foreground/35">--</span>],
["HTTP 状态", log.statusCode ? <span className="tabular-nums">{log.statusCode}</span> : <span className="text-foreground/35">--</span>],
["任务 ID", log.taskId ? <code className="font-mono text-sm text-foreground/70">{log.taskId}</code> : <span className="text-foreground/35">--</span>],
["供应商任务 ID", log.providerRequestId ? <code className="font-mono text-sm text-foreground/70">{log.providerRequestId}</code> : <span className="text-foreground/35">--</span>],
["上游地址", log.upstreamUrl ? <code className="break-all text-sm text-foreground/70">{log.upstreamUrl}</code> : <span className="text-foreground/35">--</span>],
].map(([label, children], index) => ({ key: String(index), label, children }));
const canQueryProviderTask = !log.mediaStage && log.capability === "video" && log.taskStatus === "failed" && Boolean(log.taskId && log.providerRequestId);
const status = failed ? { label: "失败", tone: "error" as const } : processing ? { label: "处理中", tone: "warning" as const } : { label: "成功", tone: "success" as const };
return (
<div className="space-y-6">
{canQueryProviderTask ? (
<div className="flex justify-end">
<Button icon={<RefreshCw className="size-4" />} loading={querying} onClick={onQueryProviderTask}>
{querying ? "正在下载并入库" : "手动查询任务"}
</Button>
<div className="api-log-detail-body">
<section className="api-log-detail-section">
<div className="api-log-detail-section-heading">
<div>
<div className="api-log-detail-eyebrow">REQUEST OVERVIEW</div>
<h2>请求概览</h2>
</div>
<AdminStatusBadge label={status.label} tone={status.tone} />
</div>
) : null}
<Descriptions bordered size="middle" column={{ xs: 1, sm: 1, md: 1, lg: 2, xl: 2 }} labelStyle={{ width: "160px", fontWeight: 500 }} items={items} />
<section>
<div className="mb-3 text-base font-semibold text-foreground/90">原始报文</div>
<Tabs
items={[
{ key: "request", label: "请求报文", children: <PayloadPanel value={log.requestBody} empty="该请求未记录请求报文" /> },
{ key: "response", label: "响应报文", children: <PayloadPanel value={log.responseBody} empty="该请求未记录响应报文" /> },
]}
/>
<div className="api-log-detail-summary-grid">
<InfoItem label="能力类型" value={capabilityText(log.capability)} />
<InfoItem label="请求阶段" value={requestKindText(log.requestKind)} />
<InfoItem label="模型 / 渠道" value={`${log.model || "未识别模型"} / ${log.channelName || "未记录渠道"}`} />
<InfoItem label="用户" value={log.userDisplayName || log.userAccount || "未知用户"} detail={log.userAccount ? `@${log.userAccount}` : undefined} />
<InfoItem label="任务状态" value={mediaDeliveryText(log)} />
<InfoItem label="计费" value={billingText(log)} />
<InfoItem label="请求 ID" value={<CopyValue value={log.id} />} />
<InfoItem label="任务 ID" value={log.taskId ? <CopyValue value={log.taskId} /> : "未记录"} />
<InfoItem label="供应商任务 ID" value={log.providerRequestId ? <CopyValue value={log.providerRequestId} /> : "未记录"} />
<InfoItem label="耗时" value={<span className="tabular-nums">{formatDuration(log.durationMs)}</span>} />
<InfoItem label="HTTP 状态" value={log.statusCode || "未记录"} />
<InfoItem label="请求地址" value={<span className="break-all font-mono text-xs">{log.method} {log.path || "未记录"}</span>} />
</div>
{log.error || log.errorCode ? (
<div className="api-log-detail-error">
<div className="text-xs font-semibold text-red-500">{log.errorCode || `HTTP ${log.statusCode || "失败"}`}</div>
<div className="mt-1 text-sm text-foreground/70">{log.error || "上游未返回错误详情"}</div>
</div>
) : null}
</section>
{log.capability === "video" ? (
<section className="api-log-detail-section api-log-recovery-section">
<div className="api-log-detail-section-heading">
<div className="flex items-center gap-2.5">
<div className="grid size-8 place-items-center rounded-md bg-violet-500/12 text-violet-500">
<Video className="size-4" aria-hidden="true" />
</div>
<div>
<div className="api-log-detail-eyebrow">VIDEO RECOVERY</div>
<h2>视频任务恢复</h2>
</div>
</div>
<span className="text-xs text-foreground/45">失败任务可用</span>
</div>
<div className="api-log-recovery-body">
<div className="api-log-recovery-row">
<div className="api-log-recovery-label">
<span>供应商任务 ID</span>
<small>默认使用当前记录,可手动覆盖</small>
</div>
<Input className="api-log-recovery-input" value={providerRequestId} onChange={(event) => onProviderRequestIdChange(event.target.value)} placeholder="输入供应商任务 ID" />
<Button icon={<RefreshCw className="size-3.5" />} loading={querying} onClick={onQuery}>自动查询</Button>
<Button type="primary" icon={<Save className="size-3.5" />} loading={querying} onClick={onQuery}>保存并查询</Button>
</div>
<div className="api-log-recovery-row">
<div className="api-log-recovery-label">
<span>视频 URL 转存</span>
<small>仅支持公网 HTTP / HTTPS 视频地址</small>
</div>
<Input className="api-log-recovery-input" prefix={<Link2 className="size-4 text-foreground/35" />} value={videoURL} onChange={(event) => onVideoURLChange(event.target.value)} placeholder="https://example.com/video.mp4" />
<Button icon={<Save className="size-3.5" />} loading={querying} disabled={!videoURL.trim()} onClick={onRecoverURL}>转存并回写</Button>
</div>
{resultText ? <Alert className="api-log-recovery-result" type={resultText.includes("失败") || resultText.includes("错误") ? "error" : resultText.includes("生成中") ? "warning" : "success"} showIcon message={resultText} /> : null}
</div>
</section>
) : null}
<section className="api-log-detail-section api-log-payload-section">
<div className="api-log-detail-section-heading">
<div>
<div className="api-log-detail-eyebrow">RAW PAYLOAD</div>
<h2>请求报文</h2>
</div>
<span className="text-xs text-foreground/45">{log.requestBody || log.responseBody ? "可复制原始内容" : "暂无报文"}</span>
</div>
<Tabs items={[{ key: "request", label: "请求", children: <PayloadPanel value={log.requestBody} empty="该请求未记录请求报文" /> }, { key: "response", label: "响应", children: <PayloadPanel value={log.responseBody} empty="该请求未记录响应报文" /> }]} />
</section>
</div>
);
}
function InfoItem({ label, value, detail }: { label: string; value: ReactNode; detail?: string }) {
return (
<div className="api-log-detail-info-item">
<div className="api-log-detail-info-label">{label}</div>
<div className="api-log-detail-info-value">{value}</div>
{detail ? <div className="api-log-detail-info-detail">{detail}</div> : null}
</div>
);
}
function CopyValue({ value }: { value: string }) {
return (
<Typography.Text className="api-log-copy-value" copyable={{ text: value, tooltips: ["复制", "已复制"] }}>
<code>{value}</code>
</Typography.Text>
);
}
function PayloadPanel({ value, empty }: { value?: string; empty: string }) {
if (!value) return <AdminEmpty size="compact" title={empty} />;
return (
<div className="api-log-payload-wrap">
<div className="api-log-payload-actions">
<Typography.Text copyable={{ text: value, tooltips: ["复制报文", "已复制"] }}>
<Clipboard className="mr-1 inline size-3.5" />复制报文
</Typography.Text>
</div>
<pre className="api-log-payload">{value}</pre>
</div>
);
}
@@ -155,9 +237,14 @@ function LogDetail({ log, querying, onQueryProviderTask }: { log: ApiCallLog; qu
function billingText(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}`;
const labels = { settled: "已结算", refunded: "已退回", uncertain: "待核对", running: "运行中", reserved: "已预授权" } as const;
return `${formatCredits(log.billingAmountMicrocredits)} 积分 · ${labels[log.billingStatus || "reserved"]}`;
}
function mediaDeliveryText(log: ApiCallLog) {
if (log.taskStatus === "succeeded") return log.mediaStage === "completed" ? "已保存并登记" : "任务成功";
if (log.taskStatus === "failed") return "任务失败";
return log.taskStatus || "未记录";
}
function requestKindText(value: ApiCallLog["requestKind"]) {
@@ -165,25 +252,16 @@ function requestKindText(value: ApiCallLog["requestKind"]) {
return labels[value] || "上游请求";
}
function PayloadPanel({ value, empty }: { value?: string; empty: string }) {
if (!value) return <AdminEmpty size="compact" title={empty} />;
return (
<div className="relative">
<div className="absolute right-3 top-2 z-10">
<Typography.Text copyable={{ text: value }} className="text-xs text-foreground/50">
复制报文
</Typography.Text>
</div>
<pre className="thin-scrollbar max-h-[46vh] overflow-auto whitespace-pre-wrap break-all rounded-md border border-border/70 bg-foreground/[.035] px-4 pb-4 pt-10 font-mono text-xs leading-5 text-foreground/75">{value}</pre>
</div>
);
}
function capabilityText(value: string) {
return ({ text: "文本", image: "图片", video: "视频", audio: "音频" } as Record<string, string>)[value] || "未知";
}
function formatTime(value?: string) {
return value ? new Date(value).toLocaleString("zh-CN", { hour12: false }) : "--";
}
function formatDuration(value: number) {
if (value < 1_000) return `${value} ms`;
if (value < 60_000) return `${(value / 1_000).toFixed(1)} 秒`;
if (value < 60_000) return `${(value / 1_000).toFixed(value < 10_000 ? 1 : 0)} 秒`;
return `${Math.floor(value / 60_000)} 分 ${Math.round((value % 60_000) / 1_000)} 秒`;
}

View File

@@ -88,7 +88,7 @@ export function validateChannelModelPrices(values: Pick<ChannelModelFormValues,
throw new Error(`价格档 ${index + 1}:${text}`);
};
if (!["fixed_request", "per_second", "token"].includes(tier.billingMode)) fail("请选择计费方式");
if (tier.billingMode === "per_second" && capability !== "video") fail("按秒计费仅支持视频,请重新选择计费方式并核对价格");
if (tier.billingMode === "per_second" && capability !== "video" && capability !== "audio") fail("按秒计费仅支持视频或音频,请重新选择计费方式并核对价格");
if (tier.billingMode === "token" && !modelProtocolSupportsTokenBilling(capability, protocol)) fail("当前模型能力不支持 Token 计费,请重新选择计费方式并核对价格");
if (tier.matchMode === "advanced") {
if (tier.operation && tier.operation !== "*" && !operations[capability]?.includes(tier.operation)) fail("生成方式与模型能力不匹配");

View File

@@ -41,6 +41,8 @@ export function PriceTierFields({
const resolutionOptions = video?.resolutions || [];
const tokenEnabled = modelProtocolSupportsTokenBilling(capability, protocol);
const isVideo = capability === "video";
const isAudio = capability === "audio";
const isDurationPriced = isVideo || isAudio;
const isImage = capability === "image";
return (
<article className="admin-price-tier-card">
@@ -149,7 +151,7 @@ export function PriceTierFields({
className="w-full"
options={[
{ label: "按次", value: "fixed_request" },
{ label: "按秒", value: "per_second", disabled: !isVideo },
{ label: "按秒", value: "per_second", disabled: !isDurationPriced },
{ label: isVideo ? "视频 Token" : "Token", value: "token", disabled: !tokenEnabled },
]}
/>

View File

@@ -30,3 +30,275 @@
[data-admin-root] .admin-log-error {
color: var(--admin-status-error);
}
/* Request-log drawer: dense, scannable sections instead of nested bordered panels. */
.api-log-detail-drawer .ant-drawer-content-wrapper {
width: min(1120px, 66vw) !important;
}
.api-log-detail-drawer-panel .ant-drawer-header {
min-height: 68px;
padding: 16px 24px;
}
.api-log-detail-drawer-panel .ant-drawer-body {
padding: 20px 24px 28px;
}
.api-log-detail-drawer-panel .ant-drawer-close {
margin-inline-end: 4px;
}
.api-log-detail-body {
display: flex;
flex-direction: column;
gap: 16px;
}
.api-log-detail-section {
overflow: hidden;
border: 1px solid var(--admin-border);
border-radius: 12px;
background: color-mix(in srgb, var(--admin-surface) 94%, var(--admin-text) 6%);
}
.api-log-detail-section-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
min-height: 56px;
padding: 12px 16px;
border-bottom: 1px solid var(--admin-border);
}
.api-log-detail-section-heading h2 {
margin: 2px 0 0;
color: var(--admin-text);
font-size: 15px;
font-weight: 650;
letter-spacing: 0;
}
.api-log-detail-eyebrow {
color: var(--admin-text-muted);
font-size: 10px;
font-weight: 700;
letter-spacing: 0.08em;
line-height: 1.2;
}
.api-log-detail-summary-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0;
padding: 4px 16px 8px;
}
.api-log-detail-info-item {
min-width: 0;
padding: 11px 16px 10px 0;
border-bottom: 1px solid color-mix(in srgb, var(--admin-border) 70%, transparent);
}
.api-log-detail-info-label {
color: var(--admin-text-muted);
font-size: 11px;
line-height: 1.3;
}
.api-log-detail-info-value {
min-width: 0;
margin-top: 4px;
overflow: hidden;
color: var(--admin-text);
font-size: 13px;
line-height: 1.4;
text-overflow: ellipsis;
white-space: nowrap;
}
.api-log-detail-info-detail {
margin-top: 2px;
overflow: hidden;
color: var(--admin-text-muted);
font-size: 11px;
text-overflow: ellipsis;
white-space: nowrap;
}
.api-log-copy-value {
display: inline-flex;
min-width: 0;
max-width: 100%;
align-items: center;
color: var(--admin-text);
}
.api-log-copy-value code {
overflow: hidden;
font-size: 11px;
text-overflow: ellipsis;
white-space: nowrap;
}
.api-log-copy-value .ant-typography-copy {
margin-inline-start: 6px;
color: var(--admin-text-muted);
}
.api-log-detail-error {
margin: 4px 16px 16px;
padding: 10px 12px;
border: 1px solid color-mix(in srgb, var(--admin-status-error) 28%, var(--admin-border));
border-radius: 8px;
background: color-mix(in srgb, var(--admin-status-error) 7%, transparent);
}
.api-log-recovery-body {
padding: 12px 16px 16px;
}
.api-log-recovery-row {
display: grid;
grid-template-columns: 170px minmax(0, 1fr) auto auto;
align-items: center;
gap: 10px;
min-height: 48px;
}
.api-log-recovery-row + .api-log-recovery-row {
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid color-mix(in srgb, var(--admin-border) 70%, transparent);
}
.api-log-recovery-label {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
color: var(--admin-text);
font-size: 13px;
font-weight: 600;
}
.api-log-recovery-label small {
overflow: hidden;
color: var(--admin-text-muted);
font-size: 11px;
font-weight: 400;
text-overflow: ellipsis;
white-space: nowrap;
}
.api-log-recovery-input {
min-width: 0;
}
.api-log-recovery-result {
margin-top: 12px;
}
.api-log-payload-section .ant-tabs-nav {
margin: 0;
padding: 0 16px;
}
.api-log-payload-section .ant-tabs-content-holder {
padding: 0 16px 16px;
}
.api-log-payload-wrap {
position: relative;
}
.api-log-payload-actions {
display: flex;
justify-content: flex-end;
min-height: 28px;
align-items: center;
color: var(--admin-text-muted);
font-size: 11px;
}
.api-log-payload {
max-height: min(34vh, 360px);
margin: 0;
overflow: auto;
padding: 12px;
border: 1px solid var(--admin-border);
border-radius: 8px;
background: color-mix(in srgb, var(--admin-canvas) 84%, var(--admin-text) 4%);
color: var(--admin-text-secondary);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
font-size: 11px;
line-height: 1.65;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
@media (max-width: 1000px) {
.api-log-detail-drawer .ant-drawer-content-wrapper {
width: min(860px, 88vw) !important;
}
.api-log-detail-summary-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.api-log-recovery-row {
grid-template-columns: 150px minmax(0, 1fr) auto;
}
.api-log-recovery-row .api-log-recovery-label {
grid-column: 1 / -1;
}
}
@media (max-width: 680px) {
.api-log-detail-drawer .ant-drawer-content-wrapper {
width: 100vw !important;
}
.api-log-detail-drawer-panel .ant-drawer-header {
padding: 14px 16px;
}
.api-log-detail-drawer-panel .ant-drawer-body {
padding: 16px 12px 24px;
}
.api-log-detail-summary-grid {
grid-template-columns: 1fr;
padding-inline: 12px;
}
.api-log-detail-info-item {
padding-right: 0;
}
.api-log-recovery-body {
padding-inline: 12px;
}
.api-log-recovery-row,
.api-log-recovery-row + .api-log-recovery-row {
display: flex;
flex-wrap: wrap;
align-items: stretch;
gap: 8px;
}
.api-log-recovery-label {
width: 100%;
}
.api-log-recovery-input {
flex: 1 1 100%;
}
.api-log-recovery-row .ant-btn {
flex: 1 1 auto;
}
}

View File

@@ -1,7 +1,7 @@
import { Alert, App, Button, Input, Modal, Segmented } from "antd";
import { IconButton } from "@/pages/admin/ui/controls";
import type { ColumnsType } from "antd/es/table";
import { Download, Eye, Play, Search } from "lucide-react";
import { Download, Eye, Play, Search, RefreshCw } from "lucide-react";
import { saveAs } from "file-saver";
import { useEffect, useRef, useState } from "react";
import { useSearchParams } from "react-router";
@@ -11,7 +11,7 @@ import { MediaPreview } from "@/components/media-preview";
import { formatCredits } from "@/constant/credits";
import { useDebouncedValue } from "@/hooks/use-debounced-value";
import { mediaDeliverySummary } from "@/lib/generation-task-display";
import { exportAdminApiLogs, listAdminApiLogs, type ApiCallLog } from "@/services/api/auth";
import { batchRecoverAdminApiLogs, 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";
@@ -26,6 +26,7 @@ export default function LogsPage() {
const view = normalizeLogView(searchParams.get("view"));
const status = normalizeStatus(searchParams.get("status"));
const recordType = searchParams.get("recordType") === "download" ? "download" : searchParams.get("recordType") === "all" ? "all" : "request";
const capability = normalizeCapability(searchParams.get("capability"));
const page = positiveInt(searchParams.get("page"), 1);
const pageSize = normalizePageSize(searchParams.get("pageSize"));
const debouncedKeyword = useDebouncedValue(keyword);
@@ -36,14 +37,15 @@ export default function LogsPage() {
const [retry, setRetry] = useState(0);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [detailLogId, setDetailLogId] = useState<string | null>(null);
const [batchRecovering, setBatchRecovering] = useState(false);
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 hasFilters = Boolean(keyword || status !== "all" || recordType !== "request" || capability !== "all");
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);
const isDefault = (key === "filter" && value === "") || (key === "status" && value === "all") || (key === "recordType" && value === "request") || (key === "capability" && value === "all") || (key === "page" && value === 1) || (key === "pageSize" && value === 20);
if (isDefault) next.delete(key);
else next.set(key, String(value));
});
@@ -57,7 +59,7 @@ export default function LogsPage() {
setLogs([]);
setTotal(0);
setSelectedIds([]);
void listAdminApiLogs({ recordType, keyword: debouncedKeyword || undefined, status: status === "all" ? undefined : status, page, pageSize })
void listAdminApiLogs({ recordType, capability: capability === "all" ? undefined : capability, keyword: debouncedKeyword || undefined, status: status === "all" ? undefined : status, page, pageSize })
.then((result) => {
if (sequence !== requestSequence.current) return;
setLogs(result.logs);
@@ -73,7 +75,7 @@ export default function LogsPage() {
})
.finally(() => sequence === requestSequence.current && setLoading(false));
return () => { requestSequence.current += 1; };
}, [debouncedKeyword, status, recordType, page, pageSize, retry]);
}, [debouncedKeyword, status, recordType, capability, page, pageSize, retry]);
const fullColumns: ColumnsType<ApiCallLog> = [
{ title: "时间", width: 168, render: (_, log) => <button type="button" className="admin-log-detail-link" aria-label={`查看请求 ${log.id} 详情`} onClick={() => setDetailLogId(log.id)}>{formatTime(log.startedAt || log.createdAt)}</button> },
@@ -160,7 +162,24 @@ export default function LogsPage() {
{ title: "耗时", dataIndex: "durationMs", align: "right", width: 90, render: formatDuration },
{ title: "错误摘要", key: "error", ellipsis: true, render: (_, log) => <span className={logStatus(log).tone === "error" ? "admin-log-error" : undefined} title={[log.errorCode, log.error].filter(Boolean).join(" · ")}>{[log.errorCode, log.error].filter(Boolean).join(" · ") || (logStatus(log).tone === "error" ? `HTTP ${log.statusCode || "失败"} · 未返回详情` : "—")}</span> },
];
const columns = view === "all" ? fullColumns : compactColumns;
const baseColumns = view === "all" ? fullColumns : compactColumns;
const columns: ColumnsType<ApiCallLog> = [...baseColumns, { title: "操作", key: "actions", width: 126, fixed: "right", render: (_, log) => log.capability === "video" ? <Button type="link" size="small" icon={<RefreshCw className="size-3.5" />} onClick={() => setDetailLogId(log.id)}>恢复任务</Button> : <span className="text-foreground/30">--</span> }];
const runBatchRecovery = async () => {
const selectedLogs = logs.filter((log) => selectedIds.includes(log.id));
const videoIds = selectedLogs.filter((log) => log.capability === "video").map((log) => log.id);
const skipped = selectedIds.length - videoIds.length;
if (videoIds.length === 0) { message.info("所选记录中没有视频任务可恢复"); return; }
setBatchRecovering(true);
try {
const result = await batchRecoverAdminApiLogs(videoIds);
const totalSkipped = skipped + result.skipped;
Modal.info({ title: "批量恢复处理结果", width: 560, content: <div className="max-h-[50vh] space-y-2 overflow-auto"><div className="mb-3">成功 {result.success} 条,失败 {result.failed} 条,跳过 {totalSkipped} 条</div>{result.items.map((item) => <div key={item.logId} className="rounded border border-border/70 px-3 py-2 text-xs"><div className="font-mono">{item.logId}</div><div>{item.recovered ? "成功" : item.error?.startsWith("跳过") ? item.error : item.providerStatus ? `仍在生成中(${item.providerStatus})` : `失败:${item.error || "未恢复"}`}</div></div>)}</div> });
if (result.success > 0) window.dispatchEvent(new CustomEvent("wallet:updated"));
setRetry((value) => value + 1);
} catch (error) { message.error(error instanceof Error ? error.message : "批量恢复失败"); }
finally { setBatchRecovering(false); }
};
return (
<AdminPageFrame
@@ -168,7 +187,7 @@ export default function LogsPage() {
description="模型生成与结果下载记录;仅计费调用扣除积分"
actions={
<AdminExportButton
exportFile={() => exportAdminApiLogs({ recordType, keyword: debouncedKeyword || undefined, status: status === "all" ? undefined : status })}
exportFile={() => exportAdminApiLogs({ recordType, capability: capability === "all" ? undefined : capability, keyword: debouncedKeyword || undefined, status: status === "all" ? undefined : status })}
fileName={() => `请求明细-${new Date().toISOString().slice(0, 10)}.csv`}
label="导出当前筛选"
successMessage="已按当前筛选导出请求明细"
@@ -194,11 +213,13 @@ export default function LogsPage() {
<>
{keyword ? <AdminFilterChip label={`搜索:${keyword}`} onRemove={() => updateUrl({ filter: "", page: 1 })} /> : null}
{status !== "all" ? <AdminFilterChip label={`结果:${status === "succeeded" ? "成功" : "失败"}`} onRemove={() => updateUrl({ status: "all", page: 1 })} /> : null}
{capability !== "all" ? <AdminFilterChip label={`能力:${capabilityText(capability)}`} onRemove={() => updateUrl({ capability: "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 aria-label="能力类型" className="w-28" value={capability} onChange={(value) => updateUrl({ capability: value, page: 1 })} options={[{ label: "全部能力", value: "all" }, { label: "文本", value: "text" }, { label: "图片", value: "image" }, { label: "视频", value: "video" }, { label: "音频", value: "audio" }]} />
<Select
aria-label="请求结果"
className="w-32"
@@ -213,9 +234,10 @@ export default function LogsPage() {
</>
}
toolbarActive={hasFilters}
onReset={() => updateUrl({ filter: "", status: "all", recordType: "request", page: 1 })}
onReset={() => updateUrl({ filter: "", status: "all", recordType: "request", capability: "all", page: 1 })}
batchActions={
<AdminBatchBar count={selectedIds.length} onClear={() => setSelectedIds([])}>
<Button size="small" icon={<RefreshCw className="size-3.5" />} loading={batchRecovering} onClick={() => void runBatchRecovery()}>批量恢复</Button>
<AdminExportButton
type="primary"
size="small"
@@ -245,7 +267,7 @@ export default function LogsPage() {
tableLayout: "fixed",
dataSource: logs,
pagination: false,
scroll: { x: view === "all" ? 1775 : view === "billing" ? 1120 : 1060 },
scroll: { x: view === "all" ? 1901 : view === "billing" ? 1246 : 1186 },
}}
empty={loadError ? <span role="status">数据暂不可用,请重试</span> : <AdminTableEmpty filtered={hasFilters} />}
footer={<PaginationBar alwaysShow current={page} pageSize={pageSize} total={total} onChange={(nextPage, nextSize) => updateUrl({ page: nextSize !== pageSize ? 1 : nextPage, pageSize: nextSize })} />}
@@ -288,6 +310,7 @@ function normalizePageSize(value: string | null) {
const parsed = positiveInt(value, 20);
return [20, 50, 100].includes(parsed) ? parsed : 20;
}
function normalizeCapability(value: string | null): "all" | "text" | "image" | "video" | "audio" { return value === "text" || value === "image" || value === "video" || value === "audio" ? value : "all"; }
function normalizeStatus(value: string | null): "all" | "succeeded" | "failed" {
return value === "succeeded" || value === "failed" ? value : "all";
}