fix(#1777): Step2素材库只显示视频库 + 失效模板运行时自动回退 #1780
@@ -5,10 +5,22 @@ import apiClient from "../client"
|
||||
import { getOrCreateDefaultProject } from "../projects"
|
||||
import type { AssetLibraryItem } from "./types"
|
||||
|
||||
/** 获取当前用户的所有素材库 */
|
||||
export const getAssetLibraries = async (): Promise<AssetLibraryItem[]> => {
|
||||
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<AssetLibraryItem[]> => {
|
||||
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) */
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<EditingTemplate[]> => {
|
||||
const response = await apiClient.get<ListTemplatesResponse>("/templates", {
|
||||
params: {
|
||||
skip: params?.skip ?? 0,
|
||||
limit: params?.limit ?? 50,
|
||||
...(params?.validOnly ? { valid_only: true } : {}),
|
||||
},
|
||||
})
|
||||
let list = response.data.items
|
||||
|
||||
@@ -91,7 +91,7 @@ export async function createClipsFromAssets(
|
||||
assetIds: string[],
|
||||
clipType = "main",
|
||||
requiredClipsCount?: number,
|
||||
opts?: { signal?: AbortSignal },
|
||||
opts?: { signal?: AbortSignal; silentErrorToast?: boolean },
|
||||
): Promise<ClipsFromAssetsResponse> {
|
||||
const body: Record<string, unknown> = {
|
||||
asset_ids: assetIds,
|
||||
@@ -104,7 +104,12 @@ export async function createClipsFromAssets(
|
||||
const response = await apiClient.post<ClipsFromAssetsResponse>(
|
||||
`/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<string, unknown>) : {}),
|
||||
},
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
@@ -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<string, unknown>) : {}),
|
||||
},
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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<GenerateStepContentProps> = (props) =
|
||||
selectedVoice,
|
||||
onSelectedVoiceChange,
|
||||
onServerClipsChange,
|
||||
onTemplateInvalid,
|
||||
generating,
|
||||
generated,
|
||||
generateError,
|
||||
@@ -153,6 +156,7 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
|
||||
selectedTemplate={selectedTemplate}
|
||||
templateSegments={templateSegments}
|
||||
onServerClipsChange={onServerClipsChange}
|
||||
onTemplateInvalid={onTemplateInvalid}
|
||||
/>
|
||||
)
|
||||
case 3:
|
||||
|
||||
@@ -23,6 +23,8 @@ interface Step2MaterialSelectProps {
|
||||
templateSegments?: TemplateSegment[]
|
||||
/** 服务端 clips 创建成功后的回调 */
|
||||
onServerClipsChange?: (clips: EditPlanClip[]) => void
|
||||
/** 当前模板创建片段返回 404/400/422(模板失效)时的自动回退回调(#1777) */
|
||||
onTemplateInvalid?: () => boolean
|
||||
}
|
||||
|
||||
const Step2MaterialSelect: React.FC<Step2MaterialSelectProps> = (props) => {
|
||||
@@ -36,16 +38,25 @@ const Step2MaterialSelect: React.FC<Step2MaterialSelectProps> = (props) => {
|
||||
|
||||
<div className="xx-form-field" style={{ marginTop: 12 }}>
|
||||
<label>选择视频库</label>
|
||||
<select
|
||||
value={m.selectedLibraryId}
|
||||
onChange={(e) => m.setSelectedLibraryId(e.target.value)}
|
||||
>
|
||||
{m.libraries.map((lib) => (
|
||||
<option key={lib.id} value={lib.id}>
|
||||
{lib.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{m.libraries.length === 0 && !m.materialsLoading ? (
|
||||
<div className="xx-empty-state">
|
||||
<p>暂无视频素材库</p>
|
||||
<p style={{ fontSize: 13, color: "var(--text-tertiary)" }}>
|
||||
请先在「素材库」中创建视频素材库并上传视频
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<select
|
||||
value={m.selectedLibraryId}
|
||||
onChange={(e) => m.setSelectedLibraryId(e.target.value)}
|
||||
>
|
||||
{m.libraries.map((lib) => (
|
||||
<option key={lib.id} value={lib.id}>
|
||||
{lib.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{m.materialMode === "manual" && (
|
||||
|
||||
@@ -8,11 +8,22 @@ import type { AssetItem } from "@/api/assets"
|
||||
* 管理素材库列表、当前选中库、素材列表加载
|
||||
*/
|
||||
export function useMaterialLibrary() {
|
||||
/* ── 素材库数据 API ── */
|
||||
const { data: libraries = [] } = useQuery({
|
||||
queryKey: ["asset-libraries"],
|
||||
queryFn: getAssetLibraries,
|
||||
/* ── 素材库数据 API ──
|
||||
* Step2 是视频选片,只拉取 kind=video 的素材库(#1777):
|
||||
* 后端按 kind 查询参数过滤,前端 getAssetLibraries("video") 再兜底过滤一次,
|
||||
* 避免配音库(voice)/图片库(image) 混进「选择视频库」下拉。
|
||||
* queryKey 带 kind,与素材管理页/配音页的 ["asset-libraries"] 全量缓存隔离。
|
||||
*/
|
||||
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<string>("")
|
||||
|
||||
// 自动选中第一个视频库
|
||||
|
||||
@@ -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,8 @@ 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 +231,7 @@ export const useGenerateFormState = (): GenerateFormState => {
|
||||
selectedTemplate,
|
||||
setSelectedTemplate,
|
||||
userTemplates,
|
||||
handleInvalidTemplate,
|
||||
selectedMaterials,
|
||||
setSelectedMaterials,
|
||||
materialMode,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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<EditingTemplate[]>({
|
||||
|
||||
const { data: allTemplates = [] } = useQuery<EditingTemplate[]>({
|
||||
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<string>("")
|
||||
|
||||
/* 自动选择:模板加载完成且当前未选中时,自动选中第一个有效模板。
|
||||
* 用户手动选择(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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
+1
-1
@@ -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("无法创建配音库")
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
|
||||
@@ -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("配音库不存在,请先在配音库页面创建")
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* 失效模板判定/回退纯函数单测(#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<EditingTemplate> & { 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<EditingTemplate>).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()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* 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 }) =>
|
||||
(<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>) 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("")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* 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)——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", () => ({
|
||||
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> = {}): 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 }) =>
|
||||
(<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>) 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")
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user