feat(media): 作品保存恢复 - 按原任务重试交付并避免重复生成计费

This commit is contained in:
ddCat
2026-09-22 15:47:24 +08:00
parent acbb354e97
commit 48a82ded74
49 changed files with 1710 additions and 50 deletions

View File

@@ -184,6 +184,7 @@ function LoadingContent({ node, theme, onOpenTaskDetails }: Pick<CanvasNodeConte
provider: node.metadata?.taskProvider,
status: (node.metadata?.taskStatus || "running") as GenerationTask["status"],
stage: node.metadata?.taskStage,
mediaStage: node.metadata?.taskMediaStage,
officialStatus: node.metadata?.taskOfficialStatus,
errorCode: node.metadata?.taskErrorCode,
};
@@ -245,6 +246,7 @@ function ErrorContent({ node, theme, onRetry, onReloadResource }: Pick<CanvasNod
provider: node.metadata?.taskProvider,
status: (node.metadata?.taskStatus || "failed") as GenerationTask["status"],
stage: node.metadata?.taskStage,
mediaStage: node.metadata?.taskMediaStage,
officialStatus: node.metadata?.taskOfficialStatus,
errorCode: node.metadata?.taskErrorCode,
};
@@ -295,7 +297,7 @@ function ErrorContent({ node, theme, onRetry, onReloadResource }: Pick<CanvasNod
onMouseDown={(event) => event.stopPropagation()}
>
<RefreshCw className="size-3.5" />
{node.metadata?.isBatchRoot ? "重新生成失败项" : "重新生成"}
{node.metadata?.taskCanRecoverMedia ? "重试保存(不重新生成)" : node.metadata?.isBatchRoot ? "重新生成失败项" : "重新生成"}
</button>
)}
</div>

View File

@@ -453,7 +453,7 @@ export const CanvasNode = React.memo(function CanvasNode({
<BatchChildActionButton theme={theme} label={batchPrimary ? "当前主图" : "设为主图"} icon={<Star className={`size-3.5 ${batchPrimary ? "fill-current" : ""}`} style={{ color: theme.accent.primary }} />} onClick={() => onSetBatchPrimary?.(data)} />
) : null}
{data.metadata?.status === "error" && data.metadata.resourceReloadAvailable ? <BatchChildActionButton theme={theme} label="重新加载资源" icon={<Download className="size-3.5" />} onClick={() => onReloadResource?.(data)} /> : null}
{data.metadata?.status === "error" ? <BatchChildActionButton theme={theme} label="重新生成" icon={<RefreshCw className="size-3.5" />} onClick={() => onRetry?.(data)} /> : null}
{data.metadata?.status === "error" ? <BatchChildActionButton theme={theme} label={data.metadata?.taskCanRecoverMedia ? "重试保存" : "重新生成"} icon={<RefreshCw className="size-3.5" />} onClick={() => onRetry?.(data)} /> : null}
{data.metadata?.status === "error" ? <BatchChildActionButton theme={theme} label="删除" icon={<Trash2 className="size-3.5" />} danger onClick={() => deleteNode?.(data)} /> : null}
</div>
</div>

View File

@@ -321,6 +321,8 @@ function completedTaskMetadata(task: GenerationTask): CanvasNodeMetadata {
taskStatus: task.status,
taskProgress: typeof task.progress === "number" && Number.isFinite(task.progress) ? Math.max(0, Math.min(100, Math.round(task.progress))) : 100,
taskStage: task.stage,
taskMediaStage: task.mediaStage,
taskCanRecoverMedia: task.canRecoverMedia,
taskStartedAt: task.startedAt,
taskCompletedAt: task.completedAt,
taskDurationMs: task.startedAt && task.completedAt ? Math.max(0, Date.parse(task.completedAt) - Date.parse(task.startedAt)) : undefined,

View File

@@ -158,6 +158,8 @@ export function generationTaskMetadata(task: GenerationTask): CanvasNodeMetadata
taskStatus: task.status,
taskProgress: progress,
taskStage: task.stage,
taskMediaStage: task.mediaStage,
taskCanRecoverMedia: task.canRecoverMedia,
taskProvider: task.provider,
taskStartedAt: task.startedAt,
taskCompletedAt: task.completedAt,

View File

@@ -23,6 +23,8 @@ export function resetGenerationTaskMetadata(metadata: CanvasNodeMetadata | undef
delete next.taskStatus;
delete next.taskProgress;
delete next.taskStage;
delete next.taskMediaStage;
delete next.taskCanRecoverMedia;
delete next.taskProvider;
delete next.taskStartedAt;
delete next.taskCompletedAt;

View File

@@ -8,13 +8,23 @@ export const statusLabel: Record<TaskStatus, string> = {
cancelled: "已取消",
};
type GenerationTaskDisplayTarget = Pick<GenerationTask, "status" | "stage">;
type GenerationTaskDisplayTarget = Pick<GenerationTask, "status" | "stage" | "mediaStage">;
export function mediaDeliverySummary(status: TaskStatus | undefined, stage: GenerationTask["mediaStage"]) {
if (!stage) return "";
if (stage === "completed") return "生成成功 · 文件保存成功 · 素材登记成功";
const prefix = stage === "download" || stage === "checkpoint" ? "生成成功" : stage === "register" ? "生成成功 · 文件保存成功" : "生成成功 · 下载成功";
const action = { download: "下载", upload: "上传 OSS", local_save: "保存文件", register: "登记素材", checkpoint: "记录恢复信息" }[stage];
return `${prefix} · ${action}${status === "failed" ? "失败" : status === "cancelled" ? "已停止" : "待完成"}`;
}
export function isGenerationTaskSubmissionUncertain(task: GenerationTaskDisplayTarget) {
return task.stage === "submission_unknown";
}
export function generationTaskStatusLabel(task: GenerationTaskDisplayTarget) {
if (task.mediaStage && task.status === "failed") return "作品保存未完成";
if (task.mediaStage && (task.status === "queued" || task.status === "running")) return "作品已生成,正在保存";
if (isGenerationTaskSubmissionUncertain(task)) return "提交结果待确认";
return statusLabel[task.status];
}
@@ -27,6 +37,7 @@ export function generationTaskStageLabel(task: GenerationTaskDisplayTarget) {
}
export function generationTaskShowsProgress(task: GenerationTaskDisplayTarget) {
if (task.mediaStage && task.mediaStage !== "completed") return false;
if (isGenerationTaskSubmissionUncertain(task)) return false;
// 排队、后端接管和连接供应商都没有真实百分比。只有上游状态响应
// 已经写回任务后才显示进度,避免所有图片/视频长期停在同一个假数值。

View File

@@ -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] || "上游请求";
}

View File

@@ -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] || "上游请求";
}

View File

@@ -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("当前节点不能使用通用生成重试");

View File

@@ -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[]) {

View File

@@ -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>

View File

@@ -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}

View File

@@ -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 || "生成失败,打开详情查看原因";

View File

@@ -59,6 +59,7 @@ export type ApiCallLog = {
channelName: string;
taskId?: string;
taskStatus?: TaskStatus;
mediaStage?: GenerationTask["mediaStage"];
billingOrderId?: string;
billingStatus?: BillingOrder["status"];
billingAmountMicrocredits: number;
@@ -66,7 +67,7 @@ export type ApiCallLog = {
source: string;
capability: "text" | "image" | "video" | "audio" | "";
operation?: string;
requestKind: "create" | "poll" | "download" | "repair" | "";
requestKind: "create" | "poll" | "download" | "upload" | "local_save" | "register" | "repair" | "";
billable: boolean;
apiFormat: string;
method: string;

View File

@@ -27,6 +27,8 @@ export type GenerationTask = {
status: TaskStatus;
progress?: number;
stage?: string;
mediaStage?: "download" | "upload" | "local_save" | "register" | "completed" | "checkpoint";
canRecoverMedia?: boolean;
prompt: string;
operation?: string;
provider?: string;
@@ -282,6 +284,10 @@ export function retryGenerationTask(id: string) {
return http.post<GenerationTask>(`/tasks/${encodeURIComponent(id)}/retry`);
}
export function recoverGenerationTaskMedia(id: string) {
return http.post<GenerationTask>(`/tasks/${encodeURIComponent(id)}/recover-media`);
}
export function cancelGenerationTask(id: string) {
return http.post<GenerationTask>(`/tasks/${encodeURIComponent(id)}/cancel`).then((task) => {
window.dispatchEvent(new CustomEvent("canvas:task-cancelled", { detail: { task } }));

View File

@@ -7,6 +7,7 @@ import type { ArtCritiqueNodeState } from "@/lib/art-critique/contracts";
import type { CameraControlOptions } from "@/lib/canvas/camera-prompt-library";
import type { SrtEntry, SubtitleHighlight, SubtitleStyle } from "@/types/timeline";
import type { GenerationSpec } from "@/lib/canvas/generation-contract.generated";
import type { GenerationTask } from "@/services/api/task-center";
export type Position = {
x: number;
@@ -360,6 +361,8 @@ export type CanvasNodeMetadata = {
taskStatus?: "queued" | "running" | "succeeded" | "failed" | "cancelled" | string;
taskProgress?: number;
taskStage?: string;
taskMediaStage?: GenerationTask["mediaStage"];
taskCanRecoverMedia?: boolean;
taskProvider?: string;
taskStartedAt?: string;
taskCompletedAt?: string;

View File

@@ -0,0 +1,24 @@
import { describe, expect, test } from "bun:test";
import { generationTaskShowsProgress, generationTaskStatusLabel, mediaDeliverySummary } from "@/lib/generation-task-display";
import { resetGenerationTaskMetadata } from "@/lib/canvas/canvas-task-state";
describe("作品保存阶段", () => {
test("分别表达生成、下载、上传和登记,不把保存失败当成生成失败", () => {
expect(mediaDeliverySummary("failed", "download")).toBe("生成成功 · 下载失败");
expect(mediaDeliverySummary("failed", "upload")).toBe("生成成功 · 下载成功 · 上传 OSS失败");
expect(mediaDeliverySummary("failed", "register")).toBe("生成成功 · 文件保存成功 · 登记素材失败");
expect(mediaDeliverySummary("failed", "checkpoint")).not.toContain("下载成功");
expect(mediaDeliverySummary("succeeded", "completed")).toContain("素材登记成功");
});
test("恢复中不显示虚假生成进度", () => {
expect(generationTaskStatusLabel({ status: "running", mediaStage: "upload" })).toBe("作品已生成,正在保存");
expect(generationTaskShowsProgress({ status: "running", mediaStage: "upload" })).toBe(false);
expect(generationTaskStatusLabel({ status: "failed", mediaStage: "download" })).toBe("作品保存未完成");
});
test("重新生成清除旧恢复信息", () => {
const metadata = resetGenerationTaskMetadata({ taskId: "old", taskMediaStage: "upload", taskCanRecoverMedia: true });
expect(metadata.taskId).toBeUndefined();
expect(metadata.taskMediaStage).toBeUndefined();
expect(metadata.taskCanRecoverMedia).toBeUndefined();
});
});