feat: 生成任务取消功能前端对接 #428

Merged
xiaoxia merged 4 commits from feat/generation-cancel-frontend into develop 2026-07-17 09:18:34 +08:00
7 changed files with 250 additions and 58 deletions
Regular → Executable
+7 -1
View File
@@ -20,7 +20,7 @@ import type {
/** 剪辑计划状态枚举 */
export type EditPlanStatus =
"draft" | "editing" | "rendering" | "completed" | "failed";
"draft" | "editing" | "rendering" | "completed" | "failed" | "cancelled";
/** 标题配置(对齐后端 title_config */
export interface TitleConfig {
@@ -453,6 +453,11 @@ export async function getGenerationTaskResults(
return response.data.items || response.data || [];
}
/** 取消生成任务 */
export async function cancelGeneration(planId: string): Promise<void> {
await apiClient.post(`/edit-plans/${planId}/cancel`);
}
/**
* 获取素材库列表 — 调用 GET /api/v1/assets?library_id=xxx
* 将后端 AssetResponse 映射为前端 MediaAsset 类型
@@ -553,6 +558,7 @@ export const PLAN_STATUS_LABELS: Record<EditPlanStatus, string> = {
rendering: "渲染中",
completed: "已完成",
failed: "失败",
cancelled: "已取消",
};
/** 质量分筛选选项 */
+45 -2
View File
@@ -24,12 +24,14 @@ import {
DeleteOutlined,
FileTextOutlined,
ThunderboltOutlined,
StopOutlined,
} from "@ant-design/icons";
import type { ColumnsType } from "antd/es/table";
import {
getEditPlans,
deleteEditPlan,
generateEditPlan,
cancelGeneration,
type EditPlan,
type EditPlanStatus,
type EditPlanListParams,
@@ -47,6 +49,7 @@ const STATUS_TABS: { key: EditPlanStatus | "all"; label: string }[] = [
{ key: "rendering", label: "渲染中" },
{ key: "completed", label: "已完成" },
{ key: "failed", label: "失败" },
{ key: "cancelled", label: "已取消" },
];
/** 状态标签配置 */
@@ -79,6 +82,11 @@ const STATUS_CONFIG: Record<
color: "error",
icon: <CloseCircleOutlined />,
},
cancelled: {
label: "已取消",
color: "default",
icon: <StopOutlined />,
},
};
/* ──────────── 工具函数 ──────────── */
@@ -184,6 +192,18 @@ export default function EditPlans() {
},
});
// 取消生成
const cancelMutation = useMutation({
mutationFn: cancelGeneration,
onSuccess: () => {
message.success("已提交取消请求");
queryClient.invalidateQueries({ queryKey: ["edit-plans"] });
},
onError: () => {
message.error("取消失败,请稍后重试");
},
});
// 跳转到剪辑编辑器
const handleEdit = useCallback(
(plan: EditPlan) => {
@@ -285,7 +305,7 @@ export default function EditPlans() {
{
title: "操作",
key: "action",
width: 180,
width: 240,
fixed: "right",
render: (_: unknown, record: EditPlan) => (
<div className="plan-actions">
@@ -298,7 +318,30 @@ export default function EditPlans() {
>
</Button>
{(record.status === "failed" || record.status === "completed") && (
{record.status === "rendering" && (
<Popconfirm
title="确认取消生成"
description="确定要取消当前生成任务吗?此操作不可恢复。"
onConfirm={() => cancelMutation.mutate(record.id)}
okText="确定"
cancelText="再等等"
okButtonProps={{ danger: true }}
>
<Button
type="link"
size="small"
danger
icon={<StopOutlined />}
loading={cancelMutation.isPending}
className="plan-action-btn plan-cancel-btn"
>
</Button>
</Popconfirm>
)}
{(record.status === "failed" ||
record.status === "completed" ||
record.status === "cancelled") && (
<Popconfirm
title="确认重新生成"
description="确定要重新生成这个剪辑计划吗?"
+26
View File
@@ -6048,3 +6048,29 @@
color: #ef4444;
background: #fef2f2;
}
/* ── 生成历史取消按钮 ── */
.ep-gh-td-action {
width: 60px;
text-align: center;
}
.ep-gh-cancel-btn {
background: none;
border: none;
color: var(--color-error, #ff4d4f);
cursor: pointer;
font-size: 12px;
padding: 2px 8px;
border-radius: 4px;
transition: background 0.2s;
}
.ep-gh-cancel-btn:hover:not(:disabled) {
background: rgba(255, 77, 79, 0.1);
}
.ep-gh-cancel-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.ep-gh-action-placeholder {
color: var(--text-tertiary, #bfbfbf);
}
@@ -37,6 +37,7 @@ import {
generateEditPlan,
getGenerationStatus,
getGenerationTaskResults,
cancelGeneration,
} from "@/api/editPlans";
import { useUndoRedo } from "./hooks/useUndoRedo";
import type {
@@ -81,8 +82,8 @@ import TimelinePanel from "./components/TimelinePanel";
import ClipPropertiesPanel from "./components/ClipPropertiesPanel";
import BgmSelector from "./components/BgmSelector";
import SubtitleStylePanel from "./components/SubtitleStylePanel";
import type { SubtitleStyleConfig } from "./components/SubtitleStylePanel";
import { DEFAULT_SUBTITLE_STYLE } from "./components/SubtitleStylePanel";
import type { SubtitleStyleConfig } from "./types/subtitle";
import { DEFAULT_SUBTITLE_STYLE } from "./types/subtitle";
import TransitionSelector from "./components/TransitionSelector";
import SpeedPanel from "./components/SpeedPanel";
import TtsPanel from "./components/TtsPanel";
@@ -270,6 +271,7 @@ const EditingPlanner: React.FC = () => {
const [generated, setGenerated] = useState(false);
const [generatedVideos, setGeneratedVideos] = useState<GeneratedVideo[]>([]);
const [genError, setGenError] = useState<string | null>(null);
const [cancelling, setCancelling] = useState(false);
const genTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
/* ── 播放 ── */
@@ -558,11 +560,14 @@ const EditingPlanner: React.FC = () => {
if (selectedClipId === clipId) setSelectedClipId(null);
};
const handleClipUpdate = (clipId: string, data: Partial<ClipData>) => {
setClips((prev) =>
prev.map((c) => (c.id === clipId ? { ...c, ...data } : c)),
);
};
const handleClipUpdate = useCallback(
(clipId: string, data: Partial<ClipData>) => {
setClips((prev) =>
prev.map((c) => (c.id === clipId ? { ...c, ...data } : c)),
);
},
[setClips],
);
/**
*
@@ -670,7 +675,7 @@ const EditingPlanner: React.FC = () => {
}
// 同时更新全局默认转场(供新片段使用)
},
[transitionTargetClipId],
[transitionTargetClipId, handleClipUpdate],
);
/* ── 打开转场选择器 ── */
@@ -686,7 +691,7 @@ const EditingPlanner: React.FC = () => {
handleClipUpdate(speedTargetClipId, { speed: config });
}
},
[speedTargetClipId],
[speedTargetClipId, handleClipUpdate],
);
/* ── 打开调速面板 ── */
@@ -701,7 +706,7 @@ const EditingPlanner: React.FC = () => {
if (!ttsTargetClipId) return;
handleClipUpdate(ttsTargetClipId, { tts_config: ttsConfig });
},
[ttsTargetClipId],
[ttsTargetClipId, handleClipUpdate],
);
/* ── 打开 TTS 配音面板 ── */
@@ -711,10 +716,13 @@ const EditingPlanner: React.FC = () => {
}, []);
/* ── 调速应用到所有片段 ── */
const handleApplySpeedAll = useCallback((config: SpeedConfig) => {
setClips((prev) => prev.map((c) => ({ ...c, speed: { ...config } })));
message.success("已应用到所有片段");
}, []);
const handleApplySpeedAll = useCallback(
(config: SpeedConfig) => {
setClips((prev) => prev.map((c) => ({ ...c, speed: { ...config } })));
message.success("已应用到所有片段");
},
[setClips],
);
/* ── 水印配置变更 ── */
const handleWatermarkChange = useCallback((config: WatermarkConfig) => {
@@ -1041,6 +1049,13 @@ const EditingPlanner: React.FC = () => {
return; // 停止轮询
}
if (status.plan_status === "cancelled") {
setGenerating(false);
setGenError("生成已取消");
message.info("生成任务已取消");
return; // 停止轮询
}
// 继续轮询
genTimerRef.current = setTimeout(poll, 2000);
} catch (err) {
@@ -1060,6 +1075,33 @@ const EditingPlanner: React.FC = () => {
};
}, []);
/** 取消生成任务 */
const handleCancelGeneration = async () => {
const targetId = loadedPlanId;
if (!targetId) return;
Modal.confirm({
title: "确认取消生成",
content: "取消后已开始的生成任务,已生成的片段不会保留。确定要取消吗?",
okText: "确认取消",
cancelText: "继续生成",
okButtonProps: { danger: true },
onOk: async () => {
try {
setCancelling(true);
await cancelGeneration(targetId);
message.success("已提交取消请求");
// 轮询会继续运行直到检测到 cancelled 状态
} catch (err) {
console.error("[取消失败]", err);
message.error("取消失败,请稍后重试");
} finally {
setCancelling(false);
}
},
});
};
/* 查看生成历史 */
const handleViewGenHistory = async () => {
const targetId = loadedPlanId || loadedTemplateId;
@@ -1284,6 +1326,28 @@ const EditingPlanner: React.FC = () => {
loading={genHistoryLoading}
history={genHistory}
onClose={() => setGenHistoryOpen(false)}
onCancel={async () => {
Modal.confirm({
title: "确认取消生成",
content: "确定要取消这个生成任务吗?此操作不可恢复。",
okText: "确认取消",
cancelText: "再等等",
okButtonProps: { danger: true },
onOk: async () => {
if (!loadedPlanId) return;
try {
await cancelGeneration(loadedPlanId);
message.success("已提交取消请求");
// 刷新历史列表
handleViewGenHistory();
} catch (err) {
console.error("[取消失败]", err);
message.error("取消失败,请稍后重试");
}
},
});
}}
cancelLoading={cancelling}
/>
{/* ═══ 生成进度弹窗 ═══ */}
@@ -1298,6 +1362,7 @@ const EditingPlanner: React.FC = () => {
onClick={() => {
setGenerated(false);
setGenerating(false);
setGenError(null);
}}
>
@@ -1324,7 +1389,30 @@ const EditingPlanner: React.FC = () => {
</Button>
),
]
: null
: generating
? [
<Button
key="cancel"
danger
loading={cancelling}
onClick={handleCancelGeneration}
>
</Button>,
]
: genError
? [
<Button
key="close"
onClick={() => {
setGenError(null);
setGenerating(false);
}}
>
</Button>,
]
: null
}
closable={!generating}
maskClosable={false}
+22
View File
@@ -12,6 +12,8 @@ interface GenerationHistoryModalProps {
loading: boolean;
history: EditPlanGeneration[];
onClose: () => void;
onCancel?: (taskId: string) => void;
cancelLoading?: boolean;
}
const GenerationHistoryModal: React.FC<GenerationHistoryModalProps> = ({
@@ -19,6 +21,8 @@ const GenerationHistoryModal: React.FC<GenerationHistoryModalProps> = ({
loading,
history,
onClose,
onCancel,
cancelLoading,
}) => {
if (!open) return null;
@@ -57,11 +61,14 @@ const GenerationHistoryModal: React.FC<GenerationHistoryModalProps> = ({
<th className="ep-gh-th"></th>
<th className="ep-gh-th"></th>
<th className="ep-gh-th"></th>
{onCancel && <th className="ep-gh-th"></th>}
</tr>
</thead>
<tbody>
{history.map((gen) => {
const statusClass = `ep-gh-status-tag--${gen.status}`;
const canCancel =
gen.status === "rendering" || gen.status === "editing";
return (
<tr key={gen.id} className="ep-gh-table-row">
<td className="ep-gh-td ep-gh-td-id">
@@ -82,6 +89,21 @@ const GenerationHistoryModal: React.FC<GenerationHistoryModalProps> = ({
? new Date(gen.updated_at).toLocaleString("zh-CN")
: "—"}
</td>
{onCancel && (
<td className="ep-gh-td ep-gh-td-action">
{canCancel ? (
<button
className="ep-gh-cancel-btn"
onClick={() => onCancel(gen.id)}
disabled={cancelLoading}
>
</button>
) : (
<span className="ep-gh-action-placeholder"></span>
)}
</td>
)}
</tr>
);
})}
+1 -40
View File
@@ -5,46 +5,7 @@
import React from "react";
import { Drawer, Slider, ColorPicker, Select } from "antd";
import type { Color } from "antd/es/color-picker";
/* ──────────── 类型 ──────────── */
export type SubtitleMode = "manual" | "asr";
export interface SubtitleStyleConfig {
/** 是否启用字幕 */
enabled: boolean;
/** 字幕模式:手动输入 / ASR 自动识别 */
mode: SubtitleMode;
/** 字体大小 px */
fontSize: number;
/** 字体颜色 */
fontColor: string;
/** 描边 */
stroke: boolean;
/** 阴影 */
shadow: boolean;
/** 字幕位置 */
position: "top" | "center" | "bottom";
/** 字体 */
font: string;
/** 动画效果 */
animation: string;
/** ASR 语言(仅 ASR 模式) */
asrLanguage: "zh" | "en";
}
export const DEFAULT_SUBTITLE_STYLE: SubtitleStyleConfig = {
enabled: true,
mode: "asr",
fontSize: 16,
fontColor: "#ffffff",
stroke: true,
shadow: false,
position: "bottom",
font: "思源黑体",
animation: "none",
asrLanguage: "zh",
};
import type { SubtitleStyleConfig } from "../types/subtitle";
/* ──────────── 选项常量 ──────────── */
+46
View File
@@ -0,0 +1,46 @@
/**
*
* react-refresh/only-export-components
*/
/* ──────────── 类型 ──────────── */
export type SubtitleMode = "manual" | "asr";
export interface SubtitleStyleConfig {
/** 是否启用字幕 */
enabled: boolean;
/** 字幕模式:手动输入 / ASR 自动识别 */
mode: SubtitleMode;
/** 字体大小 px */
fontSize: number;
/** 字体颜色 */
fontColor: string;
/** 描边 */
stroke: boolean;
/** 阴影 */
shadow: boolean;
/** 字幕位置 */
position: "top" | "center" | "bottom";
/** 字体 */
font: string;
/** 动画效果 */
animation: string;
/** ASR 语言(仅 ASR 模式) */
asrLanguage: "zh" | "en";
}
/* ──────────── 默认值 ──────────── */
export const DEFAULT_SUBTITLE_STYLE: SubtitleStyleConfig = {
enabled: true,
mode: "asr",
fontSize: 16,
fontColor: "#ffffff",
stroke: true,
shadow: false,
position: "bottom",
font: "思源黑体",
animation: "none",
asrLanguage: "zh",
};