diff --git a/apps/web/src/api/bgm/bgm.ts b/apps/web/src/api/bgm/bgm.ts index d9dc58e62..181d84ddc 100644 --- a/apps/web/src/api/bgm/bgm.ts +++ b/apps/web/src/api/bgm/bgm.ts @@ -4,11 +4,20 @@ import apiClient from "../client" import type { BgmPreset, BgmPresetsQuery } from "./types" -/** 获取 BGM 预设列表 */ -export const getBgmPresets = async (params?: BgmPresetsQuery): Promise => { +/** + * 获取 BGM 预设列表 + * @param templateId 模板/草稿 ID + * @param params 分类/关键词筛选 + */ +export const getBgmPresets = async ( + templateId: string, + params?: BgmPresetsQuery, +): Promise => { const searchParams: Record = {} if (params?.category) searchParams.category = params.category if (params?.keyword) searchParams.keyword = params.keyword - const res = await apiClient.get("/bgm/presets", { params: searchParams }) + const res = await apiClient.get(`/templates/${templateId}/editor/bgm/presets`, { + params: searchParams, + }) return res.data?.data ?? res.data ?? [] } diff --git a/apps/web/src/api/generation/cover.ts b/apps/web/src/api/generation/cover.ts index 089e6de25..3a230220d 100644 --- a/apps/web/src/api/generation/cover.ts +++ b/apps/web/src/api/generation/cover.ts @@ -1,9 +1,22 @@ import apiClient from "../client" +export interface GenerateCoverTitleConfig { + text?: string + font?: string + font_size?: number + font_color?: string + position?: string + bold?: boolean + stroke?: boolean + shadow?: boolean +} + export interface GenerateCoverRequest { asset_ids: string[] cover_type?: "ai_frame" | "manual" | "upload" | "ai_regenerate" frame_time?: number + /** 标题样式,用于在封面上叠加标题文字 */ + title_config?: GenerateCoverTitleConfig } export interface GenerateCoverResponse { diff --git a/apps/web/src/api/tasks/tasks.ts b/apps/web/src/api/tasks/tasks.ts index 6b0ad6cc3..80b0fdb08 100644 --- a/apps/web/src/api/tasks/tasks.ts +++ b/apps/web/src/api/tasks/tasks.ts @@ -6,6 +6,7 @@ import apiClient from "../client" import type { CreateGenerationTaskRequest, CreateGenerationTaskResponse, + GenerationTaskDetail, TaskItem, TaskListParams, TaskListResponse, @@ -19,6 +20,12 @@ export const createGenerationTask = async ( return data } +/** 获取单个生成任务详情(轮询用) */ +export const getGenerationTask = async (taskId: string): Promise => { + const { data } = await apiClient.get(`/generation/tasks/${taskId}`) + return data +} + /** 获取任务列表(支持分页和筛选) */ export const getTasks = async (params?: TaskListParams): Promise => { const { data } = await apiClient.get("/tasks", { diff --git a/apps/web/src/api/tasks/types.ts b/apps/web/src/api/tasks/types.ts index 27f588cda..3dc06469d 100644 --- a/apps/web/src/api/tasks/types.ts +++ b/apps/web/src/api/tasks/types.ts @@ -82,10 +82,12 @@ export interface CreateGenerationTaskRequest { stroke?: boolean shadow?: boolean } + /** 关联的草稿 ID(编辑流程数据链路用) */ + source_edit_plan_id?: string } -/** 创建生成任务响应(对齐后端 GenerationTaskResponse) */ -export interface CreateGenerationTaskResponse { +/** 单个生成任务详情(对齐后端 GenerationTaskResponse) */ +export interface GenerationTaskDetail { id: string project_id: string asset_library_id: string @@ -95,8 +97,18 @@ export interface CreateGenerationTaskResponse { asset_ids: string[] title_ids: string[] voice_ids: string[] + source_edit_plan_id?: string status: string progress: number result_count: number error_message: string + error_info?: TaskErrorInfo + created_at?: string | null + updated_at?: string | null +} + +/** 创建生成任务响应(后端返回批量结构 {items, total}) */ +export interface CreateGenerationTaskResponse { + items: GenerationTaskDetail[] + total: number } diff --git a/apps/web/src/api/template-editor/editPlans.ts b/apps/web/src/api/template-editor/editPlans.ts index c4d0785f1..8d3d46d2b 100644 --- a/apps/web/src/api/template-editor/editPlans.ts +++ b/apps/web/src/api/template-editor/editPlans.ts @@ -4,37 +4,19 @@ import apiClient from "../client" import type { EditPlan, - EditPlanListParams, - EditPlanListResponse, - CreateEditPlanRequest, UpdateEditPlanRequest, GenerateResponse, GenerationStatusResponse, EditPlanGeneration, GeneratedVideo, - CopyEditPlanRequest, } from "./types" -/** 获取模板草稿列表(支持分页和筛选) */ -export async function getEditPlans(params?: EditPlanListParams): Promise { - const response = await apiClient.get("/templates/drafts", { - params, - }) - return response.data -} - /** 获取单个模板草稿 */ export async function getEditPlan(templateId: string): Promise { const response = await apiClient.get(`/templates/${templateId}/editor`) return response.data } -/** 创建模板草稿 */ -export async function createEditPlan(data: CreateEditPlanRequest): Promise { - const response = await apiClient.post("/templates/drafts", data) - return response.data -} - /** 更新模板草稿 */ export async function updateEditPlan( templateId: string, @@ -44,11 +26,6 @@ export async function updateEditPlan( return response.data } -/** 删除模板草稿 */ -export async function deleteEditPlan(templateId: string): Promise { - await apiClient.delete(`/templates/${templateId}/editor`) -} - /** 触发生成 */ export async function generateEditPlan(templateId: string): Promise { const response = await apiClient.post(`/templates/${templateId}/editor/generate`) @@ -72,20 +49,3 @@ export async function getGenerationTaskResults(taskId: string): Promise { - await apiClient.post(`/templates/${templateId}/editor/cancel`) -} - -/** 复制模板草稿(含所有片段配置) */ -export async function copyEditPlan( - templateId: string, - data?: CopyEditPlanRequest, -): Promise { - const response = await apiClient.post( - `/templates/${templateId}/editor/copy`, - data || {}, - ) - return response.data -} diff --git a/apps/web/src/api/template-editor/index.ts b/apps/web/src/api/template-editor/index.ts index af50dd265..41a521c7f 100644 --- a/apps/web/src/api/template-editor/index.ts +++ b/apps/web/src/api/template-editor/index.ts @@ -15,10 +15,7 @@ export type { EditPlanSegment, EditPlanConfig, EditPlan, - CreateEditPlanRequest, UpdateEditPlanRequest, - EditPlanListParams, - EditPlanListResponse, GenerateResponse, EditPlanGeneration, ClipStatusItem, @@ -37,7 +34,6 @@ export type { ClipReorderResponse, ClipBatchDeleteResponse, ClipsFromAssetsResponse, - CopyEditPlanRequest, TransitionEffect, MediaAsset, } from "./types" @@ -53,17 +49,12 @@ export { // 模板草稿 CRUD + 生成 export { - getEditPlans, getEditPlan, - createEditPlan, updateEditPlan, - deleteEditPlan, generateEditPlan, getGenerationStatus, getEditPlanGenerations, getGenerationTaskResults, - cancelGeneration, - copyEditPlan, } from "./editPlans" // 片段 CRUD + 批量操作 diff --git a/apps/web/src/api/template-editor/types.ts b/apps/web/src/api/template-editor/types.ts index 9e1920e3d..46c367624 100644 --- a/apps/web/src/api/template-editor/types.ts +++ b/apps/web/src/api/template-editor/types.ts @@ -118,6 +118,17 @@ export interface EditPlanConfig { generate_count?: number /** 素材模式 */ material_mode?: string + /** 前端标题设置(Step4 自动保存,与 title_config 字段分离,不影响后端渲染) */ + title?: { + text?: string + font?: string + font_size?: number + color?: string + position?: string + bold?: boolean + stroke?: boolean + shadow?: boolean + } /** 预览视频 URL(封面生成用) */ rendered_storage_key?: string /** 生成任务 ID */ diff --git a/apps/web/src/api/templates/index.ts b/apps/web/src/api/templates/index.ts index bece1cbb4..2ed58152f 100644 --- a/apps/web/src/api/templates/index.ts +++ b/apps/web/src/api/templates/index.ts @@ -9,8 +9,6 @@ export type { TemplateSegment, TemplateListParams, TemplateListResponse, - GenerateFromTemplateRequest, - GenerateFromTemplateResponse, CopyTemplateResponse, } from "./types" @@ -24,5 +22,4 @@ export { getTemplate, toggleFavoriteTemplate, copyTemplate, - generateFromTemplate, } from "./templates" diff --git a/apps/web/src/api/templates/templates.ts b/apps/web/src/api/templates/templates.ts index d388f932a..07b26ecba 100644 --- a/apps/web/src/api/templates/templates.ts +++ b/apps/web/src/api/templates/templates.ts @@ -5,8 +5,6 @@ import apiClient from "../client" import type { CopyTemplateResponse, - GenerateFromTemplateRequest, - GenerateFromTemplateResponse, TemplateItem, TemplateListParams, TemplateListResponse, @@ -45,15 +43,3 @@ export const copyTemplate = async (templateId: string): Promise(`/templates/${templateId}/copy`) return response.data } - -/** 从模板生成 */ -export const generateFromTemplate = async ( - templateId: string, - data?: GenerateFromTemplateRequest, -): Promise => { - const response = await apiClient.post( - `/templates/${templateId}/generate`, - data, - ) - return response.data -} diff --git a/apps/web/src/pages/editing-planner/components/bgm-selector/index.tsx b/apps/web/src/pages/editing-planner/components/bgm-selector/index.tsx index 51aa19e31..6964addc2 100644 --- a/apps/web/src/pages/editing-planner/components/bgm-selector/index.tsx +++ b/apps/web/src/pages/editing-planner/components/bgm-selector/index.tsx @@ -16,9 +16,17 @@ interface BgmSelectorProps { onClose: () => void config: BgmMixConfig onChange: (config: BgmMixConfig) => void + /** 模板/草稿 ID,用于请求 BGM 预设 */ + templateId?: string } -const BgmSelector: React.FC = ({ open, onClose, config, onChange }) => { +const BgmSelector: React.FC = ({ + open, + onClose, + config, + onChange, + templateId, +}) => { const { presets, loading, @@ -30,7 +38,7 @@ const BgmSelector: React.FC = ({ open, onClose, config, onChan loadPresets, handlePreview, stopPreview, - } = useBgmSelector(open) + } = useBgmSelector(open, templateId) /* ── 选中 BGM ── */ const handleSelect = useCallback( diff --git a/apps/web/src/pages/editing-planner/components/bgm-selector/useBgmSelector.ts b/apps/web/src/pages/editing-planner/components/bgm-selector/useBgmSelector.ts index 8a47b6a6a..755adf839 100644 --- a/apps/web/src/pages/editing-planner/components/bgm-selector/useBgmSelector.ts +++ b/apps/web/src/pages/editing-planner/components/bgm-selector/useBgmSelector.ts @@ -19,7 +19,7 @@ export const CATEGORY_LIST: { * BGM 选择器数据与交互 Hook * 封装列表加载、搜索、分类筛选、试听播放逻辑 */ -export function useBgmSelector(open: boolean) { +export function useBgmSelector(open: boolean, templateId?: string) { const [presets, setPresets] = useState([]) const [loading, setLoading] = useState(false) const [activeCategory, setActiveCategory] = useState("all") @@ -30,19 +30,23 @@ export function useBgmSelector(open: boolean) { /* ── 加载 BGM 列表 ── */ const loadPresets = useCallback(async () => { + if (!templateId) { + setPresets([]) + return + } setLoading(true) try { const params: { category?: string; keyword?: string } = {} if (activeCategory !== "all") params.category = activeCategory if (keyword.trim()) params.keyword = keyword.trim() - const data = await getBgmPresets(params) + const data = await getBgmPresets(templateId, params) setPresets(data) } catch { message.error("加载 BGM 列表失败") } finally { setLoading(false) } - }, [activeCategory, keyword]) + }, [activeCategory, keyword, templateId]) useEffect(() => { if (open) loadPresets() diff --git a/apps/web/src/pages/generate/GeneratePage.tsx b/apps/web/src/pages/generate/GeneratePage.tsx index c6bf2d163..449b30174 100644 --- a/apps/web/src/pages/generate/GeneratePage.tsx +++ b/apps/web/src/pages/generate/GeneratePage.tsx @@ -171,6 +171,7 @@ const GeneratePage: React.FC = () => { autoSubtitles, bgm, generateCount, + sourceEditPlanId: editPlanId, }) /* ================================================================ diff --git a/apps/web/src/pages/generate/components/GenerateStepContent.tsx b/apps/web/src/pages/generate/components/GenerateStepContent.tsx index 433841a3b..e7cbca646 100644 --- a/apps/web/src/pages/generate/components/GenerateStepContent.tsx +++ b/apps/web/src/pages/generate/components/GenerateStepContent.tsx @@ -141,6 +141,7 @@ export const GenerateStepContent: React.FC = (props) = onSelectedMaterialsChange={onSelectedMaterialsChange} smartSelectedIds={smartSelectedIds} onSmartSelectedIdsChange={onSmartSelectedIdsChange} + selectedTemplate={selectedTemplate} /> ) case 3: @@ -156,6 +157,7 @@ export const GenerateStepContent: React.FC = (props) = ) case 5: diff --git a/apps/web/src/pages/generate/components/Step2MaterialSelect.tsx b/apps/web/src/pages/generate/components/Step2MaterialSelect.tsx index 395916d42..19ff574e6 100644 --- a/apps/web/src/pages/generate/components/Step2MaterialSelect.tsx +++ b/apps/web/src/pages/generate/components/Step2MaterialSelect.tsx @@ -15,6 +15,8 @@ interface Step2MaterialSelectProps { onSelectedMaterialsChange: (ids: string[]) => void smartSelectedIds: string[] onSmartSelectedIdsChange: (ids: string[]) => void + /** 当前选中的模板/草稿 ID,用于自动保存 */ + selectedTemplate?: string } const Step2MaterialSelect: React.FC = (props) => { diff --git a/apps/web/src/pages/generate/components/Step4TitleSettings.tsx b/apps/web/src/pages/generate/components/Step4TitleSettings.tsx index d01dbf48d..c60d48c56 100644 --- a/apps/web/src/pages/generate/components/Step4TitleSettings.tsx +++ b/apps/web/src/pages/generate/components/Step4TitleSettings.tsx @@ -12,6 +12,8 @@ import AiTitleGenerator from "./title/AiTitleGenerator" interface Step4TitleSettingsProps { titleSettings: TitleSettings onTitleSettingsChange: (settings: TitleSettings) => void + /** 当前选中的模板/草稿 ID,用于自动保存 */ + selectedTemplate?: string } const Step4TitleSettings: React.FC = (props) => { diff --git a/apps/web/src/pages/generate/hooks/generate-video/types.ts b/apps/web/src/pages/generate/hooks/generate-video/types.ts index a1f0341c6..1c2b6e2a5 100755 --- a/apps/web/src/pages/generate/hooks/generate-video/types.ts +++ b/apps/web/src/pages/generate/hooks/generate-video/types.ts @@ -19,6 +19,8 @@ export interface UseGenerateVideoProps { autoSubtitles: boolean bgm: boolean generateCount: number + /** 当前草稿 ID(URL 参数 edit_plan_id,用于后端回写任务关联) */ + sourceEditPlanId?: string | null } /** 生成阶段 */ diff --git a/apps/web/src/pages/generate/hooks/generate-video/useGenerationPolling.ts b/apps/web/src/pages/generate/hooks/generate-video/useGenerationPolling.ts index 54bf7a27d..5f3c52168 100644 --- a/apps/web/src/pages/generate/hooks/generate-video/useGenerationPolling.ts +++ b/apps/web/src/pages/generate/hooks/generate-video/useGenerationPolling.ts @@ -1,87 +1,86 @@ import { useRef, useCallback } from "react" import { message } from "antd" -import { getGenerationStatus, getGenerationTaskResults } from "@/api/template-editor" +import { getGenerationTask } from "@/api/tasks/tasks" +import { getGenerationTaskResults } from "@/api/template-editor" import { safeExtractError } from "./errorUtils" interface UseGenerationPollingOptions { - templateId: string onProgress: (progress: number) => void onComplete: (videos: unknown[]) => void onFailed: (errorMsg: string) => void } /** - * 生成状态轮询 Hook - * 轮询生成状态,更新进度,处理完成/失败 + * 生成状态轮询 Hook(v2 — 改用 /generation/tasks/{task_id}) + * + * 旧版轮询 GET /templates/{id}/editor/generation-status 依赖 plan 维度状态, + * 在编辑流程数据链路断裂时拿不到 task_id。新版直接使用 POST /generation/tasks + * 返回的 task_id 轮询任务详情,不再依赖 plan。 */ export const useGenerationPolling = ({ - templateId, onProgress, onComplete, onFailed, }: UseGenerationPollingOptions) => { const progressTimer = useRef>() + const cancelledRef = useRef(false) const clearTimer = useCallback(() => { + cancelledRef.current = true if (progressTimer.current) { clearTimeout(progressTimer.current) progressTimer.current = undefined } }, []) - const startPolling = useCallback(() => { - const poll = async () => { - try { - const data = await getGenerationStatus(templateId) + const startPolling = useCallback( + (taskId: string) => { + cancelledRef.current = false - if (data.plan_status === "completed") { - onProgress(100) - // 获取生成的视频结果 - let videos: unknown[] = [] - if (data.generation_task_id) { + const poll = async () => { + if (cancelledRef.current) return + try { + const task = await getGenerationTask(taskId) + + if (task.status === "completed") { + onProgress(100) + let videos: unknown[] = [] try { - videos = await getGenerationTaskResults(data.generation_task_id) + videos = await getGenerationTaskResults(taskId) } catch (err) { console.error("[获取生成结果失败]", err) } + onComplete(videos) + message.success("视频生成完成!") + return } - onComplete(videos) - message.success("视频生成完成!") - return - } - if (data.plan_status === "failed") { - const dataAny = data as unknown as Record - const rawMsg = - dataAny.error_message || - dataAny.error || - dataAny.message || - (Array.isArray(data.clips) - ? (data.clips as { status: string; error_message?: string }[]).find( - (c) => c.status === "failed", - )?.error_message - : undefined) || - "视频生成失败,请联系管理员或重试" - const errorMsg = safeExtractError(rawMsg) - console.error("[生成失败] templateId:", templateId, "响应:", data) - onFailed(errorMsg) - message.error(errorMsg) - return - } - const clips = data.clips || [] - const total = clips.length || 1 - const done = (clips as { status: string }[]).filter((c) => c.status === "completed").length - onProgress(Math.round((done / total) * 100)) + if (task.status === "failed" || task.status === "cancelled") { + const rawMsg = + task.error_info?.error_message || + task.error_message || + (task.status === "cancelled" ? "任务已取消" : "视频生成失败,请联系管理员或重试") + const errorMsg = safeExtractError(rawMsg) + console.error("[生成失败] taskId:", taskId, "响应:", task) + onFailed(errorMsg) + message.error(errorMsg) + return + } - progressTimer.current = setTimeout(poll, 2000) - } catch (pollErr) { - console.error("[轮询出错] templateId:", templateId, pollErr) - progressTimer.current = setTimeout(poll, 3000) + // pending / waiting / running — 继续轮询 + const pct = Math.max(0, Math.min(99, Math.round(Number(task.progress) || 0))) + onProgress(pct) + progressTimer.current = setTimeout(poll, 2000) + } catch (pollErr) { + console.error("[轮询出错] taskId:", taskId, pollErr) + progressTimer.current = setTimeout(poll, 3000) + } } - } - progressTimer.current = setTimeout(poll, 2000) - }, [templateId, onProgress, onComplete, onFailed]) + progressTimer.current = setTimeout(poll, 1500) + }, + [onProgress, onComplete, onFailed], + ) return { startPolling, clearTimer } } diff --git a/apps/web/src/pages/generate/hooks/useGenerateVideo.ts b/apps/web/src/pages/generate/hooks/useGenerateVideo.ts index 912fc6fe3..58d1c3b8e 100755 --- a/apps/web/src/pages/generate/hooks/useGenerateVideo.ts +++ b/apps/web/src/pages/generate/hooks/useGenerateVideo.ts @@ -34,7 +34,6 @@ export function useGenerateVideo(props: UseGenerateVideoProps) { }, []) const { startPolling, clearTimer } = useGenerationPolling({ - templateId: selectedTemplate, onProgress: handleProgress, onComplete: handleComplete, onFailed: handleFailed, @@ -90,16 +89,20 @@ export function useGenerateVideo(props: UseGenerateVideoProps) { const assetIds = props.materialMode === "auto" ? props.smartSelectedIds : props.selectedMaterials + // 封面 URL:优先 AI 生成缩略图,兜底用户上传 + const coverUrl = props.coverSettings?.thumbnail_url || props.coverSettings?.upload_url || "" + // 直接创建正式生成任务 - await createGenerationTask({ + const taskResp = await createGenerationTask({ template_id: selectedTemplate, asset_ids: assetIds, output_width: outputWidth, output_height: outputHeight, - cover_url: props.coverSettings?.upload_url || "", + cover_url: coverUrl, custom_title: props.titleSettings?.title || "", duration: props.duration || undefined, video_ratio: props.videoRatio, + ...(props.sourceEditPlanId ? { source_edit_plan_id: props.sourceEditPlanId } : {}), ...(props.titleSettings?.title ? { title_config: { @@ -116,7 +119,12 @@ export function useGenerateVideo(props: UseGenerateVideoProps) { : {}), }) - startPolling() + // 从创建响应直接拿 task_id,改用新接口轮询 + const taskId = taskResp.items?.[0]?.id + if (!taskId) { + throw new Error("创建任务成功但未返回任务 ID,请稍后在任务列表查看") + } + startPolling(taskId) } catch (err: unknown) { console.error("[handleGenerate] 生成失败:", err) setGenerating(false) diff --git a/apps/web/src/pages/generate/hooks/useStep2Materials.ts b/apps/web/src/pages/generate/hooks/useStep2Materials.ts index 0d5a8c406..e3fbb59c4 100755 --- a/apps/web/src/pages/generate/hooks/useStep2Materials.ts +++ b/apps/web/src/pages/generate/hooks/useStep2Materials.ts @@ -3,6 +3,7 @@ * 组合素材库加载 + 智能匹配两个子 Hook */ import { useCallback, useEffect, useRef } from "react" +import { updateEditPlan } from "@/api/template-editor" import { formatDuration } from "../utils/formatDuration" import { useMaterialLibrary } from "./step2-materials/useMaterialLibrary" import { useSmartMatch } from "./step2-materials/useSmartMatch" @@ -14,6 +15,8 @@ interface UseStep2MaterialsProps { onSelectedMaterialsChange: (ids: string[]) => void smartSelectedIds: string[] onSmartSelectedIdsChange: (ids: string[]) => void + /** 当前选中的模板/草稿 ID,用于自动保存 */ + selectedTemplate?: string } export function useStep2Materials({ @@ -23,6 +26,7 @@ export function useStep2Materials({ onSelectedMaterialsChange, smartSelectedIds, onSmartSelectedIdsChange, + selectedTemplate, }: UseStep2MaterialsProps) { const { libraries, selectedLibraryId, setSelectedLibraryId, materials, materialsLoading } = useMaterialLibrary() @@ -53,6 +57,27 @@ export function useStep2Materials({ handleSmartMatch() }, [selectedLibraryId, materialMode, materialsLoading, materials.items, handleSmartMatch]) + /* ── Step2 选择素材后自动保存草稿(防抖 500ms,失败静默) ── */ + const saveTimerRef = useRef>() + useEffect(() => { + if (!selectedTemplate) return + if (saveTimerRef.current) clearTimeout(saveTimerRef.current) + saveTimerRef.current = setTimeout(() => { + const ids = materialMode === "auto" ? smartSelectedIds : selectedMaterials + updateEditPlan(selectedTemplate, { config: { asset_ids: ids } }).catch((err) => { + console.warn("[Step2] 自动保存素材草稿失败:", err) + }) + }, 500) + return () => { + if (saveTimerRef.current) clearTimeout(saveTimerRef.current) + } + }, [selectedTemplate, materialMode, selectedMaterials, smartSelectedIds]) + useEffect(() => { + return () => { + if (saveTimerRef.current) clearTimeout(saveTimerRef.current) + } + }, []) + /* ── 手动选择素材 ── */ const handleToggleMaterial = useCallback( (materialId: string) => { diff --git a/apps/web/src/pages/generate/hooks/useStep4Title/index.ts b/apps/web/src/pages/generate/hooks/useStep4Title/index.ts index 3f6aaac81..497ed9477 100644 --- a/apps/web/src/pages/generate/hooks/useStep4Title/index.ts +++ b/apps/web/src/pages/generate/hooks/useStep4Title/index.ts @@ -1,6 +1,7 @@ import { useEffect, useRef } from "react" import { useQuery } from "@tanstack/react-query" import { getTitles } from "@/api/titles" +import { updateEditPlan } from "@/api/template-editor" import type { TitleSettings } from "../../types" import { useAiTitleGenerator } from "./useAiTitleGenerator" import { useTitleStyleUpdaters } from "./useTitleStyleUpdaters" @@ -8,13 +9,19 @@ import { useTitleStyleUpdaters } from "./useTitleStyleUpdaters" interface UseStep4TitleProps { titleSettings: TitleSettings onTitleSettingsChange: (settings: TitleSettings) => void + /** 当前选中的模板/草稿 ID,用于自动保存 */ + selectedTemplate?: string } /** * Step 4 标题设置 Hook * 封装 AI 标题生成、标题样式设置等逻辑 */ -export function useStep4Title({ titleSettings, onTitleSettingsChange }: UseStep4TitleProps) { +export function useStep4Title({ + titleSettings, + onTitleSettingsChange, + selectedTemplate, +}: UseStep4TitleProps) { // 标题库数据 const { data: userTitles = [] } = useQuery({ queryKey: ["titles"], @@ -42,6 +49,49 @@ export function useStep4Title({ titleSettings, onTitleSettingsChange }: UseStep4 const prevAiAutoSelect = useRef(titleSettings.aiAutoSelect) const isFirstMount = useRef(true) + /* ── Step4 标题内容/样式变化后自动保存草稿(防抖 800ms,失败静默) ── */ + const saveTimerRef = useRef>() + useEffect(() => { + if (!selectedTemplate) return + if (saveTimerRef.current) clearTimeout(saveTimerRef.current) + saveTimerRef.current = setTimeout(() => { + updateEditPlan(selectedTemplate, { + config: { + title: { + text: titleSettings.title, + font: titleSettings.font, + font_size: titleSettings.size, + color: titleSettings.color, + position: titleSettings.position, + bold: titleSettings.bold, + stroke: titleSettings.stroke, + shadow: titleSettings.shadow, + }, + }, + }).catch((err) => { + console.warn("[Step4] 自动保存标题草稿失败:", err) + }) + }, 800) + return () => { + if (saveTimerRef.current) clearTimeout(saveTimerRef.current) + } + }, [ + selectedTemplate, + titleSettings.title, + titleSettings.font, + titleSettings.size, + titleSettings.color, + titleSettings.position, + titleSettings.bold, + titleSettings.stroke, + titleSettings.shadow, + ]) + useEffect(() => { + return () => { + if (saveTimerRef.current) clearTimeout(saveTimerRef.current) + } + }, []) + // 当 AI 自动选择开关打开时,自动生成/选择一个标题填入 // 首次挂载时如果开关已经是 true 且无标题,也需要触发 useEffect(() => { diff --git a/apps/web/src/pages/generate/hooks/useStep6Cover.ts b/apps/web/src/pages/generate/hooks/useStep6Cover.ts index 2cec825cf..f86141680 100644 --- a/apps/web/src/pages/generate/hooks/useStep6Cover.ts +++ b/apps/web/src/pages/generate/hooks/useStep6Cover.ts @@ -97,6 +97,20 @@ export function useStep6Cover({ const response = await generateCover(selectedTemplate, { asset_ids: assetIds, cover_type: "ai_frame", + ...(titleSettings?.title + ? { + title_config: { + text: titleSettings.title, + font: titleSettings.font, + font_size: titleSettings.size, + font_color: titleSettings.color, + position: titleSettings.position, + bold: titleSettings.bold, + stroke: titleSettings.stroke, + shadow: titleSettings.shadow, + }, + } + : {}), }) clearTimeout(timeoutId) const thumbnailUrl = response.cover?.image_url || "" @@ -205,6 +219,20 @@ export function useStep6Cover({ const retryResp = await generateCover(selectedTemplate, { asset_ids: assetIds, cover_type: "ai_frame", + ...(titleSettings?.title + ? { + title_config: { + text: titleSettings.title, + font: titleSettings.font, + font_size: titleSettings.size, + font_color: titleSettings.color, + position: titleSettings.position, + bold: titleSettings.bold, + stroke: titleSettings.stroke, + shadow: titleSettings.shadow, + }, + } + : {}), }) const retryUrl = retryResp.cover?.image_url || "" if (retryUrl) { diff --git a/apps/web/src/test/api/bgm.test.ts b/apps/web/src/test/api/bgm.test.ts index 3817c6dfa..3f4b23c25 100644 --- a/apps/web/src/test/api/bgm.test.ts +++ b/apps/web/src/test/api/bgm.test.ts @@ -32,7 +32,7 @@ describe("bgm API", () => { describe("getBgmPresets", () => { it("should resolve successfully", async () => { - await expect(getBgmPresets("test-params?")).resolves.not.toThrow() + await expect(getBgmPresets("test-template", { category: "test" })).resolves.not.toThrow() }) it("should reject on API error", async () => { @@ -42,7 +42,7 @@ describe("bgm API", () => { mockDelete.mockRejectedValue(new Error("Network error")) mockPatch.mockRejectedValue(new Error("Network error")) - await expect(getBgmPresets("test-params?")).rejects.toThrow() + await expect(getBgmPresets("test-template", { category: "test" })).rejects.toThrow() }) }) }) diff --git a/apps/web/src/test/api/edit-plans.test.ts b/apps/web/src/test/api/edit-plans.test.ts index e2327dd36..2186497dc 100644 --- a/apps/web/src/test/api/edit-plans.test.ts +++ b/apps/web/src/test/api/edit-plans.test.ts @@ -1,16 +1,12 @@ import { describe, expect, it, vi, beforeEach } from "vitest" import { - getEditPlans, getEditPlan, - createEditPlan, updateEditPlan, - deleteEditPlan, generateEditPlan, getGenerationStatus, aiRecommendClips, getEditPlanGenerations, getGenerationTaskResults, - cancelGeneration, getEditPlanClips, getEditPlanClip, createEditPlanClip, @@ -19,7 +15,6 @@ import { reorderEditPlanClips, batchDeleteEditPlanClips, createClipsFromAssets, - copyEditPlan, getMediaAssets, getMediaAsset, } from "@/api/template-editor" @@ -53,22 +48,6 @@ describe("editPlans API", () => { mockPatch.mockResolvedValue({ data: { success: true, items: [] } }) }) - describe("getEditPlans", () => { - it("should resolve successfully", async () => { - await expect(getEditPlans("test-params?")).resolves.not.toThrow() - }) - - it("should reject on API error", async () => { - mockGet.mockRejectedValue(new Error("Network error")) - mockPost.mockRejectedValue(new Error("Network error")) - mockPut.mockRejectedValue(new Error("Network error")) - mockDelete.mockRejectedValue(new Error("Network error")) - mockPatch.mockRejectedValue(new Error("Network error")) - - await expect(getEditPlans("test-params?")).rejects.toThrow() - }) - }) - describe("getEditPlan", () => { it("should resolve successfully", async () => { await expect(getEditPlan("test-planId")).resolves.not.toThrow() @@ -85,22 +64,6 @@ describe("editPlans API", () => { }) }) - describe("createEditPlan", () => { - it("should resolve successfully", async () => { - await expect(createEditPlan({ name: "test-item" })).resolves.not.toThrow() - }) - - it("should reject on API error", async () => { - mockGet.mockRejectedValue(new Error("Network error")) - mockPost.mockRejectedValue(new Error("Network error")) - mockPut.mockRejectedValue(new Error("Network error")) - mockDelete.mockRejectedValue(new Error("Network error")) - mockPatch.mockRejectedValue(new Error("Network error")) - - await expect(createEditPlan({ name: "test-item" })).rejects.toThrow() - }) - }) - describe("updateEditPlan", () => { it("should resolve successfully", async () => { await expect(updateEditPlan("test-planId")).resolves.not.toThrow() @@ -117,22 +80,6 @@ describe("editPlans API", () => { }) }) - describe("deleteEditPlan", () => { - it("should resolve successfully", async () => { - await expect(deleteEditPlan("test-planId")).resolves.not.toThrow() - }) - - it("should reject on API error", async () => { - mockGet.mockRejectedValue(new Error("Network error")) - mockPost.mockRejectedValue(new Error("Network error")) - mockPut.mockRejectedValue(new Error("Network error")) - mockDelete.mockRejectedValue(new Error("Network error")) - mockPatch.mockRejectedValue(new Error("Network error")) - - await expect(deleteEditPlan("test-planId")).rejects.toThrow() - }) - }) - describe("generateEditPlan", () => { it("should resolve successfully", async () => { await expect(generateEditPlan("test-planId")).resolves.not.toThrow() @@ -213,22 +160,6 @@ describe("editPlans API", () => { }) }) - describe("cancelGeneration", () => { - it("should resolve successfully", async () => { - await expect(cancelGeneration("test-planId")).resolves.not.toThrow() - }) - - it("should reject on API error", async () => { - mockGet.mockRejectedValue(new Error("Network error")) - mockPost.mockRejectedValue(new Error("Network error")) - mockPut.mockRejectedValue(new Error("Network error")) - mockDelete.mockRejectedValue(new Error("Network error")) - mockPatch.mockRejectedValue(new Error("Network error")) - - await expect(cancelGeneration("test-planId")).rejects.toThrow() - }) - }) - describe("getEditPlanClips", () => { it("should resolve successfully", async () => { await expect(getEditPlanClips("test-planId")).resolves.not.toThrow() @@ -357,22 +288,6 @@ describe("editPlans API", () => { }) }) - describe("copyEditPlan", () => { - it("should resolve successfully", async () => { - await expect(copyEditPlan("test-planId")).resolves.not.toThrow() - }) - - it("should reject on API error", async () => { - mockGet.mockRejectedValue(new Error("Network error")) - mockPost.mockRejectedValue(new Error("Network error")) - mockPut.mockRejectedValue(new Error("Network error")) - mockDelete.mockRejectedValue(new Error("Network error")) - mockPatch.mockRejectedValue(new Error("Network error")) - - await expect(copyEditPlan("test-planId")).rejects.toThrow() - }) - }) - describe("getMediaAssets", () => { it("should resolve successfully", async () => { await expect(getMediaAssets("test-libraryId?")).resolves.not.toThrow() diff --git a/apps/web/src/test/api/templates.test.ts b/apps/web/src/test/api/templates.test.ts index 973093483..348c05bfe 100644 --- a/apps/web/src/test/api/templates.test.ts +++ b/apps/web/src/test/api/templates.test.ts @@ -5,7 +5,6 @@ import { getTemplate, toggleFavoriteTemplate, copyTemplate, - generateFromTemplate, } from "@/api/templates" const mockGet = vi.fn() @@ -117,19 +116,4 @@ describe("templates API", () => { }) }) - describe("generateFromTemplate", () => { - it("should resolve successfully", async () => { - await expect(generateFromTemplate("test-templateId")).resolves.not.toThrow() - }) - - it("should reject on API error", async () => { - mockGet.mockRejectedValue(new Error("Network error")) - mockPost.mockRejectedValue(new Error("Network error")) - mockPut.mockRejectedValue(new Error("Network error")) - mockDelete.mockRejectedValue(new Error("Network error")) - mockPatch.mockRejectedValue(new Error("Network error")) - - await expect(generateFromTemplate("test-templateId")).rejects.toThrow() - }) - }) }) diff --git a/apps/web/src/test/pages/EditingPlanner.test.tsx b/apps/web/src/test/pages/EditingPlanner.test.tsx index bdfb5f3e9..5a14b4acc 100644 --- a/apps/web/src/test/pages/EditingPlanner.test.tsx +++ b/apps/web/src/test/pages/EditingPlanner.test.tsx @@ -150,12 +150,10 @@ vi.mock("@/api/template-editor", () => ({ getMediaAssets: vi.fn().mockResolvedValue({ items: [] }), getEditPlanGenerations: vi.fn().mockResolvedValue({ items: [] }), getEditPlan: vi.fn().mockResolvedValue({}), - createEditPlan: vi.fn().mockResolvedValue({ id: "test-plan" }), updateEditPlan: vi.fn().mockResolvedValue({}), generateEditPlan: vi.fn().mockResolvedValue({ task_id: "test-task" }), getGenerationStatus: vi.fn().mockResolvedValue({ status: "completed" }), getGenerationTaskResults: vi.fn().mockResolvedValue({ items: [] }), - cancelGeneration: vi.fn().mockResolvedValue({}), getEditPlanClips: vi.fn().mockResolvedValue({ items: [] }), createEditPlanClip: vi.fn().mockResolvedValue({}), batchDeleteEditPlanClips: vi.fn().mockResolvedValue({}), diff --git a/apps/web/src/test/pages/TemplateLibrary.test.tsx b/apps/web/src/test/pages/TemplateLibrary.test.tsx index 99c97942d..18a8f7fe5 100755 --- a/apps/web/src/test/pages/TemplateLibrary.test.tsx +++ b/apps/web/src/test/pages/TemplateLibrary.test.tsx @@ -109,7 +109,6 @@ vi.mock("@/api/templates", () => ({ getTemplate: vi.fn().mockResolvedValue({ items: [], total: 0, success: true }), toggleFavoriteTemplate: vi.fn().mockResolvedValue({ items: [], total: 0, success: true }), copyTemplate: vi.fn().mockResolvedValue({ items: [], total: 0, success: true }), - generateFromTemplate: vi.fn().mockResolvedValue({ items: [], total: 0, success: true }), })) vi.mock("@/pages/templates/TemplateLibrary.css", () => ({}))