Compare commits

..

2 Commits

Author SHA1 Message Date
xiaoxia 6d601dfbc3 Merge branch 'develop' into refactor/task-center
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 12s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 52s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m7s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m11s
CI/CD Pipeline / Validate - Code Quality (pull_request) Failing after 1m37s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 30s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 1m4s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m54s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 47s
AI Code Review / AI Code Review (pull_request) Successful in 3m3s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 3m45s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 15m25s
CI/CD Pipeline / CI Gate (pull_request) 失败: CI/CD Pipeline / Validate - Code Quality (pull_request) [frontend-only]
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 23s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 16s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1220h40m10s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 1221h14m39s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1221h14m40s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1221h14m41s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 1223h1m32s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1223h1m18s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 1223h1m20s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 1223h1m26s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 1223h1m28s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 1225h36m10s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 1225h36m14s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 1221h47m25s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 1223h34m16s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 1226h8m57s
2026-07-27 12:34:48 +08:00
xiaoxia a3e098b8da refactor(task-center): 三阶段重构 TaskCenter(423→76行, -82%)
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 31s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m17s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 47s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m12s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 33s
CI/CD Pipeline / Validate - Code Quality (pull_request) Failing after 2m5s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 59s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 1m14s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m18s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 2m37s
AI Code Review / AI Code Review (pull_request) Successful in 2m25s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 6m14s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 57s
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1243h50m51s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 1243h57m34s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1243h57m36s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1244h2m55s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 1244h43m13s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1244h43m31s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 1244h46m44s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 1244h46m46s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 1244h47m0s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 1244h46m48s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 1245h13m10s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 1245h13m14s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 1244h30m15s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 1245h45m52s
- Phase 1: 抽 constants(STATUS_CONFIG/TYPE_LABELS等) + utils(formatDuration/formatTime)
- Phase 2: 抽 3 个 UI 组件(TaskFilterBar/TaskTable/TaskErrorDetail)
- Phase 3: 抽 useTaskList 业务 Hook(筛选+查询+重试)
- 主文件 423→76 行,职责清晰,表格列定义独立维护
2026-07-26 17:47:24 +08:00
12 changed files with 895 additions and 989 deletions
@@ -1,157 +0,0 @@
import { useEffect, type Dispatch, type SetStateAction } from "react"
import { message } from "antd"
import { getEditPlan, getEditPlanClips } from "@/api/template-editor"
import type { ClipData, ClipType, TtsConfig, TtsMode, TrimConfig } from "../../types"
import type { SubtitleStyleConfig } from "../../types/subtitle"
import type { TitleConfig } from "@/api/template-editor"
import type { BgmMixConfig } from "@/api/bgm"
import type { TransitionEffect } from "@/api/template-editor"
import type { CoverConfig } from "../../types"
interface UsePlanLoadingOptions {
loadedPlanId: string | null
resetClips: (clips: ClipData[]) => void
setLoadedTemplateId: (id: string | null) => void
setDraftName: (name: string) => void
setTitleConfig: Dispatch<SetStateAction<TitleConfig>>
setSubtitleSettings: Dispatch<SetStateAction<SubtitleStyleConfig>>
setBgmSettings: Dispatch<SetStateAction<BgmMixConfig>>
setCoverConfig: Dispatch<SetStateAction<CoverConfig>>
}
/**
* 加载已有计划草稿数据到编辑器
* 从列表页"编辑"按钮进入时,URL 带 planId,需要还原计划配置
*/
export function usePlanLoading({
loadedPlanId,
resetClips,
setLoadedTemplateId,
setDraftName,
setTitleConfig,
setSubtitleSettings,
setBgmSettings,
setCoverConfig,
}: UsePlanLoadingOptions) {
useEffect(() => {
if (!loadedPlanId) return
Promise.all([
getEditPlan(loadedPlanId),
getEditPlanClips(loadedPlanId, { limit: 500 }).catch(() => ({
items: [],
total: 0,
})),
])
.then(([plan, clipsRes]) => {
setLoadedTemplateId(plan.template_id)
setDraftName(plan.name)
const cfg = plan.config
if (cfg.title_config) {
setTitleConfig({
ai_auto_select: cfg.title_config!.ai_auto_select,
content: cfg.title_config!.content,
position: cfg.title_config!.position,
font_preset: cfg.title_config!.font_preset,
font_size: cfg.title_config!.font_size,
font_color: cfg.title_config!.font_color || "#ffffff",
})
}
if (cfg.subtitle_config) {
setSubtitleSettings((prev) => ({
...prev,
enabled: cfg.subtitle_config!.enabled,
position: (cfg.subtitle_config!.position ||
"bottom") as SubtitleStyleConfig["position"],
font: cfg.subtitle_config!.font,
fontSize: cfg.subtitle_config!.size,
fontColor: cfg.subtitle_config!.color || "#ffffff",
animation: cfg.subtitle_config!.animation,
}))
}
if (cfg.bgm_config) {
setBgmSettings((prev) => ({
...prev,
enabled: cfg.bgm_config!.enabled,
music_id: cfg.bgm_config!.music_id || "",
}))
}
if (cfg.cover_config) {
setCoverConfig((prev: CoverConfig) => ({
...prev,
enabled: cfg.cover_config!.enabled ?? prev.enabled,
mode: (cfg.cover_config!.mode as CoverConfig["mode"]) || prev.mode,
frame_time: cfg.cover_config!.frame_time ?? prev.frame_time,
upload_url: cfg.cover_config!.upload_url || prev.upload_url,
thumbnail_url: cfg.cover_config!.thumbnail_url || prev.thumbnail_url,
ai_suggested_time: cfg.cover_config!.ai_suggested_time ?? prev.ai_suggested_time,
}))
}
/* 还原片段:优先从后端 clips 表,其次从 config.segments 兜底 */
const backendClips = clipsRes?.items || []
if (backendClips.length > 0) {
const sorted = [...backendClips].sort((a, b) => a.order - b.order)
const mapped: ClipData[] = sorted.map((clip) => ({
id: clip.id,
template_segment_id: (clip.config?.template_segment_id as string) || "",
type: (clip.clip_type === "voiceover" ? "voice" : "pip") as ClipType,
duration: clip.duration || 3,
startOffset: 0,
script_text: clip.text_content || "",
order: clip.order,
media_asset_id: clip.asset_id || undefined,
transition:
clip.transition_effect && clip.transition_effect !== "none"
? {
type: clip.transition_effect as TransitionEffect["type"],
duration: clip.transition_duration || 0.3,
}
: undefined,
speed: clip.playback_speed
? { rate: clip.playback_speed, pitchCorrection: true }
: undefined,
tts_config: (clip.config?.tts_config as TtsConfig) || undefined,
trim_config: (clip.config?.trim_config as TrimConfig) || undefined,
}))
setTimeout(() => resetClips(mapped), 100)
} else if (cfg.segments && cfg.segments.length > 0) {
/* 兜底:从 config.segments 还原(老数据兼容) */
const mapped: ClipData[] = cfg.segments.map((seg, idx) => ({
id: `seg-${idx}`,
template_segment_id: `seg-${idx}`,
type: (seg.material_type === "voiceover" ? "voice" : "pip") as ClipType,
duration: (seg.duration_min + seg.duration_max) / 2,
startOffset: 0,
script_text: "",
order: seg.segment_order,
transition: seg.transition
? {
type: seg.transition.type as TransitionEffect["type"],
duration: seg.transition.duration,
}
: undefined,
speed: seg.playback_speed
? { rate: seg.playback_speed, pitchCorrection: true }
: undefined,
tts_config: seg.tts_config
? { ...seg.tts_config, mode: seg.tts_config.mode as TtsMode }
: undefined,
trim_config: seg.trim_config || undefined,
}))
setTimeout(() => resetClips(mapped), 100)
}
})
.catch(() => message.error("加载模板草稿失败"))
}, [
loadedPlanId,
resetClips,
setLoadedTemplateId,
setDraftName,
setTitleConfig,
setSubtitleSettings,
setBgmSettings,
setCoverConfig,
])
}
@@ -1,91 +0,0 @@
import { useEffect, type Dispatch, type SetStateAction } from "react"
import { message } from "antd"
import { getEditingTemplate, type TemplateMode } from "@/api/editing-planner"
import type { TitleConfig } from "@/api/template-editor"
import type { ClipData, ClipType } from "../../types"
import type { SubtitleStyleConfig } from "../../types/subtitle"
import type { BgmMixConfig } from "@/api/bgm"
interface UseTemplateDetailOptions {
loadedTemplateId: string | null
resetClips: (clips: ClipData[]) => void
setCurrentMode: (mode: TemplateMode) => void
setTitleConfig: Dispatch<SetStateAction<TitleConfig>>
setSubtitleSettings: Dispatch<SetStateAction<SubtitleStyleConfig>>
setBgmSettings: Dispatch<SetStateAction<BgmMixConfig>>
setDraftName: (name: string) => void
setDraftCategory: (cat: string) => void
setDraftTags: (tags: string) => void
}
/**
* 加载模板详情并初始化片段列表 + 配置
*/
export function useTemplateDetail({
loadedTemplateId,
resetClips,
setCurrentMode,
setTitleConfig,
setSubtitleSettings,
setBgmSettings,
setDraftName,
setDraftCategory,
setDraftTags,
}: UseTemplateDetailOptions) {
useEffect(() => {
if (!loadedTemplateId) return
getEditingTemplate(loadedTemplateId)
.then((tpl) => {
if (!tpl) return
setCurrentMode(tpl.mode)
const mapped: ClipData[] = tpl.segments.map((seg, idx) => ({
id: seg.id || `seg-${idx}`,
template_segment_id: seg.id || `seg-${idx}`,
type: (seg.material_type === "voiceover" ? "voice" : "pip") as ClipType,
duration: (seg.duration_min + seg.duration_max) / 2,
startOffset: 0,
script_text: "",
order: seg.segment_order,
}))
resetClips(mapped)
setTitleConfig({
ai_auto_select: tpl.title_config.ai_auto_select,
content: tpl.title_config.content,
position: tpl.title_config.position,
font_preset: tpl.title_config.font_preset,
font_size: tpl.title_config.font_size,
font_color: tpl.title_config.font_color || "#ffffff",
})
setSubtitleSettings((prev) => ({
...prev,
enabled: tpl.subtitle_config.enabled,
position: (tpl.subtitle_config.position || "bottom") as SubtitleStyleConfig["position"],
font: tpl.subtitle_config.font,
fontSize: tpl.subtitle_config.size,
fontColor: tpl.subtitle_config.color || "#ffffff",
animation: tpl.subtitle_config.animation,
}))
setBgmSettings((prev) => ({
...prev,
enabled: tpl.bgm_config.enabled,
music_id: tpl.bgm_config.music_id || "",
}))
setDraftName(tpl.name)
setDraftCategory(tpl.category)
setDraftTags(tpl.tags.join(", "))
})
.catch(() => message.error("加载模板详情失败"))
}, [
loadedTemplateId,
resetClips,
setCurrentMode,
setTitleConfig,
setSubtitleSettings,
setBgmSettings,
setDraftName,
setDraftCategory,
setDraftTags,
])
}
@@ -1,82 +0,0 @@
import { useState, useCallback, useEffect, useMemo } from "react"
import { message } from "antd"
import {
getEditingTemplates,
getTemplateCategories,
type EditingTemplate,
type TemplateCategory,
} from "@/api/editing-planner"
import { getMediaAssets, type MediaAsset } from "@/api/template-editor"
import { FILTER_CATEGORIES } from "../../constants"
/**
* 模板列表 + 分类 + 筛选搜索
*/
export function useTemplateList(
setMediaAssets: (assets: MediaAsset[]) => void,
initialTemplateId: string | null,
) {
const [templates, setTemplates] = useState<EditingTemplate[]>([])
const [categories, setCategories] = useState<TemplateCategory[]>([])
const [loadingTemplates, setLoadingTemplates] = useState(false)
const [currentFilter, setCurrentFilter] = useState("全部")
const [searchQuery, setSearchQuery] = useState("")
const [loadedTemplateId, setLoadedTemplateId] = useState<string | null>(initialTemplateId)
/**
* 并行加载模板列表、分类、素材库
* 三个接口无依赖关系,用 Promise.all 并发
*/
const loadTemplates = useCallback(async () => {
setLoadingTemplates(true)
try {
const [tpls, cats, assets] = await Promise.all([
getEditingTemplates(),
getTemplateCategories(),
getMediaAssets(),
])
setTemplates(tpls)
setCategories(cats)
setMediaAssets(assets)
} catch {
message.error("加载模板失败")
} finally {
setLoadingTemplates(false)
}
}, [setMediaAssets])
useEffect(() => {
loadTemplates()
}, [loadTemplates])
const filteredTemplates = useMemo(
() =>
templates.filter((t) => {
if (currentFilter !== "全部" && t.category !== currentFilter) return false
if (searchQuery && !t.name.toLowerCase().includes(searchQuery.toLowerCase())) return false
return true
}),
[templates, currentFilter, searchQuery],
)
const currentTemplate = useMemo(
() => templates.find((t) => t.id === loadedTemplateId) || null,
[templates, loadedTemplateId],
)
return {
templates,
categories,
loadingTemplates,
loadedTemplateId,
setLoadedTemplateId,
currentFilter,
setCurrentFilter,
searchQuery,
setSearchQuery,
filteredTemplates,
currentTemplate,
loadTemplates,
FILTER_CATEGORIES,
}
}
@@ -1,184 +0,0 @@
import { useState, useCallback } from "react"
import { message } from "antd"
import {
createEditingTemplate,
updateEditingTemplate,
type SaveTemplatePayload,
type TemplateMode,
} from "@/api/editing-planner"
import type {
ClipData,
WatermarkConfig,
IntroOutroConfig,
PipConfig,
FilterConfig,
ChromaKeyConfig,
StickerConfig,
} from "../../types"
import type { SubtitleStyleConfig } from "../../types/subtitle"
import type { TitleConfig } from "@/api/template-editor"
import type { CoverConfig } from "../../types"
import type { BgmMixConfig } from "@/api/bgm"
interface UseTemplateSaveOptions {
currentMode: TemplateMode
clips: ClipData[]
totalDuration: number
titleConfig: TitleConfig
subtitleSettings: SubtitleStyleConfig
bgmSettings: BgmMixConfig
watermarkSettings: WatermarkConfig
introOutroSettings: IntroOutroConfig
pipSettings: PipConfig
filterSettings: FilterConfig
chromaKeySettings: ChromaKeyConfig
stickerSettings: StickerConfig
coverConfig: CoverConfig
loadedTemplateId: string | null
loadTemplates: () => Promise<void>
}
/**
* 模板保存(创建/更新)
*/
export function useTemplateSave(options: UseTemplateSaveOptions) {
const {
currentMode,
clips,
totalDuration,
titleConfig,
subtitleSettings,
bgmSettings,
watermarkSettings,
introOutroSettings,
pipSettings,
filterSettings,
chromaKeySettings,
stickerSettings,
coverConfig,
loadedTemplateId,
loadTemplates,
} = options
const [saveModalOpen, setSaveModalOpen] = useState(false)
const [draftName, setDraftName] = useState("")
const [draftCategory, setDraftCategory] = useState("")
const [draftTags, setDraftTags] = useState("")
const [saveLoading, setSaveLoading] = useState(false)
const handleOpenSaveModal = useCallback(() => {
setSaveModalOpen(true)
}, [])
const handleSave = useCallback(async () => {
if (!draftName.trim()) {
message.warning("请输入模板名称")
return
}
setSaveLoading(true)
try {
const payload: SaveTemplatePayload = {
name: draftName,
mode: currentMode,
category: draftCategory,
tags: draftTags
.split(",")
.map((t) => t.trim())
.filter(Boolean),
title_config: titleConfig,
subtitle_config: {
enabled: subtitleSettings.enabled,
position: subtitleSettings.position,
font: subtitleSettings.font,
color: subtitleSettings.fontColor,
size: subtitleSettings.fontSize,
animation: subtitleSettings.animation,
},
bgm_config: {
enabled: bgmSettings.enabled,
music_id: bgmSettings.music_id,
},
estimated_duration: totalDuration,
segments: clips.map((c, i) => ({
segment_order: i,
duration_min: Math.max(1, c.duration - 2),
duration_max: c.duration + 2,
material_type: c.type === "voice" ? "voiceover" : "video",
transition: c.transition
? { type: c.transition.type, duration: c.transition.duration }
: undefined,
playback_speed: c.speed ? c.speed.rate : undefined,
tts_config: c.tts_config
? {
mode: c.tts_config.mode,
text: c.tts_config.text,
voice_id: c.tts_config.voice_id,
speed: c.tts_config.speed,
pitch: c.tts_config.pitch,
volume: c.tts_config.volume,
subtitle_sync: c.tts_config.subtitle_sync,
}
: undefined,
trim_config: c.trim_config
? {
start_time: c.trim_config.start_time,
end_time: c.trim_config.end_time,
}
: undefined,
})),
watermark_config: { ...watermarkSettings },
intro_outro_config: { ...introOutroSettings },
pip_config: { ...pipSettings },
filter_config: { ...filterSettings },
green_screen_config: { ...chromaKeySettings },
sticker_config: { ...stickerSettings },
cover_config: { ...coverConfig },
}
if (loadedTemplateId) {
await updateEditingTemplate(loadedTemplateId, payload)
} else {
await createEditingTemplate(payload)
}
message.success(loadedTemplateId ? "模板保存成功" : "模板创建成功")
setSaveModalOpen(false)
loadTemplates()
} catch {
message.error("保存失败")
} finally {
setSaveLoading(false)
}
}, [
draftName,
draftCategory,
draftTags,
currentMode,
clips,
totalDuration,
titleConfig,
subtitleSettings,
bgmSettings,
watermarkSettings,
introOutroSettings,
pipSettings,
filterSettings,
chromaKeySettings,
stickerSettings,
coverConfig,
loadedTemplateId,
loadTemplates,
])
return {
saveModalOpen,
setSaveModalOpen,
draftName,
setDraftName,
draftCategory,
setDraftCategory,
draftTags,
setDraftTags,
saveLoading,
handleOpenSaveModal,
handleSave,
}
}
@@ -1,8 +1,27 @@
import { useState, useCallback, type Dispatch, type SetStateAction } from "react"
import type { TemplateMode } from "@/api/editing-planner"
import type { MediaAsset, TitleConfig } from "@/api/template-editor"
import { useState, useCallback, useEffect, type Dispatch, type SetStateAction } from "react"
import { FILTER_CATEGORIES } from "../constants"
import { message } from "antd"
import type {
EditingTemplate,
TemplateCategory,
TemplateMode,
SaveTemplatePayload,
} from "@/api/editing-planner"
import {
getEditingTemplates,
getEditingTemplate,
createEditingTemplate,
updateEditingTemplate,
getTemplateCategories,
} from "@/api/editing-planner"
import type { MediaAsset, TitleConfig, TransitionEffect } from "@/api/template-editor"
import { getMediaAssets, getEditPlan, getEditPlanClips } from "@/api/template-editor"
import type {
ClipData,
ClipType,
TtsConfig,
TtsMode,
TrimConfig,
WatermarkConfig,
IntroOutroConfig,
PipConfig,
@@ -13,11 +32,6 @@ import type {
} from "../types"
import type { SubtitleStyleConfig } from "../types/subtitle"
import type { BgmMixConfig } from "@/api/bgm"
import { useTemplateList } from "./template-management/useTemplateList"
import { useTemplateDetail } from "./template-management/useTemplateDetail"
import { usePlanLoading } from "./template-management/usePlanLoading"
import { useTemplateSave } from "./template-management/useTemplateSave"
import { FILTER_CATEGORIES } from "../constants"
interface UseTemplateManagementParams {
urlTemplateId: string
@@ -30,6 +44,7 @@ interface UseTemplateManagementParams {
setSubtitleSettings: Dispatch<SetStateAction<SubtitleStyleConfig>>
setBgmSettings: Dispatch<SetStateAction<BgmMixConfig>>
setCoverConfig: Dispatch<SetStateAction<CoverConfig>>
// 保存时需要的配置
clips: ClipData[]
totalDuration: number
titleConfig: TitleConfig
@@ -46,7 +61,7 @@ interface UseTemplateManagementParams {
/**
* 模板管理 Hook
* 模板列表/分类/加载/保存/模式切换/筛选搜索
* 模板列表/分类/加载/保存/模式切换/筛选搜索 + 3 个 useEffect
*/
export const useTemplateManagement = (params: UseTemplateManagementParams) => {
const {
@@ -74,101 +89,346 @@ export const useTemplateManagement = (params: UseTemplateManagementParams) => {
coverConfig,
} = params
/* ── 模板列表 ── */
const [templates, setTemplates] = useState<EditingTemplate[]>([])
const [categories, setCategories] = useState<TemplateCategory[]>([])
const [loadingTemplates, setLoadingTemplates] = useState(false)
const [loadedTemplateId, setLoadedTemplateId] = useState<string | null>(urlTemplateId || null)
const [currentMode, setCurrentMode] = useState<TemplateMode>("pip")
/* ── 左栏筛选 ── */
const [currentFilter, setCurrentFilter] = useState("全部")
const [searchQuery, setSearchQuery] = useState("")
/* ── 保存弹窗 ── */
const [saveModalOpen, setSaveModalOpen] = useState(false)
const [draftName, setDraftName] = useState("")
const [draftCategory, setDraftCategory] = useState("")
const [draftTags, setDraftTags] = useState("")
const [saveLoading, setSaveLoading] = useState(false)
/* ── 计划 ID(从 URL 传入,不变) ── */
const [loadedPlanId] = useState<string | null>(urlPlanId || null)
/* ── 模板列表 ── */
const {
templates,
categories,
loadingTemplates,
loadedTemplateId,
setLoadedTemplateId,
currentFilter,
setCurrentFilter,
searchQuery,
setSearchQuery,
filteredTemplates,
currentTemplate,
loadTemplates,
} = useTemplateList(setMediaAssets, urlTemplateId || null)
/* ── 保存 ── */
const {
saveModalOpen,
setSaveModalOpen,
draftName,
setDraftName,
draftCategory,
setDraftCategory,
draftTags,
setDraftTags,
saveLoading,
handleOpenSaveModal,
handleSave,
} = useTemplateSave({
currentMode,
clips,
totalDuration,
titleConfig,
subtitleSettings,
bgmSettings,
watermarkSettings,
introOutroSettings,
pipSettings,
filterSettings,
chromaKeySettings,
stickerSettings,
coverConfig,
loadedTemplateId,
loadTemplates,
/* ── 计算 ── */
const filteredTemplates = templates.filter((t) => {
if (currentFilter !== "全部" && t.category !== currentFilter) return false
if (searchQuery && !t.name.toLowerCase().includes(searchQuery.toLowerCase())) return false
return true
})
/* ── 模板详情加载 ── */
useTemplateDetail({
loadedTemplateId,
resetClips,
setCurrentMode,
setTitleConfig,
setSubtitleSettings,
setBgmSettings,
setDraftName,
setDraftCategory,
setDraftTags,
})
const currentTemplate = templates.find((t) => t.id === loadedTemplateId) || null
/* ── 计划草稿加载 ── */
usePlanLoading({
/* ──────────── 加载 ──────────── */
/**
* 并行加载模板列表、分类、素材库
* 首次挂载时调用,三个接口无依赖关系,用 Promise.all 并发
*/
const loadTemplates = useCallback(async () => {
setLoadingTemplates(true)
try {
const [tpls, cats, assets] = await Promise.all([
getEditingTemplates(),
getTemplateCategories(),
getMediaAssets(),
])
setTemplates(tpls)
setCategories(cats)
setMediaAssets(assets)
} catch {
message.error("加载模板失败")
} finally {
setLoadingTemplates(false)
}
}, [setMediaAssets])
useEffect(() => {
loadTemplates()
}, [loadTemplates])
/**
* 加载模板详情并初始化片段列表
* 将后端 segments 映射为前端 ClipData,取 duration_min/max 均值作为默认时长
* 同时还原标题/字幕/BGM 配置
*/
useEffect(() => {
if (!loadedTemplateId) return
getEditingTemplate(loadedTemplateId)
.then((tpl) => {
if (!tpl) return
setCurrentMode(tpl.mode)
const mapped: ClipData[] = tpl.segments.map((seg, idx) => ({
id: seg.id || `seg-${idx}`,
template_segment_id: seg.id || `seg-${idx}`,
type: (seg.material_type === "voiceover" ? "voice" : "pip") as ClipType,
duration: (seg.duration_min + seg.duration_max) / 2,
startOffset: 0,
script_text: "",
order: seg.segment_order,
}))
resetClips(mapped)
setTitleConfig({
ai_auto_select: tpl.title_config.ai_auto_select,
content: tpl.title_config.content,
position: tpl.title_config.position,
font_preset: tpl.title_config.font_preset,
font_size: tpl.title_config.font_size,
font_color: tpl.title_config.font_color || "#ffffff",
})
setSubtitleSettings((prev) => ({
...prev,
enabled: tpl.subtitle_config.enabled,
position: (tpl.subtitle_config.position || "bottom") as SubtitleStyleConfig["position"],
font: tpl.subtitle_config.font,
fontSize: tpl.subtitle_config.size,
fontColor: tpl.subtitle_config.color || "#ffffff",
animation: tpl.subtitle_config.animation,
}))
setBgmSettings((prev) => ({
...prev,
enabled: tpl.bgm_config.enabled,
music_id: tpl.bgm_config.music_id || "",
}))
setDraftName(tpl.name)
setDraftCategory(tpl.category)
setDraftTags(tpl.tags.join(", "))
})
.catch(() => message.error("加载模板详情失败"))
}, [loadedTemplateId, resetClips, setTitleConfig, setSubtitleSettings, setBgmSettings])
/**
* 加载已有模板草稿数据到编辑器
* 从列表页"编辑"按钮进入时,URL 带 planId,需要还原计划配置
*/
useEffect(() => {
if (!loadedPlanId) return
// 并行加载计划基本信息 + 片段列表
Promise.all([
getEditPlan(loadedPlanId),
getEditPlanClips(loadedPlanId, { limit: 500 }).catch(() => ({
items: [],
total: 0,
})),
])
.then(([plan, clipsRes]) => {
// 设置关联的模板(触发模板加载 effect)
setLoadedTemplateId(plan.template_id)
// 还原基本信息
setDraftName(plan.name)
// 还原 config 中的编辑器状态
const cfg = plan.config
if (cfg.title_config) {
setTitleConfig({
ai_auto_select: cfg.title_config!.ai_auto_select,
content: cfg.title_config!.content,
position: cfg.title_config!.position,
font_preset: cfg.title_config!.font_preset,
font_size: cfg.title_config!.font_size,
font_color: cfg.title_config!.font_color || "#ffffff",
})
}
if (cfg.subtitle_config) {
setSubtitleSettings((prev) => ({
...prev,
enabled: cfg.subtitle_config!.enabled,
position: (cfg.subtitle_config!.position ||
"bottom") as SubtitleStyleConfig["position"],
font: cfg.subtitle_config!.font,
fontSize: cfg.subtitle_config!.size,
fontColor: cfg.subtitle_config!.color || "#ffffff",
animation: cfg.subtitle_config!.animation,
}))
}
if (cfg.bgm_config) {
setBgmSettings((prev) => ({
...prev,
enabled: cfg.bgm_config!.enabled,
music_id: cfg.bgm_config!.music_id || "",
}))
}
// 还原封面配置
if (cfg.cover_config) {
setCoverConfig((prev) => ({
...prev,
enabled: cfg.cover_config!.enabled ?? prev.enabled,
mode: (cfg.cover_config!.mode as CoverConfig["mode"]) || prev.mode,
frame_time: cfg.cover_config!.frame_time ?? prev.frame_time,
upload_url: cfg.cover_config!.upload_url || prev.upload_url,
thumbnail_url: cfg.cover_config!.thumbnail_url || prev.thumbnail_url,
ai_suggested_time: cfg.cover_config!.ai_suggested_time ?? prev.ai_suggested_time,
}))
}
// 还原片段:优先从后端 clips 表,其次从 config.segments 兜底
const backendClips = clipsRes?.items || []
if (backendClips.length > 0) {
// 从后端 clips 表还原
const sorted = [...backendClips].sort((a, b) => a.order - b.order)
const mapped: ClipData[] = sorted.map((clip) => ({
id: clip.id,
template_segment_id: (clip.config?.template_segment_id as string) || "",
type: (clip.clip_type === "voiceover" ? "voice" : "pip") as ClipType,
duration: clip.duration || 3,
startOffset: 0,
script_text: clip.text_content || "",
order: clip.order,
media_asset_id: clip.asset_id || undefined,
transition:
clip.transition_effect && clip.transition_effect !== "none"
? {
type: clip.transition_effect as TransitionEffect["type"],
duration: clip.transition_duration || 0.3,
}
: undefined,
speed: clip.playback_speed
? { rate: clip.playback_speed, pitchCorrection: true }
: undefined,
tts_config: (clip.config?.tts_config as TtsConfig) || undefined,
trim_config: (clip.config?.trim_config as TrimConfig) || undefined,
}))
setTimeout(() => resetClips(mapped), 100)
} else if (cfg.segments && cfg.segments.length > 0) {
// 兜底:从 config.segments 还原(老数据兼容)
const mapped: ClipData[] = cfg.segments.map((seg, idx) => ({
id: `seg-${idx}`,
template_segment_id: `seg-${idx}`,
type: (seg.material_type === "voiceover" ? "voice" : "pip") as ClipType,
duration: (seg.duration_min + seg.duration_max) / 2,
startOffset: 0,
script_text: "",
order: seg.segment_order,
transition: seg.transition
? {
type: seg.transition.type as TransitionEffect["type"],
duration: seg.transition.duration,
}
: undefined,
speed: seg.playback_speed
? { rate: seg.playback_speed, pitchCorrection: true }
: undefined,
tts_config: seg.tts_config
? { ...seg.tts_config, mode: seg.tts_config.mode as TtsMode }
: undefined,
trim_config: seg.trim_config || undefined,
}))
setTimeout(() => resetClips(mapped), 100)
}
})
.catch(() => message.error("加载模板草稿失败"))
}, [
loadedPlanId,
resetClips,
setLoadedTemplateId,
setDraftName,
setTitleConfig,
setSubtitleSettings,
setBgmSettings,
setCoverConfig,
})
])
/* ── 事件 ── */
const handleLoadTemplate = useCallback(
(templateId: string) => {
setLoadedTemplateId(templateId)
setSelectedClipId(null)
},
[setLoadedTemplateId, setSelectedClipId],
)
/* ──────────── 事件 ──────────── */
const handleModeChange = useCallback(
(mode: TemplateMode) => {
setCurrentMode(mode)
if (mode === "voice_over") {
setClips((prev) => prev.map((c) => ({ ...c, type: "voice" as const })))
} else if (mode === "pip") {
setClips((prev) => prev.map((c) => ({ ...c, type: "pip" as const })))
const handleLoadTemplate = (templateId: string) => {
setLoadedTemplateId(templateId)
setSelectedClipId(null)
}
const handleModeChange = (mode: TemplateMode) => {
setCurrentMode(mode)
// 切换纯单类型模式时,自动转换所有已有片段的类型
if (mode === "voice_over") {
setClips((prev) => prev.map((c) => ({ ...c, type: "voice" as const })))
} else if (mode === "pip") {
setClips((prev) => prev.map((c) => ({ ...c, type: "pip" as const })))
}
// 混合模式(voice_pip)和一镜到底(one_take)不自动转换,保留原有类型
}
const handleOpenSaveModal = () => {
setSaveModalOpen(true)
}
const handleSave = async () => {
if (!draftName.trim()) {
message.warning("请输入模板名称")
return
}
setSaveLoading(true)
try {
const payload: SaveTemplatePayload = {
name: draftName,
mode: currentMode,
category: draftCategory,
tags: draftTags
.split(",")
.map((t) => t.trim())
.filter(Boolean),
title_config: titleConfig,
subtitle_config: {
enabled: subtitleSettings.enabled,
position: subtitleSettings.position,
font: subtitleSettings.font,
color: subtitleSettings.fontColor,
size: subtitleSettings.fontSize,
animation: subtitleSettings.animation,
},
bgm_config: {
enabled: bgmSettings.enabled,
music_id: bgmSettings.music_id,
},
estimated_duration: totalDuration,
segments: clips.map((c, i) => ({
segment_order: i,
duration_min: Math.max(1, c.duration - 2),
duration_max: c.duration + 2,
material_type: c.type === "voice" ? "voiceover" : "video",
transition: c.transition
? { type: c.transition.type, duration: c.transition.duration }
: undefined,
playback_speed: c.speed ? c.speed.rate : undefined,
tts_config: c.tts_config
? {
mode: c.tts_config.mode,
text: c.tts_config.text,
voice_id: c.tts_config.voice_id,
speed: c.tts_config.speed,
pitch: c.tts_config.pitch,
volume: c.tts_config.volume,
subtitle_sync: c.tts_config.subtitle_sync,
}
: undefined,
trim_config: c.trim_config
? {
start_time: c.trim_config.start_time,
end_time: c.trim_config.end_time,
}
: undefined,
})),
watermark_config: { ...watermarkSettings },
intro_outro_config: { ...introOutroSettings },
pip_config: { ...pipSettings },
filter_config: { ...filterSettings },
green_screen_config: { ...chromaKeySettings },
sticker_config: { ...stickerSettings },
cover_config: { ...coverConfig },
}
},
[setClips],
)
if (loadedTemplateId) {
await updateEditingTemplate(loadedTemplateId, payload)
} else {
await createEditingTemplate(payload)
}
message.success(loadedTemplateId ? "模板保存成功" : "模板创建成功")
setSaveModalOpen(false)
loadTemplates()
} catch {
message.error("保存失败")
} finally {
setSaveLoading(false)
}
}
return {
// state
+39 -386
View File
@@ -2,333 +2,32 @@
* 任务中心页面
* 展示用户的所有任务(生成任务、素材导入等),支持状态筛选、类型筛选、分页、重试
*/
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 { 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 "./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 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 = {
const {
statusFilter,
typeFilter,
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 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>
)
}
pageSize,
expandedTaskId,
expandedTaskDetail,
data,
isLoading,
error,
retryLoading,
handleStatusChange,
handleTypeChange,
handlePageChange,
handleExpand,
handleViewDetail,
handleRetry,
} = useTaskList()
// 错误处理
if (error) {
@@ -351,72 +50,26 @@ export default function TaskCenter() {
<p className="task-subtitle"></p>
</div>
{/* 筛选栏 */}
<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"
/>
<TaskFilterBar
statusFilter={statusFilter}
typeFilter={typeFilter}
onStatusChange={handleStatusChange}
onTypeChange={handleTypeChange}
/>
{/* 类型筛选 */}
<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}
<TaskTable
dataSource={data?.items || []}
rowKey="id"
loading={isLoading}
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>
),
}}
total={data?.total || 0}
page={page}
pageSize={pageSize}
expandedTaskId={expandedTaskId}
expandedTaskDetail={expandedTaskDetail}
retryLoading={retryLoading}
onPageChange={handlePageChange}
onExpand={handleExpand}
onRetry={handleRetry}
onViewDetail={handleViewDetail}
/>
</div>
)
@@ -0,0 +1,58 @@
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>
)
}
@@ -0,0 +1,48 @@
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>
)
}
@@ -0,0 +1,190 @@
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
@@ -0,0 +1,69 @@
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" },
}
@@ -0,0 +1,119 @@
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
@@ -0,0 +1,23 @@
/** 格式化耗时 */
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",
})
}