From 6ce40d76eaff024ef91bd225b54eb1c0e26949aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=81=B5=E5=BA=94?= Date: Wed, 1 Jul 2026 18:09:58 +0800 Subject: [PATCH] =?UTF-8?q?feat(editing-planner):=20=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?=E7=94=9F=E6=88=90=E8=BF=9B=E5=BA=A6=E5=B1=95=E7=A4=BA=20?= =?UTF-8?q?=E2=80=94=20=E4=BB=BB=E5=8A=A1=202.17?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 GenerationProgressModal 组件:三阶段 UI(setup → progress → completed/failed) - 集成 React Query 轮询:refetchInterval 2s 轮询任务状态,终态自动停止 - 进度展示:SVG 进度环 + 进度条 + 步骤文案 + 任务 ID - 失败处理:错误信息展示 + 可重试时显示重试按钮 - 对接 createGenerationTask / getTask / retryTask API - V21 设计系统,CSS 类名前缀 ep-gen- --- apps/web/src/api/tasks.ts | 6 + .../pages/editing-planner/EditingPlanner.css | 149 +++++++++++ .../pages/editing-planner/EditingPlanner.tsx | 90 ++++++- .../components/GenerationProgressModal.tsx | 251 ++++++++++++++++++ 4 files changed, 482 insertions(+), 14 deletions(-) create mode 100644 apps/web/src/pages/editing-planner/components/GenerationProgressModal.tsx diff --git a/apps/web/src/api/tasks.ts b/apps/web/src/api/tasks.ts index 23276f5c7..4e80832f5 100644 --- a/apps/web/src/api/tasks.ts +++ b/apps/web/src/api/tasks.ts @@ -70,6 +70,12 @@ export const getUserTasks = async (): Promise => { return data.items || []; }; +/** 获取单个任务详情(用于轮询进度) */ +export const getTask = async (taskId: string): Promise => { + const { data } = await apiClient.get(`/tasks/${taskId}`); + return data; +}; + /** 重试失败的任务 */ export const retryTask = async (taskId: string): Promise => { const { data } = await apiClient.post(`/tasks/${taskId}/retry`); diff --git a/apps/web/src/pages/editing-planner/EditingPlanner.css b/apps/web/src/pages/editing-planner/EditingPlanner.css index a7b38a69a..807122bbd 100644 --- a/apps/web/src/pages/editing-planner/EditingPlanner.css +++ b/apps/web/src/pages/editing-planner/EditingPlanner.css @@ -1120,3 +1120,152 @@ padding: 8px 10px; } } + +/* ============================================================ + 生成进度弹窗 — 任务 2.17 + ============================================================ */ + +/* ── setup 阶段 ─────────────────────────────────────────────── */ + +.ep-gen-setup { + display: flex; + flex-direction: column; + gap: var(--space-md); +} + +.ep-gen-field-label { + font-size: var(--font-size-sm); + font-weight: 500; + color: var(--text-primary); + margin-bottom: -4px; +} + +.ep-gen-estimate { + font-size: var(--font-size-sm); + color: var(--text-secondary); + padding: 8px 12px; + background: var(--bg-secondary); + border-radius: var(--radius-md); +} + +.ep-gen-estimate strong { + color: var(--primary-color); + font-weight: 600; +} + +/* ── progress 阶段 ──────────────────────────────────────────── */ + +.ep-gen-progress { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--space-md); + padding: var(--space-lg) 0; +} + +.ep-gen-progress-ring-wrap { + position: relative; + width: 120px; + height: 120px; +} + +.ep-gen-progress-ring { + width: 100%; + height: 100%; + transform: rotate(-90deg); +} + +.ep-gen-progress-ring-bg { + fill: none; + stroke: var(--border-color); + stroke-width: 8; +} + +.ep-gen-progress-ring-fill { + fill: none; + stroke-width: 8; + stroke-linecap: round; + transition: stroke-dashoffset 0.5s ease, stroke 0.3s ease; +} + +.ep-gen-progress-pct { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + font-size: 24px; + font-weight: 700; + font-family: var(--font-mono, monospace); +} + +.ep-gen-step-text { + font-size: var(--font-size-base); + color: var(--text-primary); + font-weight: 500; + text-align: center; +} + +.ep-gen-progress-bar { + width: 100%; + height: 6px; + background: var(--bg-secondary); + border-radius: 3px; + overflow: hidden; +} + +.ep-gen-progress-bar-fill { + height: 100%; + border-radius: 3px; + transition: width 0.5s ease, background-color 0.3s ease; +} + +.ep-gen-task-id { + font-size: var(--font-size-xs); + color: var(--text-muted, #9ca3af); + font-family: var(--font-mono, monospace); +} + +/* ── 结果阶段(completed / failed)──────────────────────────── */ + +.ep-gen-result { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--space-md); + padding: var(--space-lg) 0; + text-align: center; +} + +.ep-gen-result-icon { + font-size: 48px; + line-height: 1; +} + +.ep-gen-result-title { + font-size: var(--font-size-lg); + font-weight: 600; + color: var(--text-primary); +} + +.ep-gen-result-msg { + font-size: var(--font-size-sm); + color: var(--text-secondary); + max-width: 320px; + line-height: 1.5; +} + +.ep-gen-result-msg--error { + color: var(--error-color, #ef4444); +} + +.ep-gen-result-count { + font-size: var(--font-size-sm); + color: var(--success-color, #10b981); + font-weight: 500; +} + +.ep-gen-result-actions { + display: flex; + gap: var(--space-sm); + margin-top: var(--space-sm); +} diff --git a/apps/web/src/pages/editing-planner/EditingPlanner.tsx b/apps/web/src/pages/editing-planner/EditingPlanner.tsx index 72b5f7576..e6ad4acc8 100644 --- a/apps/web/src/pages/editing-planner/EditingPlanner.tsx +++ b/apps/web/src/pages/editing-planner/EditingPlanner.tsx @@ -13,7 +13,6 @@ import { getTemplateCategories, createEditingTemplate, updateEditingTemplate, - generateFromTemplate, MODE_LABELS, type EditingTemplate, type TemplateMode, @@ -26,13 +25,21 @@ import { type EditPlanClip, type MediaAsset, } from "@/api/editPlans"; +import { + createGenerationTask, + getTask, + retryTask, + type TaskItem, +} from "@/api/tasks"; /* ── 子组件 ── */ import MediaPanel from "./components/MediaPanel"; import TimelinePanel from "./components/TimelinePanel"; import ClipPropertiesPanel from "./components/ClipPropertiesPanel"; import SaveModal from "./components/SaveModal"; -import GenerateModal from "./components/GenerateModal"; +import GenerationProgressModal, { + type GenPhase, +} from "./components/GenerationProgressModal"; /* ──────────── 常量 ──────────── */ @@ -113,6 +120,8 @@ const EditingPlanner: React.FC = () => { /* ── UI 状态 ── */ const [saveModalOpen, setSaveModalOpen] = useState(false); const [generateModalOpen, setGenerateModalOpen] = useState(false); + const [genPhase, setGenPhase] = useState("setup"); + const [taskId, setTaskId] = useState(null); const [draftName, setDraftName] = useState(""); const [draftCategory, setDraftCategory] = useState(""); const [draftTags, setDraftTags] = useState(""); @@ -186,25 +195,56 @@ const EditingPlanner: React.FC = () => { onError: () => showToast("更新失败", "error"), }); + /* ── 任务轮询 ── */ + const { data: taskData } = useQuery({ + queryKey: ["task", taskId], + queryFn: () => getTask(taskId!), + enabled: !!taskId && (genPhase === "progress"), + refetchInterval: (query) => { + const data = query.state.data; + if (!data) return 2000; + // 终态停止轮询 + if (data.status === "completed" || data.status === "failed") return false; + return 2000; + }, + }); + + /* 监听任务状态变化,自动切换阶段 */ + useEffect(() => { + if (!taskData) return; + if (taskData.status === "completed") { + setGenPhase("completed"); + } else if (taskData.status === "failed") { + setGenPhase("failed"); + } + }, [taskData]); + const generateMutation = useMutation({ mutationFn: ({ templateId, - duration, }: { templateId: string; duration: number; - }) => generateFromTemplate(templateId, { voiceover_duration: duration }), + }) => + createGenerationTask({ + template_id: templateId, + asset_ids: clips + .filter((c) => c.media_asset_id) + .map((c) => c.media_asset_id!), + title_ids: [], + voice_ids: clips + .filter((c) => c.material_type === "voiceover" && c.media_asset_id) + .map((c) => c.media_asset_id!), + }), onSuccess: (data) => { - const msg = - data.warnings && data.warnings.length > 0 - ? `生成任务已提交(${data.warnings.map((w) => w.message).join("; ")})` - : "生成任务已提交"; - showToast(msg, "success"); - setGenerateModalOpen(false); + setTaskId(data.id); + setGenPhase("progress"); + showToast("生成任务已提交", "success"); }, onError: (err: unknown) => { if (!(err as { __msgShown?: boolean })?.__msgShown) showToast("生成失败", "error"); + setGenPhase("setup"); }, }); @@ -433,6 +473,8 @@ const EditingPlanner: React.FC = () => { showToast("请先选择模板", "warning"); return; } + setGenPhase("setup"); + setTaskId(null); setGenerateModalOpen(true); }; @@ -447,6 +489,22 @@ const EditingPlanner: React.FC = () => { }); }; + const handleRetry = () => { + if (!taskId) return; + retryTask(taskId).then(() => { + setGenPhase("progress"); + showToast("任务已重新提交", "success"); + }).catch(() => { + showToast("重试失败", "error"); + }); + }; + + const handleCloseProgressModal = () => { + setGenerateModalOpen(false); + setGenPhase("setup"); + setTaskId(null); + }; + const totalDuration = clips.reduce((s, c) => s + c.duration, 0); const selectedClip = clips.find((c) => c.id === selectedClipId) || null; @@ -553,15 +611,19 @@ const EditingPlanner: React.FC = () => { onCancel={() => setSaveModalOpen(false)} /> - {/* 使用模板生成弹窗 */} - setGenerateModalOpen(false)} + task={taskData || null} + submitting={generateMutation.isPending} + onCancel={handleCloseProgressModal} + onRetry={handleRetry} + onClose={handleCloseProgressModal} /> ); diff --git a/apps/web/src/pages/editing-planner/components/GenerationProgressModal.tsx b/apps/web/src/pages/editing-planner/components/GenerationProgressModal.tsx new file mode 100644 index 000000000..ebfe367da --- /dev/null +++ b/apps/web/src/pages/editing-planner/components/GenerationProgressModal.tsx @@ -0,0 +1,251 @@ +/** + * 生成进度弹窗 — 任务 2.17 + * 三阶段 UI:setup(配置)→ progress(进度轮询)→ completed / failed(结果) + * V21 设计系统,CSS 类名前缀 ep-gen- + */ +import React, { useEffect, useRef } from "react"; +import { Modal, Button } from "@/components/ui"; +import type { TaskItem } from "@/api/tasks"; + +/* ──────────── 类型 ──────────── */ + +export type GenPhase = "setup" | "progress" | "completed" | "failed"; + +export interface GenerationProgressModalProps { + open: boolean; + phase: GenPhase; + + /* setup 阶段 */ + voiceoverDuration: number | null; + estimatedDuration: number; + onDurationChange: (v: number | null) => void; + onGenerate: () => void; + + /* progress / 结果阶段 */ + task: TaskItem | null; + + /* 通用 */ + submitting: boolean; + onCancel: () => void; + onRetry?: () => void; + onClose?: () => void; +} + +/* ──────────── 步骤文案映射 ──────────── */ + +const STEP_LABELS: Record = { + queued: "排队中…", + preparing: "准备素材…", + generating_video: "渲染视频中…", + adding_effects: "添加特效…", + composing: "合成中…", + encoding: "编码输出中…", + completed: "生成完成!", + failed: "生成失败", +}; + +const getStepLabel = (step: string) => + STEP_LABELS[step] || step.replace(/_/g, " "); + +/* ──────────── 状态徽标颜色 ──────────── */ + +const STATUS_COLOR: Record = { + queued: "#6b7280", + pending: "#6b7280", + preparing: "#f59e0b", + generating_video: "#4f46e5", + adding_effects: "#7c3aed", + composing: "#2563eb", + encoding: "#0891b2", + completed: "#10b981", + failed: "#ef4444", +}; + +/* ──────────── 组件 ──────────── */ + +const GenerationProgressModal: React.FC = ({ + open, + phase, + voiceoverDuration, + estimatedDuration, + onDurationChange, + onGenerate, + task, + submitting, + onCancel, + onRetry, + onClose, +}) => { + /* 关闭弹窗时重置(避免下次打开残留旧状态) */ + const prevOpen = useRef(false); + useEffect(() => { + if (prevOpen.current && !open) { + /* modal just closed — parent handles reset */ + } + prevOpen.current = open; + }, [open]); + + const progress = task?.progress ?? 0; + const status = task?.status ?? ""; + const currentStep = task?.current_step ?? ""; + const userMessage = task?.user_message ?? ""; + const errorMessage = task?.error_message ?? ""; + const retryable = task?.retryable ?? false; + + /* ── setup 阶段 ── */ + if (phase === "setup") { + return ( + +
+ + { + const v = e.target.value ? Number(e.target.value) : null; + onDurationChange(v); + }} + min={1} + max={600} + /> +
+ 预估总时长:{estimatedDuration}s +
+
+
+ ); + } + + /* ── progress 阶段 ── */ + if (phase === "progress") { + const stepColor = STATUS_COLOR[status] || STATUS_COLOR[currentStep] || "#4f46e5"; + + return ( + +
+ {/* 进度环 */} +
+ + + + + + {progress}% + +
+ + {/* 当前步骤 */} +
+ {userMessage || getStepLabel(currentStep) || "处理中…"} +
+ + {/* 进度条 */} +
+
+
+ + {/* 任务 ID */} + {task?.id && ( +
任务 ID: {task.id}
+ )} +
+ + ); + } + + /* ── completed 阶段 ── */ + if (phase === "completed") { + return ( + +
+
🎉
+
视频生成完成!
+ {userMessage && ( +
{userMessage}
+ )} +
+ +
+
+
+ ); + } + + /* ── failed 阶段 ── */ + return ( + +
+
😥
+
视频生成失败
+ {(errorMessage || userMessage) && ( +
+ {errorMessage || userMessage} +
+ )} +
+ {retryable && onRetry && ( + + )} + +
+
+
+ ); +}; + +export default GenerationProgressModal; -- 2.54.0