feat(editing-planner): 生成进度展示 — 任务 2.17 #158
@@ -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
|
||||
* 三阶段 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<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;
|
||||
Reference in New Issue
Block a user