From b4c5925291f1f195da1874553898776387b99c93 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Mon, 7 Sep 2026 23:18:43 +0800 Subject: [PATCH 1/3] =?UTF-8?q?wip:=20#1777=20=E5=89=8D=E7=AB=AF=E7=B4=A0?= =?UTF-8?q?=E6=9D=90=E5=BA=93kind=E8=BF=87=E6=BB=A4+=E5=A4=B1=E6=95=88?= =?UTF-8?q?=E6=A8=A1=E6=9D=BF=E5=9B=9E=E9=80=80=EF=BC=88=E5=BE=85=E9=AA=8C?= =?UTF-8?q?=E8=AF=81=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/src/api/assets/libraries.ts | 20 +- apps/web/src/api/client.ts | 13 ++ apps/web/src/api/editing-planner/templates.ts | 10 +- apps/web/src/api/template-editor/clips.ts | 11 +- apps/web/src/api/template-editor/editPlans.ts | 8 +- apps/web/src/pages/generate/GeneratePage.tsx | 2 + .../components/GenerateStepContent.tsx | 4 + .../components/Step2MaterialSelect.tsx | 31 ++- .../step2-materials/useMaterialLibrary.ts | 12 +- .../hooks/useGenerateFormState/index.ts | 10 +- .../useGenerateFormState/templateFallback.ts | 81 ++++++++ .../useTemplateSelection.ts | 85 ++++++++- .../pages/generate/hooks/useStep2Materials.ts | 19 +- .../pages/generate/templateFallback.test.ts | 133 +++++++++++++ .../generate/useMaterialLibrary.test.tsx | 76 ++++++++ .../generate/useTemplateSelection.test.tsx | 179 ++++++++++++++++++ 16 files changed, 660 insertions(+), 34 deletions(-) create mode 100644 apps/web/src/pages/generate/hooks/useGenerateFormState/templateFallback.ts create mode 100644 apps/web/src/test/pages/generate/templateFallback.test.ts create mode 100644 apps/web/src/test/pages/generate/useMaterialLibrary.test.tsx create mode 100644 apps/web/src/test/pages/generate/useTemplateSelection.test.tsx diff --git a/apps/web/src/api/assets/libraries.ts b/apps/web/src/api/assets/libraries.ts index 4d3327116..19f04d5b5 100644 --- a/apps/web/src/api/assets/libraries.ts +++ b/apps/web/src/api/assets/libraries.ts @@ -5,10 +5,22 @@ import apiClient from "../client" import { getOrCreateDefaultProject } from "../projects" import type { AssetLibraryItem } from "./types" -/** 获取当前用户的所有素材库 */ -export const getAssetLibraries = async (): Promise => { - const response = await apiClient.get("/asset-libraries") - return response.data.items || [] +/** + * 获取当前用户的素材库 + * + * @param kind 可选,按素材库类型过滤(video/voice/image)。 + * 后端 GET /asset-libraries 支持 kind 查询参数;这里同时在前端再按返回数据的 + * kind 字段兜底过滤一次,保证旧后端(忽略未知 query 参数)也不会把其他类型的库 + * 混进来(#1777:视频选择器只展示视频库)。 + */ +export const getAssetLibraries = async ( + kind?: AssetLibraryItem["kind"], +): Promise => { + const response = await apiClient.get<{ items?: AssetLibraryItem[] }>("/asset-libraries", { + params: kind ? { kind } : undefined, + }) + const items = response.data.items || [] + return kind ? items.filter((lib) => lib.kind === kind) : items } /** 创建素材库(自动获取或创建默认项目以提供 project_id) */ diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index df3f52e16..1d3ce3170 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -55,6 +55,19 @@ apiClient.interceptors.response.use( async (error: AxiosError<{ detail?: string; message?: string; msg?: string }>) => { const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean + /** + * 调用方自行处理错误提示时置 true:拦截器跳过全局 message 弹窗(#1777)。 + * 例如失效模板自动回退时,调用方会弹「原模板已失效,已自动切换」, + * 不再叠加后端原始错误文案。错误仍会 reject,不影响 catch 逻辑。 + */ + _silentErrorToast?: boolean + } + + // 调用方声明自行处理提示:标记为已展示,跳过下面所有全局 message 弹窗 + if (originalRequest?._silentErrorToast) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(error as any).__msgShown = true + return Promise.reject(error) } // 401 → 尝试刷新 Token diff --git a/apps/web/src/api/editing-planner/templates.ts b/apps/web/src/api/editing-planner/templates.ts index 2458de451..ea77b1f4f 100644 --- a/apps/web/src/api/editing-planner/templates.ts +++ b/apps/web/src/api/editing-planner/templates.ts @@ -12,17 +12,25 @@ import type { ListCategoriesResponse, } from "./types" -/** 获取模板列表 */ +/** 获取模板列表 + * + * valid_only=true 时请求后端仅返回已配置片段的模板(剪辑页选模板使用, + * 避免选中无片段配置的模板导致 from-assets 400,#1769/#1772); + * 后端尚未支持该参数时会忽略未知 query 字段,前端再按 segments/is_active 兜底过滤。 + * 模板编辑器/我的模板不传,可查看全部模板(含未配置片段的草稿)。 + */ export const getEditingTemplates = async (params?: { category?: string tag?: string skip?: number limit?: number + validOnly?: boolean }): Promise => { const response = await apiClient.get("/templates", { params: { skip: params?.skip ?? 0, limit: params?.limit ?? 50, + ...(params?.validOnly ? { valid_only: true } : {}), }, }) let list = response.data.items diff --git a/apps/web/src/api/template-editor/clips.ts b/apps/web/src/api/template-editor/clips.ts index b65c23972..c7e287298 100644 --- a/apps/web/src/api/template-editor/clips.ts +++ b/apps/web/src/api/template-editor/clips.ts @@ -91,7 +91,7 @@ export async function createClipsFromAssets( assetIds: string[], clipType = "main", requiredClipsCount?: number, - opts?: { signal?: AbortSignal }, + opts?: { signal?: AbortSignal; silentErrorToast?: boolean }, ): Promise { const body: Record = { asset_ids: assetIds, @@ -104,7 +104,14 @@ export async function createClipsFromAssets( const response = await apiClient.post( `/templates/${templateId}/editor/clips/from-assets`, body, - { timeout: 60000, signal: opts?.signal }, + { + timeout: 60000, + signal: opts?.signal, + // _silentErrorToast 由 api/client.ts 响应拦截器读取(抑制全局错误 toast,#1777) + ...(opts?.silentErrorToast + ? ({ _silentErrorToast: true } as Record) + : {}), + }, ) return response.data } diff --git a/apps/web/src/api/template-editor/editPlans.ts b/apps/web/src/api/template-editor/editPlans.ts index 2ec4acdea..c4d017c16 100644 --- a/apps/web/src/api/template-editor/editPlans.ts +++ b/apps/web/src/api/template-editor/editPlans.ts @@ -43,11 +43,17 @@ export async function updateEditPlanClips( templateId: string, clips: EditPlanClipInput[], signal?: AbortSignal, + /** 为 true 时抑制全局错误 toast(调用方自行提示,如失效模板回退 #1777) */ + silentErrorToast?: boolean, ): Promise<{ count: number }> { const response = await apiClient.put( `/templates/${templateId}/editor/clips`, { clips }, - { signal }, + { + signal, + // _silentErrorToast 由 api/client.ts 响应拦截器读取(抑制全局错误 toast) + ...(silentErrorToast ? ({ _silentErrorToast: true } as Record) : {}), + }, ) return response.data } diff --git a/apps/web/src/pages/generate/GeneratePage.tsx b/apps/web/src/pages/generate/GeneratePage.tsx index 368a56437..ae383928c 100644 --- a/apps/web/src/pages/generate/GeneratePage.tsx +++ b/apps/web/src/pages/generate/GeneratePage.tsx @@ -45,6 +45,7 @@ const GeneratePage: React.FC = () => { selectedTemplate, setSelectedTemplate, userTemplates, + handleInvalidTemplate, selectedMaterials, setSelectedMaterials, materialMode, @@ -524,6 +525,7 @@ const GeneratePage: React.FC = () => { selectedVoice={selectedVoice} onSelectedVoiceChange={setSelectedVoice} onServerClipsChange={setServerClips} + onTemplateInvalid={handleInvalidTemplate} generating={generating} generated={generated} generateError={generateError} diff --git a/apps/web/src/pages/generate/components/GenerateStepContent.tsx b/apps/web/src/pages/generate/components/GenerateStepContent.tsx index 639c99aa5..eddd10254 100644 --- a/apps/web/src/pages/generate/components/GenerateStepContent.tsx +++ b/apps/web/src/pages/generate/components/GenerateStepContent.tsx @@ -50,6 +50,8 @@ export interface GenerateStepContentProps { selectedVoice: string onSelectedVoiceChange: (id: string) => void onServerClipsChange: (clips: EditPlanClip[]) => void + /** 当前模板创建片段被判失效(404/400/422)时的自动回退回调(#1777) */ + onTemplateInvalid?: () => boolean /* 生成 */ generating: boolean generated: boolean @@ -108,6 +110,7 @@ export const GenerateStepContent: React.FC = (props) = selectedVoice, onSelectedVoiceChange, onServerClipsChange, + onTemplateInvalid, generating, generated, generateError, @@ -153,6 +156,7 @@ export const GenerateStepContent: React.FC = (props) = selectedTemplate={selectedTemplate} templateSegments={templateSegments} onServerClipsChange={onServerClipsChange} + onTemplateInvalid={onTemplateInvalid} /> ) case 3: diff --git a/apps/web/src/pages/generate/components/Step2MaterialSelect.tsx b/apps/web/src/pages/generate/components/Step2MaterialSelect.tsx index b0c655435..39ee02a15 100644 --- a/apps/web/src/pages/generate/components/Step2MaterialSelect.tsx +++ b/apps/web/src/pages/generate/components/Step2MaterialSelect.tsx @@ -23,6 +23,8 @@ interface Step2MaterialSelectProps { templateSegments?: TemplateSegment[] /** 服务端 clips 创建成功后的回调 */ onServerClipsChange?: (clips: EditPlanClip[]) => void + /** 当前模板创建片段返回 404/400/422(模板失效)时的自动回退回调(#1777) */ + onTemplateInvalid?: () => boolean } const Step2MaterialSelect: React.FC = (props) => { @@ -36,16 +38,25 @@ const Step2MaterialSelect: React.FC = (props) => {
- + {m.libraries.length === 0 && !m.materialsLoading ? ( +
+

暂无视频素材库

+

+ 请先在「素材库」中创建视频素材库并上传视频 +

+
+ ) : ( + + )}
{m.materialMode === "manual" && ( diff --git a/apps/web/src/pages/generate/hooks/step2-materials/useMaterialLibrary.ts b/apps/web/src/pages/generate/hooks/step2-materials/useMaterialLibrary.ts index 8d9579881..8e6e0d891 100755 --- a/apps/web/src/pages/generate/hooks/step2-materials/useMaterialLibrary.ts +++ b/apps/web/src/pages/generate/hooks/step2-materials/useMaterialLibrary.ts @@ -8,10 +8,16 @@ import type { AssetItem } from "@/api/assets" * 管理素材库列表、当前选中库、素材列表加载 */ export function useMaterialLibrary() { - /* ── 素材库数据 API ── */ + /* ── 素材库数据 API ── + * Step2 是视频选片,只拉取 kind=video 的素材库(#1777): + * 后端按 kind 查询参数过滤,前端 getAssetLibraries("video") 再兜底过滤一次, + * 避免配音库(voice)/图片库(image) 混进「选择视频库」下拉。 + * queryKey 带 kind,与素材管理页/配音页的 ["asset-libraries"] 全量缓存隔离。 + */ const { data: libraries = [] } = useQuery({ - queryKey: ["asset-libraries"], - queryFn: getAssetLibraries, + queryKey: ["asset-libraries", "video"], + queryFn: () => getAssetLibraries("video"), + staleTime: 60_000, }) const [selectedLibraryId, setSelectedLibraryId] = useState("") diff --git a/apps/web/src/pages/generate/hooks/useGenerateFormState/index.ts b/apps/web/src/pages/generate/hooks/useGenerateFormState/index.ts index 3d1f60f2e..2e5f01617 100755 --- a/apps/web/src/pages/generate/hooks/useGenerateFormState/index.ts +++ b/apps/web/src/pages/generate/hooks/useGenerateFormState/index.ts @@ -40,6 +40,8 @@ export interface GenerateFormState { selectedTemplate: string setSelectedTemplate: (id: string) => void userTemplates: EditingTemplate[] + /** 当前选中模板在创建片段时被判失效(404/400/422)后的运行时自动回退 */ + handleInvalidTemplate: () => boolean /* 素材 */ selectedMaterials: string[] @@ -131,7 +133,12 @@ export const useGenerateFormState = (): GenerateFormState => { const [currentStep, setCurrentStep] = useState(1) /* ── 模板选择 ── */ - const { selectedTemplate, setSelectedTemplate, userTemplates } = useTemplateSelection() + const { + selectedTemplate, + setSelectedTemplate, + userTemplates, + handleInvalidTemplate, + } = useTemplateSelection() /* ── source_edit_plan_id:仅取 URL 参数,无则 null 让后端兜底 ── */ // selectedTemplate 是模板 ID 而非 edit_plan_id,不能混淆; @@ -228,6 +235,7 @@ export const useGenerateFormState = (): GenerateFormState => { selectedTemplate, setSelectedTemplate, userTemplates, + handleInvalidTemplate, selectedMaterials, setSelectedMaterials, materialMode, diff --git a/apps/web/src/pages/generate/hooks/useGenerateFormState/templateFallback.ts b/apps/web/src/pages/generate/hooks/useGenerateFormState/templateFallback.ts new file mode 100644 index 000000000..ca50ed6d0 --- /dev/null +++ b/apps/web/src/pages/generate/hooks/useGenerateFormState/templateFallback.ts @@ -0,0 +1,81 @@ +/** + * 失效模板判定与自动回退工具(#1777) + * + * 背景:用户进入生成页后,之前选中的模板可能已被删除、或从未配置片段。 + * 调用片段相关接口(PUT/POST /templates/{id}/editor/clips[...]/from-assets)时: + * - 模板不存在 → 后端返回 404(并行工单 #1774 把「模板不存在」统一为该状态码) + * - 模板无片段配置 → 当前部分场景返回 400(detail 含「片段配置」), + * 参数校验类错误返回 422 + * 这三类响应都说明「当前选中的模板不可用于生成」,应清除失效选择并自动切换到 + * 第一个有效模板,同时提示用户,而不是让页面卡死、无任何反馈。 + */ +import type { EditingTemplate } from "@/api/editing-planner" + +/** 失效模板相关的 HTTP 状态码 */ +const INVALID_TEMPLATE_STATUSES = new Set([404, 400, 422]) + +/** + * 从任意抛出值(axios 错误)提取 HTTP 状态码。 + * 非 axios 错误 / 无响应时返回 null。 + */ +export function getHttpStatus(err: unknown): number | null { + if (!err || typeof err !== "object") return null + const status = (err as { response?: { status?: number }; status?: number })?.response?.status + return typeof status === "number" ? status : null +} + +/** 安全提取后端错误文本(detail/message/msg,422 数组也兜底拼一下) */ +function extractErrorText(err: unknown): string { + if (!err || typeof err !== "object") return "" + const data = (err as { response?: { data?: unknown } })?.response?.data + if (!data) return "" + try { + const text = JSON.stringify(data) + return typeof text === "string" ? text : "" + } catch { + return "" + } +} + +/** + * 判断一次 clips/from-assets 请求失败是否因为「模板失效」。 + * + * 严格判定,避免把无关的 400/422(例如素材参数问题)误判为模板失效: + * - 404:模板/编辑计划不存在,一定是模板失效 + * - 400:仅当后端文本明确提到「片段配置」(无片段配置无法创建片段)才判定 + * - 422:参数校验类,from-assets 场景下命中「片段/segments」相关字段才判定 + */ +export function isInvalidTemplateError(err: unknown): boolean { + const status = getHttpStatus(err) + if (status === null || !INVALID_TEMPLATE_STATUSES.has(status)) return false + if (status === 404) return true + + const text = extractErrorText(err) + if (status === 400) { + // 后端当前返回:「模板没有片段配置,无法创建片段」 + return /片段配置|没有片段|无片段|segments?|clip.*config/i.test(text) + } + // 422:FastAPI 校验错误,命中模板片段相关字段 + return /segment|clip|片段|模板/i.test(text) +} + +/** + * 判断模板是否可用于生成(有效模板)。 + * + * 有效 = 处于激活态(is_active !== false,字段缺失视为 true 兼容旧后端) + * 且至少配置了一个片段。 + * 与后端 valid_only 过滤口径保持一致(#1769/#1772),这里是前端双保险。 + */ +export function isValidTemplate(template: EditingTemplate | null | undefined): boolean { + if (!template) return false + if (template.is_active === false) return false + return (template.segments?.length ?? 0) > 0 +} + +/** 从模板列表中取出第一个有效模板,没有则返回 null */ +export function findFirstValidTemplate( + templates: EditingTemplate[] | null | undefined, +): EditingTemplate | null { + if (!Array.isArray(templates)) return null + return templates.find(isValidTemplate) ?? null +} diff --git a/apps/web/src/pages/generate/hooks/useGenerateFormState/useTemplateSelection.ts b/apps/web/src/pages/generate/hooks/useGenerateFormState/useTemplateSelection.ts index 1b2b64920..8ee66f4f5 100755 --- a/apps/web/src/pages/generate/hooks/useGenerateFormState/useTemplateSelection.ts +++ b/apps/web/src/pages/generate/hooks/useGenerateFormState/useTemplateSelection.ts @@ -1,22 +1,87 @@ -import { useState, useEffect } from "react" +import { useState, useEffect, useRef, useCallback } from "react" import { useQuery } from "@tanstack/react-query" +import { message } from "antd" import { getEditingTemplates } from "@/api/editing-planner" import type { EditingTemplate } from "@/api/editing-planner" +import { findFirstValidTemplate, isValidTemplate } from "./templateFallback" + +/** 失效模板自动切换的提示文案 */ +export const INVALID_TEMPLATE_FALLBACK_TOAST = "原模板已失效,已自动切换" export function useTemplateSelection() { + // selectedTemplate 纯内存状态,绝不写入 localStorage/sessionStorage/URL, + // 因此失效模板 ID 不会被持久化、刷新后也不会恢复(#1777 要求 4) const [selectedTemplate, setSelectedTemplate] = useState("") - const { data: userTemplates = [] } = useQuery({ + + const { data: allTemplates = [] } = useQuery({ queryKey: ["generate-templates"], - queryFn: () => getEditingTemplates(), + // valid_only:后端过滤掉没有片段配置的无效模板(#1769/#1772)。 + // 旧后端忽略该 query 参数时,下方 isValidTemplate 前端兜底再过滤一次。 + queryFn: () => getEditingTemplates({ validOnly: true }), staleTime: 60_000, }) - /* 模板加载完成后自动选中第一个 */ - useEffect(() => { - if (userTemplates.length > 0 && !selectedTemplate) { - setSelectedTemplate(userTemplates[0].id) - } - }, [userTemplates, selectedTemplate]) + // 双保险:后端 valid_only 已过滤,前端再按 is_active + segments 兜底, + // 保证下拉/自动选择只包含可用于生成的有效模板 + const validTemplates = allTemplates.filter(isValidTemplate) + const userTemplates = validTemplates - return { selectedTemplate, setSelectedTemplate, userTemplates } + // 用 ref 持有最新值,供稳定回调 handleInvalidTemplate 使用(避免闭包拿到旧值) + const templatesRef = useRef(validTemplates) + templatesRef.current = validTemplates + const selectedRef = useRef(selectedTemplate) + selectedRef.current = selectedTemplate + // 已提示过失效的模板 ID,避免用户停留在失效模板上时 clips 防抖请求反复弹 toast; + // 用户手动切换/成功切换后重置,保证下一个失效模板仍能提示 + const fallbackNotifiedRef = useRef("") + + /* 自动选择:模板加载完成且当前未选中时,自动选中第一个有效模板。 + * 用户手动选择(setSelectedTemplate 被显式调用)后 selectedTemplate 非空, + * 本 effect 直接 return,绝不覆盖用户的手动选择(#1777 要求 4:手动优先)。 */ + useEffect(() => { + if (selectedTemplate) return + const firstValid = validTemplates[0] + if (firstValid) { + setSelectedTemplate(firstValid.id) + } + }, [validTemplates, selectedTemplate]) + + /** 用户手动选择模板:优先级最高,重置失效提示标记 */ + const handleSelectTemplate = useCallback((id: string) => { + fallbackNotifiedRef.current = "" + setSelectedTemplate(id) + }, []) + + /** + * 运行时失效回退(#1777 要求 3): + * 创建片段接口返回 404(模板不存在)/ 400/422(模板无片段配置)时调用。 + * - 清除失效选择,自动切换到第一个有效模板,并 toast 提示; + * - 没有有效模板时清空选择,Step1 展示明确的「暂无可用模板」空状态引导, + * 不让用户卡在失效模板上。 + * 返回 true 表示已按「模板失效」处理(调用方可据此静默原始错误提示)。 + */ + const handleInvalidTemplate = useCallback((): boolean => { + const current = selectedRef.current + // 同一个失效模板只提示一次(clips 防抖 effect 在素材/模板变化时会反复触发) + if (current && fallbackNotifiedRef.current === current) return true + + const fallback = findFirstValidTemplate(templatesRef.current) + fallbackNotifiedRef.current = current || "__empty__" + if (fallback) { + setSelectedTemplate(fallback.id) + message.warning(INVALID_TEMPLATE_FALLBACK_TOAST) + } else { + // 没有任何有效模板:清空选择,交由 Step1 空状态引导用户去模板编辑器创建 + setSelectedTemplate("") + message.warning("当前没有可用模板,请先在「模板编辑器」中创建并配置片段") + } + return true + }, []) + + return { + selectedTemplate, + setSelectedTemplate: handleSelectTemplate, + userTemplates, + handleInvalidTemplate, + } } diff --git a/apps/web/src/pages/generate/hooks/useStep2Materials.ts b/apps/web/src/pages/generate/hooks/useStep2Materials.ts index 7f9a0388f..be8870caf 100644 --- a/apps/web/src/pages/generate/hooks/useStep2Materials.ts +++ b/apps/web/src/pages/generate/hooks/useStep2Materials.ts @@ -10,6 +10,7 @@ import { updateEditPlanClips, createClipsFromAssets, getEditPlanClips } from "@/ import { useMaterialLibrary } from "./step2-materials/useMaterialLibrary" import { useSmartMatch } from "./step2-materials/useSmartMatch" import { useDraftAutoSave } from "./useDraftAutoSave" +import { isInvalidTemplateError } from "./useGenerateFormState/templateFallback" interface UseStep2MaterialsProps { materialMode: "manual" | "auto" @@ -24,6 +25,8 @@ interface UseStep2MaterialsProps { templateSegments?: TemplateSegment[] /** 服务端 clips 创建成功后的回调,用于通知预览播放器 */ onServerClipsChange?: (clips: EditPlanClip[]) => void + /** 当前模板创建片段返回 404/400/422(模板失效)时的自动回退回调(#1777) */ + onTemplateInvalid?: () => boolean } export function useStep2Materials({ @@ -36,6 +39,7 @@ export function useStep2Materials({ selectedTemplate, templateSegments, onServerClipsChange, + onTemplateInvalid, }: UseStep2MaterialsProps) { const { libraries, @@ -102,6 +106,8 @@ export function useStep2Materials({ selectedTemplateRef.current = selectedTemplate const onServerClipsChangeRef = useRef(onServerClipsChange) onServerClipsChangeRef.current = onServerClipsChange + const onTemplateInvalidRef = useRef(onTemplateInvalid) + onTemplateInvalidRef.current = onTemplateInvalid useEffect(() => { const tid = selectedTemplateRef.current @@ -123,11 +129,12 @@ export function useStep2Materials({ const requiredClipsCount = segs.length > 0 ? segs.length : undefined try { - // 1. 清空旧片段 - await updateEditPlanClips(tid, [], controller.signal) + // 1. 清空旧片段(静默全局 toast:模板失效时由下方回退统一提示) + await updateEditPlanClips(tid, [], controller.signal, true) // 2. 调用后端 from-assets 接口创建片段(异步秒级返回,60s 超时仅为兜底) await createClipsFromAssets(tid, ids, "main", requiredClipsCount, { signal: controller.signal, + silentErrorToast: true, }) // 3. 获取服务端生成的 clips(含 start_time/duration),供预览播放器使用 const clipList = await getEditPlanClips(tid, { limit: 500 }) @@ -146,6 +153,14 @@ export function useStep2Materials({ message.error("智能选片失败,请重试") return } + // 模板失效(404 模板不存在 / 400/422 无片段配置): + // 清空失效选择并自动切到第一个有效模板 + toast,避免页面卡死无提示(#1777) + if (isInvalidTemplateError(err)) { + console.warn("[useStep2Materials] 当前模板已失效,触发自动回退:", err) + onServerClipsChangeRef.current?.([]) + onTemplateInvalidRef.current?.() + return + } console.warn("[useStep2Materials] 写入 clips 失败:", err) } }, 800) diff --git a/apps/web/src/test/pages/generate/templateFallback.test.ts b/apps/web/src/test/pages/generate/templateFallback.test.ts new file mode 100644 index 000000000..8c9cc72fc --- /dev/null +++ b/apps/web/src/test/pages/generate/templateFallback.test.ts @@ -0,0 +1,133 @@ +/** + * 失效模板判定/回退纯函数单测(#1777) + */ +import { describe, it, expect } from "vitest" +import type { EditingTemplate } from "@/api/editing-planner" +import { + getHttpStatus, + isInvalidTemplateError, + isValidTemplate, + findFirstValidTemplate, +} from "@/pages/generate/hooks/useGenerateFormState/templateFallback" + +function makeTemplate(partial: Partial & { id: string }): EditingTemplate { + return { + name: partial.id, + mode: "pip", + category: "默认", + tags: [], + title_config: { + ai_auto_select: false, + content: "", + font_preset: "", + font_color: "", + font_size: 28, + position: "top", + }, + subtitle_config: { enabled: true, position: "bottom", font: "", color: "", size: 20, animation: "" }, + bgm_config: { enabled: false, music_id: "" }, + segments: [{ segment_order: 0, material_type: null }], + is_active: true, + created_at: "", + updated_at: "", + ...partial, + } as EditingTemplate +} + +function axiosError(status: number, data?: unknown) { + return { isAxiosError: true, response: { status, data } } +} + +describe("getHttpStatus", () => { + it("提取 axios 错误的 HTTP 状态码", () => { + expect(getHttpStatus(axiosError(404))).toBe(404) + expect(getHttpStatus(axiosError(400))).toBe(400) + }) + it("非 axios/无响应错误返回 null", () => { + expect(getHttpStatus(new Error("network"))).toBeNull() + expect(getHttpStatus(null)).toBeNull() + expect(getHttpStatus(undefined)).toBeNull() + expect(getHttpStatus({ isAxiosError: true })).toBeNull() + }) +}) + +describe("isInvalidTemplateError", () => { + it("404 始终判定为模板失效(模板不存在)", () => { + expect(isInvalidTemplateError(axiosError(404))).toBe(true) + expect(isInvalidTemplateError(axiosError(404, { detail: "Not Found" }))).toBe(true) + }) + + it("400 且后端文案提到「片段配置」判定为模板无片段配置", () => { + expect( + isInvalidTemplateError(axiosError(400, { detail: "模板没有片段配置,无法创建片段" })), + ).toBe(true) + }) + + it("400 但文案与片段配置无关 → 不误判", () => { + expect(isInvalidTemplateError(axiosError(400, { detail: "素材参数错误" }))).toBe(false) + }) + + it("422 命中片段/模板字段判定为失效", () => { + expect( + isInvalidTemplateError( + axiosError(422, { detail: [{ loc: ["body", "segments"], msg: "field required" }] }), + ), + ).toBe(true) + }) + + it("其他状态码(401/403/500/超时/网络)不判定为模板失效", () => { + expect(isInvalidTemplateError(axiosError(401))).toBe(false) + expect(isInvalidTemplateError(axiosError(403))).toBe(false) + expect(isInvalidTemplateError(axiosError(500))).toBe(false) + expect(isInvalidTemplateError({ code: "ECONNABORTED", message: "timeout of 60000ms" })).toBe( + false, + ) + expect(isInvalidTemplateError(new Error("Network Error"))).toBe(false) + }) +}) + +describe("isValidTemplate", () => { + it("有片段且未被标记 inactive → 有效", () => { + expect(isValidTemplate(makeTemplate({ id: "t1" }))).toBe(true) + }) + it("segments 为空 → 无效(无片段配置)", () => { + expect(isValidTemplate(makeTemplate({ id: "t2", segments: [] }))).toBe(false) + }) + it("is_active=false → 无效(已停用/删除)", () => { + expect(isValidTemplate(makeTemplate({ id: "t3", is_active: false }))).toBe(false) + }) + it("is_active 字段缺失时视为有效(兼容旧后端)", () => { + const t = makeTemplate({ id: "t4" }) + delete (t as Partial).is_active + expect(isValidTemplate(t)).toBe(true) + }) + it("null/undefined → 无效", () => { + expect(isValidTemplate(null)).toBe(false) + expect(isValidTemplate(undefined)).toBe(false) + }) +}) + +describe("findFirstValidTemplate", () => { + it("跳过无效模板,返回第一个有效模板", () => { + const list = [ + makeTemplate({ id: "empty", segments: [] }), + makeTemplate({ id: "inactive", is_active: false }), + makeTemplate({ id: "valid1" }), + makeTemplate({ id: "valid2" }), + ] + expect(findFirstValidTemplate(list)?.id).toBe("valid1") + }) + it("全部无效 → null(用于空状态引导)", () => { + expect( + findFirstValidTemplate([ + makeTemplate({ id: "a", segments: [] }), + makeTemplate({ id: "b", is_active: false }), + ]), + ).toBeNull() + }) + it("空数组/null → null", () => { + expect(findFirstValidTemplate([])).toBeNull() + expect(findFirstValidTemplate(null)).toBeNull() + expect(findFirstValidTemplate(undefined)).toBeNull() + }) +}) diff --git a/apps/web/src/test/pages/generate/useMaterialLibrary.test.tsx b/apps/web/src/test/pages/generate/useMaterialLibrary.test.tsx new file mode 100644 index 000000000..192b4b904 --- /dev/null +++ b/apps/web/src/test/pages/generate/useMaterialLibrary.test.tsx @@ -0,0 +1,76 @@ +/** + * useMaterialLibrary Hook 单测(#1777) + * - Step2 视频库选择器只拉取 kind=video 的素材库,配音库(voice)/图片库(image) 不混入 + * - 自动选中第一个视频库 + */ +import { describe, it, expect, vi, beforeEach } from "vitest" +import { renderHook, waitFor } from "@testing-library/react" +import { QueryClient, QueryClientProvider } from "@tanstack/react-query" +import type { ReactNode } from "react" +import type { AssetItem, AssetLibraryItem } from "@/api/assets" + +vi.mock("@/api/assets", () => ({ + getAssetLibraries: vi.fn(), + getAssets: vi.fn(), + isAssetUsable: vi.fn(() => true), +})) + +import { getAssetLibraries, getAssets } from "@/api/assets" +import { useMaterialLibrary } from "@/pages/generate/hooks/step2-materials/useMaterialLibrary" + +const mockGetLibraries = vi.mocked(getAssetLibraries) +const mockGetAssets = vi.mocked(getAssets) + +function lib(id: string, kind: AssetLibraryItem["kind"], name = id): AssetLibraryItem { + return { id, name, kind } +} + +function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: 0 } }, + }) + return ({ children }: { children: ReactNode }) => + ( + {children} + ) as ReactNode +} + +beforeEach(() => { + vi.clearAllMocks() + mockGetAssets.mockResolvedValue({ items: [] as AssetItem[], total: 0 }) +}) + +describe("useMaterialLibrary (#1777 kind=video 过滤)", () => { + it("按 kind=video 拉取素材库(后端参数过滤)", async () => { + mockGetLibraries.mockResolvedValueOnce([lib("v1", "video")]) + renderHook(() => useMaterialLibrary(), { wrapper: createWrapper() }) + + await waitFor(() => expect(mockGetLibraries).toHaveBeenCalledTimes(1)) + expect(mockGetLibraries).toHaveBeenCalledWith("video") + }) + + it("下拉库列表只包含视频库(自动选中第一个视频库)", async () => { + mockGetLibraries.mockResolvedValueOnce([ + lib("voice-1", "voice"), + lib("img-1", "image"), + lib("video-1", "video"), + lib("video-2", "video"), + ]) + const { result } = renderHook(() => useMaterialLibrary(), { wrapper: createWrapper() }) + + await waitFor(() => expect(result.current.libraries).toHaveLength(2)) + expect(result.current.libraries.map((l) => l.id)).toEqual(["video-1", "video-2"]) + expect(result.current.libraries.every((l) => l.kind === "video")).toBe(true) + // 自动选中第一个视频库 + expect(result.current.selectedLibraryId).toBe("video-1") + }) + + it("没有视频库时库列表为空且不自动选中(UI 展示空状态)", async () => { + mockGetLibraries.mockResolvedValueOnce([lib("voice-1", "voice"), lib("img-1", "image")]) + const { result } = renderHook(() => useMaterialLibrary(), { wrapper: createWrapper() }) + + await waitFor(() => expect(mockGetLibraries).toHaveBeenCalled()) + expect(result.current.libraries).toEqual([]) + expect(result.current.selectedLibraryId).toBe("") + }) +}) diff --git a/apps/web/src/test/pages/generate/useTemplateSelection.test.tsx b/apps/web/src/test/pages/generate/useTemplateSelection.test.tsx new file mode 100644 index 000000000..c891fe854 --- /dev/null +++ b/apps/web/src/test/pages/generate/useTemplateSelection.test.tsx @@ -0,0 +1,179 @@ +/** + * useTemplateSelection Hook 单测(#1777) + * - 自动选择跳过无片段/inactive 模板,只选第一个有效模板 + * - 传 validOnly=true 给后端 + * - 用户手动选择优先,自动逻辑不覆盖 + * - handleInvalidTemplate:失效时自动切到第一个有效模板 + toast;无有效模板时清空 + * - selectedTemplate 仅内存态,不写入 localStorage/sessionStorage + */ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest" +import { renderHook, waitFor, act } from "@testing-library/react" +import { QueryClient, QueryClientProvider } from "@tanstack/react-query" +import type { ReactNode } from "react" + +// antd message mock(拦截 toast) +const messageMock = { + warning: vi.fn(), + error: vi.fn(), + success: vi.fn(), + info: vi.fn(), + loading: vi.fn(() => vi.fn()), +} +vi.mock("antd", () => ({ message: messageMock })) + +vi.mock("@/api/editing-planner", () => ({ + getEditingTemplates: vi.fn(), +})) + +import { getEditingTemplates } from "@/api/editing-planner" +import type { EditingTemplate } from "@/api/editing-planner" +import { useTemplateSelection } from "@/pages/generate/hooks/useGenerateFormState/useTemplateSelection" + +const mockGetTemplates = vi.mocked(getEditingTemplates) + +function tpl(id: string, partial: Partial = {}): EditingTemplate { + return { + id, + name: id, + mode: "pip", + category: "默认", + tags: [], + title_config: { + ai_auto_select: false, + content: "", + font_preset: "", + font_color: "", + font_size: 28, + position: "top", + }, + subtitle_config: { + enabled: true, + position: "bottom", + font: "", + color: "", + size: 20, + animation: "", + }, + bgm_config: { enabled: false, music_id: "" }, + segments: [{ segment_order: 0, material_type: null }], + is_active: true, + created_at: "", + updated_at: "", + ...partial, + } as EditingTemplate +} + +function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: 0 } }, + }) + return ({ children }: { children: ReactNode }) => + ( + {children} + ) as ReactNode +} + +beforeEach(() => { + vi.clearAllMocks() + localStorage.clear() + sessionStorage.clear() +}) + +afterEach(() => { + localStorage.clear() + sessionStorage.clear() +}) + +describe("useTemplateSelection (#1777)", () => { + it("请求模板时传 validOnly=true,并自动选中第一个有片段的有效模板", async () => { + mockGetTemplates.mockResolvedValueOnce([ + tpl("empty", { segments: [] }), + tpl("inactive", { is_active: false }), + tpl("valid-a"), + tpl("valid-b"), + ]) + const { result } = renderHook(() => useTemplateSelection(), { wrapper: createWrapper() }) + + await waitFor(() => expect(result.current.selectedTemplate).toBe("valid-a")) + expect(mockGetTemplates).toHaveBeenCalledWith({ validOnly: true }) + // 暴露给 UI 的 userTemplates 已过滤掉无效模板 + expect(result.current.userTemplates.map((t) => t.id)).toEqual(["valid-a", "valid-b"]) + }) + + it("列表全部无效时 selectedTemplate 为空(交空状态引导),不选中失效模板", async () => { + mockGetTemplates.mockResolvedValueOnce([ + tpl("empty", { segments: [] }), + tpl("inactive", { is_active: false }), + ]) + const { result } = renderHook(() => useTemplateSelection(), { wrapper: createWrapper() }) + await waitFor(() => expect(mockGetTemplates).toHaveBeenCalled()) + // 给 effect 一个 tick + await waitFor(() => expect(result.current.selectedTemplate).toBe("")) + expect(result.current.userTemplates).toHaveLength(0) + }) + + it("用户手动选择优先:自动逻辑不会覆盖手动选择", async () => { + mockGetTemplates.mockResolvedValueOnce([tpl("a"), tpl("b")]) + const { result } = renderHook(() => useTemplateSelection(), { wrapper: createWrapper() }) + await waitFor(() => expect(result.current.selectedTemplate).toBe("a")) + + act(() => result.current.setSelectedTemplate("b")) + expect(result.current.selectedTemplate).toBe("b") + + // 重新渲染 / refetch 后仍保持用户的手动选择 + await waitFor(() => expect(result.current.selectedTemplate).toBe("b")) + }) + + it("handleInvalidTemplate:当前模板失效时自动切到第一个有效模板并 toast", async () => { + mockGetTemplates.mockResolvedValueOnce([ + tpl("bad", { segments: [] }), + tpl("good"), + ]) + const { result } = renderHook(() => useTemplateSelection(), { wrapper: createWrapper() }) + // 自动选中有效模板 good(bad 无片段不会被自动选中) + await waitFor(() => expect(result.current.selectedTemplate).toBe("good")) + messageMock.warning.mockClear() + + // 模拟运行时用户停留在一个已失效的模板 id(外部/草稿态),触发回退 + act(() => result.current.setSelectedTemplate("stale-id")) + expect(result.current.selectedTemplate).toBe("stale-id") + + act(() => { + const handled = result.current.handleInvalidTemplate() + expect(handled).toBe(true) + }) + await waitFor(() => expect(result.current.selectedTemplate).toBe("good")) + expect(messageMock.warning).toHaveBeenCalledWith("原模板已失效,已自动切换") + }) + + it("handleInvalidTemplate:无有效模板时清空选择并提示去创建", async () => { + mockGetTemplates.mockResolvedValueOnce([tpl("bad", { segments: [] })]) + const { result } = renderHook(() => useTemplateSelection(), { wrapper: createWrapper() }) + await waitFor(() => expect(result.current.userTemplates).toHaveLength(0)) + + act(() => result.current.setSelectedTemplate("stale-id")) + act(() => { + result.current.handleInvalidTemplate() + }) + await waitFor(() => expect(result.current.selectedTemplate).toBe("")) + expect(messageMock.warning).toHaveBeenCalledWith( + expect.stringContaining("没有可用模板"), + ) + }) + + it("失效模板 ID 不写入任何持久化存储", async () => { + mockGetTemplates.mockResolvedValueOnce([tpl("good")]) + const { result } = renderHook(() => useTemplateSelection(), { wrapper: createWrapper() }) + await waitFor(() => expect(result.current.selectedTemplate).toBe("good")) + + act(() => result.current.setSelectedTemplate("stale-invalid-id")) + act(() => result.current.handleInvalidTemplate()) + + const ls = JSON.stringify(localStorage) + const ss = JSON.stringify(sessionStorage) + expect(ls).not.toContain("stale-invalid-id") + expect(ss).not.toContain("stale-invalid-id") + // URL 也不含 + expect(window.location.href).not.toContain("stale-invalid-id") + }) +}) -- 2.54.0 From e062e18bd01afde026f53ed922bf7ab4d823c1bd Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Mon, 7 Sep 2026 23:35:48 +0800 Subject: [PATCH 2/3] =?UTF-8?q?fix(#1777):=20Step2=E7=B4=A0=E6=9D=90?= =?UTF-8?q?=E5=BA=93=E5=8F=AA=E6=98=BE=E7=A4=BA=E8=A7=86=E9=A2=91=E5=BA=93?= =?UTF-8?q?+=E5=A4=B1=E6=95=88=E6=A8=A1=E6=9D=BF=E8=BF=90=E8=A1=8C?= =?UTF-8?q?=E6=97=B6=E8=87=AA=E5=8A=A8=E5=9B=9E=E9=80=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 素材库选择器按 kind=video 拉取(queryKey 独立缓存), API层+hook层双重过滤,配音库/图片库不再混入视频库下拉; 无视频库时展示引导空状态 - 新增 templateFallback 纯函数:404 必判模板失效, 400/422 按片段配置语义严格判定,避免误判素材参数错误 - useTemplateSelection 暴露 handleInvalidTemplate:失效时自动切到 第一个有效模板并 toast,无有效模板时清空并引导创建; 手动选择优先;selectedTemplate 纯内存不持久化 - from-assets 失败触发回退,静默全局错误 toast 防重复弹窗 - 修复 getAssetLibraries 加 kind 参数后 voice 页面 queryFn 类型不匹配 - 新增 24 个测试(纯函数+两个 hook),746 单测全过,tsc/build 通过 --- .../step2-materials/useMaterialLibrary.ts | 7 ++++- .../actions/useVoiceUpload.ts | 2 +- .../useVoiceMaterials/useVoiceMaterialData.ts | 2 +- .../src/pages/voices/hooks/useVoiceUpload.ts | 2 +- .../pages/generate/templateFallback.test.ts | 9 +++++- .../generate/useMaterialLibrary.test.tsx | 4 +-- .../generate/useTemplateSelection.test.tsx | 31 ++++++++----------- 7 files changed, 31 insertions(+), 26 deletions(-) diff --git a/apps/web/src/pages/generate/hooks/step2-materials/useMaterialLibrary.ts b/apps/web/src/pages/generate/hooks/step2-materials/useMaterialLibrary.ts index 8e6e0d891..cc9cd25b3 100755 --- a/apps/web/src/pages/generate/hooks/step2-materials/useMaterialLibrary.ts +++ b/apps/web/src/pages/generate/hooks/step2-materials/useMaterialLibrary.ts @@ -14,11 +14,16 @@ export function useMaterialLibrary() { * 避免配音库(voice)/图片库(image) 混进「选择视频库」下拉。 * queryKey 带 kind,与素材管理页/配音页的 ["asset-libraries"] 全量缓存隔离。 */ - const { data: libraries = [] } = useQuery({ + const { data: allLibraries = [] } = useQuery({ queryKey: ["asset-libraries", "video"], queryFn: () => getAssetLibraries("video"), staleTime: 60_000, }) + // 前端兜底过滤:仅保留 kind=video 的素材库(后端按 kind 查询参数过滤) + const libraries = useMemo( + () => allLibraries.filter((lib) => lib.kind === "video"), + [allLibraries], + ) const [selectedLibraryId, setSelectedLibraryId] = useState("") // 自动选中第一个视频库 diff --git a/apps/web/src/pages/voice-materials/hooks/useVoiceMaterials/actions/useVoiceUpload.ts b/apps/web/src/pages/voice-materials/hooks/useVoiceMaterials/actions/useVoiceUpload.ts index a22e5bdd8..0e4ed77ed 100755 --- a/apps/web/src/pages/voice-materials/hooks/useVoiceMaterials/actions/useVoiceUpload.ts +++ b/apps/web/src/pages/voice-materials/hooks/useVoiceMaterials/actions/useVoiceUpload.ts @@ -41,7 +41,7 @@ export function useVoiceUpload({ voiceLibrary, createLibMutation }: UseVoiceUplo } const libs = await queryClient.fetchQuery({ queryKey: ["asset-libraries"], - queryFn: getAssetLibraries, + queryFn: () => getAssetLibraries(), }) lib = libs.find((l: AssetLibraryItem) => l.kind === "voice") if (!lib) throw new Error("无法创建配音库") diff --git a/apps/web/src/pages/voice-materials/hooks/useVoiceMaterials/useVoiceMaterialData.ts b/apps/web/src/pages/voice-materials/hooks/useVoiceMaterials/useVoiceMaterialData.ts index bde83ea04..5358b410f 100644 --- a/apps/web/src/pages/voice-materials/hooks/useVoiceMaterials/useVoiceMaterialData.ts +++ b/apps/web/src/pages/voice-materials/hooks/useVoiceMaterials/useVoiceMaterialData.ts @@ -24,7 +24,7 @@ export function useVoiceMaterialData({ keyword, gender, tagIds }: UseVoiceMateri // ── 获取 voice 类型素材库 ───────────────────────────────── const { data: libraries = [] } = useQuery({ queryKey: ["asset-libraries"], - queryFn: getAssetLibraries, + queryFn: () => getAssetLibraries(), staleTime: 60_000, }) diff --git a/apps/web/src/pages/voices/hooks/useVoiceUpload.ts b/apps/web/src/pages/voices/hooks/useVoiceUpload.ts index eb25d6fa5..47921745e 100644 --- a/apps/web/src/pages/voices/hooks/useVoiceUpload.ts +++ b/apps/web/src/pages/voices/hooks/useVoiceUpload.ts @@ -26,7 +26,7 @@ export function useVoiceUpload({ showToast }: UseVoiceUploadProps) { /* 获取或创建默认配音库 */ const libs = await queryClient.fetchQuery({ queryKey: ["asset-libraries"], - queryFn: getAssetLibraries, + queryFn: () => getAssetLibraries(), }) const lib = libs.find((l) => l.kind === "voice") if (!lib) throw new Error("配音库不存在,请先在配音库页面创建") diff --git a/apps/web/src/test/pages/generate/templateFallback.test.ts b/apps/web/src/test/pages/generate/templateFallback.test.ts index 8c9cc72fc..524fb9f70 100644 --- a/apps/web/src/test/pages/generate/templateFallback.test.ts +++ b/apps/web/src/test/pages/generate/templateFallback.test.ts @@ -24,7 +24,14 @@ function makeTemplate(partial: Partial & { id: string }): Editi font_size: 28, position: "top", }, - subtitle_config: { enabled: true, position: "bottom", font: "", color: "", size: 20, animation: "" }, + subtitle_config: { + enabled: true, + position: "bottom", + font: "", + color: "", + size: 20, + animation: "", + }, bgm_config: { enabled: false, music_id: "" }, segments: [{ segment_order: 0, material_type: null }], is_active: true, diff --git a/apps/web/src/test/pages/generate/useMaterialLibrary.test.tsx b/apps/web/src/test/pages/generate/useMaterialLibrary.test.tsx index 192b4b904..c1cba4746 100644 --- a/apps/web/src/test/pages/generate/useMaterialLibrary.test.tsx +++ b/apps/web/src/test/pages/generate/useMaterialLibrary.test.tsx @@ -30,9 +30,7 @@ function createWrapper() { defaultOptions: { queries: { retry: false, gcTime: 0 } }, }) return ({ children }: { children: ReactNode }) => - ( - {children} - ) as ReactNode + ({children}) as ReactNode } beforeEach(() => { diff --git a/apps/web/src/test/pages/generate/useTemplateSelection.test.tsx b/apps/web/src/test/pages/generate/useTemplateSelection.test.tsx index c891fe854..44967c8bb 100644 --- a/apps/web/src/test/pages/generate/useTemplateSelection.test.tsx +++ b/apps/web/src/test/pages/generate/useTemplateSelection.test.tsx @@ -11,14 +11,16 @@ import { renderHook, waitFor, act } from "@testing-library/react" import { QueryClient, QueryClientProvider } from "@tanstack/react-query" import type { ReactNode } from "react" -// antd message mock(拦截 toast) -const messageMock = { - warning: vi.fn(), - error: vi.fn(), - success: vi.fn(), - info: vi.fn(), - loading: vi.fn(() => vi.fn()), -} +// antd message mock(拦截 toast)——vi.hoisted 保证 mock 工厂可引用 +const { messageMock } = vi.hoisted(() => ({ + messageMock: { + warning: vi.fn(), + error: vi.fn(), + success: vi.fn(), + info: vi.fn(), + loading: vi.fn(() => vi.fn()), + }, +})) vi.mock("antd", () => ({ message: messageMock })) vi.mock("@/api/editing-planner", () => ({ @@ -68,9 +70,7 @@ function createWrapper() { defaultOptions: { queries: { retry: false, gcTime: 0 } }, }) return ({ children }: { children: ReactNode }) => - ( - {children} - ) as ReactNode + ({children}) as ReactNode } beforeEach(() => { @@ -125,10 +125,7 @@ describe("useTemplateSelection (#1777)", () => { }) it("handleInvalidTemplate:当前模板失效时自动切到第一个有效模板并 toast", async () => { - mockGetTemplates.mockResolvedValueOnce([ - tpl("bad", { segments: [] }), - tpl("good"), - ]) + mockGetTemplates.mockResolvedValueOnce([tpl("bad", { segments: [] }), tpl("good")]) const { result } = renderHook(() => useTemplateSelection(), { wrapper: createWrapper() }) // 自动选中有效模板 good(bad 无片段不会被自动选中) await waitFor(() => expect(result.current.selectedTemplate).toBe("good")) @@ -156,9 +153,7 @@ describe("useTemplateSelection (#1777)", () => { result.current.handleInvalidTemplate() }) await waitFor(() => expect(result.current.selectedTemplate).toBe("")) - expect(messageMock.warning).toHaveBeenCalledWith( - expect.stringContaining("没有可用模板"), - ) + expect(messageMock.warning).toHaveBeenCalledWith(expect.stringContaining("没有可用模板")) }) it("失效模板 ID 不写入任何持久化存储", async () => { -- 2.54.0 From d31b25d8c3126f5176781a9fab30b9af7d404765 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Mon, 7 Sep 2026 23:56:37 +0800 Subject: [PATCH 3/3] =?UTF-8?q?style:=20#1777=20prettier=20=E6=A0=BC?= =?UTF-8?q?=E5=BC=8F=E5=8C=96=20clips.ts=20=E4=B8=8E=20useGenerateFormStat?= =?UTF-8?q?e/index.ts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/src/api/template-editor/clips.ts | 4 +--- .../pages/generate/hooks/useGenerateFormState/index.ts | 8 ++------ 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/apps/web/src/api/template-editor/clips.ts b/apps/web/src/api/template-editor/clips.ts index c7e287298..7a63ba843 100644 --- a/apps/web/src/api/template-editor/clips.ts +++ b/apps/web/src/api/template-editor/clips.ts @@ -108,9 +108,7 @@ export async function createClipsFromAssets( timeout: 60000, signal: opts?.signal, // _silentErrorToast 由 api/client.ts 响应拦截器读取(抑制全局错误 toast,#1777) - ...(opts?.silentErrorToast - ? ({ _silentErrorToast: true } as Record) - : {}), + ...(opts?.silentErrorToast ? ({ _silentErrorToast: true } as Record) : {}), }, ) return response.data diff --git a/apps/web/src/pages/generate/hooks/useGenerateFormState/index.ts b/apps/web/src/pages/generate/hooks/useGenerateFormState/index.ts index 2e5f01617..1985d579c 100755 --- a/apps/web/src/pages/generate/hooks/useGenerateFormState/index.ts +++ b/apps/web/src/pages/generate/hooks/useGenerateFormState/index.ts @@ -133,12 +133,8 @@ export const useGenerateFormState = (): GenerateFormState => { const [currentStep, setCurrentStep] = useState(1) /* ── 模板选择 ── */ - const { - selectedTemplate, - setSelectedTemplate, - userTemplates, - handleInvalidTemplate, - } = useTemplateSelection() + const { selectedTemplate, setSelectedTemplate, userTemplates, handleInvalidTemplate } = + useTemplateSelection() /* ── source_edit_plan_id:仅取 URL 参数,无则 null 让后端兜底 ── */ // selectedTemplate 是模板 ID 而非 edit_plan_id,不能混淆; -- 2.54.0