feat(media): 作品保存恢复 - 按原任务重试交付并避免重复生成计费
This commit is contained in:
@@ -4,6 +4,7 @@ import { AdminEmpty } from "@/pages/admin/components/admin-ui";
|
||||
import { RefreshCw } 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";
|
||||
|
||||
@@ -80,6 +81,7 @@ function LogDetail({ log, querying, onQueryProviderTask }: { log: ApiCallLog; qu
|
||||
],
|
||||
["能力", 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>],
|
||||
@@ -125,7 +127,7 @@ function LogDetail({ log, querying, onQueryProviderTask }: { log: ApiCallLog; qu
|
||||
["上游地址", 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.capability === "video" && log.taskStatus === "failed" && Boolean(log.taskId && log.providerRequestId);
|
||||
const canQueryProviderTask = !log.mediaStage && log.capability === "video" && log.taskStatus === "failed" && Boolean(log.taskId && log.providerRequestId);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -159,7 +161,7 @@ function billingText(log: ApiCallLog) {
|
||||
}
|
||||
|
||||
function requestKindText(value: ApiCallLog["requestKind"]) {
|
||||
const labels: Partial<Record<ApiCallLog["requestKind"], string>> = { create: "模型生成", poll: "状态查询", download: "结果下载", repair: "结果修复" };
|
||||
const labels: Partial<Record<ApiCallLog["requestKind"], string>> = { create: "模型生成", poll: "状态查询", download: "结果下载", upload: "上传 OSS", local_save: "保存文件", register: "登记素材", repair: "结果修复" };
|
||||
return labels[value] || "上游请求";
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import { PaginationBar } from "@/pages/admin/components/admin-ui";
|
||||
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 { ApiLogDetailDrawer } from "../components/api-log-detail-drawer";
|
||||
import { AdminPageFrame } from "../components/admin-shell";
|
||||
@@ -361,12 +362,13 @@ function CallStatus({ log }: { log: ApiCallLog }) {
|
||||
<div>
|
||||
<div className="mb-1 text-xs font-medium text-foreground/70">{requestKindText(log.requestKind)}</div>
|
||||
<AdminStatusBadge {...logStatus(log)} />
|
||||
{log.mediaStage ? <div className="mt-1 text-xs text-foreground/60">{mediaDeliverySummary(log.taskStatus, log.mediaStage)}</div> : null}
|
||||
{log.capability === "video" ? <div className="mt-1 text-xs tabular-nums text-foreground/45">已轮询 {log.pollCount || 0} 次</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function requestKindText(value: ApiCallLog["requestKind"]) {
|
||||
const labels: Partial<Record<ApiCallLog["requestKind"], string>> = { create: "模型生成", poll: "状态查询", download: "结果下载", repair: "结果修复" };
|
||||
const labels: Partial<Record<ApiCallLog["requestKind"], string>> = { create: "模型生成", poll: "状态查询", download: "结果下载", upload: "上传 OSS", local_save: "保存文件", register: "登记素材", repair: "结果修复" };
|
||||
return labels[value] || "上游请求";
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ import { modelCapabilityConfigFor } from "@/lib/model-capabilities";
|
||||
import { navigateToSettings } from "@/lib/settings-navigation";
|
||||
import type { Skill } from "@/services/api/skills";
|
||||
import { skillRuntime, type SkillRuntimeMetadata } from "@/services/skill-runtime";
|
||||
import type { GenerationTask } from "@/services/api/task-center";
|
||||
import { queryGenerationTask, recoverGenerationTaskMedia, waitForGenerationTask, type GenerationTask } from "@/services/api/task-center";
|
||||
import { resolveImageUrl } from "@/services/image-storage";
|
||||
import { resolveModelRequestConfig, useConfigStore, useEffectiveConfig } from "@/stores/use-config-store";
|
||||
import type { Asset } from "@/stores/use-asset-store";
|
||||
@@ -75,6 +75,35 @@ export function useCanvasGenerationRetry({
|
||||
|
||||
return useCallback(
|
||||
async (node: CanvasNodeData) => {
|
||||
// Canvas caches may predate the failure. Consult the original task
|
||||
// before deciding to submit another paid generation.
|
||||
let sourceTask: GenerationTask | undefined;
|
||||
if (node.metadata?.taskId) {
|
||||
try {
|
||||
sourceTask = await queryGenerationTask(node.metadata.taskId);
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "无法核对原任务,请稍后重试");
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (sourceTask?.mediaStage && sourceTask.status !== "cancelled") {
|
||||
const controller = startGenerationRequest(node.id, node.id, node.id);
|
||||
try {
|
||||
const initialTask = sourceTask.status === "failed" ? await recoverGenerationTaskMedia(sourceTask.id) : sourceTask;
|
||||
if (controller.signal.aborted) return;
|
||||
bindGenerationTask(node.id, initialTask);
|
||||
const completed = await waitForGenerationTask(initialTask.id, {
|
||||
initialTask, signal: controller.signal, timeoutMs: 25 * 60_000,
|
||||
onTaskUpdate: (task) => bindGenerationTask(node.id, task),
|
||||
});
|
||||
await applyGenerationTaskResult(node.id, completed);
|
||||
} catch (error) {
|
||||
if (!controller.signal.aborted) message.error(error instanceof Error ? error.message : "恢复作品保存失败");
|
||||
} finally {
|
||||
finishGenerationRequest(node.id, controller);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const retryMode = retryModeForNode(node.type);
|
||||
if (!retryMode) {
|
||||
message.warning("当前节点不能使用通用生成重试");
|
||||
|
||||
@@ -10,11 +10,11 @@ import { MediaPreview } from "@/components/media-preview";
|
||||
import { PageHeader, PaginationBar, WorkspacePage } from "@/components/layout/workspace-page";
|
||||
import { WorkspaceState } from "@/components/layout/workspace-state";
|
||||
import { CONTENT_MODERATION_ERROR_CODE, generationErrorMessage, isContentModerationError } from "@/lib/generation-error";
|
||||
import { formatTaskKind, operationOptions, statusLabel } from "@/lib/generation-task-display";
|
||||
import { formatTaskKind, generationTaskStatusLabel, mediaDeliverySummary, operationOptions, statusLabel } from "@/lib/generation-task-display";
|
||||
import { buildVideoOperationPrompt } from "@/lib/prompts";
|
||||
import { backendProviderConfig, logicalModelIDForConfig } from "@/services/api/generation-task";
|
||||
|
||||
import { createGenerationTask, formatTaskLog, listGenerationTasks, listTaskLogs, queryFailedVideoProviderTask, queryGenerationTask, retryGenerationTask, type CreateTaskInput, type GenerationTask, type TaskLog } from "@/services/api/task-center";
|
||||
import { createGenerationTask, formatTaskLog, listGenerationTasks, listTaskLogs, queryFailedVideoProviderTask, queryGenerationTask, retryGenerationTask, recoverGenerationTaskMedia, type CreateTaskInput, type GenerationTask, type TaskLog } from "@/services/api/task-center";
|
||||
import { syncGenerationTaskToCanvasStore } from "@/lib/canvas/canvas-generation-task-sync";
|
||||
import { useCanvasStore } from "@/stores/canvas/use-canvas-store";
|
||||
import { resolveModelRequestConfig, useConfigStore, useEffectiveConfig } from "@/stores/use-config-store";
|
||||
@@ -299,15 +299,18 @@ export default function TasksPage() {
|
||||
}, [loadTasks]);
|
||||
|
||||
const runAction = async (id: string) => {
|
||||
const currentTask = tasksRef.current.find((task) => task.id === id);
|
||||
setActingId(id);
|
||||
try {
|
||||
const next = await retryGenerationTask(id);
|
||||
const currentTask = await queryGenerationTask(id);
|
||||
const savingMedia = Boolean(currentTask.mediaStage);
|
||||
const next = currentTask.status === "queued" || currentTask.status === "running" || currentTask.status === "succeeded"
|
||||
? currentTask
|
||||
: savingMedia ? await recoverGenerationTaskMedia(id) : await retryGenerationTask(id);
|
||||
setTasks((items) => items.map((item) => (item.id === id ? next : item)));
|
||||
setDetailTask((current) => (current?.id === id ? { ...current, ...next } : current));
|
||||
setStatusFilter("active");
|
||||
setPage(1);
|
||||
message.success("任务已重新入队");
|
||||
message.success(next.status === "succeeded" ? "任务已完成" : savingMedia ? "正在恢复作品保存,不会重新生成" : "任务已在队列中");
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "操作失败");
|
||||
} finally {
|
||||
@@ -482,7 +485,8 @@ export default function TasksPage() {
|
||||
{detailTask ? (
|
||||
<div className="space-y-5">
|
||||
<div className="task-detail-facts grid text-sm sm:grid-cols-2">
|
||||
<InfoItem label="状态" value={statusLabel[detailTask.status]} />
|
||||
<InfoItem label="状态" value={generationTaskStatusLabel(detailTask)} />
|
||||
{detailTask.mediaStage ? <InfoItem label="作品交付" value={mediaDeliverySummary(detailTask.status, detailTask.mediaStage)} /> : null}
|
||||
<InfoItem label="画布名称" value={getTaskCanvasContext(detailTask, canvasById, domainProjectNameById).canvasName} />
|
||||
<InfoItem label="任务类型" value={formatTaskKind(detailTask)} />
|
||||
<InfoItem label="模型" value={formatModelName(effectiveConfig, detailTask)} />
|
||||
@@ -495,6 +499,7 @@ export default function TasksPage() {
|
||||
{detailTask.providerCancelRequestedAt ? <InfoItem label="请求取消时间" value={formatDate(detailTask.providerCancelRequestedAt)} /> : null}
|
||||
</div>
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
{detailTask.canRecoverMedia ? <Button icon={<RefreshCw className="size-4" />} loading={actingId === detailTask.id} onClick={() => void runAction(detailTask.id)}>重试保存</Button> : null}
|
||||
{canQueryProviderTask(detailTask) ? <Button icon={<RefreshCw className="size-4" />} loading={actingId === detailTask.id} onClick={() => void queryProviderTask(detailTask)}>手动查询任务</Button> : null}
|
||||
{isTaskFailed(detailTask) ? <Button icon={<Bug className="size-4" />} onClick={() => navigate(`/settings?section=diagnostics&taskId=${encodeURIComponent(detailTask.id)}${detailTask.projectId ? `&projectId=${encodeURIComponent(detailTask.projectId)}` : ""}`)}>导出诊断包</Button> : null}
|
||||
</div>
|
||||
@@ -538,7 +543,7 @@ export default function TasksPage() {
|
||||
}
|
||||
|
||||
function canQueryProviderTask(task: GenerationTask) {
|
||||
return task.status === "failed" && (task.type.startsWith("canvas_video") || task.type.startsWith("video_")) && Boolean(task.providerRequestId);
|
||||
return task.status === "failed" && !task.mediaStage && (task.type.startsWith("canvas_video") || task.type.startsWith("video_")) && Boolean(task.providerRequestId);
|
||||
}
|
||||
|
||||
function reconcileTaskSummaries(current: GenerationTask[], next: GenerationTask[]) {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Eye, FileText, Image as ImageIcon, RotateCcw, Video } from "lucide-reac
|
||||
|
||||
import { MediaPreview } from "@/components/media-preview";
|
||||
import { CONTENT_MODERATION_ERROR_CODE, isContentModerationError } from "@/lib/generation-error";
|
||||
import { statusLabel } from "@/lib/generation-task-display";
|
||||
import { generationTaskStatusLabel } from "@/lib/generation-task-display";
|
||||
import type { GenerationTask } from "@/services/api/task-center";
|
||||
import { isTaskFailed, statusDotClassName, taskAttentionReason, TaskDate } from "./task-shared";
|
||||
import { TaskVideoThumbnail } from "./task-video-thumbnail";
|
||||
@@ -33,12 +33,12 @@ export function TaskGridCard({ task, actingId, onOpen, onRetry }: { task: Genera
|
||||
<IconButton size="sm" variant="ghost" icon={Eye} aria-label="查看详情" onClick={onOpen} />
|
||||
</Tooltip>
|
||||
{isFailed ? (
|
||||
<Tooltip title={retryDisabled ? "内容审核失败,无法自动重试" : "重试任务"}>
|
||||
<Tooltip title={retryDisabled ? "内容审核失败,无法自动重试" : task.canRecoverMedia ? "重试保存,不重新生成" : "重试任务"}>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<RotateCcw className="size-3.5" />}
|
||||
aria-label="重试任务"
|
||||
aria-label={task.canRecoverMedia ? "重试保存" : "重试任务"}
|
||||
loading={actingId === task.id}
|
||||
disabled={retryDisabled}
|
||||
onClick={onRetry}
|
||||
@@ -54,13 +54,13 @@ export function TaskGridCard({ task, actingId, onOpen, onRetry }: { task: Genera
|
||||
<div className="task-grid-meta">
|
||||
<span className={`task-grid-status ${isFailed ? "is-failed" : isActive ? "is-active" : task.status === "succeeded" ? "is-success" : ""}`}>
|
||||
<i className={statusDotClassName(task.status)} />
|
||||
{statusLabel[task.status]}
|
||||
{generationTaskStatusLabel(task)}
|
||||
</span>
|
||||
<span className="task-grid-date">
|
||||
<TaskDate value={task.createdAt} />
|
||||
</span>
|
||||
</div>
|
||||
{isActive ? (
|
||||
{isActive && task.mediaStage ? <p>{task.stage || "作品已生成,正在保存"}</p> : isActive ? (
|
||||
<div className="task-grid-progress" role="progressbar" aria-label={task.stage || "任务生成进度"} aria-valuemin={0} aria-valuemax={100} aria-valuenow={task.progress || 0}>
|
||||
<span>{task.stage || "正在生成"}</span>
|
||||
<strong>{task.progress || 0}%</strong>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useState } from "react";
|
||||
|
||||
import { MediaPreview } from "@/components/media-preview";
|
||||
import { CONTENT_MODERATION_ERROR_CODE, generationErrorMessage, isContentModerationError } from "@/lib/generation-error";
|
||||
import { formatTaskKind, statusLabel } from "@/lib/generation-task-display";
|
||||
import { formatTaskKind, generationTaskStatusLabel } from "@/lib/generation-task-display";
|
||||
import type { GenerationTask } from "@/services/api/task-center";
|
||||
import type { AiConfig } from "@/stores/use-config-store";
|
||||
import { formatModelName, getTaskCanvasContext, isTaskFailed, statusDotClassName, taskAttentionReason, TaskBilling, TaskDate } from "./task-shared";
|
||||
@@ -44,7 +44,7 @@ export function TaskListRow({
|
||||
<div className="task-record-heading">
|
||||
<span className={`task-record-status ${isFailed ? "is-failed" : isActive ? "is-active" : "is-success"}`}>
|
||||
<i className={statusDotClassName(task.status)} />
|
||||
{statusLabel[task.status]}
|
||||
{generationTaskStatusLabel(task)}
|
||||
</span>
|
||||
<button type="button" className="task-record-title" title={task.prompt} onClick={onOpen}>
|
||||
{task.prompt || "未命名任务"}
|
||||
@@ -60,7 +60,7 @@ export function TaskListRow({
|
||||
{context.projectName ? ` · ${context.projectName}` : ""}
|
||||
</span>
|
||||
</div>
|
||||
{isActive ? (
|
||||
{isActive && task.mediaStage ? <p>{task.stage || "作品已生成,正在保存"}</p> : isActive ? (
|
||||
<div className="task-record-progress" role="progressbar" aria-label={task.stage || "任务生成进度"} aria-valuemin={0} aria-valuemax={100} aria-valuenow={task.progress || 0}>
|
||||
<span>{task.stage || "正在生成"}</span>
|
||||
<span>{task.progress || 0}%</span>
|
||||
@@ -84,12 +84,12 @@ export function TaskListRow({
|
||||
<IconButton size="sm" variant="ghost" icon={Eye} aria-label="查看详情" onClick={onOpen} />
|
||||
</Tooltip>
|
||||
{isFailed ? (
|
||||
<Tooltip title={retryDisabled ? "内容审核失败,无法自动重试" : "重试任务"}>
|
||||
<Tooltip title={retryDisabled ? "内容审核失败,无法自动重试" : task.canRecoverMedia ? "重试保存,不重新生成" : "重试任务"}>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<RotateCcw className="size-3.5" />}
|
||||
aria-label="重试任务"
|
||||
aria-label={task.canRecoverMedia ? "重试保存" : "重试任务"}
|
||||
loading={actingId === task.id}
|
||||
disabled={retryDisabled}
|
||||
onClick={onRetry}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Coins } from "lucide-react";
|
||||
|
||||
import { formatCredits } from "@/constant/credits";
|
||||
import { CONTENT_MODERATION_ERROR_CODE, generationErrorMessage, isContentModerationError } from "@/lib/generation-error";
|
||||
import { mediaDeliverySummary } from "@/lib/generation-task-display";
|
||||
import type { GenerationTask, TaskStatus } from "@/services/api/task-center";
|
||||
import { modelDisplayName, type AiConfig } from "@/stores/use-config-store";
|
||||
|
||||
@@ -19,6 +20,7 @@ export function isTaskFailed(task: GenerationTask) {
|
||||
|
||||
export function taskAttentionReason(task: GenerationTask) {
|
||||
if (task.status === "cancelled") return providerCancelStatusLabel(task);
|
||||
if (task.mediaStage) return mediaDeliverySummary(task.status, task.mediaStage);
|
||||
if (task.errorCode === CONTENT_MODERATION_ERROR_CODE || isContentModerationError(task.error)) return "内容审核未通过,请修改输入后新建任务";
|
||||
if (task.error) return generationErrorMessage(task.error);
|
||||
return task.stage || "生成失败,打开详情查看原因";
|
||||
|
||||
Reference in New Issue
Block a user