Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0709ea4cd0 | |||
| 0a59902957 |
@@ -368,9 +368,7 @@ def create_generation_task(
|
||||
log_task_status=True,
|
||||
):
|
||||
created_tasks.append(task)
|
||||
# 只在首个成功任务时回写一次 plan.config,
|
||||
# 避免批量生成时循环覆盖 generation_task_id
|
||||
if request.source_edit_plan_id and len(created_tasks) == 1:
|
||||
if request.source_edit_plan_id:
|
||||
_writeback_edit_plan_config(
|
||||
plan_id=request.source_edit_plan_id,
|
||||
task_id=task.id,
|
||||
|
||||
@@ -4,20 +4,11 @@
|
||||
import apiClient from "../client"
|
||||
import type { BgmPreset, BgmPresetsQuery } from "./types"
|
||||
|
||||
/**
|
||||
* 获取 BGM 预设列表
|
||||
* @param templateId 模板/草稿 ID
|
||||
* @param params 分类/关键词筛选
|
||||
*/
|
||||
export const getBgmPresets = async (
|
||||
templateId: string,
|
||||
params?: BgmPresetsQuery,
|
||||
): Promise<BgmPreset[]> => {
|
||||
/** 获取 BGM 预设列表 */
|
||||
export const getBgmPresets = async (params?: BgmPresetsQuery): Promise<BgmPreset[]> => {
|
||||
const searchParams: Record<string, string> = {}
|
||||
if (params?.category) searchParams.category = params.category
|
||||
if (params?.keyword) searchParams.keyword = params.keyword
|
||||
const res = await apiClient.get(`/templates/${templateId}/editor/bgm/presets`, {
|
||||
params: searchParams,
|
||||
})
|
||||
const res = await apiClient.get("/bgm/presets", { params: searchParams })
|
||||
return res.data?.data ?? res.data ?? []
|
||||
}
|
||||
|
||||
@@ -1,22 +1,9 @@
|
||||
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 {
|
||||
|
||||
@@ -6,7 +6,6 @@ import apiClient from "../client"
|
||||
import type {
|
||||
CreateGenerationTaskRequest,
|
||||
CreateGenerationTaskResponse,
|
||||
GenerationTaskDetail,
|
||||
TaskItem,
|
||||
TaskListParams,
|
||||
TaskListResponse,
|
||||
@@ -20,12 +19,6 @@ export const createGenerationTask = async (
|
||||
return data
|
||||
}
|
||||
|
||||
/** 获取单个生成任务详情(轮询用) */
|
||||
export const getGenerationTask = async (taskId: string): Promise<GenerationTaskDetail> => {
|
||||
const { data } = await apiClient.get<GenerationTaskDetail>(`/generation/tasks/${taskId}`)
|
||||
return data
|
||||
}
|
||||
|
||||
/** 获取任务列表(支持分页和筛选) */
|
||||
export const getTasks = async (params?: TaskListParams): Promise<TaskListResponse> => {
|
||||
const { data } = await apiClient.get<TaskListResponse>("/tasks", {
|
||||
|
||||
@@ -82,12 +82,10 @@ export interface CreateGenerationTaskRequest {
|
||||
stroke?: boolean
|
||||
shadow?: boolean
|
||||
}
|
||||
/** 关联的草稿 ID(编辑流程数据链路用) */
|
||||
source_edit_plan_id?: string
|
||||
}
|
||||
|
||||
/** 单个生成任务详情(对齐后端 GenerationTaskResponse) */
|
||||
export interface GenerationTaskDetail {
|
||||
/** 创建生成任务响应(对齐后端 GenerationTaskResponse) */
|
||||
export interface CreateGenerationTaskResponse {
|
||||
id: string
|
||||
project_id: string
|
||||
asset_library_id: string
|
||||
@@ -97,18 +95,8 @@ export interface GenerationTaskDetail {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -4,29 +4,51 @@
|
||||
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<EditPlanListResponse> {
|
||||
const response = await apiClient.get<EditPlanListResponse>("/templates/drafts", {
|
||||
params,
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 获取单个模板草稿 */
|
||||
export async function getEditPlan(templateId: string): Promise<EditPlan> {
|
||||
const response = await apiClient.get(`/templates/${templateId}/editor`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 更新模板草稿(支持传入 AbortSignal 用于自动保存竞态取消) */
|
||||
/** 创建模板草稿 */
|
||||
export async function createEditPlan(data: CreateEditPlanRequest): Promise<EditPlan> {
|
||||
const response = await apiClient.post("/templates/drafts", data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 更新模板草稿 */
|
||||
export async function updateEditPlan(
|
||||
templateId: string,
|
||||
data: UpdateEditPlanRequest,
|
||||
signal?: AbortSignal,
|
||||
): Promise<EditPlan> {
|
||||
const response = await apiClient.put(`/templates/${templateId}/editor`, data, { signal })
|
||||
const response = await apiClient.put(`/templates/${templateId}/editor`, data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 删除模板草稿 */
|
||||
export async function deleteEditPlan(templateId: string): Promise<void> {
|
||||
await apiClient.delete(`/templates/${templateId}/editor`)
|
||||
}
|
||||
|
||||
/** 触发生成 */
|
||||
export async function generateEditPlan(templateId: string): Promise<GenerateResponse> {
|
||||
const response = await apiClient.post(`/templates/${templateId}/editor/generate`)
|
||||
@@ -50,3 +72,20 @@ export async function getGenerationTaskResults(taskId: string): Promise<Generate
|
||||
const response = await apiClient.get(`/generation/tasks/${taskId}/results`)
|
||||
return response.data.items || response.data || []
|
||||
}
|
||||
|
||||
/** 取消生成任务 */
|
||||
export async function cancelGeneration(templateId: string): Promise<void> {
|
||||
await apiClient.post(`/templates/${templateId}/editor/cancel`)
|
||||
}
|
||||
|
||||
/** 复制模板草稿(含所有片段配置) */
|
||||
export async function copyEditPlan(
|
||||
templateId: string,
|
||||
data?: CopyEditPlanRequest,
|
||||
): Promise<EditPlan> {
|
||||
const response = await apiClient.post<EditPlan>(
|
||||
`/templates/${templateId}/editor/copy`,
|
||||
data || {},
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
@@ -15,7 +15,10 @@ export type {
|
||||
EditPlanSegment,
|
||||
EditPlanConfig,
|
||||
EditPlan,
|
||||
CreateEditPlanRequest,
|
||||
UpdateEditPlanRequest,
|
||||
EditPlanListParams,
|
||||
EditPlanListResponse,
|
||||
GenerateResponse,
|
||||
EditPlanGeneration,
|
||||
ClipStatusItem,
|
||||
@@ -34,6 +37,7 @@ export type {
|
||||
ClipReorderResponse,
|
||||
ClipBatchDeleteResponse,
|
||||
ClipsFromAssetsResponse,
|
||||
CopyEditPlanRequest,
|
||||
TransitionEffect,
|
||||
MediaAsset,
|
||||
} from "./types"
|
||||
@@ -49,12 +53,17 @@ export {
|
||||
|
||||
// 模板草稿 CRUD + 生成
|
||||
export {
|
||||
getEditPlans,
|
||||
getEditPlan,
|
||||
createEditPlan,
|
||||
updateEditPlan,
|
||||
deleteEditPlan,
|
||||
generateEditPlan,
|
||||
getGenerationStatus,
|
||||
getEditPlanGenerations,
|
||||
getGenerationTaskResults,
|
||||
cancelGeneration,
|
||||
copyEditPlan,
|
||||
} from "./editPlans"
|
||||
|
||||
// 片段 CRUD + 批量操作
|
||||
|
||||
@@ -118,17 +118,6 @@ 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 */
|
||||
|
||||
@@ -9,6 +9,8 @@ export type {
|
||||
TemplateSegment,
|
||||
TemplateListParams,
|
||||
TemplateListResponse,
|
||||
GenerateFromTemplateRequest,
|
||||
GenerateFromTemplateResponse,
|
||||
CopyTemplateResponse,
|
||||
} from "./types"
|
||||
|
||||
@@ -22,4 +24,5 @@ export {
|
||||
getTemplate,
|
||||
toggleFavoriteTemplate,
|
||||
copyTemplate,
|
||||
generateFromTemplate,
|
||||
} from "./templates"
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
import apiClient from "../client"
|
||||
import type {
|
||||
CopyTemplateResponse,
|
||||
GenerateFromTemplateRequest,
|
||||
GenerateFromTemplateResponse,
|
||||
TemplateItem,
|
||||
TemplateListParams,
|
||||
TemplateListResponse,
|
||||
@@ -43,3 +45,15 @@ export const copyTemplate = async (templateId: string): Promise<CopyTemplateResp
|
||||
const response = await apiClient.post<CopyTemplateResponse>(`/templates/${templateId}/copy`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 从模板生成 */
|
||||
export const generateFromTemplate = async (
|
||||
templateId: string,
|
||||
data?: GenerateFromTemplateRequest,
|
||||
): Promise<GenerateFromTemplateResponse> => {
|
||||
const response = await apiClient.post<GenerateFromTemplateResponse>(
|
||||
`/templates/${templateId}/generate`,
|
||||
data,
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
@@ -16,17 +16,9 @@ interface BgmSelectorProps {
|
||||
onClose: () => void
|
||||
config: BgmMixConfig
|
||||
onChange: (config: BgmMixConfig) => void
|
||||
/** 模板/草稿 ID,用于请求 BGM 预设 */
|
||||
templateId?: string
|
||||
}
|
||||
|
||||
const BgmSelector: React.FC<BgmSelectorProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
config,
|
||||
onChange,
|
||||
templateId,
|
||||
}) => {
|
||||
const BgmSelector: React.FC<BgmSelectorProps> = ({ open, onClose, config, onChange }) => {
|
||||
const {
|
||||
presets,
|
||||
loading,
|
||||
@@ -38,7 +30,7 @@ const BgmSelector: React.FC<BgmSelectorProps> = ({
|
||||
loadPresets,
|
||||
handlePreview,
|
||||
stopPreview,
|
||||
} = useBgmSelector(open, templateId)
|
||||
} = useBgmSelector(open)
|
||||
|
||||
/* ── 选中 BGM ── */
|
||||
const handleSelect = useCallback(
|
||||
|
||||
@@ -19,7 +19,7 @@ export const CATEGORY_LIST: {
|
||||
* BGM 选择器数据与交互 Hook
|
||||
* 封装列表加载、搜索、分类筛选、试听播放逻辑
|
||||
*/
|
||||
export function useBgmSelector(open: boolean, templateId?: string) {
|
||||
export function useBgmSelector(open: boolean) {
|
||||
const [presets, setPresets] = useState<BgmPreset[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [activeCategory, setActiveCategory] = useState<BgmCategory | "all">("all")
|
||||
@@ -30,23 +30,19 @@ export function useBgmSelector(open: boolean, templateId?: string) {
|
||||
|
||||
/* ── 加载 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(templateId, params)
|
||||
const data = await getBgmPresets(params)
|
||||
setPresets(data)
|
||||
} catch {
|
||||
message.error("加载 BGM 列表失败")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [activeCategory, keyword, templateId])
|
||||
}, [activeCategory, keyword])
|
||||
|
||||
useEffect(() => {
|
||||
if (open) loadPresets()
|
||||
|
||||
@@ -171,7 +171,6 @@ const GeneratePage: React.FC = () => {
|
||||
autoSubtitles,
|
||||
bgm,
|
||||
generateCount,
|
||||
sourceEditPlanId: editPlanId,
|
||||
})
|
||||
|
||||
/* ================================================================
|
||||
|
||||
@@ -141,7 +141,6 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
|
||||
onSelectedMaterialsChange={onSelectedMaterialsChange}
|
||||
smartSelectedIds={smartSelectedIds}
|
||||
onSmartSelectedIdsChange={onSmartSelectedIdsChange}
|
||||
selectedTemplate={selectedTemplate}
|
||||
/>
|
||||
)
|
||||
case 3:
|
||||
@@ -157,7 +156,6 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
|
||||
<Step4TitleSettings
|
||||
titleSettings={titleSettings}
|
||||
onTitleSettingsChange={onTitleSettingsChange}
|
||||
selectedTemplate={selectedTemplate}
|
||||
/>
|
||||
)
|
||||
case 5:
|
||||
|
||||
@@ -15,8 +15,6 @@ interface Step2MaterialSelectProps {
|
||||
onSelectedMaterialsChange: (ids: string[]) => void
|
||||
smartSelectedIds: string[]
|
||||
onSmartSelectedIdsChange: (ids: string[]) => void
|
||||
/** 当前选中的模板/草稿 ID,用于自动保存 */
|
||||
selectedTemplate?: string
|
||||
}
|
||||
|
||||
const Step2MaterialSelect: React.FC<Step2MaterialSelectProps> = (props) => {
|
||||
|
||||
@@ -12,8 +12,6 @@ import AiTitleGenerator from "./title/AiTitleGenerator"
|
||||
interface Step4TitleSettingsProps {
|
||||
titleSettings: TitleSettings
|
||||
onTitleSettingsChange: (settings: TitleSettings) => void
|
||||
/** 当前选中的模板/草稿 ID,用于自动保存 */
|
||||
selectedTemplate?: string
|
||||
}
|
||||
|
||||
const Step4TitleSettings: React.FC<Step4TitleSettingsProps> = (props) => {
|
||||
|
||||
@@ -19,8 +19,6 @@ export interface UseGenerateVideoProps {
|
||||
autoSubtitles: boolean
|
||||
bgm: boolean
|
||||
generateCount: number
|
||||
/** 当前草稿 ID(URL 参数 edit_plan_id,用于后端回写任务关联) */
|
||||
sourceEditPlanId?: string | null
|
||||
}
|
||||
|
||||
/** 生成阶段 */
|
||||
|
||||
@@ -1,146 +1,87 @@
|
||||
import { useRef, useCallback } from "react"
|
||||
import { message } from "antd"
|
||||
import axios from "axios"
|
||||
import { getGenerationTask } from "@/api/tasks/tasks"
|
||||
import { getGenerationTaskResults } from "@/api/template-editor"
|
||||
import { getGenerationStatus, 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
|
||||
}
|
||||
|
||||
/** 最大连续错误次数(仅对可重试错误),超过后终止轮询 */
|
||||
const MAX_RETRYABLE_ERRORS = 10
|
||||
/** 获取结果的最大重试次数 */
|
||||
const MAX_RESULTS_RETRIES = 3
|
||||
|
||||
/**
|
||||
* 生成状态轮询 Hook(v2 — 改用 /generation/tasks/{task_id})
|
||||
*
|
||||
* 旧版轮询 GET /templates/{id}/editor/generation-status 依赖 plan 维度状态,
|
||||
* 在编辑流程数据链路断裂时拿不到 task_id。新版直接使用 POST /generation/tasks
|
||||
* 返回的 task_id 轮询任务详情,不再依赖 plan。
|
||||
*
|
||||
* 错误处理:
|
||||
* - 4xx(尤其 404)视为不可恢复,立即 onFailed,不再重试
|
||||
* - 5xx / 网络错误重试,最多连续 MAX_RETRYABLE_ERRORS 次
|
||||
* - 任务完成后获取结果失败会重试 MAX_RESULTS_RETRIES 次,仍失败则 onFailed
|
||||
* 生成状态轮询 Hook
|
||||
* 轮询生成状态,更新进度,处理完成/失败
|
||||
*/
|
||||
export const useGenerationPolling = ({
|
||||
templateId,
|
||||
onProgress,
|
||||
onComplete,
|
||||
onFailed,
|
||||
}: UseGenerationPollingOptions) => {
|
||||
const progressTimer = useRef<ReturnType<typeof setTimeout>>()
|
||||
const cancelledRef = useRef(false)
|
||||
|
||||
const clearTimer = useCallback(() => {
|
||||
cancelledRef.current = true
|
||||
if (progressTimer.current) {
|
||||
clearTimeout(progressTimer.current)
|
||||
progressTimer.current = undefined
|
||||
}
|
||||
}, [])
|
||||
|
||||
/** 任务完成后拉取结果列表,带重试 */
|
||||
const fetchResultsWithRetry = useCallback(
|
||||
async (taskId: string, attempt = 0): Promise<unknown[] | null> => {
|
||||
const startPolling = useCallback(() => {
|
||||
const poll = async () => {
|
||||
try {
|
||||
return await getGenerationTaskResults(taskId)
|
||||
} catch (err) {
|
||||
if (cancelledRef.current) return null
|
||||
console.error(`[获取生成结果失败] 第 ${attempt + 1} 次`, err)
|
||||
if (attempt < MAX_RESULTS_RETRIES - 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000 * (attempt + 1)))
|
||||
return fetchResultsWithRetry(taskId, attempt + 1)
|
||||
}
|
||||
return null
|
||||
}
|
||||
},
|
||||
[],
|
||||
)
|
||||
const data = await getGenerationStatus(templateId)
|
||||
|
||||
const startPolling = useCallback(
|
||||
(taskId: string) => {
|
||||
cancelledRef.current = false
|
||||
let consecutiveErrors = 0
|
||||
|
||||
const poll = async () => {
|
||||
if (cancelledRef.current) return
|
||||
try {
|
||||
const task = await getGenerationTask(taskId)
|
||||
consecutiveErrors = 0
|
||||
|
||||
if (task.status === "completed") {
|
||||
onProgress(100)
|
||||
const videos = await fetchResultsWithRetry(taskId)
|
||||
if (cancelledRef.current) return
|
||||
if (videos === null) {
|
||||
const errorMsg = "视频已生成,但获取结果列表失败,请稍后在任务列表查看"
|
||||
console.error("[生成结果获取失败] taskId:", taskId)
|
||||
onFailed(errorMsg)
|
||||
message.error(errorMsg)
|
||||
return
|
||||
if (data.plan_status === "completed") {
|
||||
onProgress(100)
|
||||
// 获取生成的视频结果
|
||||
let videos: unknown[] = []
|
||||
if (data.generation_task_id) {
|
||||
try {
|
||||
videos = await getGenerationTaskResults(data.generation_task_id)
|
||||
} catch (err) {
|
||||
console.error("[获取生成结果失败]", err)
|
||||
}
|
||||
onComplete(videos)
|
||||
message.success("视频生成完成!")
|
||||
return
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// 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) {
|
||||
if (cancelledRef.current) return
|
||||
console.error("[轮询出错] taskId:", taskId, pollErr)
|
||||
|
||||
// 4xx 不可恢复,立即失败
|
||||
const status = axios.isAxiosError(pollErr) ? pollErr.response?.status : undefined
|
||||
if (status && status >= 400 && status < 500) {
|
||||
const msg =
|
||||
(axios.isAxiosError(pollErr) &&
|
||||
(pollErr.response?.data as { detail?: string; message?: string } | undefined)
|
||||
?.detail) ||
|
||||
(axios.isAxiosError(pollErr) &&
|
||||
(pollErr.response?.data as { detail?: string; message?: string } | undefined)
|
||||
?.message) ||
|
||||
`查询任务失败 (${status})`
|
||||
const errorMsg = safeExtractError(msg)
|
||||
onFailed(errorMsg)
|
||||
message.error(errorMsg)
|
||||
return
|
||||
}
|
||||
|
||||
consecutiveErrors += 1
|
||||
if (consecutiveErrors >= MAX_RETRYABLE_ERRORS) {
|
||||
const errorMsg = "任务状态查询连续失败,请稍后在任务列表查看结果"
|
||||
onFailed(errorMsg)
|
||||
message.error(errorMsg)
|
||||
return
|
||||
}
|
||||
progressTimer.current = setTimeout(poll, 3000)
|
||||
onComplete(videos)
|
||||
message.success("视频生成完成!")
|
||||
return
|
||||
}
|
||||
if (data.plan_status === "failed") {
|
||||
const dataAny = data as unknown as Record<string, unknown>
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
progressTimer.current = setTimeout(poll, 1500)
|
||||
},
|
||||
[onProgress, onComplete, onFailed, fetchResultsWithRetry],
|
||||
)
|
||||
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))
|
||||
|
||||
progressTimer.current = setTimeout(poll, 2000)
|
||||
} catch (pollErr) {
|
||||
console.error("[轮询出错] templateId:", templateId, pollErr)
|
||||
progressTimer.current = setTimeout(poll, 3000)
|
||||
}
|
||||
}
|
||||
|
||||
progressTimer.current = setTimeout(poll, 2000)
|
||||
}, [templateId, onProgress, onComplete, onFailed])
|
||||
|
||||
return { startPolling, clearTimer }
|
||||
}
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
/**
|
||||
* 草稿自动保存工具 Hook
|
||||
*
|
||||
* 背景:后端 PUT /templates/{id}/editor 的 config 是「整体替换」语义,
|
||||
* 直接发送 { config: { asset_ids } } 会把 title 等其他字段覆盖掉。
|
||||
* 本 Hook 统一执行「GET 当前 config → 浅合并新字段 → PUT 回去」,
|
||||
* 并用串行队列 + AbortController 保证:
|
||||
* - 同一时刻只有一个保存请求在飞
|
||||
* - 快速连续变化时只提交最后一次
|
||||
* - 组件卸载时取消未完成请求
|
||||
* - 保存失败时保留补丁,自动重试(指数退避,最多 5 次)
|
||||
*
|
||||
* 保存失败只 console.warn,不弹窗、不阻塞。
|
||||
*/
|
||||
import { useCallback, useEffect, useRef } from "react"
|
||||
import { getEditPlan, updateEditPlan } from "@/api/template-editor"
|
||||
|
||||
type ConfigPatch = Record<string, unknown>
|
||||
|
||||
/** 最大自动重试次数 */
|
||||
const MAX_RETRIES = 5
|
||||
/** 初始重试延迟(ms),每次翻倍 */
|
||||
const BASE_RETRY_DELAY = 1000
|
||||
|
||||
export function useDraftAutoSave(templateId?: string) {
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout>>()
|
||||
const abortRef = useRef<AbortController | null>(null)
|
||||
// 待合并的补丁队列(解决「保存进行中又来了新变化」)
|
||||
const pendingPatchRef = useRef<ConfigPatch | null>(null)
|
||||
const savingRef = useRef(false)
|
||||
const templateIdRef = useRef(templateId)
|
||||
templateIdRef.current = templateId
|
||||
|
||||
const flush = useCallback(async (retryCount = 0) => {
|
||||
const tid = templateIdRef.current
|
||||
if (!tid) return
|
||||
// 已有保存在飞:把新补丁暂存,等当前请求结束后再合并一次
|
||||
if (savingRef.current) return
|
||||
|
||||
// 快照当前补丁,但先不清空 —— 成功后才清除,失败时保留以便重试
|
||||
const patchToSave = pendingPatchRef.current
|
||||
if (!patchToSave) {
|
||||
savingRef.current = false
|
||||
return
|
||||
}
|
||||
savingRef.current = true
|
||||
|
||||
const controller = new AbortController()
|
||||
abortRef.current = controller
|
||||
try {
|
||||
// 1. 读当前 config(拿最新,避免覆盖别人/别的步骤写入的字段)
|
||||
const current = await getEditPlan(tid)
|
||||
if (controller.signal.aborted) return
|
||||
const merged = { ...(current.config || {}), ...patchToSave }
|
||||
// 2. 写回完整合并后的 config
|
||||
await updateEditPlan(tid, { config: merged }, controller.signal)
|
||||
// 3. 保存成功才清除已保存的补丁
|
||||
// (保存期间可能有新补丁进来,只清除我们已经保存的部分)
|
||||
pendingPatchRef.current = null
|
||||
} catch (err) {
|
||||
const name = (err as { name?: string })?.name
|
||||
if (name === "CanceledError" || name === "AbortError") {
|
||||
// 组件卸载或新请求取消,不重试
|
||||
return
|
||||
}
|
||||
console.warn("[useDraftAutoSave] 自动保存草稿失败:", err)
|
||||
|
||||
// 保存失败:把本次尝试保存的补丁合并回 pendingPatchRef
|
||||
// (保存期间可能有新补丁,新补丁优先)
|
||||
pendingPatchRef.current = {
|
||||
...patchToSave,
|
||||
...(pendingPatchRef.current || {}),
|
||||
}
|
||||
|
||||
// 指数退避重试
|
||||
if (retryCount < MAX_RETRIES && !controller.signal.aborted) {
|
||||
const delay = BASE_RETRY_DELAY * Math.pow(2, retryCount)
|
||||
timerRef.current = setTimeout(() => {
|
||||
void flush(retryCount + 1)
|
||||
}, delay)
|
||||
}
|
||||
// 超过最大重试次数后,补丁仍保留在 pendingPatchRef 中,
|
||||
// 下次 scheduleSave 触发时会一起带上
|
||||
} finally {
|
||||
savingRef.current = false
|
||||
// 保存期间又积累了新变化(且不是在重试路径中),再触发一次
|
||||
if (pendingPatchRef.current && !controller.signal.aborted && retryCount === 0) {
|
||||
timerRef.current = setTimeout(() => {
|
||||
void flush()
|
||||
}, 50)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* 调度一次自动保存(防抖)
|
||||
* @param patch 要合并进 config 的局部字段
|
||||
* @param delay 防抖毫秒数
|
||||
*/
|
||||
const scheduleSave = useCallback(
|
||||
(patch: ConfigPatch, delay = 500) => {
|
||||
const tid = templateIdRef.current
|
||||
if (!tid) return
|
||||
if (timerRef.current) clearTimeout(timerRef.current)
|
||||
// 累计补丁(同一周期内多次变化合并成一次写入)
|
||||
pendingPatchRef.current = { ...(pendingPatchRef.current || {}), ...patch }
|
||||
timerRef.current = setTimeout(() => {
|
||||
void flush()
|
||||
}, delay)
|
||||
},
|
||||
[flush],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current)
|
||||
if (abortRef.current) abortRef.current.abort()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return { scheduleSave }
|
||||
}
|
||||
|
||||
export default useDraftAutoSave
|
||||
@@ -34,6 +34,7 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
}, [])
|
||||
|
||||
const { startPolling, clearTimer } = useGenerationPolling({
|
||||
templateId: selectedTemplate,
|
||||
onProgress: handleProgress,
|
||||
onComplete: handleComplete,
|
||||
onFailed: handleFailed,
|
||||
@@ -89,20 +90,16 @@ 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 || ""
|
||||
|
||||
// 直接创建正式生成任务
|
||||
const taskResp = await createGenerationTask({
|
||||
await createGenerationTask({
|
||||
template_id: selectedTemplate,
|
||||
asset_ids: assetIds,
|
||||
output_width: outputWidth,
|
||||
output_height: outputHeight,
|
||||
cover_url: coverUrl,
|
||||
cover_url: props.coverSettings?.upload_url || "",
|
||||
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: {
|
||||
@@ -119,12 +116,7 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
: {}),
|
||||
})
|
||||
|
||||
// 从创建响应直接拿 task_id,改用新接口轮询
|
||||
const taskId = taskResp.items?.[0]?.id
|
||||
if (!taskId) {
|
||||
throw new Error("创建任务成功但未返回任务 ID,请稍后在任务列表查看")
|
||||
}
|
||||
startPolling(taskId)
|
||||
startPolling()
|
||||
} catch (err: unknown) {
|
||||
console.error("[handleGenerate] 生成失败:", err)
|
||||
setGenerating(false)
|
||||
|
||||
@@ -6,7 +6,6 @@ import { useCallback, useEffect, useRef } from "react"
|
||||
import { formatDuration } from "../utils/formatDuration"
|
||||
import { useMaterialLibrary } from "./step2-materials/useMaterialLibrary"
|
||||
import { useSmartMatch } from "./step2-materials/useSmartMatch"
|
||||
import { useDraftAutoSave } from "./useDraftAutoSave"
|
||||
|
||||
interface UseStep2MaterialsProps {
|
||||
materialMode: "manual" | "auto"
|
||||
@@ -15,8 +14,6 @@ interface UseStep2MaterialsProps {
|
||||
onSelectedMaterialsChange: (ids: string[]) => void
|
||||
smartSelectedIds: string[]
|
||||
onSmartSelectedIdsChange: (ids: string[]) => void
|
||||
/** 当前选中的模板/草稿 ID,用于自动保存 */
|
||||
selectedTemplate?: string
|
||||
}
|
||||
|
||||
export function useStep2Materials({
|
||||
@@ -26,7 +23,6 @@ export function useStep2Materials({
|
||||
onSelectedMaterialsChange,
|
||||
smartSelectedIds,
|
||||
onSmartSelectedIdsChange,
|
||||
selectedTemplate,
|
||||
}: UseStep2MaterialsProps) {
|
||||
const { libraries, selectedLibraryId, setSelectedLibraryId, materials, materialsLoading } =
|
||||
useMaterialLibrary()
|
||||
@@ -57,14 +53,6 @@ export function useStep2Materials({
|
||||
handleSmartMatch()
|
||||
}, [selectedLibraryId, materialMode, materialsLoading, materials.items, handleSmartMatch])
|
||||
|
||||
/* ── Step2 选择素材后自动保存草稿(防抖 500ms,失败静默) ── */
|
||||
const { scheduleSave } = useDraftAutoSave(selectedTemplate)
|
||||
useEffect(() => {
|
||||
if (!selectedTemplate) return
|
||||
const ids = materialMode === "auto" ? smartSelectedIds : selectedMaterials
|
||||
scheduleSave({ asset_ids: ids }, 500)
|
||||
}, [selectedTemplate, materialMode, selectedMaterials, smartSelectedIds, scheduleSave])
|
||||
|
||||
/* ── 手动选择素材 ── */
|
||||
const handleToggleMaterial = useCallback(
|
||||
(materialId: string) => {
|
||||
|
||||
@@ -4,24 +4,17 @@ import { getTitles } from "@/api/titles"
|
||||
import type { TitleSettings } from "../../types"
|
||||
import { useAiTitleGenerator } from "./useAiTitleGenerator"
|
||||
import { useTitleStyleUpdaters } from "./useTitleStyleUpdaters"
|
||||
import { useDraftAutoSave } from "../useDraftAutoSave"
|
||||
|
||||
interface UseStep4TitleProps {
|
||||
titleSettings: TitleSettings
|
||||
onTitleSettingsChange: (settings: TitleSettings) => void
|
||||
/** 当前选中的模板/草稿 ID,用于自动保存 */
|
||||
selectedTemplate?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 4 标题设置 Hook
|
||||
* 封装 AI 标题生成、标题样式设置等逻辑
|
||||
*/
|
||||
export function useStep4Title({
|
||||
titleSettings,
|
||||
onTitleSettingsChange,
|
||||
selectedTemplate,
|
||||
}: UseStep4TitleProps) {
|
||||
export function useStep4Title({ titleSettings, onTitleSettingsChange }: UseStep4TitleProps) {
|
||||
// 标题库数据
|
||||
const { data: userTitles = [] } = useQuery({
|
||||
queryKey: ["titles"],
|
||||
@@ -49,38 +42,6 @@ export function useStep4Title({
|
||||
const prevAiAutoSelect = useRef(titleSettings.aiAutoSelect)
|
||||
const isFirstMount = useRef(true)
|
||||
|
||||
/* ── Step4 标题内容/样式变化后自动保存草稿(防抖 800ms,失败静默) ── */
|
||||
const { scheduleSave: scheduleTitleSave } = useDraftAutoSave(selectedTemplate)
|
||||
useEffect(() => {
|
||||
if (!selectedTemplate) return
|
||||
scheduleTitleSave(
|
||||
{
|
||||
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,
|
||||
},
|
||||
},
|
||||
800,
|
||||
)
|
||||
}, [
|
||||
selectedTemplate,
|
||||
titleSettings.title,
|
||||
titleSettings.font,
|
||||
titleSettings.size,
|
||||
titleSettings.color,
|
||||
titleSettings.position,
|
||||
titleSettings.bold,
|
||||
titleSettings.stroke,
|
||||
titleSettings.shadow,
|
||||
scheduleTitleSave,
|
||||
])
|
||||
|
||||
// 当 AI 自动选择开关打开时,自动生成/选择一个标题填入
|
||||
// 首次挂载时如果开关已经是 true 且无标题,也需要触发
|
||||
useEffect(() => {
|
||||
|
||||
@@ -97,20 +97,6 @@ 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 || ""
|
||||
@@ -219,20 +205,6 @@ 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) {
|
||||
|
||||
@@ -32,7 +32,7 @@ describe("bgm API", () => {
|
||||
|
||||
describe("getBgmPresets", () => {
|
||||
it("should resolve successfully", async () => {
|
||||
await expect(getBgmPresets("test-template", { category: "test" })).resolves.not.toThrow()
|
||||
await expect(getBgmPresets("test-params?")).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-template", { category: "test" })).rejects.toThrow()
|
||||
await expect(getBgmPresets("test-params?")).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest"
|
||||
import {
|
||||
getEditPlans,
|
||||
getEditPlan,
|
||||
createEditPlan,
|
||||
updateEditPlan,
|
||||
deleteEditPlan,
|
||||
generateEditPlan,
|
||||
getGenerationStatus,
|
||||
aiRecommendClips,
|
||||
getEditPlanGenerations,
|
||||
getGenerationTaskResults,
|
||||
cancelGeneration,
|
||||
getEditPlanClips,
|
||||
getEditPlanClip,
|
||||
createEditPlanClip,
|
||||
@@ -15,6 +19,7 @@ import {
|
||||
reorderEditPlanClips,
|
||||
batchDeleteEditPlanClips,
|
||||
createClipsFromAssets,
|
||||
copyEditPlan,
|
||||
getMediaAssets,
|
||||
getMediaAsset,
|
||||
} from "@/api/template-editor"
|
||||
@@ -48,6 +53,22 @@ 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()
|
||||
@@ -64,6 +85,22 @@ 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()
|
||||
@@ -80,6 +117,22 @@ 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()
|
||||
@@ -160,6 +213,22 @@ 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()
|
||||
@@ -288,6 +357,22 @@ 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()
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
getTemplate,
|
||||
toggleFavoriteTemplate,
|
||||
copyTemplate,
|
||||
generateFromTemplate,
|
||||
} from "@/api/templates"
|
||||
|
||||
const mockGet = vi.fn()
|
||||
@@ -115,4 +116,20 @@ describe("templates API", () => {
|
||||
await expect(copyTemplate("test-templateId")).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -150,10 +150,12 @@ 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({}),
|
||||
|
||||
@@ -109,6 +109,7 @@ 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", () => ({}))
|
||||
|
||||
Reference in New Issue
Block a user