feat(web): 角色与音频工作台 - 完善素材选择、生成参数与同步交互
This commit is contained in:
@@ -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)} 秒`;
|
||||
}
|
||||
|
||||
@@ -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("生成方式与模型能力不匹配");
|
||||
|
||||
@@ -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 },
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user