Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 691c811cd4 | |||
| 2114b7e7ae | |||
| 7766ba1479 | |||
| ef686dde8f | |||
| 01026156ae | |||
| 42c0885813 |
@@ -106,6 +106,10 @@ def list_templates(
|
||||
tag: str | None = Query(None, description="按标签筛选"),
|
||||
keyword: str | None = Query(None, description="按名称关键词搜索"),
|
||||
mode: str | None = Query(None, description="按剪辑模式筛选"),
|
||||
valid_only: bool = Query(
|
||||
False,
|
||||
description="仅返回已配置片段的模板(剪辑页传 true;模板编辑器不传,可查看全部模板含草稿)",
|
||||
),
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
template_repository: SQLAlchemyTemplateRepository = Depends(_get_template_repository),
|
||||
) -> ListTemplatesResponse:
|
||||
@@ -116,6 +120,7 @@ def list_templates(
|
||||
tag=tag,
|
||||
keyword=keyword,
|
||||
mode=mode,
|
||||
valid_only=valid_only,
|
||||
)
|
||||
use_case = ListTemplatesUseCase(template_repository)
|
||||
templates = use_case.execute(user_id, skip=skip, limit=limit, filter=tpl_filter)
|
||||
|
||||
@@ -36,17 +36,11 @@ from app.services.asset_segment_tracker import (
|
||||
remove_used_segment,
|
||||
)
|
||||
from app.services.edit_plan_service import EditPlanService
|
||||
from app.services.edit_template_service import EditTemplateService
|
||||
from app.services.edit_template_service import EditTemplateService, TemplateNotFoundError
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.asset_repository import SQLAlchemyAssetRepository
|
||||
from packages.adapters.sqlalchemy_impl.template_clip_config_repository import (
|
||||
SQLAlchemyTemplateClipConfigRepository,
|
||||
)
|
||||
from packages.adapters.sqlalchemy_impl.template_repository import (
|
||||
SQLAlchemyTemplateRepository,
|
||||
)
|
||||
from packages.domain.plan_generator_utils import (
|
||||
_calc_random_start_time,
|
||||
build_scene_segments,
|
||||
@@ -399,73 +393,42 @@ def _safe_segment_duration(value, default: float) -> float:
|
||||
|
||||
def _get_template_segments(
|
||||
template_id: str,
|
||||
user_id: str,
|
||||
tpl_svc: EditTemplateService,
|
||||
db: Session,
|
||||
) -> list[tuple[int, float, float]]:
|
||||
"""获取模板的片段配置(顺序、最短时长、最长时长).
|
||||
|
||||
优先从新模板系统(template_clip_configs)查询,
|
||||
若不存在则回退到旧模板系统(template_segments)。
|
||||
单一数据源:模板主表为 ``templates``(用户自建,归属 user_id)/
|
||||
``edit_templates``(全局模板库),片段配置主表为 ``template_clip_configs``
|
||||
(由 ``EditTemplateService.list_clip_configs_for_editor`` 统一读取)。
|
||||
|
||||
不再使用"新表抛异常 → 降级直查配置表 → 再降级查 segments"的异常控制流,
|
||||
也不在正常请求中打印 ``ValueError: 模板不存在`` 堆栈。
|
||||
|
||||
Args:
|
||||
template_id: 模板 ID
|
||||
user_id: 当前登录用户 ID(用于归属校验)
|
||||
tpl_svc: 模板编辑器服务
|
||||
|
||||
Returns:
|
||||
[(segment_order, duration_min, duration_max), ...] 按 order 排序
|
||||
[(segment_order, duration_min, duration_max), ...] 按 order 排序;
|
||||
模板存在但未配置片段时返回空列表。
|
||||
|
||||
Raises:
|
||||
TemplateNotFoundError: 模板不存在、已删除或不归属于当前用户。
|
||||
"""
|
||||
# 优先查新模板系统
|
||||
try:
|
||||
clip_configs = tpl_svc.list_clip_configs(template_id)
|
||||
if clip_configs:
|
||||
result = []
|
||||
for cc in clip_configs:
|
||||
dur_min = _safe_segment_duration(cc.min_duration, _DEFAULT_EDITOR_CLIP_DURATION)
|
||||
dur_max = _safe_segment_duration(
|
||||
cc.max_duration or cc.min_duration,
|
||||
_DEFAULT_EDITOR_CLIP_DURATION,
|
||||
)
|
||||
dur_min, dur_max = min(dur_min, dur_max), max(dur_min, dur_max)
|
||||
result.append((cc.order, dur_min, dur_max))
|
||||
return sorted(result, key=lambda x: x[0])
|
||||
except Exception:
|
||||
logger.warning("新模板系统查询clip_configs失败(主表可能不存在),直接查clip_configs表", exc_info=True)
|
||||
clip_configs = tpl_svc.list_clip_configs_for_editor(template_id, user_id)
|
||||
|
||||
# 兜底:直接查 template_clip_configs 表(片段表有 template_id 外键,不依赖模板主表)
|
||||
try:
|
||||
direct_repo = SQLAlchemyTemplateClipConfigRepository(db)
|
||||
direct_configs = direct_repo.list_by_template(template_id)
|
||||
if direct_configs:
|
||||
result = []
|
||||
for cc in direct_configs:
|
||||
dur_min = _safe_segment_duration(cc.min_duration, _DEFAULT_EDITOR_CLIP_DURATION)
|
||||
dur_max = _safe_segment_duration(
|
||||
cc.max_duration or cc.min_duration,
|
||||
_DEFAULT_EDITOR_CLIP_DURATION,
|
||||
)
|
||||
dur_min, dur_max = min(dur_min, dur_max), max(dur_min, dur_max)
|
||||
result.append((cc.order, dur_min, dur_max))
|
||||
return sorted(result, key=lambda x: x[0])
|
||||
except Exception:
|
||||
logger.warning("直接查clip_configs表也失败,继续回退旧系统", exc_info=True)
|
||||
|
||||
# 回退到旧模板系统(template_segments表)
|
||||
try:
|
||||
old_repo = SQLAlchemyTemplateRepository(db)
|
||||
segments = old_repo.list_segments(template_id)
|
||||
if segments:
|
||||
result = []
|
||||
for s in segments:
|
||||
dur_min = _safe_segment_duration(s.duration_min, _DEFAULT_EDITOR_CLIP_DURATION)
|
||||
dur_max = _safe_segment_duration(s.duration_max, _DEFAULT_EDITOR_CLIP_DURATION)
|
||||
dur_min, dur_max = min(dur_min, dur_max), max(dur_min, dur_max)
|
||||
result.append((s.segment_order, dur_min, dur_max))
|
||||
return sorted(result, key=lambda x: x[0])
|
||||
except Exception:
|
||||
logger.warning("旧模板系统查询segments失败", exc_info=True)
|
||||
|
||||
# 所有途径都失败:模板没有片段配置(可能是无效测试模板)
|
||||
logger.error(
|
||||
"模板无片段配置:template_id=%s(可能是 is_active=false 的无效模板)",
|
||||
template_id,
|
||||
)
|
||||
return []
|
||||
result = []
|
||||
for cc in clip_configs:
|
||||
dur_min = _safe_segment_duration(cc.min_duration, _DEFAULT_EDITOR_CLIP_DURATION)
|
||||
dur_max = _safe_segment_duration(
|
||||
cc.max_duration or cc.min_duration,
|
||||
_DEFAULT_EDITOR_CLIP_DURATION,
|
||||
)
|
||||
dur_min, dur_max = min(dur_min, dur_max), max(dur_min, dur_max)
|
||||
result.append((cc.order, dur_min, dur_max))
|
||||
return sorted(result, key=lambda x: x[0])
|
||||
|
||||
|
||||
def _recommended_time_conflicts(
|
||||
@@ -668,13 +631,21 @@ def create_clips_from_assets_editor(
|
||||
7. 素材时长为 0 或缺失时报 400,不创建无效片段
|
||||
"""
|
||||
tpl_svc, plan_svc = services
|
||||
user_id = str(current_user.user.id)
|
||||
|
||||
# 1. 查询模板 segments
|
||||
segments = _get_template_segments(template_id, tpl_svc, db)
|
||||
# 1. 查询模板片段配置。模板不存在/已删除/无权限 → 404;
|
||||
# 模板存在但确实未配置片段 → 422(配置错误,与 404 区分)。
|
||||
try:
|
||||
segments = _get_template_segments(template_id, user_id, tpl_svc)
|
||||
except TemplateNotFoundError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="模板不存在或无权访问",
|
||||
) from exc
|
||||
if not segments:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="模板没有片段配置,无法创建片段",
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="模板未配置片段",
|
||||
)
|
||||
|
||||
# 防御:schema validator 已过滤 null/空串,这里再归一化一次,
|
||||
|
||||
@@ -41,29 +41,33 @@ def get_draft_plan_id(
|
||||
这是模板编辑器路由的核心依赖——所有编辑器端点都先经过这里,
|
||||
确保 template_id → plan_id 的映射始终存在。
|
||||
|
||||
兼容策略:优先从新模板系统(edit_templates 表)查找,
|
||||
若不存在则回退到旧模板系统(templates 表),确保用户自建模板可用。
|
||||
模板读取遵循单一数据源、显式判定(不使用异常降级):
|
||||
- 用户自建模板在旧表 ``templates``(归属 user_id,is_active=True);
|
||||
- 全局模板在新表 ``edit_templates``(无 user_id,全局可读)。
|
||||
模板不存在、已删除或不归属于当前用户时,一律返回 404。
|
||||
"""
|
||||
tpl_svc, plan_svc = services
|
||||
user_id = str(current_user.user.id)
|
||||
|
||||
# 0. 门禁:校验模板存在且可访问(即使草稿已缓存命中也要校验,
|
||||
# 避免模板被删除/无权访问后仍可通过既有草稿 plan 继续操作)。
|
||||
old_repo = SQLAlchemyTemplateRepository(db)
|
||||
old_template = old_repo.get_active(template_id, user_id)
|
||||
is_global_template = tpl_svc.get_template(template_id) is not None
|
||||
if old_template is None and not is_global_template:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="模板不存在")
|
||||
|
||||
# 1. 草稿已存在 → 直接返回
|
||||
draft = tpl_svc.get_template_draft(template_id)
|
||||
if draft is not None:
|
||||
return draft.id
|
||||
|
||||
# 2. 新系统有模板 → 用新服务创建草稿
|
||||
if tpl_svc.get_template(template_id) is not None:
|
||||
# 2. 全局模板(新系统)→ 用新服务创建草稿
|
||||
if is_global_template:
|
||||
draft = tpl_svc.create_template_draft(template_id, user_id=user_id)
|
||||
return draft.id
|
||||
|
||||
# 3. 回退到旧模板系统(templates 表)
|
||||
old_repo = SQLAlchemyTemplateRepository(db)
|
||||
old_template = old_repo.get(template_id, user_id=user_id)
|
||||
if old_template is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="模板不存在")
|
||||
|
||||
# 4. 基于旧模板创建草稿计划
|
||||
# 3. 旧模板(templates 表)→ 基于旧模板创建草稿计划
|
||||
from app.services.plan_generator_service import PlanGeneratorService
|
||||
|
||||
from packages.domain.edit_template import EditTemplate, EditTemplateStatus
|
||||
|
||||
@@ -150,7 +150,7 @@ def rollback_template(
|
||||
try:
|
||||
tpl = tpl_svc.rollback_to_version(template_id, request.version)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||
|
||||
clip_configs = tpl_svc.list_clip_configs(template_id)
|
||||
return EditorRollbackResponse(
|
||||
|
||||
@@ -34,6 +34,17 @@ from packages.domain.template_clip_converter import (
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TemplateNotFoundError(Exception):
|
||||
"""模板不存在、已删除或当前用户无权访问.
|
||||
|
||||
与"模板存在但无片段配置"区分:路由层应映射为 HTTP 404。
|
||||
"""
|
||||
|
||||
def __init__(self, template_id: str) -> None:
|
||||
self.template_id = template_id
|
||||
super().__init__(f"模板不存在: {template_id}")
|
||||
|
||||
|
||||
class EditTemplateService:
|
||||
"""模板管理服务
|
||||
|
||||
@@ -217,7 +228,14 @@ class EditTemplateService:
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
) -> List[TemplateClipConfig]:
|
||||
"""列出模板的片段配置"""
|
||||
"""列出模板的片段配置
|
||||
|
||||
注意:本方法要求模板存在于新表 ``edit_templates``(全局模板库),
|
||||
主要服务于新模板系统的写入/发布路径。用户自建模板存放在旧表
|
||||
``templates``,不在 ``edit_templates`` 中,读取其片段配置请改用
|
||||
:meth:`list_clip_configs_for_editor`,后者直接读取片段配置主表
|
||||
``template_clip_configs``,不依赖新模板主表、也不靠异常降级。
|
||||
"""
|
||||
# 确保模板存在
|
||||
self.get_template_or_raise(template_id)
|
||||
return self._clip_config_repo.list_by_template(
|
||||
@@ -227,6 +245,52 @@ class EditTemplateService:
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
def list_clip_configs_for_editor(
|
||||
self,
|
||||
template_id: str,
|
||||
user_id: str,
|
||||
*,
|
||||
clip_type: Optional[ClipType] = None,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
) -> List[TemplateClipConfig]:
|
||||
"""编辑器读取模板片段配置的单一数据源入口.
|
||||
|
||||
片段配置主表是 ``template_clip_configs``(直接读取,不抛异常、不降级)。
|
||||
模板主表按双表现状显式判定,不使用 try/except 控制流:
|
||||
|
||||
1. 用户自建模板在旧表 ``templates``(归属 user_id)→ 校验归属与未删除后直接读;
|
||||
2. 全局模板在新表 ``edit_templates``(无 user_id,全局可读)→ 直接读;
|
||||
3. 两者都没有 → 模板不存在/无权限,抛 :class:`TemplateNotFoundError`。
|
||||
|
||||
Args:
|
||||
template_id: 模板 ID
|
||||
user_id: 当前登录用户 ID(用于旧表模板归属校验)
|
||||
|
||||
Raises:
|
||||
TemplateNotFoundError: 模板不存在、已删除或不归属于当前用户。
|
||||
"""
|
||||
# 1) 用户自建模板(旧表 templates,归属 user_id)
|
||||
if self._clip_config_repo.template_owned_by(template_id, user_id):
|
||||
return self._clip_config_repo.list_by_template(
|
||||
template_id,
|
||||
clip_type=clip_type,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
# 2) 全局模板(新表 edit_templates,无 user_id,全局可读)
|
||||
if self._template_repo.get(template_id) is not None:
|
||||
return self._clip_config_repo.list_by_template(
|
||||
template_id,
|
||||
clip_type=clip_type,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
# 3) 两表都没有:不存在 / 已删除 / 无权限
|
||||
raise TemplateNotFoundError(template_id)
|
||||
|
||||
def get_clip_config(self, config_id: str) -> Optional[TemplateClipConfig]:
|
||||
"""获取片段配置详情"""
|
||||
return self._clip_config_repo.get(config_id)
|
||||
|
||||
@@ -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,
|
||||
@@ -416,15 +417,11 @@ const GeneratePage: React.FC = () => {
|
||||
/* ── 最终成片(单视频右侧播放) ── */
|
||||
const finalVideo = generatedVideos[0]
|
||||
|
||||
/* ── 布局 class:步骤4标题页=预览+标题侧栏;步骤5/6批量=整行宽;步骤1~3=整行宽 ── */
|
||||
/* ── 布局 class:步骤4标题页=预览+标题侧栏两栏;其余步骤(含步骤5确认生成、步骤6封面)=整行宽 ── */
|
||||
const layoutClassName = useMemo(() => {
|
||||
if (currentStep < 4) return "xx-generate-layout full-width"
|
||||
if (currentStep === 4) return "xx-generate-layout step4-layout"
|
||||
// 步骤5:全宽+内容居中(单视频视频播放器居中,批量网格居中)
|
||||
if (currentStep === 5) return "xx-generate-layout full-width"
|
||||
// 步骤6:封面选择保持两栏布局
|
||||
return isBatch ? "xx-generate-layout full-width" : "xx-generate-layout"
|
||||
}, [currentStep, isBatch])
|
||||
return "xx-generate-layout full-width"
|
||||
}, [currentStep])
|
||||
|
||||
/* ================================================================
|
||||
渲染
|
||||
@@ -524,6 +521,7 @@ const GeneratePage: React.FC = () => {
|
||||
selectedVoice={selectedVoice}
|
||||
onSelectedVoiceChange={setSelectedVoice}
|
||||
onServerClipsChange={setServerClips}
|
||||
onTemplateInvalid={handleInvalidTemplate}
|
||||
generating={generating}
|
||||
generated={generated}
|
||||
generateError={generateError}
|
||||
@@ -545,18 +543,7 @@ const GeneratePage: React.FC = () => {
|
||||
selectedVariantIds={selectedVariantIds}
|
||||
/>
|
||||
|
||||
<GenerateStepActions
|
||||
currentStep={currentStep}
|
||||
onPrev={goPrev}
|
||||
onNext={goNext}
|
||||
onConfirmGenerate={handleConfirmGenerate}
|
||||
generating={generating}
|
||||
generated={generated}
|
||||
generateError={generateError}
|
||||
selectedCount={isBatch ? selectedVariantIds.length : 1}
|
||||
/>
|
||||
|
||||
{/* ════ 步骤5(单视频):成片播放器内联居中(#1761) ════ */}
|
||||
{/* ════ 步骤5(单视频):成片播放器置于按钮上方、居中展示 ════ */}
|
||||
{currentStep === 5 && !isBatch && generated && finalVideo && (
|
||||
<div
|
||||
style={{
|
||||
@@ -580,6 +567,7 @@ const GeneratePage: React.FC = () => {
|
||||
<video
|
||||
src={finalVideo.download_url || finalVideo.file_url}
|
||||
controls
|
||||
autoPlay
|
||||
style={{
|
||||
width: "auto",
|
||||
maxWidth: "100%",
|
||||
@@ -607,55 +595,18 @@ const GeneratePage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ════ 步骤6(单视频):右侧成片播放器 ════ */}
|
||||
{currentStep >= 6 && !isBatch && generated && finalVideo && (
|
||||
<div className="xx-generate-right-col">
|
||||
<div
|
||||
className="xx-inline-video-player"
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
background: "#000",
|
||||
borderRadius: 12,
|
||||
padding: 8,
|
||||
}}
|
||||
>
|
||||
<video
|
||||
src={finalVideo.download_url || finalVideo.file_url}
|
||||
controls
|
||||
autoPlay={currentStep === 5}
|
||||
// 竖屏自适应(#1750):成片固定 1080×1920(9:16),元数据到达前按 9:16 占位,
|
||||
// 到达后浏览器按真实宽高比 contain;黑底居中杜绝左右大黑边
|
||||
style={{
|
||||
width: "auto",
|
||||
maxWidth: "100%",
|
||||
maxHeight: "70vh",
|
||||
aspectRatio: "9 / 16",
|
||||
objectFit: "contain",
|
||||
borderRadius: 8,
|
||||
}}
|
||||
poster={finalVideo.thumbnail_url || undefined}
|
||||
/>
|
||||
<div style={{ display: "flex", gap: 8, marginTop: 12, justifyContent: "center" }}>
|
||||
<button className="xx-btn xx-btn-ghost xx-btn-sm" onClick={handleDownload}>
|
||||
⬇️ 下载
|
||||
</button>
|
||||
<button className="xx-btn xx-btn-ghost xx-btn-sm" onClick={handleShare}>
|
||||
🔗 分享
|
||||
</button>
|
||||
<button
|
||||
className="xx-btn xx-btn-ghost xx-btn-sm"
|
||||
onClick={() => navigate("/app/products")}
|
||||
>
|
||||
📁 前往成片库
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<GenerateStepActions
|
||||
currentStep={currentStep}
|
||||
onPrev={goPrev}
|
||||
onNext={goNext}
|
||||
onConfirmGenerate={handleConfirmGenerate}
|
||||
generating={generating}
|
||||
generated={generated}
|
||||
generateError={generateError}
|
||||
selectedCount={isBatch ? selectedVariantIds.length : 1}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 数量选择弹窗 */}
|
||||
|
||||
@@ -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:
|
||||
@@ -189,7 +193,7 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
|
||||
/>
|
||||
)
|
||||
case 5:
|
||||
/* 确认生成页:批量=逐任务进度网格;单视频=进度状态卡(成片播放器在左侧大区域) */
|
||||
/* 确认生成页:批量=逐任务进度网格;单视频=仅渲染进度/失败状态(完成后只显示成片播放器,播放器在按钮上方) */
|
||||
if (previewCount > 1) {
|
||||
return (
|
||||
<BatchGenerationGrid
|
||||
@@ -199,10 +203,10 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
|
||||
/>
|
||||
)
|
||||
}
|
||||
/* 单视频:渲染进度 / 失败重试 / 完成提示(成片播放器在右侧栏) */
|
||||
/* 单视频:生成中显示进度卡、失败显示重试卡;生成完成后不再渲染提示卡,页面只保留成片播放器+操作按钮 */
|
||||
if (generated && !generating && !generateError) return null
|
||||
return (
|
||||
<div className="xx-form-section">
|
||||
<h3>🎬 确认生成</h3>
|
||||
{generating && (
|
||||
<div className="xx-gen-progress-card">
|
||||
<div className="xx-gen-progress-header">
|
||||
@@ -234,14 +238,6 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{generated && !generating && (
|
||||
<div className="xx-gen-success-card">
|
||||
<div className="xx-gen-success-info">
|
||||
<div className="xx-gen-success-title">✅ 视频生成完成!</div>
|
||||
<div className="xx-gen-success-sub">右侧可预览成片,点击「下一步」选择封面</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
case 6:
|
||||
|
||||
@@ -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" && (
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
import React from "react"
|
||||
import { LoadingOutlined, CheckCircleFilled, CloseCircleOutlined } from "@ant-design/icons"
|
||||
import type { GeneratedVideo } from "@/api/template-editor"
|
||||
|
||||
interface GenerationStatusProps {
|
||||
generating: boolean
|
||||
generated: boolean
|
||||
generateError: string | null
|
||||
progress: number
|
||||
generatedVideos: GeneratedVideo[]
|
||||
getGenerationPhase: (progress: number) => { icon: string; label: string }
|
||||
onScrollToPreview: () => void
|
||||
onRetry: () => void
|
||||
onDismissError: () => void
|
||||
}
|
||||
|
||||
const GenerationStatus: React.FC<GenerationStatusProps> = ({
|
||||
generating,
|
||||
generated,
|
||||
generateError,
|
||||
progress,
|
||||
generatedVideos,
|
||||
getGenerationPhase,
|
||||
onScrollToPreview,
|
||||
onRetry,
|
||||
onDismissError,
|
||||
}) => {
|
||||
return (
|
||||
<div style={{ marginTop: 16 }}>
|
||||
{!generating && !generated && !generateError && (
|
||||
<div className="xx-gen-progress-card" style={{ opacity: 0.85 }}>
|
||||
<div className="xx-gen-progress-header">
|
||||
<div className="xx-gen-progress-icon">🎬</div>
|
||||
<div className="xx-gen-progress-info">
|
||||
<div className="xx-gen-progress-phase">尚未开始生成视频</div>
|
||||
<div className="xx-gen-progress-sub">
|
||||
请返回「选择标题」步骤,点击「确认生成视频」开始渲染最终视频
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{generating && (
|
||||
<div className="xx-gen-progress-card">
|
||||
<div className="xx-gen-progress-header">
|
||||
<div className="xx-gen-progress-icon">
|
||||
<LoadingOutlined />
|
||||
</div>
|
||||
<div className="xx-gen-progress-info">
|
||||
<div className="xx-gen-progress-phase">
|
||||
{getGenerationPhase(progress).icon} {getGenerationPhase(progress).label}
|
||||
</div>
|
||||
<div className="xx-gen-progress-sub">预计还需 1-2 分钟,请稍候…</div>
|
||||
</div>
|
||||
<div className="xx-gen-progress-percent">{Math.round(progress)}%</div>
|
||||
</div>
|
||||
<div className="xx-gen-progress-bar">
|
||||
<div
|
||||
className="xx-gen-progress-bar-fill"
|
||||
style={{ width: `${Math.min(Math.round(progress), 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="xx-gen-progress-tip">
|
||||
💡 生成过程中可以切换到其他页面操作,完成后会自动通知
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{generated && !generating && (
|
||||
<div className="xx-gen-success-card">
|
||||
<div className="xx-gen-success-icon">
|
||||
<CheckCircleFilled style={{ fontSize: 32, color: "#52c41a" }} />
|
||||
</div>
|
||||
<div className="xx-gen-success-info">
|
||||
<div className="xx-gen-success-title">视频生成完成!</div>
|
||||
<div className="xx-gen-success-sub">
|
||||
共生成 {generatedVideos.length} 条视频,可在右侧预览或前往成片库查看
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="xx-btn xx-btn-primary xx-btn-sm"
|
||||
onClick={onScrollToPreview}
|
||||
>
|
||||
查看结果
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{generateError && !generating && (
|
||||
<div className="xx-gen-error-card">
|
||||
<div className="xx-gen-error-icon">
|
||||
<CloseCircleOutlined style={{ fontSize: 28, color: "#ef4444" }} />
|
||||
</div>
|
||||
<div className="xx-gen-error-info">
|
||||
<div className="xx-gen-error-title">生成失败</div>
|
||||
<div className="xx-gen-error-msg">
|
||||
{typeof generateError === "string" ? generateError : JSON.stringify(generateError)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<button type="button" className="xx-btn xx-btn-primary xx-btn-sm" onClick={onRetry}>
|
||||
🔄 重试
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="xx-btn xx-btn-ghost xx-btn-sm"
|
||||
onClick={onDismissError}
|
||||
>
|
||||
知道了
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default GenerationStatus
|
||||
@@ -117,10 +117,6 @@
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.xx-generate-layout.full-width .xx-generate-right-col {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
左侧表单区 generate-form
|
||||
============================================================ */
|
||||
@@ -2323,28 +2319,6 @@
|
||||
生成结果(右侧)
|
||||
================================================================ */
|
||||
|
||||
.xx-generate-right-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* ── 内联视频播放器(右侧) ── */
|
||||
.xx-inline-video-player {
|
||||
width: 100%;
|
||||
max-width: 320px;
|
||||
background: var(--bg-surface, #fff);
|
||||
border: 1px solid var(--border-primary, #e2e8f0);
|
||||
border-radius: 16px;
|
||||
padding: 16px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.xx-inline-video-player video {
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.xx-preview-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -2710,11 +2684,12 @@
|
||||
|
||||
/* ── 封面设置区域改造样式 ── */
|
||||
|
||||
/* 封面操作按钮区 */
|
||||
/* 封面操作按钮区(单视频全宽页居中) */
|
||||
.xx-cover-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* 已选模板文字 */
|
||||
@@ -3202,10 +3177,11 @@
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── 批量封面网格 ── */
|
||||
/* ── 批量封面网格(单卡/少卡时居中排列,卡片限宽不拉伸) ── */
|
||||
.xx-cover-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 220px));
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
@@ -6,7 +6,10 @@ from typing import List, Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.models import TemplateClipConfigModel
|
||||
from packages.adapters.sqlalchemy_impl.models import (
|
||||
TemplateClipConfigModel,
|
||||
TemplateModel,
|
||||
)
|
||||
from packages.domain.template_clip_config import (
|
||||
ClipType,
|
||||
TemplateClipConfig,
|
||||
@@ -38,6 +41,24 @@ class SQLAlchemyTemplateClipConfigRepository:
|
||||
models = query.offset(skip).limit(limit).all()
|
||||
return [self._model_to_entity(m) for m in models]
|
||||
|
||||
def template_owned_by(self, template_id: str, user_id: str) -> bool:
|
||||
"""校验旧模板主表 ``templates`` 中模板归属当前用户且未删除(is_active=True).
|
||||
|
||||
片段配置主表 ``template_clip_configs`` 本身没有 user_id 列,
|
||||
归属关系通过模板主表 ``templates.user_id`` 确定。
|
||||
新表 ``edit_templates`` 为全局模板库(无 user_id 列),不走此校验。
|
||||
"""
|
||||
return (
|
||||
self.session.query(TemplateModel.id)
|
||||
.filter(
|
||||
TemplateModel.id == template_id,
|
||||
TemplateModel.user_id == user_id,
|
||||
TemplateModel.is_active.is_(True),
|
||||
)
|
||||
.first()
|
||||
is not None
|
||||
)
|
||||
|
||||
def get(self, config_id: str) -> Optional[TemplateClipConfig]:
|
||||
"""根据 ID 获取配置"""
|
||||
model = self.session.query(TemplateClipConfigModel).filter(TemplateClipConfigModel.id == config_id).first()
|
||||
|
||||
@@ -10,6 +10,7 @@ from __future__ import annotations
|
||||
import uuid
|
||||
from typing import List, Optional
|
||||
|
||||
from sqlalchemy import or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.models import (
|
||||
@@ -28,6 +29,20 @@ class SQLAlchemyTemplateRepository:
|
||||
def __init__(self, session: Session) -> None:
|
||||
self.session = session
|
||||
|
||||
def _filter_with_segment_configs(self, query):
|
||||
"""只保留在 template_clip_configs 或 template_segments 中存在片段配置的模板。
|
||||
|
||||
两张表都没有记录的模板无法用于生成(from-assets 会 400),
|
||||
剪辑页选模板时应排除;模板编辑器不传 valid_only,仍可见全部模板。
|
||||
"""
|
||||
has_clip_config = self.session.query(TemplateClipConfigModel.id).filter(
|
||||
TemplateClipConfigModel.template_id == TemplateModel.id,
|
||||
)
|
||||
has_segment = self.session.query(TemplateSegmentModel.id).filter(
|
||||
TemplateSegmentModel.template_id == TemplateModel.id,
|
||||
)
|
||||
return query.filter(or_(has_clip_config.exists(), has_segment.exists()))
|
||||
|
||||
# ── Template CRUD ──
|
||||
|
||||
def list_by_user(
|
||||
@@ -40,11 +55,14 @@ class SQLAlchemyTemplateRepository:
|
||||
tag: Optional[str] = None,
|
||||
keyword: Optional[str] = None,
|
||||
mode: Optional[str] = None,
|
||||
valid_only: bool = False,
|
||||
) -> List[Template]:
|
||||
query = self.session.query(TemplateModel).filter(
|
||||
TemplateModel.user_id == user_id,
|
||||
TemplateModel.is_active.is_(True),
|
||||
)
|
||||
if valid_only:
|
||||
query = self._filter_with_segment_configs(query)
|
||||
if category:
|
||||
query = query.filter(TemplateModel.category == category)
|
||||
if mode:
|
||||
@@ -102,6 +120,27 @@ class SQLAlchemyTemplateRepository:
|
||||
template.segments = self.list_segments(template.id)
|
||||
return template
|
||||
|
||||
def get_active(self, template_id: str, user_id: str) -> Optional[Template]:
|
||||
"""获取归属当前用户且未删除(is_active=True)的模板,否则返回 None.
|
||||
|
||||
用于编辑器访问门禁:模板不存在、已软删除或不属于当前用户时返回 None,
|
||||
由调用方映射为 404。与 :meth:`get` 的区别是额外过滤 is_active。
|
||||
"""
|
||||
model = (
|
||||
self.session.query(TemplateModel)
|
||||
.filter(
|
||||
TemplateModel.id == template_id,
|
||||
TemplateModel.user_id == user_id,
|
||||
TemplateModel.is_active.is_(True),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if model is None:
|
||||
return None
|
||||
template = self._model_to_entity(model)
|
||||
template.segments = self.list_segments(template.id)
|
||||
return template
|
||||
|
||||
def create(self, template: Template) -> Template:
|
||||
model = TemplateModel(
|
||||
id=template.id,
|
||||
@@ -173,11 +212,14 @@ class SQLAlchemyTemplateRepository:
|
||||
tag: Optional[str] = None,
|
||||
keyword: Optional[str] = None,
|
||||
mode: Optional[str] = None,
|
||||
valid_only: bool = False,
|
||||
) -> int:
|
||||
query = self.session.query(TemplateModel).filter(
|
||||
TemplateModel.user_id == user_id,
|
||||
TemplateModel.is_active.is_(True),
|
||||
)
|
||||
if valid_only:
|
||||
query = self._filter_with_segment_configs(query)
|
||||
if category:
|
||||
query = query.filter(TemplateModel.category == category)
|
||||
if mode:
|
||||
|
||||
@@ -62,6 +62,7 @@ class ListTemplatesFilter:
|
||||
tag: Optional[str] = None
|
||||
keyword: Optional[str] = None
|
||||
mode: Optional[str] = None
|
||||
valid_only: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -106,6 +106,7 @@ class ListTemplatesUseCase:
|
||||
tag=filter.tag,
|
||||
keyword=filter.keyword,
|
||||
mode=filter.mode,
|
||||
valid_only=filter.valid_only,
|
||||
)
|
||||
|
||||
|
||||
@@ -127,6 +128,7 @@ class CountTemplatesUseCase:
|
||||
tag=filter.tag,
|
||||
keyword=filter.keyword,
|
||||
mode=filter.mode,
|
||||
valid_only=filter.valid_only,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ class TemplateRepositoryPort(Protocol):
|
||||
tag: Optional[str] = None,
|
||||
keyword: Optional[str] = None,
|
||||
mode: Optional[str] = None,
|
||||
valid_only: bool = False,
|
||||
) -> List[Template]: ...
|
||||
def get(self, template_id: str, user_id: str) -> Optional[Template]: ...
|
||||
def create(self, template: Template) -> Template: ...
|
||||
@@ -31,6 +32,7 @@ class TemplateRepositoryPort(Protocol):
|
||||
tag: Optional[str] = None,
|
||||
keyword: Optional[str] = None,
|
||||
mode: Optional[str] = None,
|
||||
valid_only: bool = False,
|
||||
) -> int: ...
|
||||
def copy_template(self, template_id: str, user_id: str, new_name: str) -> Template: ...
|
||||
def list_segments(self, template_id: str) -> List[TemplateSegment]: ...
|
||||
|
||||
@@ -239,8 +239,8 @@ class TestEditorClipsBySegments:
|
||||
assert not hasattr(mock_plan_svc, "create_clip") or not mock_plan_svc.create_clip.called
|
||||
|
||||
@patch("app.api.routes.templates_editor.clips.get_storage_service")
|
||||
def test_no_segments_raises_400(self, mock_storage):
|
||||
"""模板没有 segment 配置时返回 400。"""
|
||||
def test_no_segments_raises_422(self, mock_storage):
|
||||
"""模板存在但未配置片段时返回 422(配置错误,与 404 区分)。"""
|
||||
from app.api.routes.templates_editor.clips import (
|
||||
create_clips_from_assets_editor,
|
||||
)
|
||||
@@ -264,11 +264,44 @@ class TestEditorClipsBySegments:
|
||||
current_user=_make_auth_user(),
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
assert "片段配置" in exc_info.value.detail
|
||||
assert exc_info.value.status_code == 422
|
||||
assert "片段" in exc_info.value.detail
|
||||
# 不应调用替换方法
|
||||
mock_plan_svc.replace_all_clips_transactional.assert_not_called()
|
||||
|
||||
@patch("app.api.routes.templates_editor.clips.get_storage_service")
|
||||
def test_template_not_found_raises_404(self, mock_storage):
|
||||
"""模板不存在/已删除/无权限(服务层抛 TemplateNotFoundError)时返回 404。"""
|
||||
from app.api.routes.templates_editor.clips import (
|
||||
create_clips_from_assets_editor,
|
||||
)
|
||||
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
|
||||
from app.services.edit_template_service import TemplateNotFoundError
|
||||
|
||||
mock_plan_svc = _make_plan_svc()
|
||||
mock_asset_repo = MagicMock()
|
||||
|
||||
body = ClipsFromAssetsRequest(asset_ids=["a1"])
|
||||
|
||||
with patch(
|
||||
"app.api.routes.templates_editor.clips._get_template_segments",
|
||||
side_effect=TemplateNotFoundError("tpl-missing"),
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
create_clips_from_assets_editor(
|
||||
template_id="tpl-missing",
|
||||
body=body,
|
||||
background_tasks=MagicMock(),
|
||||
plan_id=TEST_PLAN_ID,
|
||||
services=(MagicMock(), mock_plan_svc),
|
||||
asset_repo=mock_asset_repo,
|
||||
db=MagicMock(),
|
||||
current_user=_make_auth_user(),
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 404
|
||||
mock_plan_svc.replace_all_clips_transactional.assert_not_called()
|
||||
|
||||
|
||||
class TestEditorClipsDurationAndStartTime:
|
||||
"""测试素材时长获取、clip duration 缩短、start_time 传入。"""
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
"""_get_template_segments 回退路径测试.
|
||||
"""模板片段配置读取路径测试(#1774).
|
||||
|
||||
验证三级回退链:
|
||||
1. 新模板系统(tpl_svc.list_clip_configs)正常 → 直接返回
|
||||
2. 新模板系统主表不存在(ValueError)→ 直接查 template_clip_configs 表兜底
|
||||
3. 直接查表也失败 → 回退旧模板系统(template_segments)
|
||||
4. 全部失败 → 返回空列表
|
||||
收敛后模板读取走单一数据源,不再有"新表抛异常→降级查旧表→再降级查 segments"
|
||||
的异常控制流:
|
||||
|
||||
覆盖 P0 修复:自建模板在 edit_templates 主表不存在但在 template_clip_configs 有记录时,
|
||||
from-assets 流程不再 400。
|
||||
- ``EditTemplateService.list_clip_configs_for_editor`` 显式判定模板归属/存在性:
|
||||
1. 用户自建模板在旧表 ``templates``(归属 user_id,is_active=True)→ 直接读
|
||||
``template_clip_configs``;
|
||||
2. 全局模板在新表 ``edit_templates``(无 user_id)→ 直接读 ``template_clip_configs``;
|
||||
3. 两表都没有 → 抛 ``TemplateNotFoundError``(路由层映射 404)。
|
||||
- ``_get_template_segments`` 仅做配置→(order, min, max) 的映射与排序,
|
||||
模板存在但无配置返回空列表(路由层映射 422)。
|
||||
|
||||
使用真实 SQLite 内存库 + 真实仓储,验证端到端读路径不抛 ``ValueError: 模板不存在``。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -15,21 +19,198 @@ from __future__ import annotations
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, PropertyMock
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
|
||||
|
||||
from app.api.routes.templates_editor.clips import _get_template_segments
|
||||
import pytest # noqa: E402
|
||||
from sqlalchemy import create_engine # noqa: E402
|
||||
from sqlalchemy.orm import sessionmaker # noqa: E402
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.models import ( # noqa: E402
|
||||
Base,
|
||||
EditTemplateModel,
|
||||
TemplateClipConfigModel,
|
||||
TemplateModel,
|
||||
)
|
||||
|
||||
TEST_TEMPLATE_ID = "tmpl-orphan-001"
|
||||
DEFAULT_DUR = 5.0 # _DEFAULT_EDITOR_CLIP_DURATION
|
||||
USER_ID = "user-001"
|
||||
OTHER_USER_ID = "user-002"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 真实内存 DB fixture
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_session():
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
Base.metadata.create_all(engine)
|
||||
return sessionmaker(bind=engine)()
|
||||
|
||||
|
||||
def _seed_legacy_template(session, template_id: str, user_id: str, *, active: bool = True, clip_count: int = 3):
|
||||
"""创建旧表 templates 模板(+ template_clip_configs 片段配置)。"""
|
||||
session.add(
|
||||
TemplateModel(
|
||||
id=template_id,
|
||||
user_id=user_id,
|
||||
name=f"模板-{template_id}",
|
||||
mode="one_take",
|
||||
is_active=active,
|
||||
)
|
||||
)
|
||||
for order in range(clip_count):
|
||||
session.add(
|
||||
TemplateClipConfigModel(
|
||||
id=f"cc-{template_id}-{order}",
|
||||
template_id=template_id,
|
||||
clip_type="main",
|
||||
order=order,
|
||||
min_duration=5.0,
|
||||
max_duration=8.0,
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
|
||||
def _seed_global_template(session, template_id: str, *, status: str = "active", clip_count: int = 2):
|
||||
"""创建新表 edit_templates 全局模板(+ template_clip_configs 片段配置)。"""
|
||||
session.add(
|
||||
EditTemplateModel(
|
||||
id=template_id,
|
||||
name=f"全局模板-{template_id}",
|
||||
template_type="default",
|
||||
editing_mode="one_take",
|
||||
status=status,
|
||||
)
|
||||
)
|
||||
for order in range(clip_count):
|
||||
session.add(
|
||||
TemplateClipConfigModel(
|
||||
id=f"gcc-{template_id}-{order}",
|
||||
template_id=template_id,
|
||||
clip_type="main",
|
||||
order=order,
|
||||
min_duration=3.0,
|
||||
max_duration=6.0,
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Service 读路径测试
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestListClipConfigsForEditor:
|
||||
"""list_clip_configs_for_editor 单一数据源 + 归属/存在性判定。"""
|
||||
|
||||
def test_legacy_user_template_returns_configs(self):
|
||||
"""用户自建模板(templates 表 + 3 条 clip_configs)→ 正常返回,不抛异常。"""
|
||||
from app.services.edit_template_service import EditTemplateService
|
||||
|
||||
session = _make_session()
|
||||
_seed_legacy_template(session, "tmpl-legacy", USER_ID, clip_count=3)
|
||||
|
||||
svc = EditTemplateService(session)
|
||||
configs = svc.list_clip_configs_for_editor("tmpl-legacy", USER_ID)
|
||||
|
||||
assert len(configs) == 3
|
||||
assert [c.order for c in configs] == [0, 1, 2]
|
||||
assert all(c.min_duration == 5.0 for c in configs)
|
||||
|
||||
def test_missing_template_raises_not_found(self):
|
||||
"""模板不存在(两表都没有)→ TemplateNotFoundError。"""
|
||||
from app.services.edit_template_service import EditTemplateService, TemplateNotFoundError
|
||||
|
||||
session = _make_session()
|
||||
svc = EditTemplateService(session)
|
||||
|
||||
with pytest.raises(TemplateNotFoundError):
|
||||
svc.list_clip_configs_for_editor("tmpl-not-exist", USER_ID)
|
||||
|
||||
def test_other_users_template_raises_not_found(self):
|
||||
"""他人模板(user_id 不匹配)→ TemplateNotFoundError(归属校验)。"""
|
||||
from app.services.edit_template_service import EditTemplateService, TemplateNotFoundError
|
||||
|
||||
session = _make_session()
|
||||
_seed_legacy_template(session, "tmpl-owner", OTHER_USER_ID, clip_count=3)
|
||||
|
||||
svc = EditTemplateService(session)
|
||||
with pytest.raises(TemplateNotFoundError):
|
||||
svc.list_clip_configs_for_editor("tmpl-owner", USER_ID)
|
||||
|
||||
def test_deleted_legacy_template_raises_not_found(self):
|
||||
"""已软删除(is_active=False)的旧表模板 → TemplateNotFoundError。"""
|
||||
from app.services.edit_template_service import EditTemplateService, TemplateNotFoundError
|
||||
|
||||
session = _make_session()
|
||||
_seed_legacy_template(session, "tmpl-deleted", USER_ID, active=False, clip_count=3)
|
||||
|
||||
svc = EditTemplateService(session)
|
||||
with pytest.raises(TemplateNotFoundError):
|
||||
svc.list_clip_configs_for_editor("tmpl-deleted", USER_ID)
|
||||
|
||||
def test_legacy_template_without_configs_returns_empty(self):
|
||||
"""模板存在且归属正确但无片段配置 → 返回空列表(不抛异常,路由层映射 422)。"""
|
||||
from app.services.edit_template_service import EditTemplateService
|
||||
|
||||
session = _make_session()
|
||||
_seed_legacy_template(session, "tmpl-noconfig", USER_ID, clip_count=0)
|
||||
|
||||
svc = EditTemplateService(session)
|
||||
configs = svc.list_clip_configs_for_editor("tmpl-noconfig", USER_ID)
|
||||
assert configs == []
|
||||
|
||||
def test_global_template_returns_configs(self):
|
||||
"""新表 edit_templates 全局模板(无 user_id)→ 任意用户可读,正常返回。"""
|
||||
from app.services.edit_template_service import EditTemplateService
|
||||
|
||||
session = _make_session()
|
||||
_seed_global_template(session, "tmpl-global", clip_count=2)
|
||||
|
||||
svc = EditTemplateService(session)
|
||||
configs = svc.list_clip_configs_for_editor("tmpl-global", USER_ID)
|
||||
|
||||
assert len(configs) == 2
|
||||
assert [c.order for c in configs] == [0, 1]
|
||||
|
||||
def test_normal_legacy_request_does_not_raise_valueerror(self):
|
||||
"""正常旧表模板请求绝不在读路径抛 ValueError: 模板不存在(回归保护)。"""
|
||||
import logging
|
||||
|
||||
from app.services.edit_template_service import EditTemplateService
|
||||
|
||||
session = _make_session()
|
||||
_seed_legacy_template(session, "tmpl-ok", USER_ID, clip_count=3)
|
||||
svc = EditTemplateService(session)
|
||||
|
||||
with pytest.MonkeyPatch.context() as mp:
|
||||
# 若读路径意外抛 ValueError 并被记录为异常堆栈,测试能感知
|
||||
errors: list[str] = []
|
||||
mp.setattr(
|
||||
logging.getLogger("app.services.edit_template_service"),
|
||||
"exception",
|
||||
lambda *a, **k: errors.append(str(a)),
|
||||
)
|
||||
configs = svc.list_clip_configs_for_editor("tmpl-ok", USER_ID)
|
||||
|
||||
assert len(configs) == 3
|
||||
assert errors == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _get_template_segments 映射测试
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_clip_config(order: int, min_dur: float = 3.0, max_dur: float = 8.0):
|
||||
"""构造 mock TemplateClipConfig 领域实体."""
|
||||
cc = MagicMock()
|
||||
cc.order = order
|
||||
cc.min_duration = min_dur
|
||||
@@ -37,207 +218,53 @@ def _make_clip_config(order: int, min_dur: float = 3.0, max_dur: float = 8.0):
|
||||
return cc
|
||||
|
||||
|
||||
def _make_old_segment(segment_order: int, dur_min: float = 4.0, dur_max: float = 7.0):
|
||||
"""构造 mock 旧 TemplateSegment."""
|
||||
s = MagicMock()
|
||||
s.segment_order = segment_order
|
||||
s.duration_min = dur_min
|
||||
s.duration_max = dur_max
|
||||
return s
|
||||
class TestGetTemplateSegments:
|
||||
"""_get_template_segments 仅做映射/排序,异常与空配置语义明确。"""
|
||||
|
||||
def test_maps_and_sorts_configs(self):
|
||||
from app.api.routes.templates_editor.clips import _get_template_segments
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 测试
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGetTemplateSegmentsFallback:
|
||||
"""_get_template_segments 三级回退链."""
|
||||
|
||||
def test_new_system_works(self):
|
||||
"""路径1:新模板系统正常返回 → 直接使用."""
|
||||
configs = [_make_clip_config(0, 2.0, 6.0), _make_clip_config(1, 3.0, 9.0)]
|
||||
tpl_svc = MagicMock()
|
||||
tpl_svc.list_clip_configs.return_value = configs
|
||||
db = MagicMock()
|
||||
|
||||
result = _get_template_segments(TEST_TEMPLATE_ID, tpl_svc, db)
|
||||
|
||||
assert len(result) == 2
|
||||
assert result[0] == (0, 2.0, 6.0)
|
||||
assert result[1] == (1, 3.0, 9.0)
|
||||
tpl_svc.list_clip_configs.assert_called_once_with(TEST_TEMPLATE_ID)
|
||||
|
||||
def test_main_table_missing_direct_query_succeeds(self):
|
||||
"""路径2(P0修复):主表不存在 ValueError → 直接查表成功.
|
||||
|
||||
模拟自建模板在 edit_templates 主表已删除/不存在,
|
||||
但 template_clip_configs 表有记录。
|
||||
"""
|
||||
tpl_svc = MagicMock()
|
||||
tpl_svc.list_clip_configs.side_effect = ValueError(f"模板不存在: {TEST_TEMPLATE_ID}")
|
||||
db = MagicMock()
|
||||
|
||||
# Mock SQLAlchemyTemplateClipConfigRepository
|
||||
direct_configs = [
|
||||
_make_clip_config(0, 2.0, 5.0),
|
||||
_make_clip_config(1, 3.0, 7.0),
|
||||
_make_clip_config(2, 4.0, 8.0),
|
||||
]
|
||||
with (
|
||||
__import__("unittest.mock", fromlist=["patch"]).patch(
|
||||
"app.api.routes.templates_editor.clips.SQLAlchemyTemplateClipConfigRepository"
|
||||
) as mock_repo_cls,
|
||||
):
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.list_by_template.return_value = direct_configs
|
||||
mock_repo_cls.return_value = mock_repo
|
||||
|
||||
result = _get_template_segments(TEST_TEMPLATE_ID, tpl_svc, db)
|
||||
|
||||
assert len(result) == 3
|
||||
assert result[0] == (0, 2.0, 5.0)
|
||||
assert result[1] == (1, 3.0, 7.0)
|
||||
assert result[2] == (2, 4.0, 8.0)
|
||||
mock_repo.list_by_template.assert_called_once_with(TEST_TEMPLATE_ID)
|
||||
|
||||
def test_main_table_missing_direct_query_empty_falls_to_old(self):
|
||||
"""路径2→3:主表不存在 + 直接查表为空 → 回退旧系统."""
|
||||
tpl_svc = MagicMock()
|
||||
tpl_svc.list_clip_configs.side_effect = ValueError("模板不存在")
|
||||
db = MagicMock()
|
||||
|
||||
old_segments = [_make_old_segment(0, 3.0, 6.0)]
|
||||
|
||||
with __import__("unittest.mock", fromlist=["patch"]).patch(
|
||||
"app.api.routes.templates_editor.clips.SQLAlchemyTemplateClipConfigRepository"
|
||||
) as mock_repo_cls:
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.list_by_template.return_value = [] # 新表也没记录
|
||||
mock_repo_cls.return_value = mock_repo
|
||||
|
||||
with __import__("unittest.mock", fromlist=["patch"]).patch(
|
||||
"app.api.routes.templates_editor.clips.SQLAlchemyTemplateRepository"
|
||||
) as mock_old_cls:
|
||||
mock_old = MagicMock()
|
||||
mock_old.list_segments.return_value = old_segments
|
||||
mock_old_cls.return_value = mock_old
|
||||
|
||||
result = _get_template_segments(TEST_TEMPLATE_ID, tpl_svc, db)
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0] == (0, 3.0, 6.0)
|
||||
|
||||
def test_all_fail_returns_empty(self):
|
||||
"""路径4:三级全部失败 → 返回空列表."""
|
||||
tpl_svc = MagicMock()
|
||||
tpl_svc.list_clip_configs.side_effect = ValueError("模板不存在")
|
||||
db = MagicMock()
|
||||
|
||||
with __import__("unittest.mock", fromlist=["patch"]).patch(
|
||||
"app.api.routes.templates_editor.clips.SQLAlchemyTemplateClipConfigRepository"
|
||||
) as mock_repo_cls:
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.list_by_template.side_effect = Exception("DB error")
|
||||
mock_repo_cls.return_value = mock_repo
|
||||
|
||||
with __import__("unittest.mock", fromlist=["patch"]).patch(
|
||||
"app.api.routes.templates_editor.clips.SQLAlchemyTemplateRepository"
|
||||
) as mock_old_cls:
|
||||
mock_old = MagicMock()
|
||||
mock_old.list_segments.return_value = [] # 旧表也空
|
||||
mock_old_cls.return_value = mock_old
|
||||
|
||||
result = _get_template_segments(TEST_TEMPLATE_ID, tpl_svc, db)
|
||||
|
||||
assert result == []
|
||||
|
||||
def test_direct_query_sorts_by_order(self):
|
||||
"""直接查表返回的结果按 order 排序."""
|
||||
tpl_svc = MagicMock()
|
||||
tpl_svc.list_clip_configs.side_effect = ValueError("模板不存在")
|
||||
db = MagicMock()
|
||||
|
||||
# 故意乱序
|
||||
configs = [
|
||||
tpl_svc.list_clip_configs_for_editor.return_value = [
|
||||
_make_clip_config(2, 5.0, 10.0),
|
||||
_make_clip_config(0, 2.0, 4.0),
|
||||
_make_clip_config(1, 3.0, 6.0),
|
||||
]
|
||||
|
||||
with __import__("unittest.mock", fromlist=["patch"]).patch(
|
||||
"app.api.routes.templates_editor.clips.SQLAlchemyTemplateClipConfigRepository"
|
||||
) as mock_repo_cls:
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.list_by_template.return_value = configs
|
||||
mock_repo_cls.return_value = mock_repo
|
||||
|
||||
result = _get_template_segments(TEST_TEMPLATE_ID, tpl_svc, db)
|
||||
result = _get_template_segments("tmpl-1", USER_ID, tpl_svc)
|
||||
|
||||
assert [r[0] for r in result] == [0, 1, 2]
|
||||
assert result[0] == (0, 2.0, 4.0)
|
||||
assert result[1] == (1, 3.0, 6.0)
|
||||
assert result[2] == (2, 5.0, 10.0)
|
||||
tpl_svc.list_clip_configs_for_editor.assert_called_once_with("tmpl-1", USER_ID)
|
||||
|
||||
def test_empty_configs_returns_empty(self):
|
||||
from app.api.routes.templates_editor.clips import _get_template_segments
|
||||
|
||||
def test_direct_query_handles_none_durations(self):
|
||||
"""直接查表时 min/max_duration 为 None → 使用默认值."""
|
||||
tpl_svc = MagicMock()
|
||||
tpl_svc.list_clip_configs.side_effect = ValueError("模板不存在")
|
||||
db = MagicMock()
|
||||
tpl_svc.list_clip_configs_for_editor.return_value = []
|
||||
|
||||
assert _get_template_segments("tmpl-1", USER_ID, tpl_svc) == []
|
||||
|
||||
def test_missing_template_propagates_not_found(self):
|
||||
from app.api.routes.templates_editor.clips import _get_template_segments
|
||||
from app.services.edit_template_service import TemplateNotFoundError
|
||||
|
||||
tpl_svc = MagicMock()
|
||||
tpl_svc.list_clip_configs_for_editor.side_effect = TemplateNotFoundError("tmpl-x")
|
||||
|
||||
with pytest.raises(TemplateNotFoundError):
|
||||
_get_template_segments("tmpl-x", USER_ID, tpl_svc)
|
||||
|
||||
def test_none_durations_use_default(self):
|
||||
from app.api.routes.templates_editor.clips import _get_template_segments
|
||||
|
||||
tpl_svc = MagicMock()
|
||||
cc = MagicMock()
|
||||
cc.order = 0
|
||||
cc.min_duration = None
|
||||
cc.max_duration = None
|
||||
tpl_svc.list_clip_configs_for_editor.return_value = [cc]
|
||||
|
||||
with __import__("unittest.mock", fromlist=["patch"]).patch(
|
||||
"app.api.routes.templates_editor.clips.SQLAlchemyTemplateClipConfigRepository"
|
||||
) as mock_repo_cls:
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.list_by_template.return_value = [cc]
|
||||
mock_repo_cls.return_value = mock_repo
|
||||
|
||||
result = _get_template_segments(TEST_TEMPLATE_ID, tpl_svc, db)
|
||||
|
||||
assert len(result) == 1
|
||||
# None → default (5.0), max(None or None) → default (5.0)
|
||||
assert result[0] == (0, DEFAULT_DUR, DEFAULT_DUR)
|
||||
|
||||
def test_new_system_returns_empty_tries_direct(self):
|
||||
"""新模板系统返回空列表(非异常)→ 继续尝试直接查表."""
|
||||
tpl_svc = MagicMock()
|
||||
tpl_svc.list_clip_configs.return_value = [] # 空列表,非异常
|
||||
db = MagicMock()
|
||||
|
||||
direct_configs = [_make_clip_config(0, 3.0, 6.0)]
|
||||
|
||||
with __import__("unittest.mock", fromlist=["patch"]).patch(
|
||||
"app.api.routes.templates_editor.clips.SQLAlchemyTemplateClipConfigRepository"
|
||||
) as mock_repo_cls:
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.list_by_template.return_value = direct_configs
|
||||
mock_repo_cls.return_value = mock_repo
|
||||
|
||||
result = _get_template_segments(TEST_TEMPLATE_ID, tpl_svc, db)
|
||||
|
||||
# 新系统返回空 → 不走 except → 但也没 return → 继续往下走
|
||||
# 直接查表有数据 → 返回
|
||||
assert len(result) == 1
|
||||
assert result[0] == (0, 3.0, 6.0)
|
||||
|
||||
def test_existing_template_unaffected(self):
|
||||
"""正常模板(主表存在)行为不变."""
|
||||
configs = [_make_clip_config(0, 2.0, 5.0)]
|
||||
tpl_svc = MagicMock()
|
||||
tpl_svc.list_clip_configs.return_value = configs
|
||||
db = MagicMock()
|
||||
|
||||
with __import__("unittest.mock", fromlist=["patch"]).patch(
|
||||
"app.api.routes.templates_editor.clips.SQLAlchemyTemplateClipConfigRepository"
|
||||
) as mock_repo_cls:
|
||||
result = _get_template_segments(TEST_TEMPLATE_ID, tpl_svc, db)
|
||||
# 直接查表不应被调用(新系统已返回)
|
||||
mock_repo_cls.assert_not_called()
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0] == (0, 2.0, 5.0)
|
||||
result = _get_template_segments("tmpl-1", USER_ID, tpl_svc)
|
||||
assert result == [(0, DEFAULT_DUR, DEFAULT_DUR)]
|
||||
|
||||
@@ -226,10 +226,10 @@ class TestGetMediakitRecommendations:
|
||||
|
||||
|
||||
class TestGetTemplateSegments:
|
||||
"""测试模板片段配置查询。"""
|
||||
"""测试模板片段配置查询(单一数据源:template_clip_configs)。"""
|
||||
|
||||
def test_returns_segments_from_new_template_system(self):
|
||||
"""新模板系统(clip_configs)有数据时优先使用。"""
|
||||
def test_returns_segments_from_clip_configs(self):
|
||||
"""片段配置主表(clip_configs)有数据时按 order 排序返回。"""
|
||||
from app.api.routes.templates_editor.clips import _get_template_segments
|
||||
|
||||
mock_tpl_svc = MagicMock()
|
||||
@@ -241,66 +241,34 @@ class TestGetTemplateSegments:
|
||||
cc2.order = 1
|
||||
cc2.min_duration = 4.0
|
||||
cc2.max_duration = 8.0
|
||||
mock_tpl_svc.list_clip_configs.return_value = [cc2, cc1] # 乱序返回
|
||||
mock_tpl_svc.list_clip_configs_for_editor.return_value = [cc2, cc1] # 乱序返回
|
||||
|
||||
result = _get_template_segments("tmpl-1", mock_tpl_svc, MagicMock())
|
||||
result = _get_template_segments("tmpl-1", "user-1", mock_tpl_svc)
|
||||
assert len(result) == 2
|
||||
assert result[0] == (0, 3.0, 5.0)
|
||||
assert result[1] == (1, 4.0, 8.0)
|
||||
mock_tpl_svc.list_clip_configs_for_editor.assert_called_once_with("tmpl-1", "user-1")
|
||||
|
||||
def test_falls_back_to_old_template_segments(self):
|
||||
"""新模板系统无数据时回退到旧系统。"""
|
||||
def test_returns_empty_when_no_configs(self):
|
||||
"""模板存在但没有片段配置时返回空列表(路由层据此返回 422)。"""
|
||||
from app.api.routes.templates_editor.clips import _get_template_segments
|
||||
|
||||
mock_tpl_svc = MagicMock()
|
||||
mock_tpl_svc.list_clip_configs.return_value = []
|
||||
mock_tpl_svc.list_clip_configs_for_editor.return_value = []
|
||||
|
||||
with patch("app.api.routes.templates_editor.clips.SQLAlchemyTemplateRepository") as MockRepo:
|
||||
mock_repo = MagicMock()
|
||||
seg1 = MagicMock()
|
||||
seg1.segment_order = 0
|
||||
seg1.duration_min = 2.0
|
||||
seg1.duration_max = 4.0
|
||||
mock_repo.list_segments.return_value = [seg1]
|
||||
MockRepo.return_value = mock_repo
|
||||
result = _get_template_segments("tmpl-1", "user-1", mock_tpl_svc)
|
||||
assert result == []
|
||||
|
||||
result = _get_template_segments("tmpl-1", mock_tpl_svc, MagicMock())
|
||||
assert len(result) == 1
|
||||
assert result[0] == (0, 2.0, 4.0)
|
||||
|
||||
def test_returns_empty_when_no_segments(self):
|
||||
"""两套系统都没有片段配置时返回空列表。"""
|
||||
def test_missing_template_raises(self):
|
||||
"""模板不存在/无权限时服务层抛 TemplateNotFoundError(路由层据此返回 404)。"""
|
||||
from app.api.routes.templates_editor.clips import _get_template_segments
|
||||
from app.services.edit_template_service import TemplateNotFoundError
|
||||
|
||||
mock_tpl_svc = MagicMock()
|
||||
mock_tpl_svc.list_clip_configs.return_value = []
|
||||
mock_tpl_svc.list_clip_configs_for_editor.side_effect = TemplateNotFoundError("tmpl-x")
|
||||
|
||||
with patch("app.api.routes.templates_editor.clips.SQLAlchemyTemplateRepository") as MockRepo:
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.list_segments.return_value = []
|
||||
MockRepo.return_value = mock_repo
|
||||
|
||||
result = _get_template_segments("tmpl-1", mock_tpl_svc, MagicMock())
|
||||
assert result == []
|
||||
|
||||
def test_new_system_exception_falls_back(self):
|
||||
"""新模板系统异常时回退到旧系统。"""
|
||||
from app.api.routes.templates_editor.clips import _get_template_segments
|
||||
|
||||
mock_tpl_svc = MagicMock()
|
||||
mock_tpl_svc.list_clip_configs.side_effect = RuntimeError("db error")
|
||||
|
||||
with patch("app.api.routes.templates_editor.clips.SQLAlchemyTemplateRepository") as MockRepo:
|
||||
mock_repo = MagicMock()
|
||||
seg = MagicMock()
|
||||
seg.segment_order = 0
|
||||
seg.duration_min = 1.0
|
||||
seg.duration_max = 3.0
|
||||
mock_repo.list_segments.return_value = [seg]
|
||||
MockRepo.return_value = mock_repo
|
||||
|
||||
result = _get_template_segments("tmpl-1", mock_tpl_svc, MagicMock())
|
||||
assert len(result) == 1
|
||||
with pytest.raises(TemplateNotFoundError):
|
||||
_get_template_segments("tmpl-x", "user-1", mock_tpl_svc)
|
||||
|
||||
|
||||
# ── from-assets 端点集成测试 ────────────────────────────────────────────────
|
||||
@@ -358,7 +326,7 @@ def _make_tpl_svc_with_segments(segments):
|
||||
"""segments: list of (order, min_dur, max_dur)"""
|
||||
svc = MagicMock()
|
||||
clip_configs = [_make_clip_config(o, mn, mx) for o, mn, mx in segments]
|
||||
svc.list_clip_configs.return_value = clip_configs
|
||||
svc.list_clip_configs_for_editor.return_value = clip_configs
|
||||
return svc
|
||||
|
||||
|
||||
@@ -540,34 +508,56 @@ class TestFromAssetsByTemplateSegments:
|
||||
orders = [c["order"] for c in clips_data]
|
||||
assert orders == [0, 1, 2]
|
||||
|
||||
def test_no_segments_raises_400(self):
|
||||
"""模板没有 segment 配置时返回 400。"""
|
||||
def test_no_segments_raises_422(self):
|
||||
"""模板存在但未配置片段时返回 422(与模板不存在的 404 区分)。"""
|
||||
from app.api.routes.templates_editor.clips import create_clips_from_assets_editor
|
||||
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
|
||||
from fastapi import HTTPException
|
||||
|
||||
mock_tpl_svc = MagicMock()
|
||||
mock_tpl_svc.list_clip_configs.return_value = []
|
||||
mock_tpl_svc.list_clip_configs_for_editor.return_value = []
|
||||
mock_plan_svc = _make_plan_svc()
|
||||
|
||||
with patch("app.api.routes.templates_editor.clips.SQLAlchemyTemplateRepository") as MockRepo:
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.list_segments.return_value = []
|
||||
MockRepo.return_value = mock_repo
|
||||
body = ClipsFromAssetsRequest(asset_ids=["a1"])
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
create_clips_from_assets_editor(
|
||||
template_id="tmpl-1",
|
||||
body=body,
|
||||
background_tasks=MagicMock(),
|
||||
plan_id="plan-1",
|
||||
services=(mock_tpl_svc, mock_plan_svc),
|
||||
asset_repo=MagicMock(),
|
||||
db=MagicMock(),
|
||||
current_user=_make_auth_user(),
|
||||
)
|
||||
assert exc_info.value.status_code == 422
|
||||
|
||||
body = ClipsFromAssetsRequest(asset_ids=["a1"])
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
create_clips_from_assets_editor(
|
||||
template_id="tmpl-1",
|
||||
body=body,
|
||||
background_tasks=MagicMock(),
|
||||
plan_id="plan-1",
|
||||
services=(mock_tpl_svc, mock_plan_svc),
|
||||
asset_repo=MagicMock(),
|
||||
db=MagicMock(),
|
||||
current_user=_make_auth_user(),
|
||||
)
|
||||
assert exc_info.value.status_code == 400
|
||||
mock_plan_svc.replace_all_clips_transactional.assert_not_called()
|
||||
|
||||
def test_template_not_found_raises_404(self):
|
||||
"""模板不存在/已删除/无权限时返回 404。"""
|
||||
from app.api.routes.templates_editor.clips import create_clips_from_assets_editor
|
||||
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
|
||||
from app.services.edit_template_service import TemplateNotFoundError
|
||||
from fastapi import HTTPException
|
||||
|
||||
mock_tpl_svc = MagicMock()
|
||||
mock_tpl_svc.list_clip_configs_for_editor.side_effect = TemplateNotFoundError("tmpl-x")
|
||||
mock_plan_svc = _make_plan_svc()
|
||||
|
||||
body = ClipsFromAssetsRequest(asset_ids=["a1"])
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
create_clips_from_assets_editor(
|
||||
template_id="tmpl-x",
|
||||
body=body,
|
||||
background_tasks=MagicMock(),
|
||||
plan_id="plan-1",
|
||||
services=(mock_tpl_svc, mock_plan_svc),
|
||||
asset_repo=MagicMock(),
|
||||
db=MagicMock(),
|
||||
current_user=_make_auth_user(),
|
||||
)
|
||||
assert exc_info.value.status_code == 404
|
||||
|
||||
mock_plan_svc.replace_all_clips_transactional.assert_not_called()
|
||||
|
||||
|
||||
@@ -188,6 +188,7 @@ class TestListTemplatesUseCase:
|
||||
tag="tag1",
|
||||
keyword="test",
|
||||
mode="one_take",
|
||||
valid_only=False,
|
||||
)
|
||||
|
||||
def test_list_pagination(self):
|
||||
@@ -226,6 +227,7 @@ class TestCountTemplatesUseCase:
|
||||
tag="tag1",
|
||||
keyword="kw",
|
||||
mode="pip",
|
||||
valid_only=False,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -158,6 +158,52 @@ class TestListByUser:
|
||||
assert len(result[0].segments) == 1
|
||||
assert result[0].segments[0].duration_min == 2.0
|
||||
|
||||
def test_valid_only_filters_templates_without_segments(self, repo, session):
|
||||
"""#1769: valid_only=True 时排除两张片段表都没有记录的无效模板."""
|
||||
# 有效模板:有 clip_configs
|
||||
valid_clip = _make_template(name="有效模板-clip_configs")
|
||||
repo.create(valid_clip)
|
||||
repo.create_segments([_make_segment(valid_clip.id, order=1)])
|
||||
# 有效模板:仅有旧表 template_segments 记录
|
||||
valid_old = _make_template(name="有效模板-old_segments")
|
||||
repo.create(valid_old)
|
||||
old = TemplateSegmentModel(
|
||||
id=str(uuid.uuid4()),
|
||||
template_id=valid_old.id,
|
||||
segment_order=1,
|
||||
duration_min=2.0,
|
||||
duration_max=6.0,
|
||||
)
|
||||
session.add(old)
|
||||
session.commit()
|
||||
# 无效模板:两张表都没有记录
|
||||
invalid = _make_template(name="无效模板-无片段")
|
||||
repo.create(invalid)
|
||||
|
||||
# 默认不过滤:编辑器视角能看到全部 3 个模板
|
||||
all_templates = repo.list_by_user("u1")
|
||||
assert len(all_templates) == 3
|
||||
assert repo.count_by_user("u1") == 3
|
||||
|
||||
# valid_only=True:剪辑页视角只返回 2 个有效模板
|
||||
valid_templates = repo.list_by_user("u1", valid_only=True)
|
||||
assert {t.name for t in valid_templates} == {"有效模板-clip_configs", "有效模板-old_segments"}
|
||||
assert all(len(t.segments) > 0 for t in valid_templates)
|
||||
assert repo.count_by_user("u1", valid_only=True) == 2
|
||||
|
||||
def test_valid_only_with_filters_and_pagination(self, repo, session):
|
||||
"""valid_only 与其他过滤/分页条件组合使用."""
|
||||
tpl = _make_template(name="口播模板", mode="voice_over")
|
||||
repo.create(tpl)
|
||||
repo.create_segments([_make_segment(tpl.id, order=1, material_type="人物")])
|
||||
_invalid = _make_template(name="口播无效模板", mode="voice_over")
|
||||
repo.create(_invalid)
|
||||
|
||||
result = repo.list_by_user("u1", mode="voice_over", valid_only=True)
|
||||
assert len(result) == 1
|
||||
assert result[0].name == "口播模板"
|
||||
assert repo.count_by_user("u1", mode="voice_over", valid_only=True) == 1
|
||||
|
||||
|
||||
class TestCopyTemplate:
|
||||
def test_copy_writes_to_clip_configs(self, repo, session):
|
||||
|
||||
Reference in New Issue
Block a user