Compare commits

..

6 Commits

Author SHA1 Message Date
xiaoxia 51a79230d5 refactor(cover-selector): 主文件改为 re-export 子目录,完成拆分
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 5s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m19s
CI/CD Pipeline / Validate - Code Quality (pull_request) Failing after 1m50s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 28s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m7s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 21s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 54s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m42s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 47s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 3m32s
AI Code Review / AI Code Review (pull_request) Successful in 3m37s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 15m16s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 23s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 21s
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
2026-07-27 12:39:00 +08:00
xiaoxia be1a1dae60 fix(cover-selector): 修复 types 导入路径(../types → ../../types)
CI/CD Pipeline / Check if frontend-only change (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Lint (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / PR Build API Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Web Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
AI Code Review / AI Code Review (pull_request) Has been cancelled
Preview Deploy / Deploy Preview Environment (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 45m52s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m47s
2026-07-27 12:37:58 +08:00
xiaoxia 71701e27ae fix(cover-selector): 修复 types 导入路径(../types → ../../types) 2026-07-27 12:37:57 +08:00
xiaoxia 2781663c61 fix(cover-selector): 修复 types 导入路径(../types → ../../types)
CI/CD Pipeline / Check if frontend-only change (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Lint (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / PR Build API Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Web Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
AI Code Review / AI Code Review (pull_request) Has been cancelled
Preview Deploy / Deploy Preview Environment (pull_request) Has been cancelled
PR Automation / Auto Approve on CI Green (pull_request) Successful in 46s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 50s
2026-07-27 12:37:55 +08:00
xiaoxia 185431788c Merge branch 'develop' into refactor/cover-selector-split
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 24s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 21s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 1m7s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m52s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m52s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 1m15s
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 45s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Code Quality (pull_request) Failing after 2m39s
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 53s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 21s
AI Code Review / AI Code Review (pull_request) Successful in 2m0s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 2m57s
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / PR Build API Image (pull_request) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
2026-07-27 12:32:35 +08:00
xiaoxia 922e0bdc58 refactor(editing-planner): 拆分 CoverSelector 组件,抽离 Hook + 模式面板
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 49s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m29s
CI/CD Pipeline / Validate - Code Quality (pull_request) Failing after 2m41s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m5s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 37s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m46s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 2m7s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m10s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 55s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 2m58s
AI Code Review / AI Code Review (pull_request) Successful in 3m3s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 7m45s
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 25s
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
主文件 286→160 行(-44%),拆分为:
- useCoverSelector: 状态管理、文件上传、模式切换等逻辑
- CoverModePanels: 智能封面/抽帧/上传 三个模式面板
- index: 组合入口 + Drawer 布局

导入路径保持向后兼容
2026-07-27 07:37:56 +08:00
9 changed files with 633 additions and 556 deletions
+243
View File
@@ -0,0 +1,243 @@
/**
* 模板编辑器 API
* 对接后端 /api/v1/templates 路由
*/
import apiClient from "./client"
import type {
WatermarkConfig,
IntroOutroConfig,
PipConfig,
FilterConfig,
ChromaKeyConfig,
StickerConfig,
CoverConfig,
} from "@/pages/editing-planner/types"
/* ──────────── 类型定义 ──────────── */
/** 模板模式(后端枚举值) */
export type TemplateMode = "pip" | "voice_over" | "one_take" | "voice_pip"
/** 模式显示名称映射 */
export const MODE_LABELS: Record<TemplateMode, string> = {
pip: "混剪",
voice_over: "人物口播",
one_take: "一镜到底",
voice_pip: "口播+混剪",
}
/** 模式颜色映射 */
export const MODE_COLORS: Record<TemplateMode, string> = {
pip: "blue",
voice_over: "green",
one_take: "orange",
voice_pip: "purple",
}
/** 标题配置 */
export interface TitleConfig {
ai_auto_select: boolean
content: string
font_preset: string
font_color: string
font_size: number
position: string
}
/** 字幕配置 */
export interface SubtitleConfig {
enabled: boolean
position: string
font: string
color: string
size: number
animation: string
}
/** BGM 配置 */
export interface BgmConfig {
enabled: boolean
music_id: string
}
/** 模板片段 */
export interface TemplateSegment {
id?: string
segment_order: number
duration_min: number
duration_max: number
material_type: string | null
}
/** 剪辑模板 */
export interface EditingTemplate {
id: string
name: string
mode: TemplateMode
category: string
tags: string[]
title_config: TitleConfig
subtitle_config: SubtitleConfig
bgm_config: BgmConfig
estimated_duration: number
segments: TemplateSegment[]
/** 水印配置(后端就绪后启用) */
watermark_config?: WatermarkConfig
/** 片头片尾配置(后端就绪后启用) */
intro_outro_config?: IntroOutroConfig
/** 混剪配置 */
pip_config?: PipConfig
/** 滤镜调色配置 */
filter_config?: FilterConfig
/** 绿幕抠像配置 */
green_screen_config?: ChromaKeyConfig
/** 贴纸配置 */
sticker_config?: StickerConfig
/** 封面配置 */
cover_config?: CoverConfig
is_active?: boolean
created_at: string
updated_at: string
}
/** 模板分类 */
export interface TemplateCategory {
id: string
name: string
created_at?: string
}
/** 创建/更新模板请求体 */
export interface SaveTemplatePayload {
name: string
mode: TemplateMode
category: string
tags: string[]
title_config: TitleConfig
subtitle_config: SubtitleConfig
bgm_config: BgmConfig
estimated_duration: number
segments: Omit<TemplateSegment, "id">[]
/** 水印配置(后端就绪后启用) */
watermark_config?: WatermarkConfig
/** 片头片尾配置(后端就绪后启用) */
intro_outro_config?: IntroOutroConfig
/** 混剪配置 */
pip_config?: PipConfig
/** 滤镜调色配置 */
filter_config?: FilterConfig
/** 绿幕抠像配置 */
green_screen_config?: ChromaKeyConfig
/** 贴纸配置 */
sticker_config?: StickerConfig
/** 封面配置 */
cover_config?: CoverConfig
}
/** 使用模板生成请求体 */
export interface GenerateFromTemplatePayload {
voiceover_duration: number
}
/** 验证警告详情 */
export interface ValidationWarningDetails {
/** 相关字段名 */
field?: string
/** 期望值 */
expected?: string | number
/** 实际值 */
actual?: string | number
/** 建议值 */
suggested?: string | number
}
/** 验证/生成响应 */
export interface ValidateWarning {
code: string
message: string
details?: ValidationWarningDetails
}
/** 使用模板生成响应 */
export interface GenerateFromTemplateResponse {
template: EditingTemplate
warnings: ValidateWarning[]
}
/** 列表响应(带分页) */
export interface ListTemplatesResponse {
items: EditingTemplate[]
total: number
}
/** 分类列表响应 */
export interface ListCategoriesResponse {
items: TemplateCategory[]
}
// ============ API 函数 ============
/** 获取模板列表 */
export const getEditingTemplates = async (params?: {
category?: string
tag?: string
skip?: number
limit?: number
}): Promise<EditingTemplate[]> => {
const response = await apiClient.get<ListTemplatesResponse>("/templates", {
params: {
skip: params?.skip ?? 0,
limit: params?.limit ?? 50,
},
})
let list = response.data.items
if (params?.category) list = list.filter((t) => t.category === params.category)
if (params?.tag) list = list.filter((t) => t.tags.includes(params.tag!))
return list
}
/** 获取模板详情 */
export const getEditingTemplate = async (id: string): Promise<EditingTemplate> => {
const response = await apiClient.get<EditingTemplate>(`/templates/${id}`)
return response.data
}
/** 创建模板 */
export const createEditingTemplate = async (
data: SaveTemplatePayload,
): Promise<EditingTemplate> => {
const response = await apiClient.post<EditingTemplate>("/templates", data)
return response.data
}
/** 更新模板 */
export const updateEditingTemplate = async (
id: string,
data: SaveTemplatePayload,
): Promise<EditingTemplate> => {
const response = await apiClient.patch<EditingTemplate>(`/templates/${id}`, data)
return response.data
}
/** 删除模板 */
export const deleteEditingTemplate = async (id: string): Promise<void> => {
await apiClient.delete(`/templates/${id}`)
}
/** 获取模板分类列表 */
export const getTemplateCategories = async (): Promise<TemplateCategory[]> => {
const response = await apiClient.get<ListCategoriesResponse>("/templates/categories/list")
return response.data.items
}
/** 使用模板生成视频(调用 validate 端点) */
export const generateFromTemplate = async (
templateId: string,
data: GenerateFromTemplatePayload,
): Promise<GenerateFromTemplateResponse> => {
const response = await apiClient.post<GenerateFromTemplateResponse>(
`/templates/${templateId}/validate`,
data,
)
return response.data
}
@@ -1,20 +0,0 @@
/**
* 模板编辑器常量
*/
import type { TemplateMode } from "./types"
/** 模式显示名称映射 */
export const MODE_LABELS: Record<TemplateMode, string> = {
pip: "混剪",
voice_over: "人物口播",
one_take: "一镜到底",
voice_pip: "口播+混剪",
}
/** 模式颜色映射 */
export const MODE_COLORS: Record<TemplateMode, string> = {
pip: "blue",
voice_over: "green",
one_take: "orange",
voice_pip: "purple",
}
-36
View File
@@ -1,36 +0,0 @@
/**
* 模板编辑器 API — 目录化入口
* 保持与原 editing-planner.ts 相同导出,向后兼容
*/
// 类型
export type {
TemplateMode,
TitleConfig,
SubtitleConfig,
BgmConfig,
TemplateSegment,
EditingTemplate,
TemplateCategory,
SaveTemplatePayload,
GenerateFromTemplatePayload,
ValidationWarningDetails,
ValidateWarning,
GenerateFromTemplateResponse,
ListTemplatesResponse,
ListCategoriesResponse,
} from "./types"
// 常量
export { MODE_LABELS, MODE_COLORS } from "./constants"
// API 函数
export {
getEditingTemplates,
getEditingTemplate,
createEditingTemplate,
updateEditingTemplate,
deleteEditingTemplate,
getTemplateCategories,
generateFromTemplate,
} from "./templates"
@@ -1,78 +0,0 @@
/**
* 模板编辑器 API 函数
*/
import apiClient from "../client"
import type {
EditingTemplate,
TemplateCategory,
SaveTemplatePayload,
GenerateFromTemplatePayload,
GenerateFromTemplateResponse,
ListTemplatesResponse,
ListCategoriesResponse,
} from "./types"
/** 获取模板列表 */
export const getEditingTemplates = async (params?: {
category?: string
tag?: string
skip?: number
limit?: number
}): Promise<EditingTemplate[]> => {
const response = await apiClient.get<ListTemplatesResponse>("/templates", {
params: {
skip: params?.skip ?? 0,
limit: params?.limit ?? 50,
},
})
let list = response.data.items
if (params?.category) list = list.filter((t) => t.category === params.category)
if (params?.tag) list = list.filter((t) => t.tags.includes(params.tag!))
return list
}
/** 获取模板详情 */
export const getEditingTemplate = async (id: string): Promise<EditingTemplate> => {
const response = await apiClient.get<EditingTemplate>(`/templates/${id}`)
return response.data
}
/** 创建模板 */
export const createEditingTemplate = async (
data: SaveTemplatePayload,
): Promise<EditingTemplate> => {
const response = await apiClient.post<EditingTemplate>("/templates", data)
return response.data
}
/** 更新模板 */
export const updateEditingTemplate = async (
id: string,
data: SaveTemplatePayload,
): Promise<EditingTemplate> => {
const response = await apiClient.patch<EditingTemplate>(`/templates/${id}`, data)
return response.data
}
/** 删除模板 */
export const deleteEditingTemplate = async (id: string): Promise<void> => {
await apiClient.delete(`/templates/${id}`)
}
/** 获取模板分类列表 */
export const getTemplateCategories = async (): Promise<TemplateCategory[]> => {
const response = await apiClient.get<ListCategoriesResponse>("/templates/categories/list")
return response.data.items
}
/** 使用模板生成视频(调用 validate 端点) */
export const generateFromTemplate = async (
templateId: string,
data: GenerateFromTemplatePayload,
): Promise<GenerateFromTemplateResponse> => {
const response = await apiClient.post<GenerateFromTemplateResponse>(
`/templates/${templateId}/validate`,
data,
)
return response.data
}
-138
View File
@@ -1,138 +0,0 @@
/**
* 模板编辑器类型定义
*/
import type {
WatermarkConfig,
IntroOutroConfig,
PipConfig,
FilterConfig,
ChromaKeyConfig,
StickerConfig,
CoverConfig,
} from "@/pages/editing-planner/types"
/** 模板模式(后端枚举值) */
export type TemplateMode = "pip" | "voice_over" | "one_take" | "voice_pip"
/** 标题配置 */
export interface TitleConfig {
ai_auto_select: boolean
content: string
font_preset: string
font_color: string
font_size: number
position: string
}
/** 字幕配置 */
export interface SubtitleConfig {
enabled: boolean
position: string
font: string
color: string
size: number
animation: string
}
/** BGM 配置 */
export interface BgmConfig {
enabled: boolean
music_id: string
}
/** 模板片段 */
export interface TemplateSegment {
id?: string
segment_order: number
duration_min: number
duration_max: number
material_type: string | null
}
/** 剪辑模板 */
export interface EditingTemplate {
id: string
name: string
mode: TemplateMode
category: string
tags: string[]
title_config: TitleConfig
subtitle_config: SubtitleConfig
bgm_config: BgmConfig
estimated_duration: number
segments: TemplateSegment[]
watermark_config?: WatermarkConfig
intro_outro_config?: IntroOutroConfig
pip_config?: PipConfig
filter_config?: FilterConfig
green_screen_config?: ChromaKeyConfig
sticker_config?: StickerConfig
cover_config?: CoverConfig
is_active?: boolean
created_at: string
updated_at: string
}
/** 模板分类 */
export interface TemplateCategory {
id: string
name: string
created_at?: string
}
/** 创建/更新模板请求体 */
export interface SaveTemplatePayload {
name: string
mode: TemplateMode
category: string
tags: string[]
title_config: TitleConfig
subtitle_config: SubtitleConfig
bgm_config: BgmConfig
estimated_duration: number
segments: Omit<TemplateSegment, "id">[]
watermark_config?: WatermarkConfig
intro_outro_config?: IntroOutroConfig
pip_config?: PipConfig
filter_config?: FilterConfig
green_screen_config?: ChromaKeyConfig
sticker_config?: StickerConfig
cover_config?: CoverConfig
}
/** 使用模板生成请求体 */
export interface GenerateFromTemplatePayload {
voiceover_duration: number
}
/** 验证警告详情 */
export interface ValidationWarningDetails {
field?: string
expected?: string | number
actual?: string | number
suggested?: string | number
}
/** 验证警告 */
export interface ValidateWarning {
code: string
message: string
details?: ValidationWarningDetails
}
/** 使用模板生成响应 */
export interface GenerateFromTemplateResponse {
template: EditingTemplate
warnings: ValidateWarning[]
}
/** 列表响应(带分页) */
export interface ListTemplatesResponse {
items: EditingTemplate[]
total: number
}
/** 分类列表响应 */
export interface ListCategoriesResponse {
items: TemplateCategory[]
}
@@ -1,286 +1,5 @@
/**
* 封面选择器
* 抽帧选封面 + 上传自定义封面 + 智能封面推荐
* 封面选择器入口(向后兼容)
* 实际实现位于 ./cover-selector/ 目录
*/
import React, { useCallback, useRef, useState } from "react"
import { Drawer } from "antd"
import type { CoverConfig, CoverMode } from "../types"
import { DEFAULT_COVER_CONFIG } from "../types"
interface CoverSelectorProps {
open: boolean
onClose: () => void
config: CoverConfig
onChange: (config: CoverConfig) => void
totalDuration: number
}
/** 封面模式标签 */
const MODE_LABELS: Record<CoverMode, string> = {
auto: "智能封面",
frame: "抽帧选封面",
upload: "上传封面",
}
/** 封面模式图标 */
const MODE_ICONS: Record<CoverMode, string> = {
auto: "🤖",
frame: "🎞️",
upload: "📤",
}
const CoverSelector: React.FC<CoverSelectorProps> = ({
open,
onClose,
config,
onChange,
totalDuration,
}) => {
const fileInputRef = useRef<HTMLInputElement>(null)
const [isDragging, setIsDragging] = useState(false)
const update = useCallback(
(partial: Partial<CoverConfig>) => {
onChange({ ...config, ...partial })
},
[config, onChange],
)
const handleReset = useCallback(() => {
onChange({ ...DEFAULT_COVER_CONFIG, enabled: config.enabled })
}, [config.enabled, onChange])
/** 切换模式 */
const handleModeChange = useCallback(
(mode: CoverMode) => {
update({ mode })
},
[update],
)
/** 处理文件上传 */
const handleFileUpload = useCallback(
(file: File) => {
if (!file.type.startsWith("image/")) return
const reader = new FileReader()
reader.onload = (e) => {
const url = e.target?.result as string
update({ upload_url: url, thumbnail_url: url, mode: "upload" })
}
reader.readAsDataURL(file)
},
[update],
)
/** 拖拽上传 */
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault()
setIsDragging(false)
const file = e.dataTransfer.files[0]
if (file) handleFileUpload(file)
},
[handleFileUpload],
)
/** 使用 AI 推荐时间 */
const handleUseAiSuggestion = useCallback(() => {
if (config.ai_suggested_time !== null) {
update({ frame_time: config.ai_suggested_time, mode: "frame" })
}
}, [config.ai_suggested_time, update])
/** 格式化时间 */
const formatTime = (seconds: number) => {
const m = Math.floor(seconds / 60)
const s = Math.floor(seconds % 60)
const ms = Math.floor((seconds % 1) * 10)
return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}.${ms}`
}
return (
<Drawer
title="封面选择"
placement="right"
width={440}
open={open}
onClose={onClose}
className="cover-selector-drawer"
>
{/* 顶部开关 */}
<div className="cover-header">
<span className="cover-header-label"></span>
<label className="cover-switch">
<input
type="checkbox"
checked={config.enabled}
onChange={(e) => update({ enabled: e.target.checked })}
/>
<span className="cover-switch-slider" />
</label>
</div>
{/* 模式选择 */}
<div className="cover-mode-section">
<div className="cover-section-title"></div>
<div className="cover-mode-tabs">
{(["auto", "frame", "upload"] as CoverMode[]).map((m) => (
<button
key={m}
className={`cover-mode-tab${config.mode === m ? " active" : ""}`}
onClick={() => handleModeChange(m)}
>
<span className="cover-mode-icon">{MODE_ICONS[m]}</span>
<span className="cover-mode-label">{MODE_LABELS[m]}</span>
</button>
))}
</div>
</div>
{/* 模式内容区 */}
<div className="cover-mode-content">
{/* 智能封面 */}
{config.mode === "auto" && (
<div className="cover-auto-section">
<div className="cover-auto-desc">
AI
</div>
{config.ai_suggested_time !== null ? (
<div className="cover-auto-suggestion">
<div className="cover-auto-badge">AI </div>
<div className="cover-auto-time">
{formatTime(config.ai_suggested_time)}
</div>
<button className="cover-auto-use-btn" onClick={handleUseAiSuggestion}>
使
</button>
</div>
) : (
<div className="cover-auto-pending">
<div className="cover-auto-spinner" />
<span>AI ...</span>
</div>
)}
</div>
)}
{/* 抽帧选封面 */}
{config.mode === "frame" && (
<div className="cover-frame-section">
<div className="cover-frame-preview">
<div className="cover-frame-placeholder">
<span className="cover-frame-icon">🎞</span>
<span className="cover-frame-time">{formatTime(config.frame_time)}</span>
</div>
</div>
<div className="cover-frame-timeline">
<div className="cover-frame-slider-header">
<span className="cover-frame-slider-label"></span>
<span className="cover-frame-slider-value">{formatTime(config.frame_time)}</span>
</div>
<input
type="range"
className="cover-frame-slider"
min={0}
max={Math.max(totalDuration, 1)}
step={0.1}
value={config.frame_time}
onChange={(e) => update({ frame_time: Number(e.target.value) })}
/>
<div className="cover-frame-range">
<span>00:00</span>
<span>{formatTime(totalDuration)}</span>
</div>
</div>
{/* 快捷时间点 */}
<div className="cover-frame-quick">
<span className="cover-quick-label"></span>
{[0, 0.25, 0.5, 0.75].map((ratio) => {
const t = totalDuration * ratio
return (
<button
key={ratio}
className="cover-quick-btn"
onClick={() => update({ frame_time: t })}
>
{formatTime(t)}
</button>
)
})}
</div>
</div>
)}
{/* 上传封面 */}
{config.mode === "upload" && (
<div className="cover-upload-section">
<div
className={`cover-upload-area${isDragging ? " dragging" : ""}`}
onDragOver={(e) => {
e.preventDefault()
setIsDragging(true)
}}
onDragLeave={() => setIsDragging(false)}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
>
{config.upload_url ? (
<div className="cover-upload-preview">
<img src={config.upload_url} alt="封面预览" />
<div className="cover-upload-overlay"></div>
</div>
) : (
<div className="cover-upload-placeholder">
<span className="cover-upload-icon">📤</span>
<span className="cover-upload-text"></span>
<span className="cover-upload-hint"> JPG / PNG 16:9 </span>
</div>
)}
<input
ref={fileInputRef}
type="file"
accept="image/*"
style={{ display: "none" }}
onChange={(e) => {
const file = e.target.files?.[0]
if (file) handleFileUpload(file)
}}
/>
</div>
</div>
)}
</div>
{/* 封面预览 */}
<div className="cover-preview-section">
<div className="cover-section-title"></div>
<div className="cover-preview-box">
{config.upload_url ? (
<img src={config.upload_url} alt="封面预览" className="cover-preview-img" />
) : (
<div className="cover-preview-placeholder">
<span className="cover-preview-icon">🖼</span>
<span className="cover-preview-text">
{config.mode === "auto"
? "AI 智能选择"
: config.mode === "frame"
? `${formatTime(config.frame_time)}`
: "未上传封面"}
</span>
</div>
)}
<div className="cover-preview-ratio">16:9</div>
</div>
</div>
{/* 底部 */}
<div className="cover-footer">
<button className="cover-reset-btn" onClick={handleReset}>
</button>
</div>
</Drawer>
)
}
export default CoverSelector
export { default } from "./cover-selector"
@@ -0,0 +1,143 @@
import React from "react"
import type { CoverConfig } from "../../types"
interface CoverAutoModeProps {
config: CoverConfig
formatTime: (s: number) => string
onUseAiSuggestion: () => void
}
/** 智能封面模式面板 */
export const CoverAutoMode: React.FC<CoverAutoModeProps> = ({
config,
formatTime,
onUseAiSuggestion,
}) => (
<div className="cover-auto-section">
<div className="cover-auto-desc">AI </div>
{config.ai_suggested_time !== null ? (
<div className="cover-auto-suggestion">
<div className="cover-auto-badge">AI </div>
<div className="cover-auto-time">{formatTime(config.ai_suggested_time)}</div>
<button className="cover-auto-use-btn" onClick={onUseAiSuggestion}>
使
</button>
</div>
) : (
<div className="cover-auto-pending">
<div className="cover-auto-spinner" />
<span>AI ...</span>
</div>
)}
</div>
)
interface CoverFrameModeProps {
config: CoverConfig
totalDuration: number
formatTime: (s: number) => string
onFrameTimeChange: (time: number) => void
}
/** 抽帧选封面模式面板 */
export const CoverFrameMode: React.FC<CoverFrameModeProps> = ({
config,
totalDuration,
formatTime,
onFrameTimeChange,
}) => (
<div className="cover-frame-section">
<div className="cover-frame-preview">
<div className="cover-frame-placeholder">
<span className="cover-frame-icon">🎞</span>
<span className="cover-frame-time">{formatTime(config.frame_time)}</span>
</div>
</div>
<div className="cover-frame-timeline">
<div className="cover-frame-slider-header">
<span className="cover-frame-slider-label"></span>
<span className="cover-frame-slider-value">{formatTime(config.frame_time)}</span>
</div>
<input
type="range"
className="cover-frame-slider"
min={0}
max={Math.max(totalDuration, 1)}
step={0.1}
value={config.frame_time}
onChange={(e) => onFrameTimeChange(Number(e.target.value))}
/>
<div className="cover-frame-range">
<span>00:00</span>
<span>{formatTime(totalDuration)}</span>
</div>
</div>
<div className="cover-frame-quick">
<span className="cover-quick-label"></span>
{[0, 0.25, 0.5, 0.75].map((ratio) => {
const t = totalDuration * ratio
return (
<button key={ratio} className="cover-quick-btn" onClick={() => onFrameTimeChange(t)}>
{formatTime(t)}
</button>
)
})}
</div>
</div>
)
interface CoverUploadModeProps {
config: CoverConfig
isDragging: boolean
fileInputRef: React.RefObject<HTMLInputElement>
onDragOver: (e: React.DragEvent) => void
onDragLeave: () => void
onDrop: (e: React.DragEvent) => void
onAreaClick: () => void
onFileChange: (file: File) => void
}
/** 上传封面模式面板 */
export const CoverUploadMode: React.FC<CoverUploadModeProps> = ({
config,
isDragging,
fileInputRef,
onDragOver,
onDragLeave,
onDrop,
onAreaClick,
onFileChange,
}) => (
<div className="cover-upload-section">
<div
className={`cover-upload-area${isDragging ? " dragging" : ""}`}
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onDrop={onDrop}
onClick={onAreaClick}
>
{config.upload_url ? (
<div className="cover-upload-preview">
<img src={config.upload_url} alt="封面预览" />
<div className="cover-upload-overlay"></div>
</div>
) : (
<div className="cover-upload-placeholder">
<span className="cover-upload-icon">📤</span>
<span className="cover-upload-text"></span>
<span className="cover-upload-hint"> JPG / PNG 16:9 </span>
</div>
)}
<input
ref={fileInputRef}
type="file"
accept="image/*"
style={{ display: "none" }}
onChange={(e) => {
const file = e.target.files?.[0]
if (file) onFileChange(file)
}}
/>
</div>
</div>
)
@@ -0,0 +1,146 @@
/**
* 封面选择器
* 抽帧选封面 + 上传自定义封面 + 智能封面推荐
*/
import React from "react"
import { Drawer } from "antd"
import type { CoverConfig, CoverMode } from "../../types"
import { useCoverSelector, MODE_LABELS, MODE_ICONS } from "./useCoverSelector"
import { CoverAutoMode, CoverFrameMode, CoverUploadMode } from "./CoverModePanels"
interface CoverSelectorProps {
open: boolean
onClose: () => void
config: CoverConfig
onChange: (config: CoverConfig) => void
totalDuration: number
}
const CoverSelector: React.FC<CoverSelectorProps> = ({
open,
onClose,
config,
onChange,
totalDuration,
}) => {
const {
fileInputRef,
isDragging,
setIsDragging,
update,
handleReset,
handleModeChange,
handleFileUpload,
handleDrop,
handleUseAiSuggestion,
formatTime,
} = useCoverSelector({ config, onChange })
return (
<Drawer
title="封面选择"
placement="right"
width={440}
open={open}
onClose={onClose}
className="cover-selector-drawer"
>
{/* 顶部开关 */}
<div className="cover-header">
<span className="cover-header-label"></span>
<label className="cover-switch">
<input
type="checkbox"
checked={config.enabled}
onChange={(e) => update({ enabled: e.target.checked })}
/>
<span className="cover-switch-slider" />
</label>
</div>
{/* 模式选择 */}
<div className="cover-mode-section">
<div className="cover-section-title"></div>
<div className="cover-mode-tabs">
{(["auto", "frame", "upload"] as CoverMode[]).map((m) => (
<button
key={m}
className={`cover-mode-tab${config.mode === m ? " active" : ""}`}
onClick={() => handleModeChange(m)}
>
<span className="cover-mode-icon">{MODE_ICONS[m]}</span>
<span className="cover-mode-label">{MODE_LABELS[m]}</span>
</button>
))}
</div>
</div>
{/* 模式内容区 */}
<div className="cover-mode-content">
{config.mode === "auto" && (
<CoverAutoMode
config={config}
formatTime={formatTime}
onUseAiSuggestion={handleUseAiSuggestion}
/>
)}
{config.mode === "frame" && (
<CoverFrameMode
config={config}
totalDuration={totalDuration}
formatTime={formatTime}
onFrameTimeChange={(t) => update({ frame_time: t })}
/>
)}
{config.mode === "upload" && (
<CoverUploadMode
config={config}
isDragging={isDragging}
fileInputRef={fileInputRef}
onDragOver={(e) => {
e.preventDefault()
setIsDragging(true)
}}
onDragLeave={() => setIsDragging(false)}
onDrop={handleDrop}
onAreaClick={() => fileInputRef.current?.click()}
onFileChange={handleFileUpload}
/>
)}
</div>
{/* 封面预览 */}
<div className="cover-preview-section">
<div className="cover-section-title"></div>
<div className="cover-preview-box">
{config.upload_url ? (
<img src={config.upload_url} alt="封面预览" className="cover-preview-img" />
) : (
<div className="cover-preview-placeholder">
<span className="cover-preview-icon">🖼</span>
<span className="cover-preview-text">
{config.mode === "auto"
? "AI 智能选择"
: config.mode === "frame"
? `${formatTime(config.frame_time)}`
: "未上传封面"}
</span>
</div>
)}
<div className="cover-preview-ratio">16:9</div>
</div>
</div>
{/* 底部 */}
<div className="cover-footer">
<button className="cover-reset-btn" onClick={handleReset}>
</button>
</div>
</Drawer>
)
}
export default CoverSelector
@@ -0,0 +1,98 @@
import { useCallback, useRef, useState } from "react"
import type { CoverConfig, CoverMode } from "../../types"
import { DEFAULT_COVER_CONFIG } from "../../types"
/** 封面模式标签 */
export const MODE_LABELS: Record<CoverMode, string> = {
auto: "智能封面",
frame: "抽帧选封面",
upload: "上传封面",
}
/** 封面模式图标 */
export const MODE_ICONS: Record<CoverMode, string> = {
auto: "🤖",
frame: "🎞️",
upload: "📤",
}
interface UseCoverSelectorOptions {
config: CoverConfig
onChange: (config: CoverConfig) => void
}
/**
* 封面选择器 Hook
* 封装状态管理、文件上传、模式切换等逻辑
*/
export function useCoverSelector({ config, onChange }: UseCoverSelectorOptions) {
const fileInputRef = useRef<HTMLInputElement>(null)
const [isDragging, setIsDragging] = useState(false)
const update = useCallback(
(partial: Partial<CoverConfig>) => {
onChange({ ...config, ...partial })
},
[config, onChange],
)
const handleReset = useCallback(() => {
onChange({ ...DEFAULT_COVER_CONFIG, enabled: config.enabled })
}, [config.enabled, onChange])
const handleModeChange = useCallback(
(mode: CoverMode) => {
update({ mode })
},
[update],
)
const handleFileUpload = useCallback(
(file: File) => {
if (!file.type.startsWith("image/")) return
const reader = new FileReader()
reader.onload = (e) => {
const url = e.target?.result as string
update({ upload_url: url, thumbnail_url: url, mode: "upload" })
}
reader.readAsDataURL(file)
},
[update],
)
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault()
setIsDragging(false)
const file = e.dataTransfer.files[0]
if (file) handleFileUpload(file)
},
[handleFileUpload],
)
const handleUseAiSuggestion = useCallback(() => {
if (config.ai_suggested_time !== null) {
update({ frame_time: config.ai_suggested_time, mode: "frame" })
}
}, [config.ai_suggested_time, update])
const formatTime = (seconds: number) => {
const m = Math.floor(seconds / 60)
const s = Math.floor(seconds % 60)
const ms = Math.floor((seconds % 1) * 10)
return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}.${ms}`
}
return {
fileInputRef,
isDragging,
setIsDragging,
update,
handleReset,
handleModeChange,
handleFileUpload,
handleDrop,
handleUseAiSuggestion,
formatTime,
}
}