diff --git a/apps/web/src/api/editPlans.ts b/apps/web/src/api/editPlans.ts old mode 100644 new mode 100755 index 93bc13477..414f563e8 --- a/apps/web/src/api/editPlans.ts +++ b/apps/web/src/api/editPlans.ts @@ -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 { + 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 = { rendering: "渲染中", completed: "已完成", failed: "失败", + cancelled: "已取消", }; /** 质量分筛选选项 */ diff --git a/apps/web/src/pages/edit-plans/EditPlans.tsx b/apps/web/src/pages/edit-plans/EditPlans.tsx old mode 100644 new mode 100755 index 87283db3d..899a2f28e --- a/apps/web/src/pages/edit-plans/EditPlans.tsx +++ b/apps/web/src/pages/edit-plans/EditPlans.tsx @@ -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: , }, + cancelled: { + label: "已取消", + color: "default", + icon: , + }, }; /* ──────────── 工具函数 ──────────── */ @@ -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) => (
@@ -298,7 +318,30 @@ export default function EditPlans() { > 编辑 - {(record.status === "failed" || record.status === "completed") && ( + {record.status === "rendering" && ( + cancelMutation.mutate(record.id)} + okText="确定" + cancelText="再等等" + okButtonProps={{ danger: true }} + > + + + )} + {(record.status === "failed" || + record.status === "completed" || + record.status === "cancelled") && ( { const [generated, setGenerated] = useState(false); const [generatedVideos, setGeneratedVideos] = useState([]); const [genError, setGenError] = useState(null); + const [cancelling, setCancelling] = useState(false); const genTimerRef = useRef | null>(null); /* ── 播放 ── */ @@ -558,11 +560,14 @@ const EditingPlanner: React.FC = () => { if (selectedClipId === clipId) setSelectedClipId(null); }; - const handleClipUpdate = (clipId: string, data: Partial) => { - setClips((prev) => - prev.map((c) => (c.id === clipId ? { ...c, ...data } : c)), - ); - }; + const handleClipUpdate = useCallback( + (clipId: string, data: Partial) => { + 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 = () => { ), ] - : null + : generating + ? [ + , + ] + : genError + ? [ + , + ] + : null } closable={!generating} maskClosable={false} diff --git a/apps/web/src/pages/editing-planner/components/GenerationHistoryModal.tsx b/apps/web/src/pages/editing-planner/components/GenerationHistoryModal.tsx old mode 100644 new mode 100755 index ccd330788..7c4ce926f --- a/apps/web/src/pages/editing-planner/components/GenerationHistoryModal.tsx +++ b/apps/web/src/pages/editing-planner/components/GenerationHistoryModal.tsx @@ -12,6 +12,8 @@ interface GenerationHistoryModalProps { loading: boolean; history: EditPlanGeneration[]; onClose: () => void; + onCancel?: (taskId: string) => void; + cancelLoading?: boolean; } const GenerationHistoryModal: React.FC = ({ @@ -19,6 +21,8 @@ const GenerationHistoryModal: React.FC = ({ loading, history, onClose, + onCancel, + cancelLoading, }) => { if (!open) return null; @@ -57,11 +61,14 @@ const GenerationHistoryModal: React.FC = ({ 状态 创建时间 更新时间 + {onCancel && 操作} {history.map((gen) => { const statusClass = `ep-gh-status-tag--${gen.status}`; + const canCancel = + gen.status === "rendering" || gen.status === "editing"; return ( @@ -82,6 +89,21 @@ const GenerationHistoryModal: React.FC = ({ ? new Date(gen.updated_at).toLocaleString("zh-CN") : "—"} + {onCancel && ( + + {canCancel ? ( + + ) : ( + + )} + + )} ); })} diff --git a/apps/web/src/pages/editing-planner/components/SubtitleStylePanel.tsx b/apps/web/src/pages/editing-planner/components/SubtitleStylePanel.tsx old mode 100644 new mode 100755 index 5f75536cf..27f252da0 --- a/apps/web/src/pages/editing-planner/components/SubtitleStylePanel.tsx +++ b/apps/web/src/pages/editing-planner/components/SubtitleStylePanel.tsx @@ -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"; /* ──────────── 选项常量 ──────────── */ diff --git a/apps/web/src/pages/editing-planner/types/subtitle.ts b/apps/web/src/pages/editing-planner/types/subtitle.ts new file mode 100755 index 000000000..336effe1f --- /dev/null +++ b/apps/web/src/pages/editing-planner/types/subtitle.ts @@ -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", +};