Compare commits

..

2 Commits

Author SHA1 Message Date
xiaoxia f2bd1c25f4 Merge branch 'develop' into refactor/template-editor-api
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 18s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 36s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m17s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m18s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 26s
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 / Validate - Code Quality (pull_request) Failing after 2m36s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m46s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 5m7s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 47s
AI Code Review / AI Code Review (pull_request) Successful in 1m20s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 46s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 15m18s
CI/CD Pipeline / CI Gate (pull_request) 失败: CI/CD Pipeline / Validate - Code Quality (pull_request) [frontend-only]
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 / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 22s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Has been cancelled
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
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 21s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
2026-07-27 12:35:03 +08:00
xiaoxia be3ed6487e refactor(template-editor): API层按模块拆分(744行→88行入口, -88%)
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 22s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 54s
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 / Validate - Type Check (mypy) (pull_request) Successful in 1m49s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 58s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m50s
CI/CD Pipeline / Validate - Code Quality (pull_request) Failing after 2m33s
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 46s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
PR Automation / Auto Approve on CI Green (pull_request) Successful in 50s
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
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 2m48s
AI Code Review / AI Code Review (pull_request) Successful in 1m48s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 2m26s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 16s
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
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 / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 46m25s
将 template-editor.ts 单文件拆分为目录结构,按业务模块组织:

- types.ts — 所有类型定义(EditPlan/Clip/MediaAsset 等)
- constants.ts — 常量(TRANSITION_OPTIONS/QUALITY_OPTIONS 等)
- editPlans.ts — 模板草稿 CRUD + 生成 + 复制 + 取消
- clips.ts — 片段 CRUD + 批量操作(重排序/批量删除/从素材创建)
- aiFeatures.ts — AI 推荐 + AI 封面生成
- mediaAssets.ts — 素材库 API + AssetItem→MediaAsset 映射
- index.ts — 统一入口,re-export 所有类型/常量/函数

入口文件从 744 行精简到 88 行(仅 re-export),
各模块职责单一,便于维护和 tree-shaking。
导入路径 @/api/template-editor 保持向后兼容。
2026-07-26 16:31:25 +08:00
15 changed files with 1242 additions and 1290 deletions
-744
View File
@@ -1,744 +0,0 @@
/**
* 模板草稿 API — 对接后端 Template Editor Schema
* 字段名严格匹配后端 API 响应
*/
import apiClient from "./client"
import type { AssetItem } from "./assets"
import type {
WatermarkConfig,
IntroOutroConfig,
PipConfig,
FilterConfig,
ChromaKeyConfig,
StickerConfig,
CoverConfig,
} from "@/pages/editing-planner/types"
/* ============================================================
* 后端 API 类型(严格匹配后端 Schema)
* ============================================================ */
/** 模板草稿状态枚举 */
export type EditPlanStatus =
"draft" | "editing" | "rendering" | "completed" | "failed" | "cancelled"
/** 标题配置(对齐后端 title_config */
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
}
/** 片段 TTS 配置 */
export interface SegmentTtsConfig {
mode: string
text: string
voice_id: string
speed: number
pitch: number
volume: number
subtitle_sync: boolean
}
/** 片段裁剪配置 */
export interface SegmentTrimConfig {
start_time: number
end_time: number
}
/** 片段转场配置 */
export interface SegmentTransitionConfig {
type: string
duration: number
}
/** 模板草稿中的单个片段(config 内部 segments 项) */
export interface EditPlanSegment {
segment_order: number
duration_min: number
duration_max: number
material_type: string
transition?: SegmentTransitionConfig
playback_speed?: number
tts_config?: SegmentTtsConfig
trim_config?: SegmentTrimConfig
}
/** 模板草稿 config 完整类型(对齐后端 config JSON 结构) */
export interface EditPlanConfig {
title_config?: TitleConfig
subtitle_config?: SubtitleConfig
bgm_config?: BgmConfig
estimated_duration?: number
segments?: EditPlanSegment[]
watermark_config?: WatermarkConfig
intro_outro_config?: IntroOutroConfig
pip_config?: PipConfig
filter_config?: FilterConfig
green_screen_config?: ChromaKeyConfig
sticker_config?: StickerConfig
cover_config?: CoverConfig
/** 前端扩展:关联的素材 ID 列表 */
asset_ids?: string[]
/** 配音 ID */
voice_id?: string
/** 克隆音色档案 ID */
voice_clone_profile_id?: string
/** 自定义配音音频 URL */
custom_audio_url?: string
/** 自定义配音文本 */
custom_text?: string
/** 视频比例 */
ratio?: string
/** 视频风格 */
style?: string
/** 目标时长(秒) */
duration?: number
/** 是否自动生成字幕 */
auto_subtitles?: boolean
/** 是否启用 BGM */
bgm?: boolean
/** 生成数量 */
generate_count?: number
/** 素材模式 */
material_mode?: string
}
/** 模板草稿(后端响应) */
export interface EditPlan {
id: string
template_id: string
name: string
status: EditPlanStatus
total_duration: number
/** 生成视频数量(后端 EditPlanResponse.result_count */
result_count: number
config: EditPlanConfig
created_at: string
updated_at: string
}
/** 创建模板草稿请求(后端要求 template_id + name 必填) */
export interface CreateEditPlanRequest {
template_id: string
name: string
config?: EditPlanConfig
total_duration?: number
/** 来源模板草稿 ID(从模板编辑器跳转到智能剪辑时关联) */
source_edit_plan_id?: string
}
/** 更新模板草稿请求 */
export interface UpdateEditPlanRequest {
name?: string
config?: EditPlanConfig
total_duration?: number
status?: EditPlanStatus
}
/** 生成响应 */
export interface GenerateResponse {
plan_id: string
plan_status: EditPlanStatus
generation_task_id: string
clip_count: number
}
/** 模板草稿关联的生成记录(实际是 GenerationTask 对象) */
export interface EditPlanGeneration {
id: string // 即 generation_task_id
source_edit_plan_id: string
template_id: string
asset_ids: string[]
status: EditPlanStatus
progress: number
result_count: number
error_message: string
error_info: Record<string, unknown>
logs: Array<Record<string, unknown>>
retry_count: number
created_at?: string
updated_at?: string
}
/** 片段生成状态 */
export interface ClipStatusItem {
clip_id: string
clip_type: string
order: number
status: string
asset_id?: string
text_content?: string
duration?: number
error_message?: string
}
/** 生成状态轮询响应 */
export interface GenerationStatusResponse {
plan_id: string
plan_status: EditPlanStatus
generation_task_id?: string
error_message?: string
clips: ClipStatusItem[]
error?: string
message?: string
}
/** 生成视频详情(对应后端 GeneratedVideoResponse */
export interface GeneratedVideo {
id: string
project_id?: string
generation_task_id?: string
name: string
file_url: string
file_size?: number
duration?: number
thumbnail_url?: string
width?: number
height?: number
fps?: number
status: string
review_status?: string
download_url?: string
created_at?: string
updated_at?: string
}
/* ============================================================
* AI 推荐 & 封面生成(任务 3.09
* ============================================================ */
/** AI 推荐请求 */
export interface AIRecommendRequest {
asset_ids: string[]
editing_mode?: string
target_duration?: number
}
/** AI 推荐单个片段 */
export interface AIRecommendClipItem {
clip_type: string
order: number
text_content: string
duration: number
transition_effect: string
asset_id: string
start_time: number
config: EditPlanConfig
}
/** AI 推荐响应 */
export interface AIRecommendResponse {
plan_id: string
clips: AIRecommendClipItem[]
config: EditPlanConfig
total_duration: number
confidence: number
}
/** AI 封面生成请求 */
export interface GenerateCoverRequest {
asset_ids: string[]
cover_type?: "ai_frame" | "manual" | "upload" | "ai_regenerate"
frame_time?: number
}
/** AI 封面生成响应 */
export interface GenerateCoverResponse {
plan_id: string
cover: CoverResult
}
/** 封面生成结果 */
export interface CoverResult {
scheme?: string
asset_id?: string
frame_time?: number
thumbnail_url?: string
}
/* ============================================================
* 前端 UI 类型(EditingPlanner 组件依赖,保留兼容)
* ============================================================ */
/** 转场效果(14 种预设) */
export interface TransitionEffect {
type:
| "none"
| "cut"
| "fade"
| "dissolve"
| "zoom"
| "slide_left"
| "slide_right"
| "slide_up"
| "slide_down"
| "wipe_left"
| "wipe_right"
| "wipe_up"
| "wipe_down"
| "circlecrop"
| "rectcrop"
duration: number // 转场时长(秒)
/** 播放速度倍率 */
playback_speed?: number
}
/** 素材库资产(UI 层类型,映射自后端 AssetResponse */
export interface MediaAsset {
id: string
name: string
type: "video" | "image" | "audio"
/** 缩略图 URL */
thumbnail_url?: string
/** 时长(秒),仅 video/audio */
duration?: number
/** 文件大小(字节) */
size?: number
/** 标签 */
tags: string[]
created_at: string
/** 质量分 0-100 */
quality_score?: number
/** 分类状态 */
classification_status?: "pending" | "processing" | "completed" | "failed"
}
/* ============================================================
* API 函数 — 严格对接后端
* ============================================================ */
/** 模板草稿列表查询参数 */
export interface EditPlanListParams {
page?: number
page_size?: number
template_id?: string
status?: string
}
/** 模板草稿列表分页响应 */
export interface EditPlanListResponse {
items: EditPlan[]
total: number
page: number
page_size: number
}
/** 获取模板草稿列表(支持分页和筛选) */
export async function getEditPlans(params?: EditPlanListParams): Promise<EditPlanListResponse> {
const response = await apiClient.get<EditPlanListResponse>("/templates/drafts", {
params,
})
return response.data
}
/** 获取单个模板草稿 */
export async function getEditPlan(templateId: string): Promise<EditPlan> {
const response = await apiClient.get(`/templates/${templateId}/editor`)
return response.data
}
/** 创建模板草稿 */
export async function createEditPlan(data: CreateEditPlanRequest): Promise<EditPlan> {
const response = await apiClient.post("/templates/drafts", data)
return response.data
}
/** 更新模板草稿 */
export async function updateEditPlan(
templateId: string,
data: UpdateEditPlanRequest,
): Promise<EditPlan> {
const response = await apiClient.put(`/templates/${templateId}/editor`, data)
return response.data
}
/** 删除模板草稿 */
export async function deleteEditPlan(templateId: string): Promise<void> {
await apiClient.delete(`/templates/${templateId}/editor`)
}
/** 触发生成 */
export async function generateEditPlan(templateId: string): Promise<GenerateResponse> {
const response = await apiClient.post(`/templates/${templateId}/editor/generate`)
return response.data
}
/** 获取生成状态(轮询用) */
export async function getGenerationStatus(templateId: string): Promise<GenerationStatusResponse> {
const response = await apiClient.get(`/templates/${templateId}/editor/generation-status`)
return response.data
}
/** AI 推荐片段方案 */
export async function aiRecommendClips(
templateId: string,
data: AIRecommendRequest,
): Promise<AIRecommendResponse> {
const response = await apiClient.post(`/templates/${templateId}/editor/ai-recommend`, data)
return response.data
}
/** AI 生成封面 */
export async function generateCover(
templateId: string,
data: GenerateCoverRequest,
): Promise<GenerateCoverResponse> {
const response = await apiClient.post(`/templates/${templateId}/editor/generate-cover`, data)
return response.data
}
/** 获取模板草稿关联的生成记录 */
export async function getEditPlanGenerations(templateId: string): Promise<EditPlanGeneration[]> {
const response = await apiClient.get(`/templates/${templateId}/editor/generations`)
return response.data.items || []
}
/** 获取生成任务的视频结果列表 */
export async function getGenerationTaskResults(taskId: string): Promise<GeneratedVideo[]> {
const response = await apiClient.get(`/generation/tasks/${taskId}/results`)
return response.data.items || response.data || []
}
/** 取消生成任务 */
export async function cancelGeneration(templateId: string): Promise<void> {
await apiClient.post(`/templates/${templateId}/editor/cancel`)
}
/* ============================================================
* 片段 CRUD(后端 EditPlanClip 独立表)
* ============================================================ */
/** 片段状态 */
export type EditPlanClipStatus = "pending" | "processing" | "ready" | "failed"
/** 剪辑片段(后端响应) */
export interface EditPlanClip {
id: string
plan_id: string
clip_type: string // main / intro / outro / overlay / background / b_roll 等
order: number
asset_id: string
text_content: string
start_time: number
duration: number
transition_effect: string
transition_duration: number
playback_speed: number
status: EditPlanClipStatus
config: Record<string, unknown>
created_at?: string
updated_at?: string
}
/** 创建片段请求 */
export interface CreateEditPlanClipRequest {
clip_type: string
order: number
asset_id?: string
text_content?: string
start_time?: number
duration?: number
transition_effect?: string
transition_duration?: number
playback_speed?: number
config?: Record<string, unknown>
}
/** 更新片段请求 */
export interface UpdateEditPlanClipRequest {
clip_type?: string
order?: number
asset_id?: string
text_content?: string
start_time?: number
duration?: number
transition_effect?: string
transition_duration?: number
playback_speed?: number
config?: Record<string, unknown>
}
/** 片段列表响应 */
export interface EditPlanClipListResponse {
items: EditPlanClip[]
total: number
}
/** 片段列表查询参数 */
export interface EditPlanClipListParams {
status?: string
skip?: number
limit?: number
}
/** 获取片段列表 */
export async function getEditPlanClips(
templateId: string,
params?: EditPlanClipListParams,
): Promise<EditPlanClipListResponse> {
const response = await apiClient.get<EditPlanClipListResponse>(
`/templates/${templateId}/editor/clips`,
{
params,
},
)
return response.data
}
/** 获取单个片段详情 */
export async function getEditPlanClip(templateId: string, clipId: string): Promise<EditPlanClip> {
const response = await apiClient.get<EditPlanClip>(
`/templates/${templateId}/editor/clips/${clipId}`,
)
return response.data
}
/** 创建片段 */
export async function createEditPlanClip(
templateId: string,
data: CreateEditPlanClipRequest,
): Promise<EditPlanClip> {
const response = await apiClient.post<EditPlanClip>(`/templates/${templateId}/editor/clips`, data)
return response.data
}
/** 更新片段 */
export async function updateEditPlanClip(
templateId: string,
clipId: string,
data: UpdateEditPlanClipRequest,
): Promise<EditPlanClip> {
const response = await apiClient.put<EditPlanClip>(
`/templates/${templateId}/editor/clips/${clipId}`,
data,
)
return response.data
}
/** 删除片段 */
export async function deleteEditPlanClip(templateId: string, clipId: string): Promise<void> {
await apiClient.delete(`/templates/${templateId}/editor/clips/${clipId}`)
}
/* ============================================================
* 片段批量操作
* ============================================================ */
/** 重排序条目 */
export interface ClipReorderItem {
clip_id: string
new_order: number
}
/** 重排序响应 */
export interface ClipReorderResponse {
success: boolean
updated_count: number
message: string
}
/** 批量删除响应 */
export interface ClipBatchDeleteResponse {
success: boolean
deleted_count: number
message: string
}
/** 从素材批量创建响应 */
export interface ClipsFromAssetsResponse {
success: boolean
created_count: number
message: string
clip_ids: string[]
}
/** 片段重排序(拖拽排序后一次性提交) */
export async function reorderEditPlanClips(
templateId: string,
items: ClipReorderItem[],
): Promise<ClipReorderResponse> {
const response = await apiClient.post<ClipReorderResponse>(
`/templates/${templateId}/editor/clips/reorder`,
{ items },
)
return response.data
}
/** 批量删除片段 */
export async function batchDeleteEditPlanClips(
templateId: string,
clipIds: string[],
): Promise<ClipBatchDeleteResponse> {
const response = await apiClient.post<ClipBatchDeleteResponse>(
`/templates/${templateId}/editor/clips/batch-delete`,
{ clip_ids: clipIds },
)
return response.data
}
/** 从素材批量创建片段(追加到时间线末尾) */
export async function createClipsFromAssets(
templateId: string,
assetIds: string[],
clipType = "main",
): Promise<ClipsFromAssetsResponse> {
const response = await apiClient.post<ClipsFromAssetsResponse>(
`/templates/${templateId}/editor/clips/from-assets`,
{ asset_ids: assetIds, clip_type: clipType },
)
return response.data
}
/* ============================================================
* 复制计划
* ============================================================ */
/** 复制计划请求 */
export interface CopyEditPlanRequest {
name?: string
project_id?: string
}
/** 复制模板草稿(含所有片段配置) */
export async function copyEditPlan(
templateId: string,
data?: CopyEditPlanRequest,
): Promise<EditPlan> {
const response = await apiClient.post<EditPlan>(
`/templates/${templateId}/editor/copy`,
data || {},
)
return response.data
}
/**
* 获取素材库列表 — 调用 GET /api/v1/assets?library_id=xxx
* 将后端 AssetResponse 映射为前端 MediaAsset 类型
*/
export async function getMediaAssets(libraryId?: string): Promise<MediaAsset[]> {
const response = await apiClient.get("/assets", {
params: libraryId ? { library_id: libraryId } : undefined,
})
const items: AssetItem[] = response.data.items || []
return items.map(mapAssetToMediaAsset)
}
/** 获取单个素材 */
export async function getMediaAsset(id: string): Promise<MediaAsset> {
const response = await apiClient.get(`/assets/${id}`)
return mapAssetToMediaAsset(response.data)
}
/* ============================================================
* 映射函数:AssetResponse → MediaAsset
* ============================================================ */
function inferMediaType(mimeType: string): "video" | "image" | "audio" {
if (mimeType.startsWith("video/")) return "video"
if (mimeType.startsWith("image/")) return "image"
return "audio"
}
function mapAssetToMediaAsset(asset: AssetItem): MediaAsset {
// 优先取顶层 duration,其次从 metadata 回退
const metaDuration =
typeof asset.metadata?.duration === "number" ? asset.metadata.duration : undefined
return {
id: asset.id,
name: asset.name,
type: inferMediaType(asset.mime_type || ""),
thumbnail_url: asset.thumbnail_url,
duration: asset.duration ?? metaDuration,
size: asset.file_size ?? undefined,
tags: [],
created_at: asset.created_at ?? "",
quality_score: asset.quality_score ?? undefined,
classification_status: asset.classification_status ?? undefined,
}
}
/* ============================================================
* 常量
* ============================================================ */
/** 转场效果选项(14 种预设) */
export const TRANSITION_OPTIONS: {
value: TransitionEffect["type"]
label: string
icon: string
}[] = [
{ value: "none", label: "无转场", icon: "⊘" },
{ value: "cut", label: "硬切", icon: "✂" },
{ value: "fade", label: "淡入淡出", icon: "◐" },
{ value: "dissolve", label: "溶解", icon: "◈" },
{ value: "zoom", label: "缩放", icon: "⊕" },
{ value: "slide_left", label: "左滑", icon: "←" },
{ value: "slide_right", label: "右滑", icon: "→" },
{ value: "slide_up", label: "上滑", icon: "↑" },
{ value: "slide_down", label: "下滑", icon: "↓" },
{ value: "wipe_left", label: "左擦除", icon: "▸|" },
{ value: "wipe_right", label: "右擦除", icon: "|◂" },
{ value: "wipe_up", label: "上擦除", icon: "▴̄" },
{ value: "wipe_down", label: "下擦除", icon: "▾̄" },
{ value: "circlecrop", label: "圆形裁切", icon: "●" },
{ value: "rectcrop", label: "矩形裁切", icon: "■" },
]
/** 素材类型标签 */
export const MATERIAL_TYPE_LABELS: Record<string, string> = {
video: "视频",
image: "图片",
audio: "音频",
voiceover: "配音",
}
/** 素材类型图标 */
export const MATERIAL_TYPE_ICONS: Record<string, string> = {
video: "🎬",
image: "🖼️",
audio: "🎵",
voiceover: "🎙️",
}
/** 计划状态标签 */
export const PLAN_STATUS_LABELS: Record<EditPlanStatus, string> = {
draft: "草稿",
editing: "编辑中",
rendering: "渲染中",
completed: "已完成",
failed: "失败",
cancelled: "已取消",
}
/** 质量分筛选选项 */
export const QUALITY_OPTIONS: {
value: string
label: string
min?: number
max?: number
}[] = [
{ value: "all", label: "全部质量" },
{ value: "high", label: "高质量 (80-100)", min: 80, max: 100 },
{ value: "medium", label: "中质量 (50-79)", min: 50, max: 79 },
{ value: "low", label: "低质量 (0-49)", min: 0, max: 49 },
]
@@ -0,0 +1,28 @@
/**
* AI 推荐 + 封面生成 API
*/
import apiClient from "../client"
import type {
AIRecommendRequest,
AIRecommendResponse,
GenerateCoverRequest,
GenerateCoverResponse,
} from "./types"
/** AI 推荐片段方案 */
export async function aiRecommendClips(
templateId: string,
data: AIRecommendRequest,
): Promise<AIRecommendResponse> {
const response = await apiClient.post(`/templates/${templateId}/editor/ai-recommend`, data)
return response.data
}
/** AI 生成封面 */
export async function generateCover(
templateId: string,
data: GenerateCoverRequest,
): Promise<GenerateCoverResponse> {
const response = await apiClient.post(`/templates/${templateId}/editor/generate-cover`, data)
return response.data
}
+99
View File
@@ -0,0 +1,99 @@
/**
* 片段 CRUD + 批量操作 API
*/
import apiClient from "../client"
import type {
EditPlanClip,
EditPlanClipListParams,
EditPlanClipListResponse,
CreateEditPlanClipRequest,
UpdateEditPlanClipRequest,
ClipReorderItem,
ClipReorderResponse,
ClipBatchDeleteResponse,
ClipsFromAssetsResponse,
} from "./types"
/** 获取片段列表 */
export async function getEditPlanClips(
templateId: string,
params?: EditPlanClipListParams,
): Promise<EditPlanClipListResponse> {
const response = await apiClient.get<EditPlanClipListResponse>(
`/templates/${templateId}/editor/clips`,
{ params },
)
return response.data
}
/** 获取单个片段详情 */
export async function getEditPlanClip(templateId: string, clipId: string): Promise<EditPlanClip> {
const response = await apiClient.get<EditPlanClip>(
`/templates/${templateId}/editor/clips/${clipId}`,
)
return response.data
}
/** 创建片段 */
export async function createEditPlanClip(
templateId: string,
data: CreateEditPlanClipRequest,
): Promise<EditPlanClip> {
const response = await apiClient.post<EditPlanClip>(`/templates/${templateId}/editor/clips`, data)
return response.data
}
/** 更新片段 */
export async function updateEditPlanClip(
templateId: string,
clipId: string,
data: UpdateEditPlanClipRequest,
): Promise<EditPlanClip> {
const response = await apiClient.put<EditPlanClip>(
`/templates/${templateId}/editor/clips/${clipId}`,
data,
)
return response.data
}
/** 删除片段 */
export async function deleteEditPlanClip(templateId: string, clipId: string): Promise<void> {
await apiClient.delete(`/templates/${templateId}/editor/clips/${clipId}`)
}
/** 片段重排序(拖拽排序后一次性提交) */
export async function reorderEditPlanClips(
templateId: string,
items: ClipReorderItem[],
): Promise<ClipReorderResponse> {
const response = await apiClient.post<ClipReorderResponse>(
`/templates/${templateId}/editor/clips/reorder`,
{ items },
)
return response.data
}
/** 批量删除片段 */
export async function batchDeleteEditPlanClips(
templateId: string,
clipIds: string[],
): Promise<ClipBatchDeleteResponse> {
const response = await apiClient.post<ClipBatchDeleteResponse>(
`/templates/${templateId}/editor/clips/batch-delete`,
{ clip_ids: clipIds },
)
return response.data
}
/** 从素材批量创建片段(追加到时间线末尾) */
export async function createClipsFromAssets(
templateId: string,
assetIds: string[],
clipType = "main",
): Promise<ClipsFromAssetsResponse> {
const response = await apiClient.post<ClipsFromAssetsResponse>(
`/templates/${templateId}/editor/clips/from-assets`,
{ asset_ids: assetIds, clip_type: clipType },
)
return response.data
}
@@ -0,0 +1,66 @@
/**
* 模板编辑器常量
*/
import type { TransitionEffect, EditPlanStatus } from "./types"
/** 转场效果选项(14 种预设) */
export const TRANSITION_OPTIONS: {
value: TransitionEffect["type"]
label: string
icon: string
}[] = [
{ value: "none", label: "无转场", icon: "⊘" },
{ value: "cut", label: "硬切", icon: "✂" },
{ value: "fade", label: "淡入淡出", icon: "◐" },
{ value: "dissolve", label: "溶解", icon: "◈" },
{ value: "zoom", label: "缩放", icon: "⊕" },
{ value: "slide_left", label: "左滑", icon: "←" },
{ value: "slide_right", label: "右滑", icon: "→" },
{ value: "slide_up", label: "上滑", icon: "↑" },
{ value: "slide_down", label: "下滑", icon: "↓" },
{ value: "wipe_left", label: "左擦除", icon: "▸|" },
{ value: "wipe_right", label: "右擦除", icon: "|◂" },
{ value: "wipe_up", label: "上擦除", icon: "▴̄" },
{ value: "wipe_down", label: "下擦除", icon: "▾̄" },
{ value: "circlecrop", label: "圆形裁切", icon: "●" },
{ value: "rectcrop", label: "矩形裁切", icon: "■" },
]
/** 素材类型标签 */
export const MATERIAL_TYPE_LABELS: Record<string, string> = {
video: "视频",
image: "图片",
audio: "音频",
voiceover: "配音",
}
/** 素材类型图标 */
export const MATERIAL_TYPE_ICONS: Record<string, string> = {
video: "🎬",
image: "🖼️",
audio: "🎵",
voiceover: "🎙️",
}
/** 计划状态标签 */
export const PLAN_STATUS_LABELS: Record<EditPlanStatus, string> = {
draft: "草稿",
editing: "编辑中",
rendering: "渲染中",
completed: "已完成",
failed: "失败",
cancelled: "已取消",
}
/** 质量分筛选选项 */
export const QUALITY_OPTIONS: {
value: string
label: string
min?: number
max?: number
}[] = [
{ value: "all", label: "全部质量" },
{ value: "high", label: "高质量 (80-100)", min: 80, max: 100 },
{ value: "medium", label: "中质量 (50-79)", min: 50, max: 79 },
{ value: "low", label: "低质量 (0-49)", min: 0, max: 49 },
]
@@ -0,0 +1,91 @@
/**
* 模板草稿 CRUD + 生成相关 API
*/
import apiClient from "../client"
import type {
EditPlan,
EditPlanListParams,
EditPlanListResponse,
CreateEditPlanRequest,
UpdateEditPlanRequest,
GenerateResponse,
GenerationStatusResponse,
EditPlanGeneration,
GeneratedVideo,
CopyEditPlanRequest,
} from "./types"
/** 获取模板草稿列表(支持分页和筛选) */
export async function getEditPlans(params?: EditPlanListParams): Promise<EditPlanListResponse> {
const response = await apiClient.get<EditPlanListResponse>("/templates/drafts", {
params,
})
return response.data
}
/** 获取单个模板草稿 */
export async function getEditPlan(templateId: string): Promise<EditPlan> {
const response = await apiClient.get(`/templates/${templateId}/editor`)
return response.data
}
/** 创建模板草稿 */
export async function createEditPlan(data: CreateEditPlanRequest): Promise<EditPlan> {
const response = await apiClient.post("/templates/drafts", data)
return response.data
}
/** 更新模板草稿 */
export async function updateEditPlan(
templateId: string,
data: UpdateEditPlanRequest,
): Promise<EditPlan> {
const response = await apiClient.put(`/templates/${templateId}/editor`, data)
return response.data
}
/** 删除模板草稿 */
export async function deleteEditPlan(templateId: string): Promise<void> {
await apiClient.delete(`/templates/${templateId}/editor`)
}
/** 触发生成 */
export async function generateEditPlan(templateId: string): Promise<GenerateResponse> {
const response = await apiClient.post(`/templates/${templateId}/editor/generate`)
return response.data
}
/** 获取生成状态(轮询用) */
export async function getGenerationStatus(templateId: string): Promise<GenerationStatusResponse> {
const response = await apiClient.get(`/templates/${templateId}/editor/generation-status`)
return response.data
}
/** 获取模板草稿关联的生成记录 */
export async function getEditPlanGenerations(templateId: string): Promise<EditPlanGeneration[]> {
const response = await apiClient.get(`/templates/${templateId}/editor/generations`)
return response.data.items || []
}
/** 获取生成任务的视频结果列表 */
export async function getGenerationTaskResults(taskId: string): Promise<GeneratedVideo[]> {
const response = await apiClient.get(`/generation/tasks/${taskId}/results`)
return response.data.items || response.data || []
}
/** 取消生成任务 */
export async function cancelGeneration(templateId: string): Promise<void> {
await apiClient.post(`/templates/${templateId}/editor/cancel`)
}
/** 复制模板草稿(含所有片段配置) */
export async function copyEditPlan(
templateId: string,
data?: CopyEditPlanRequest,
): Promise<EditPlan> {
const response = await apiClient.post<EditPlan>(
`/templates/${templateId}/editor/copy`,
data || {},
)
return response.data
}
+88
View File
@@ -0,0 +1,88 @@
/**
* 模板编辑器 API — 按模块拆分后的统一入口
* 保持与原 template-editor.ts 相同的导出结构,向后兼容
*/
// 类型
export type {
EditPlanStatus,
TitleConfig,
SubtitleConfig,
BgmConfig,
SegmentTtsConfig,
SegmentTrimConfig,
SegmentTransitionConfig,
EditPlanSegment,
EditPlanConfig,
EditPlan,
CreateEditPlanRequest,
UpdateEditPlanRequest,
EditPlanListParams,
EditPlanListResponse,
GenerateResponse,
EditPlanGeneration,
ClipStatusItem,
GenerationStatusResponse,
GeneratedVideo,
AIRecommendRequest,
AIRecommendClipItem,
AIRecommendResponse,
GenerateCoverRequest,
GenerateCoverResponse,
CoverResult,
EditPlanClipStatus,
EditPlanClip,
CreateEditPlanClipRequest,
UpdateEditPlanClipRequest,
EditPlanClipListResponse,
EditPlanClipListParams,
ClipReorderItem,
ClipReorderResponse,
ClipBatchDeleteResponse,
ClipsFromAssetsResponse,
CopyEditPlanRequest,
TransitionEffect,
MediaAsset,
} from "./types"
// 常量
export {
TRANSITION_OPTIONS,
MATERIAL_TYPE_LABELS,
MATERIAL_TYPE_ICONS,
PLAN_STATUS_LABELS,
QUALITY_OPTIONS,
} from "./constants"
// 模板草稿 CRUD + 生成
export {
getEditPlans,
getEditPlan,
createEditPlan,
updateEditPlan,
deleteEditPlan,
generateEditPlan,
getGenerationStatus,
getEditPlanGenerations,
getGenerationTaskResults,
cancelGeneration,
copyEditPlan,
} from "./editPlans"
// 片段 CRUD + 批量操作
export {
getEditPlanClips,
getEditPlanClip,
createEditPlanClip,
updateEditPlanClip,
deleteEditPlanClip,
reorderEditPlanClips,
batchDeleteEditPlanClips,
createClipsFromAssets,
} from "./clips"
// AI 推荐 + 封面生成
export { aiRecommendClips, generateCover } from "./aiFeatures"
// 素材库
export { getMediaAssets, getMediaAsset } from "./mediaAssets"
@@ -0,0 +1,44 @@
/**
* 素材库 API(对接 /assets 接口,映射为 MediaAsset 类型)
*/
import apiClient from "../client"
import type { AssetItem } from "../assets"
import type { MediaAsset } from "./types"
const inferMediaType = (mimeType: string): "video" | "image" | "audio" => {
if (mimeType.startsWith("video/")) return "video"
if (mimeType.startsWith("image/")) return "image"
return "audio"
}
const mapAssetToMediaAsset = (asset: AssetItem): MediaAsset => {
const metaDuration =
typeof asset.metadata?.duration === "number" ? asset.metadata.duration : undefined
return {
id: asset.id,
name: asset.name,
type: inferMediaType(asset.mime_type || ""),
thumbnail_url: asset.thumbnail_url,
duration: asset.duration ?? metaDuration,
size: asset.file_size ?? undefined,
tags: [],
created_at: asset.created_at ?? "",
quality_score: asset.quality_score ?? undefined,
classification_status: asset.classification_status ?? undefined,
}
}
/** 获取素材库列表 */
export async function getMediaAssets(libraryId?: string): Promise<MediaAsset[]> {
const response = await apiClient.get("/assets", {
params: libraryId ? { library_id: libraryId } : undefined,
})
const items: AssetItem[] = response.data.items || []
return items.map(mapAssetToMediaAsset)
}
/** 获取单个素材 */
export async function getMediaAsset(id: string): Promise<MediaAsset> {
const response = await apiClient.get(`/assets/${id}`)
return mapAssetToMediaAsset(response.data)
}
+440
View File
@@ -0,0 +1,440 @@
/**
* 模板编辑器 API 类型定义
* 字段名严格匹配后端 API 响应
*/
import type {
WatermarkConfig,
IntroOutroConfig,
PipConfig,
FilterConfig,
ChromaKeyConfig,
StickerConfig,
CoverConfig,
} from "@/pages/editing-planner/types"
/* ── 模板草稿状态 ── */
export type EditPlanStatus =
"draft" | "editing" | "rendering" | "completed" | "failed" | "cancelled"
/* ── 配置相关类型 ── */
/** 标题配置(对齐后端 title_config */
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
}
/** 片段 TTS 配置 */
export interface SegmentTtsConfig {
mode: string
text: string
voice_id: string
speed: number
pitch: number
volume: number
subtitle_sync: boolean
}
/** 片段裁剪配置 */
export interface SegmentTrimConfig {
start_time: number
end_time: number
}
/** 片段转场配置 */
export interface SegmentTransitionConfig {
type: string
duration: number
}
/** 模板草稿中的单个片段 */
export interface EditPlanSegment {
segment_order: number
duration_min: number
duration_max: number
material_type: string
transition?: SegmentTransitionConfig
playback_speed?: number
tts_config?: SegmentTtsConfig
trim_config?: SegmentTrimConfig
}
/** 模板草稿 config 完整类型 */
export interface EditPlanConfig {
title_config?: TitleConfig
subtitle_config?: SubtitleConfig
bgm_config?: BgmConfig
estimated_duration?: number
segments?: EditPlanSegment[]
watermark_config?: WatermarkConfig
intro_outro_config?: IntroOutroConfig
pip_config?: PipConfig
filter_config?: FilterConfig
green_screen_config?: ChromaKeyConfig
sticker_config?: StickerConfig
cover_config?: CoverConfig
/** 前端扩展:关联的素材 ID 列表 */
asset_ids?: string[]
/** 配音 ID */
voice_id?: string
/** 克隆音色档案 ID */
voice_clone_profile_id?: string
/** 自定义配音音频 URL */
custom_audio_url?: string
/** 自定义配音文本 */
custom_text?: string
/** 视频比例 */
ratio?: string
/** 视频风格 */
style?: string
/** 目标时长(秒) */
duration?: number
/** 是否自动生成字幕 */
auto_subtitles?: boolean
/** 是否启用 BGM */
bgm?: boolean
/** 生成数量 */
generate_count?: number
/** 素材模式 */
material_mode?: string
}
/* ── 模板草稿主体 ── */
/** 模板草稿(后端响应) */
export interface EditPlan {
id: string
template_id: string
name: string
status: EditPlanStatus
total_duration: number
/** 生成视频数量 */
result_count: number
config: EditPlanConfig
created_at: string
updated_at: string
}
/** 创建模板草稿请求 */
export interface CreateEditPlanRequest {
template_id: string
name: string
config?: EditPlanConfig
total_duration?: number
/** 来源模板草稿 ID */
source_edit_plan_id?: string
}
/** 更新模板草稿请求 */
export interface UpdateEditPlanRequest {
name?: string
config?: EditPlanConfig
total_duration?: number
status?: EditPlanStatus
}
/** 模板草稿列表查询参数 */
export interface EditPlanListParams {
page?: number
page_size?: number
template_id?: string
status?: string
}
/** 模板草稿列表分页响应 */
export interface EditPlanListResponse {
items: EditPlan[]
total: number
page: number
page_size: number
}
/* ── 生成相关 ── */
/** 生成响应 */
export interface GenerateResponse {
plan_id: string
plan_status: EditPlanStatus
generation_task_id: string
clip_count: number
}
/** 模板草稿关联的生成记录 */
export interface EditPlanGeneration {
id: string
source_edit_plan_id: string
template_id: string
asset_ids: string[]
status: EditPlanStatus
progress: number
result_count: number
error_message: string
error_info: Record<string, unknown>
logs: Array<Record<string, unknown>>
retry_count: number
created_at?: string
updated_at?: string
}
/** 片段生成状态 */
export interface ClipStatusItem {
clip_id: string
clip_type: string
order: number
status: string
asset_id?: string
text_content?: string
duration?: number
error_message?: string
}
/** 生成状态轮询响应 */
export interface GenerationStatusResponse {
plan_id: string
plan_status: EditPlanStatus
generation_task_id?: string
error_message?: string
clips: ClipStatusItem[]
error?: string
message?: string
}
/** 生成视频详情 */
export interface GeneratedVideo {
id: string
project_id?: string
generation_task_id?: string
name: string
file_url: string
file_size?: number
duration?: number
thumbnail_url?: string
width?: number
height?: number
fps?: number
status: string
review_status?: string
download_url?: string
created_at?: string
updated_at?: string
}
/* ── AI 推荐 & 封面生成 ── */
/** AI 推荐请求 */
export interface AIRecommendRequest {
asset_ids: string[]
editing_mode?: string
target_duration?: number
}
/** AI 推荐单个片段 */
export interface AIRecommendClipItem {
clip_type: string
order: number
text_content: string
duration: number
transition_effect: string
asset_id: string
start_time: number
config: EditPlanConfig
}
/** AI 推荐响应 */
export interface AIRecommendResponse {
plan_id: string
clips: AIRecommendClipItem[]
config: EditPlanConfig
total_duration: number
confidence: number
}
/** AI 封面生成请求 */
export interface GenerateCoverRequest {
asset_ids: string[]
cover_type?: "ai_frame" | "manual" | "upload" | "ai_regenerate"
frame_time?: number
}
/** AI 封面生成响应 */
export interface GenerateCoverResponse {
plan_id: string
cover: CoverResult
}
/** 封面生成结果 */
export interface CoverResult {
scheme?: string
asset_id?: string
frame_time?: number
thumbnail_url?: string
}
/* ── 片段 CRUD 相关 ── */
/** 片段状态 */
export type EditPlanClipStatus = "pending" | "processing" | "ready" | "failed"
/** 剪辑片段(后端响应) */
export interface EditPlanClip {
id: string
plan_id: string
clip_type: string
order: number
asset_id: string
text_content: string
start_time: number
duration: number
transition_effect: string
transition_duration: number
playback_speed: number
status: EditPlanClipStatus
config: Record<string, unknown>
created_at?: string
updated_at?: string
}
/** 创建片段请求 */
export interface CreateEditPlanClipRequest {
clip_type: string
order: number
asset_id?: string
text_content?: string
start_time?: number
duration?: number
transition_effect?: string
transition_duration?: number
playback_speed?: number
config?: Record<string, unknown>
}
/** 更新片段请求 */
export interface UpdateEditPlanClipRequest {
clip_type?: string
order?: number
asset_id?: string
text_content?: string
start_time?: number
duration?: number
transition_effect?: string
transition_duration?: number
playback_speed?: number
config?: Record<string, unknown>
}
/** 片段列表响应 */
export interface EditPlanClipListResponse {
items: EditPlanClip[]
total: number
}
/** 片段列表查询参数 */
export interface EditPlanClipListParams {
status?: string
skip?: number
limit?: number
}
/* ── 片段批量操作 ── */
/** 重排序条目 */
export interface ClipReorderItem {
clip_id: string
new_order: number
}
/** 重排序响应 */
export interface ClipReorderResponse {
success: boolean
updated_count: number
message: string
}
/** 批量删除响应 */
export interface ClipBatchDeleteResponse {
success: boolean
deleted_count: number
message: string
}
/** 从素材批量创建响应 */
export interface ClipsFromAssetsResponse {
success: boolean
created_count: number
message: string
clip_ids: string[]
}
/* ── 复制计划 ── */
/** 复制计划请求 */
export interface CopyEditPlanRequest {
name?: string
project_id?: string
}
/* ── 前端 UI 类型 ── */
/** 转场效果(14 种预设) */
export interface TransitionEffect {
type:
| "none"
| "cut"
| "fade"
| "dissolve"
| "zoom"
| "slide_left"
| "slide_right"
| "slide_up"
| "slide_down"
| "wipe_left"
| "wipe_right"
| "wipe_up"
| "wipe_down"
| "circlecrop"
| "rectcrop"
duration: number
/** 播放速度倍率 */
playback_speed?: number
}
/** 素材库资产(UI 层类型) */
export interface MediaAsset {
id: string
name: string
type: "video" | "image" | "audio"
/** 缩略图 URL */
thumbnail_url?: string
/** 时长(秒),仅 video/audio */
duration?: number
/** 文件大小(字节) */
size?: number
/** 标签 */
tags: string[]
created_at: string
/** 质量分 0-100 */
quality_score?: number
/** 分类状态 */
classification_status?: "pending" | "processing" | "completed" | "failed"
}
+386 -39
View File
@@ -2,32 +2,333 @@
* 任务中心页面
* 展示用户的所有任务(生成任务、素材导入等),支持状态筛选、类型筛选、分页、重试
*/
import { Button } from "antd"
import { CloseCircleOutlined } from "@ant-design/icons"
import { TaskFilterBar } from "./components/TaskFilterBar"
import { TaskTable } from "./components/TaskTable"
import { useTaskList } from "./hooks/useTaskList"
import { useState } from "react"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import { Table, Tabs, Select, Tag, Button, message, Popconfirm, Tooltip } from "antd"
import {
CheckCircleOutlined,
ClockCircleOutlined,
SyncOutlined,
CloseCircleOutlined,
ExclamationCircleOutlined,
MinusCircleOutlined,
RedoOutlined,
InfoCircleOutlined,
} from "@ant-design/icons"
import type { ColumnsType } from "antd/es/table"
import {
getTasks,
retryTask,
type TaskItem,
type TaskStatus,
type TaskListParams,
} from "@/api/tasks"
import "./tasks.css"
/* ──────────── 常量 ──────────── */
/** 状态 Tab 配置 */
const STATUS_TABS: { key: TaskStatus | "all"; label: string }[] = [
{ key: "all", label: "全部" },
{ key: "waiting", label: "等待中" },
{ key: "running", label: "进行中" },
{ key: "completed", label: "已完成" },
{ key: "failed", label: "失败" },
{ key: "cancelled", label: "已取消" },
]
/** 类型筛选选项 */
const TYPE_OPTIONS = [
{ value: "all", label: "全部类型" },
{ value: "generation", label: "生成任务" },
{ value: "ingest", label: "素材导入" },
]
/** 状态标签配置 */
const STATUS_CONFIG: Record<TaskStatus, { label: string; color: string; icon: React.ReactNode }> = {
pending: {
label: "等待中",
color: "default",
icon: <ClockCircleOutlined />,
},
waiting: {
label: "排队中",
color: "processing",
icon: <SyncOutlined spin />,
},
running: {
label: "进行中",
color: "processing",
icon: <SyncOutlined spin />,
},
completed: {
label: "已完成",
color: "success",
icon: <CheckCircleOutlined />,
},
failed: {
label: "失败",
color: "error",
icon: <CloseCircleOutlined />,
},
cancelled: {
label: "已取消",
color: "default",
icon: <MinusCircleOutlined />,
},
}
/** 任务类型标签 */
const TYPE_LABELS: Record<string, { label: string; color: string }> = {
generation: { label: "生成任务", color: "blue" },
ingest: { label: "素材导入", color: "green" },
}
/* ──────────── 工具函数 ──────────── */
/** 格式化耗时 */
const formatDuration = (seconds?: number): string => {
if (!seconds) return "-"
if (seconds < 60) return `${Math.round(seconds)}`
const minutes = Math.floor(seconds / 60)
const secs = Math.round(seconds % 60)
if (minutes < 60) return `${minutes}${secs}`
const hours = Math.floor(minutes / 60)
const mins = minutes % 60
return `${hours}小时${mins}`
}
/** 格式化时间 */
const formatTime = (dateStr?: string | null): string => {
if (!dateStr) return "-"
const date = new Date(dateStr)
return date.toLocaleString("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
})
}
/* ──────────── 主组件 ──────────── */
export default function TaskCenter() {
const {
statusFilter,
typeFilter,
const queryClient = useQueryClient()
// 筛选状态
const [statusFilter, setStatusFilter] = useState<TaskStatus | "all">("all")
const [typeFilter, setTypeFilter] = useState<string>("all")
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const [expandedTaskId, setExpandedTaskId] = useState<string | null>(null)
const [expandedTaskDetail, setExpandedTaskDetail] = useState<TaskItem | null>(null)
// 查询参数
const queryParams: TaskListParams = {
page,
pageSize,
expandedTaskId,
expandedTaskDetail,
data,
isLoading,
error,
retryLoading,
handleStatusChange,
handleTypeChange,
handlePageChange,
handleExpand,
handleViewDetail,
handleRetry,
} = useTaskList()
page_size: pageSize,
...(statusFilter !== "all" && { status: statusFilter }),
...(typeFilter !== "all" && { task_type: typeFilter }),
}
// 获取任务列表
const { data, isLoading, error } = useQuery({
queryKey: ["tasks", queryParams],
queryFn: () => getTasks(queryParams),
refetchInterval: (query) => {
// 有进行中的任务时自动刷新
const tasks = query.state.data?.items ?? []
const hasRunning = tasks.some((t) => t.status === "running" || t.status === "waiting")
return hasRunning ? 5000 : false
},
})
// 重试任务
const retryMutation = useMutation({
mutationFn: retryTask,
onSuccess: () => {
message.success("任务已重新提交")
queryClient.invalidateQueries({ queryKey: ["tasks"] })
},
onError: () => {
message.error("重试失败,请检查任务状态")
},
})
// 展开查看详情
const handleExpand = async (expanded: boolean, record: TaskItem) => {
if (!expanded) {
setExpandedTaskId(null)
setExpandedTaskDetail(null)
return
}
setExpandedTaskId(record.id)
// 如果是失败任务,获取详情(含 error_info
if (record.status === "failed" && record.error_info) {
setExpandedTaskDetail(record)
}
}
// 表格列定义
const columns: ColumnsType<TaskItem> = [
{
title: "任务ID",
dataIndex: "id",
key: "id",
width: 120,
ellipsis: true,
render: (id: string) => (
<Tooltip title={id}>
<span className="task-id">{id.slice(0, 8)}...</span>
</Tooltip>
),
},
{
title: "类型",
dataIndex: "task_type",
key: "task_type",
width: 100,
render: (type: string) => {
const config = TYPE_LABELS[type] || { label: type, color: "default" }
return <Tag color={config.color}>{config.label}</Tag>
},
},
{
title: "状态",
dataIndex: "status",
key: "status",
width: 120,
render: (status: TaskStatus, record: TaskItem) => {
const config = STATUS_CONFIG[status] || {
label: status,
color: "default",
icon: null,
}
return (
<Tag color={config.color} icon={config.icon} className="task-status-tag">
{config.label}
{status === "running" && record.progress > 0 && (
<span className="task-progress"> {record.progress}%</span>
)}
</Tag>
)
},
},
{
title: "当前步骤",
dataIndex: "current_step",
key: "current_step",
width: 150,
ellipsis: true,
render: (step: string) => <span className="task-step">{step || "-"}</span>,
},
{
title: "耗时",
dataIndex: "duration_seconds",
key: "duration_seconds",
width: 100,
render: (seconds: number) => <span className="task-duration">{formatDuration(seconds)}</span>,
},
{
title: "创建时间",
dataIndex: "created_at",
key: "created_at",
width: 120,
render: (time: string) => <span className="task-time">{formatTime(time)}</span>,
},
{
title: "操作",
key: "action",
width: 100,
fixed: "right",
render: (_: unknown, record: TaskItem) => {
if (record.status === "failed" && record.retryable) {
return (
<Popconfirm
title="确认重试"
description="确定要重试这个失败的任务吗?"
onConfirm={() => retryMutation.mutate(record.id)}
okText="确定"
cancelText="取消"
>
<Button
type="link"
size="small"
icon={<RedoOutlined />}
loading={retryMutation.isPending}
className="task-retry-btn"
>
</Button>
</Popconfirm>
)
}
if (record.status === "failed") {
return (
<Button
type="link"
size="small"
icon={<InfoCircleOutlined />}
onClick={() => {
setExpandedTaskId(record.id)
setExpandedTaskDetail(record)
}}
>
</Button>
)
}
return <span className="task-action-placeholder">-</span>
},
},
]
// 展开行渲染(错误详情)
const expandedRowRender = (record: TaskItem) => {
const detail = expandedTaskDetail || record
const errorInfo = detail.error_info
if (!errorInfo && !detail.error_message) {
return <div className="task-expand-empty"></div>
}
return (
<div className="task-error-detail">
<div className="task-error-header">
<ExclamationCircleOutlined className="task-error-icon" />
<span></span>
</div>
<div className="task-error-body">
{errorInfo?.error_type && (
<div className="task-error-row">
<span className="task-error-label"></span>
<Tag color="error">{errorInfo.error_type}</Tag>
</div>
)}
{(errorInfo?.error_message || detail.error_message) && (
<div className="task-error-row">
<span className="task-error-label"></span>
<span className="task-error-message">
{errorInfo?.error_message || detail.error_message}
</span>
</div>
)}
{errorInfo?.failed_step && (
<div className="task-error-row">
<span className="task-error-label"></span>
<span>{errorInfo.failed_step}</span>
</div>
)}
{errorInfo?.stack_trace && (
<div className="task-error-row task-error-stack">
<span className="task-error-label"></span>
<pre>{errorInfo.stack_trace}</pre>
</div>
)}
</div>
</div>
)
}
// 错误处理
if (error) {
@@ -50,26 +351,72 @@ export default function TaskCenter() {
<p className="task-subtitle"></p>
</div>
<TaskFilterBar
statusFilter={statusFilter}
typeFilter={typeFilter}
onStatusChange={handleStatusChange}
onTypeChange={handleTypeChange}
/>
{/* 筛选栏 */}
<div className="task-filters">
{/* 状态 Tab */}
<Tabs
activeKey={statusFilter}
onChange={(key) => {
setStatusFilter(key as TaskStatus | "all")
setPage(1)
}}
items={STATUS_TABS.map((tab) => ({
key: tab.key,
label: tab.label,
}))}
className="task-status-tabs"
/>
<TaskTable
{/* 类型筛选 */}
<div className="task-type-filter">
<Select
value={typeFilter}
onChange={(value) => {
setTypeFilter(value)
setPage(1)
}}
options={TYPE_OPTIONS}
style={{ width: 140 }}
placeholder="选择类型"
/>
</div>
</div>
{/* 任务表格 */}
<Table
columns={columns}
dataSource={data?.items || []}
rowKey="id"
loading={isLoading}
total={data?.total || 0}
page={page}
pageSize={pageSize}
expandedTaskId={expandedTaskId}
expandedTaskDetail={expandedTaskDetail}
retryLoading={retryLoading}
onPageChange={handlePageChange}
onExpand={handleExpand}
onRetry={handleRetry}
onViewDetail={handleViewDetail}
pagination={{
current: page,
pageSize,
total: data?.total || 0,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total) => `${total}`,
onChange: (p, ps) => {
setPage(p)
setPageSize(ps)
},
}}
expandable={{
expandedRowRender,
expandedRowKeys: expandedTaskId ? [expandedTaskId] : [],
onExpand: handleExpand,
rowExpandable: (record) =>
record.status === "failed" && (!!record.error_info || !!record.error_message),
}}
scroll={{ x: 800 }}
className="task-table"
locale={{
emptyText: (
<div className="task-empty">
<ClockCircleOutlined />
<p></p>
</div>
),
}}
/>
</div>
)
@@ -1,58 +0,0 @@
import React from "react"
import { Tag } from "antd"
import { ExclamationCircleOutlined } from "@ant-design/icons"
import type { TaskItem } from "@/api/tasks"
interface TaskErrorDetailProps {
record: TaskItem
detail?: TaskItem | null
}
/**
* 任务错误详情展开行
*/
export const TaskErrorDetail: React.FC<TaskErrorDetailProps> = ({ record, detail }) => {
const d = detail || record
const errorInfo = d.error_info
if (!errorInfo && !d.error_message) {
return <div className="task-expand-empty"></div>
}
return (
<div className="task-error-detail">
<div className="task-error-header">
<ExclamationCircleOutlined className="task-error-icon" />
<span></span>
</div>
<div className="task-error-body">
{errorInfo?.error_type && (
<div className="task-error-row">
<span className="task-error-label"></span>
<Tag color="error">{errorInfo.error_type}</Tag>
</div>
)}
{(errorInfo?.error_message || d.error_message) && (
<div className="task-error-row">
<span className="task-error-label"></span>
<span className="task-error-message">
{errorInfo?.error_message || d.error_message}
</span>
</div>
)}
{errorInfo?.failed_step && (
<div className="task-error-row">
<span className="task-error-label"></span>
<span>{errorInfo.failed_step}</span>
</div>
)}
{errorInfo?.stack_trace && (
<div className="task-error-row task-error-stack">
<span className="task-error-label"></span>
<pre>{errorInfo.stack_trace}</pre>
</div>
)}
</div>
</div>
)
}
@@ -1,48 +0,0 @@
import React from "react"
import { Tabs, Select } from "antd"
import { STATUS_TABS, TYPE_OPTIONS } from "../constants"
import type { TaskStatus } from "@/api/tasks"
interface TaskFilterBarProps {
statusFilter: TaskStatus | "all"
typeFilter: string
onStatusChange: (key: string) => void
onTypeChange: (value: string) => void
}
/**
* 任务筛选栏
* 状态 Tab + 类型下拉筛选
*/
export const TaskFilterBar: React.FC<TaskFilterBarProps> = ({
statusFilter,
typeFilter,
onStatusChange,
onTypeChange,
}) => {
return (
<div className="task-filters">
{/* 状态 Tab */}
<Tabs
activeKey={statusFilter}
onChange={onStatusChange}
items={STATUS_TABS.map((tab) => ({
key: tab.key,
label: tab.label,
}))}
className="task-status-tabs"
/>
{/* 类型筛选 */}
<div className="task-type-filter">
<Select
value={typeFilter}
onChange={onTypeChange}
options={TYPE_OPTIONS}
style={{ width: 140 }}
placeholder="选择类型"
/>
</div>
</div>
)
}
@@ -1,190 +0,0 @@
import React from "react"
import { Table, Tag, Button, Popconfirm, Tooltip } from "antd"
import { RedoOutlined, InfoCircleOutlined, ClockCircleOutlined } from "@ant-design/icons"
import type { ColumnsType } from "antd/es/table"
import type { TaskItem, TaskStatus } from "@/api/tasks"
import { STATUS_CONFIG, TYPE_LABELS } from "../constants"
import { formatDuration, formatTime } from "../utils"
import { TaskErrorDetail } from "./TaskErrorDetail"
interface TaskTableProps {
dataSource: TaskItem[]
loading: boolean
total: number
page: number
pageSize: number
expandedTaskId: string | null
expandedTaskDetail: TaskItem | null
retryLoading: boolean
onPageChange: (page: number, pageSize: number) => void
onExpand: (expanded: boolean, record: TaskItem) => void
onRetry: (id: string) => void
onViewDetail: (record: TaskItem) => void
}
/**
* 任务列表表格
* 含列定义、分页、展开行
*/
export const TaskTable: React.FC<TaskTableProps> = ({
dataSource,
loading,
total,
page,
pageSize,
expandedTaskId,
expandedTaskDetail,
retryLoading,
onPageChange,
onExpand,
onRetry,
onViewDetail,
}) => {
// 表格列定义
const columns: ColumnsType<TaskItem> = [
{
title: "任务ID",
dataIndex: "id",
key: "id",
width: 120,
ellipsis: true,
render: (id: string) => (
<Tooltip title={id}>
<span className="task-id">{id.slice(0, 8)}...</span>
</Tooltip>
),
},
{
title: "类型",
dataIndex: "task_type",
key: "task_type",
width: 100,
render: (type: string) => {
const config = TYPE_LABELS[type] || { label: type, color: "default" }
return <Tag color={config.color}>{config.label}</Tag>
},
},
{
title: "状态",
dataIndex: "status",
key: "status",
width: 120,
render: (status: TaskStatus, record: TaskItem) => {
const config = STATUS_CONFIG[status] || {
label: status,
color: "default",
icon: null,
}
return (
<Tag color={config.color} icon={config.icon} className="task-status-tag">
{config.label}
{status === "running" && record.progress > 0 && (
<span className="task-progress"> {record.progress}%</span>
)}
</Tag>
)
},
},
{
title: "当前步骤",
dataIndex: "current_step",
key: "current_step",
width: 150,
ellipsis: true,
render: (step: string) => <span className="task-step">{step || "-"}</span>,
},
{
title: "耗时",
dataIndex: "duration_seconds",
key: "duration_seconds",
width: 100,
render: (seconds: number) => <span className="task-duration">{formatDuration(seconds)}</span>,
},
{
title: "创建时间",
dataIndex: "created_at",
key: "created_at",
width: 120,
render: (time: string) => <span className="task-time">{formatTime(time)}</span>,
},
{
title: "操作",
key: "action",
width: 100,
fixed: "right",
render: (_: unknown, record: TaskItem) => {
if (record.status === "failed" && record.retryable) {
return (
<Popconfirm
title="确认重试"
description="确定要重试这个失败的任务吗?"
onConfirm={() => onRetry(record.id)}
okText="确定"
cancelText="取消"
>
<Button
type="link"
size="small"
icon={<RedoOutlined />}
loading={retryLoading}
className="task-retry-btn"
>
</Button>
</Popconfirm>
)
}
if (record.status === "failed") {
return (
<Button
type="link"
size="small"
icon={<InfoCircleOutlined />}
onClick={() => onViewDetail(record)}
>
</Button>
)
}
return <span className="task-action-placeholder">-</span>
},
},
]
return (
<Table
columns={columns}
dataSource={dataSource}
rowKey="id"
loading={loading}
pagination={{
current: page,
pageSize,
total,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (t) => `${t}`,
onChange: onPageChange,
}}
expandable={{
expandedRowRender: (record) => (
<TaskErrorDetail record={record} detail={expandedTaskDetail} />
),
expandedRowKeys: expandedTaskId ? [expandedTaskId] : [],
onExpand,
rowExpandable: (record) =>
record.status === "failed" && (!!record.error_info || !!record.error_message),
}}
scroll={{ x: 800 }}
className="task-table"
locale={{
emptyText: (
<div className="task-empty">
<ClockCircleOutlined />
<p></p>
</div>
),
}}
/>
)
}
-69
View File
@@ -1,69 +0,0 @@
import React from "react"
import {
CheckCircleOutlined,
ClockCircleOutlined,
SyncOutlined,
CloseCircleOutlined,
MinusCircleOutlined,
} from "@ant-design/icons"
import type { TaskStatus } from "@/api/tasks"
/** 状态 Tab 配置 */
export const STATUS_TABS: { key: TaskStatus | "all"; label: string }[] = [
{ key: "all", label: "全部" },
{ key: "waiting", label: "等待中" },
{ key: "running", label: "进行中" },
{ key: "completed", label: "已完成" },
{ key: "failed", label: "失败" },
{ key: "cancelled", label: "已取消" },
]
/** 类型筛选选项 */
export const TYPE_OPTIONS = [
{ value: "all", label: "全部类型" },
{ value: "generation", label: "生成任务" },
{ value: "ingest", label: "素材导入" },
]
/** 状态标签配置 */
export const STATUS_CONFIG: Record<
TaskStatus,
{ label: string; color: string; icon: React.ReactNode }
> = {
pending: {
label: "等待中",
color: "default",
icon: <ClockCircleOutlined />,
},
waiting: {
label: "排队中",
color: "processing",
icon: <SyncOutlined spin />,
},
running: {
label: "进行中",
color: "processing",
icon: <SyncOutlined spin />,
},
completed: {
label: "已完成",
color: "success",
icon: <CheckCircleOutlined />,
},
failed: {
label: "失败",
color: "error",
icon: <CloseCircleOutlined />,
},
cancelled: {
label: "已取消",
color: "default",
icon: <MinusCircleOutlined />,
},
}
/** 任务类型标签 */
export const TYPE_LABELS: Record<string, { label: string; color: string }> = {
generation: { label: "生成任务", color: "blue" },
ingest: { label: "素材导入", color: "green" },
}
@@ -1,119 +0,0 @@
import { useState, useCallback } from "react"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import { message } from "antd"
import {
getTasks,
retryTask,
type TaskItem,
type TaskStatus,
type TaskListParams,
} from "@/api/tasks"
/**
* 任务列表业务 Hook
* 封装筛选状态、数据获取、重试操作
*/
export const useTaskList = () => {
const queryClient = useQueryClient()
// 筛选状态
const [statusFilter, setStatusFilter] = useState<TaskStatus | "all">("all")
const [typeFilter, setTypeFilter] = useState<string>("all")
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const [expandedTaskId, setExpandedTaskId] = useState<string | null>(null)
const [expandedTaskDetail, setExpandedTaskDetail] = useState<TaskItem | null>(null)
// 查询参数
const queryParams: TaskListParams = {
page,
page_size: pageSize,
...(statusFilter !== "all" && { status: statusFilter }),
...(typeFilter !== "all" && { task_type: typeFilter }),
}
// 获取任务列表
const { data, isLoading, error } = useQuery({
queryKey: ["tasks", queryParams],
queryFn: () => getTasks(queryParams),
refetchInterval: (query) => {
// 有进行中的任务时自动刷新
const tasks = query.state.data?.items ?? []
const hasRunning = tasks.some((t) => t.status === "running" || t.status === "waiting")
return hasRunning ? 5000 : false
},
})
// 重试任务
const retryMutation = useMutation({
mutationFn: retryTask,
onSuccess: () => {
message.success("任务已重新提交")
queryClient.invalidateQueries({ queryKey: ["tasks"] })
},
onError: () => {
message.error("重试失败,请检查任务状态")
},
})
// 状态筛选变化
const handleStatusChange = useCallback((key: string) => {
setStatusFilter(key as TaskStatus | "all")
setPage(1)
}, [])
// 类型筛选变化
const handleTypeChange = useCallback((value: string) => {
setTypeFilter(value)
setPage(1)
}, [])
// 分页变化
const handlePageChange = useCallback((p: number, ps: number) => {
setPage(p)
setPageSize(ps)
}, [])
// 展开查看详情
const handleExpand = useCallback((expanded: boolean, record: TaskItem) => {
if (!expanded) {
setExpandedTaskId(null)
setExpandedTaskDetail(null)
return
}
setExpandedTaskId(record.id)
// 如果是失败任务,获取详情(含 error_info
if (record.status === "failed" && record.error_info) {
setExpandedTaskDetail(record)
}
}, [])
// 查看错误详情
const handleViewDetail = useCallback((record: TaskItem) => {
setExpandedTaskId(record.id)
setExpandedTaskDetail(record)
}, [])
return {
// 状态
statusFilter,
typeFilter,
page,
pageSize,
expandedTaskId,
expandedTaskDetail,
// 数据
data,
isLoading,
error,
// Mutation
retryLoading: retryMutation.isPending,
// 操作
handleStatusChange,
handleTypeChange,
handlePageChange,
handleExpand,
handleViewDetail,
handleRetry: retryMutation.mutate,
}
}
-23
View File
@@ -1,23 +0,0 @@
/** 格式化耗时 */
export const formatDuration = (seconds?: number): string => {
if (!seconds) return "-"
if (seconds < 60) return `${Math.round(seconds)}`
const minutes = Math.floor(seconds / 60)
const secs = Math.round(seconds % 60)
if (minutes < 60) return `${minutes}${secs}`
const hours = Math.floor(minutes / 60)
const mins = minutes % 60
return `${hours}小时${mins}`
}
/** 格式化时间 */
export const formatTime = (dateStr?: string | null): string => {
if (!dateStr) return "-"
const date = new Date(dateStr)
return date.toLocaleString("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
})
}