Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6d601dfbc3 | |||
| a3e098b8da |
@@ -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
|
||||
|
||||
Regular → Executable
+39
-386
@@ -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>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user