feat: 生成任务取消功能前端对接 #428
Regular → Executable
+7
-1
@@ -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: "已取消",
|
||||
};
|
||||
|
||||
/** 质量分筛选选项 */
|
||||
|
||||
Regular → Executable
+45
-2
@@ -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="确定要重新生成这个剪辑计划吗?"
|
||||
|
||||
Regular → Executable
+26
@@ -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}
|
||||
|
||||
Regular → Executable
+22
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
Regular → Executable
+1
-40
@@ -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
@@ -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",
|
||||
};
|
||||
Reference in New Issue
Block a user