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

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