feat(editing-planner): 生成进度展示 — 任务 2.17 #158

Merged
xiaoxia merged 1 commits from feature/task-2.17-generation-progress into develop 2026-07-01 20:14:47 +08:00
4 changed files with 482 additions and 14 deletions
+6
View File
@@ -70,6 +70,12 @@ export const getUserTasks = async (): Promise<TaskItem[]> => {
return data.items || [];
};
/** 获取单个任务详情(用于轮询进度) */
export const getTask = async (taskId: string): Promise<TaskItem> => {
const { data } = await apiClient.get(`/tasks/${taskId}`);
return data;
};
/** 重试失败的任务 */
export const retryTask = async (taskId: string): Promise<TaskItem> => {
const { data } = await apiClient.post(`/tasks/${taskId}/retry`);
@@ -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);
}
@@ -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<GenPhase>("setup");
const [taskId, setTaskId] = useState<string | null>(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<TaskItem>({
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)}
/>
{/* 使用模板生成弹窗 */}
<GenerateModal
{/* 生成进度弹窗 */}
<GenerationProgressModal
open={generateModalOpen}
loading={generateMutation.isPending}
phase={genPhase}
voiceoverDuration={voiceoverDuration}
estimatedDuration={totalDuration}
onDurationChange={setVoiceoverDuration}
onGenerate={doGenerate}
onCancel={() => setGenerateModalOpen(false)}
task={taskData || null}
submitting={generateMutation.isPending}
onCancel={handleCloseProgressModal}
onRetry={handleRetry}
onClose={handleCloseProgressModal}
/>
</div>
);
@@ -0,0 +1,251 @@
/**
* 生成进度弹窗 — 任务 2.17
* 三阶段 UIsetup(配置)→ 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<string, string> = {
queued: "排队中…",
preparing: "准备素材…",
generating_video: "渲染视频中…",
adding_effects: "添加特效…",
composing: "合成中…",
encoding: "编码输出中…",
completed: "生成完成!",
failed: "生成失败",
};
const getStepLabel = (step: string) =>
STEP_LABELS[step] || step.replace(/_/g, " ");
/* ──────────── 状态徽标颜色 ──────────── */
const STATUS_COLOR: Record<string, string> = {
queued: "#6b7280",
pending: "#6b7280",
preparing: "#f59e0b",
generating_video: "#4f46e5",
adding_effects: "#7c3aed",
composing: "#2563eb",
encoding: "#0891b2",
completed: "#10b981",
failed: "#ef4444",
};
/* ──────────── 组件 ──────────── */
const GenerationProgressModal: React.FC<GenerationProgressModalProps> = ({
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 (
<Modal
open={open}
title="使用模板生成视频"
confirmLoading={submitting}
onOk={onGenerate}
onCancel={onCancel}
okText="开始生成"
cancelText="取消"
width={440}
>
<div className="ep-gen-setup">
<label className="ep-gen-field-label"></label>
<input
type="number"
className="ep-gen-duration-input"
placeholder="请输入配音时长"
value={voiceoverDuration ?? ""}
onChange={(e) => {
const v = e.target.value ? Number(e.target.value) : null;
onDurationChange(v);
}}
min={1}
max={600}
/>
<div className="ep-gen-estimate">
<strong>{estimatedDuration}s</strong>
</div>
</div>
</Modal>
);
}
/* ── progress 阶段 ── */
if (phase === "progress") {
const stepColor = STATUS_COLOR[status] || STATUS_COLOR[currentStep] || "#4f46e5";
return (
<Modal
open={open}
title="视频生成中"
footer={null}
onCancel={onCancel}
closable
width={480}
>
<div className="ep-gen-progress">
{/* 进度环 */}
<div className="ep-gen-progress-ring-wrap">
<svg className="ep-gen-progress-ring" viewBox="0 0 120 120">
<circle
className="ep-gen-progress-ring-bg"
cx="60" cy="60" r="52"
/>
<circle
className="ep-gen-progress-ring-fill"
cx="60" cy="60" r="52"
style={{
strokeDasharray: `${2 * Math.PI * 52}`,
strokeDashoffset: `${2 * Math.PI * 52 * (1 - progress / 100)}`,
stroke: stepColor,
}}
/>
</svg>
<span className="ep-gen-progress-pct" style={{ color: stepColor }}>
{progress}%
</span>
</div>
{/* 当前步骤 */}
<div className="ep-gen-step-text">
{userMessage || getStepLabel(currentStep) || "处理中…"}
</div>
{/* 进度条 */}
<div className="ep-gen-progress-bar">
<div
className="ep-gen-progress-bar-fill"
style={{
width: `${progress}%`,
backgroundColor: stepColor,
}}
/>
</div>
{/* 任务 ID */}
{task?.id && (
<div className="ep-gen-task-id"> ID: {task.id}</div>
)}
</div>
</Modal>
);
}
/* ── completed 阶段 ── */
if (phase === "completed") {
return (
<Modal
open={open}
title="✅ 生成完成"
footer={null}
onCancel={onClose || onCancel}
closable
width={440}
>
<div className="ep-gen-result">
<div className="ep-gen-result-icon">🎉</div>
<div className="ep-gen-result-title"></div>
{userMessage && (
<div className="ep-gen-result-msg">{userMessage}</div>
)}
<div className="ep-gen-result-actions">
<Button buttonType="primary" onClick={onClose || onCancel}>
</Button>
</div>
</div>
</Modal>
);
}
/* ── failed 阶段 ── */
return (
<Modal
open={open}
title="❌ 生成失败"
footer={null}
onCancel={onClose || onCancel}
closable
width={440}
>
<div className="ep-gen-result ep-gen-result--error">
<div className="ep-gen-result-icon">😥</div>
<div className="ep-gen-result-title"></div>
{(errorMessage || userMessage) && (
<div className="ep-gen-result-msg ep-gen-result-msg--error">
{errorMessage || userMessage}
</div>
)}
<div className="ep-gen-result-actions">
{retryable && onRetry && (
<Button buttonType="primary" onClick={onRetry}>
🔄
</Button>
)}
<Button buttonType="secondary" onClick={onClose || onCancel}>
</Button>
</div>
</div>
</Modal>
);
};
export default GenerationProgressModal;