Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fc4997874a | |||
| f8f6082cbd | |||
| 203f001c1d |
@@ -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
+46
-258
@@ -1,300 +1,88 @@
|
||||
/**
|
||||
* 任务历史页面 — V21 设计系统
|
||||
* 页面头部 + 圆角胶囊 Tab 筛选(含计数)+ 卡片式任务列表 + 分页 + 空状态
|
||||
* 使用 useQuery 对接后端真实 API(api/tasks.ts)
|
||||
*/
|
||||
import React, { useState } from "react"
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { Button } from "@/components/ui"
|
||||
import { getUserTasks, retryTask, type TaskItem } from "@/api/tasks"
|
||||
import React from "react"
|
||||
import { PageHeader, LoadingState, ErrorState, EmptyState } from "./components/States"
|
||||
import { HistoryTabs, TaskItem, Pagination } from "./components/TaskList"
|
||||
import { useTaskHistory } from "./hooks/useTaskHistory"
|
||||
import "./history.css"
|
||||
|
||||
/* ============================================================
|
||||
* 类型 & 常量
|
||||
* ============================================================ */
|
||||
type TaskStatus = "completed" | "processing" | "pending" | "failed"
|
||||
|
||||
const statusLabel: Record<TaskStatus, string> = {
|
||||
completed: "已完成",
|
||||
processing: "进行中",
|
||||
pending: "排队中",
|
||||
failed: "失败",
|
||||
}
|
||||
|
||||
/** 将后端 status 字符串映射为前端 TaskStatus */
|
||||
const normalizeStatus = (s: string): TaskStatus => {
|
||||
const map: Record<string, TaskStatus> = {
|
||||
completed: "completed",
|
||||
succeeded: "completed",
|
||||
success: "completed",
|
||||
processing: "processing",
|
||||
running: "processing",
|
||||
pending: "pending",
|
||||
queued: "pending",
|
||||
failed: "failed",
|
||||
error: "failed",
|
||||
}
|
||||
return map[s] ?? "pending"
|
||||
}
|
||||
|
||||
/** 格式化日期 */
|
||||
const formatDate = (iso?: string | null): string => {
|
||||
if (!iso) return "—"
|
||||
const d = new Date(iso)
|
||||
if (isNaN(d.getTime())) return "—"
|
||||
const pad = (n: number) => String(n).padStart(2, "0")
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* Tab 配置
|
||||
* ============================================================ */
|
||||
interface TabConfig {
|
||||
key: string
|
||||
label: string
|
||||
statusFilter?: TaskStatus
|
||||
}
|
||||
|
||||
const tabs: TabConfig[] = [
|
||||
{ key: "all", label: "全部" },
|
||||
{ key: "processing", label: "进行中", statusFilter: "processing" },
|
||||
{ key: "completed", label: "已完成", statusFilter: "completed" },
|
||||
{ key: "failed", label: "失败", statusFilter: "failed" },
|
||||
]
|
||||
|
||||
/* ============================================================
|
||||
* 分页配置
|
||||
* ============================================================ */
|
||||
const PAGE_SIZE = 10
|
||||
|
||||
/* ============================================================
|
||||
* 组件
|
||||
* ============================================================ */
|
||||
const TaskHistory: React.FC = () => {
|
||||
const [activeTab, setActiveTab] = useState("all")
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
// ── 获取任务列表 ──
|
||||
const {
|
||||
data: tasks = [],
|
||||
activeTab,
|
||||
currentPage,
|
||||
totalPages,
|
||||
tabs,
|
||||
tabCounts,
|
||||
paginatedTasks,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
retryLoading,
|
||||
setCurrentPage,
|
||||
handleTabChange,
|
||||
handleRetry,
|
||||
handleView,
|
||||
refetch,
|
||||
} = useQuery<TaskItem[], Error>({
|
||||
queryKey: ["tasks"],
|
||||
queryFn: getUserTasks,
|
||||
staleTime: 30_000,
|
||||
})
|
||||
} = useTaskHistory()
|
||||
|
||||
// ── 重试任务 mutation ──
|
||||
const retryMutation = useMutation({
|
||||
mutationFn: retryTask,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["tasks"] })
|
||||
},
|
||||
})
|
||||
|
||||
// 将后端数据映射为页面展示用的结构
|
||||
const mappedTasks = tasks.map((t) => ({
|
||||
id: t.id,
|
||||
name: t.user_message || t.task_type,
|
||||
type: t.task_type,
|
||||
template: t.template_id,
|
||||
status: normalizeStatus(t.status),
|
||||
date: formatDate(t.created_at),
|
||||
duration: undefined as string | undefined,
|
||||
progress: t.progress,
|
||||
retryable: t.retryable,
|
||||
errorMessage: t.error_message,
|
||||
}))
|
||||
|
||||
// 获取当前 Tab 的筛选状态
|
||||
const currentTab = tabs.find((t) => t.key === activeTab)
|
||||
const statusFilter = currentTab?.statusFilter
|
||||
|
||||
// 过滤任务
|
||||
const filteredTasks = statusFilter
|
||||
? mappedTasks.filter((t) => t.status === statusFilter)
|
||||
: mappedTasks
|
||||
|
||||
// 计算各 Tab 的数量
|
||||
const tabCounts: Record<string, number> = {
|
||||
all: mappedTasks.length,
|
||||
processing: mappedTasks.filter((t) => t.status === "processing").length,
|
||||
completed: mappedTasks.filter((t) => t.status === "completed").length,
|
||||
failed: mappedTasks.filter((t) => t.status === "failed").length,
|
||||
}
|
||||
|
||||
// 分页
|
||||
const totalPages = Math.ceil(filteredTasks.length / PAGE_SIZE)
|
||||
const paginatedTasks = filteredTasks.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE)
|
||||
|
||||
// 切换 Tab 时重置页码
|
||||
const handleTabChange = (key: string) => {
|
||||
setActiveTab(key)
|
||||
setCurrentPage(1)
|
||||
}
|
||||
|
||||
// 重试任务
|
||||
const handleRetry = (taskId: string) => {
|
||||
retryMutation.mutate(taskId)
|
||||
}
|
||||
|
||||
// 查看任务详情
|
||||
const handleView = (taskId: string) => {
|
||||
// TODO: 跳转到任务详情页(待路由实现)
|
||||
console.log("查看任务:", taskId)
|
||||
}
|
||||
|
||||
// ── Loading 状态 ──
|
||||
// Loading 状态
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="xx-history-page">
|
||||
<div className="xx-history-header">
|
||||
<h2>任务历史</h2>
|
||||
<p>查看和管理所有生成任务</p>
|
||||
</div>
|
||||
<div className="xx-history-empty">
|
||||
<div className="xx-history-empty-icon">⏳</div>
|
||||
<h3>加载中...</h3>
|
||||
</div>
|
||||
<PageHeader />
|
||||
<LoadingState />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Error 状态 ──
|
||||
// Error 状态
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="xx-history-page">
|
||||
<div className="xx-history-header">
|
||||
<h2>任务历史</h2>
|
||||
<p>查看和管理所有生成任务</p>
|
||||
</div>
|
||||
<div className="xx-history-empty">
|
||||
<div className="xx-history-empty-icon">❌</div>
|
||||
<h3>加载失败</h3>
|
||||
<p>{error?.message || "网络异常,请稍后重试"}</p>
|
||||
<Button buttonType="primary" buttonSize="md" onClick={() => refetch()}>
|
||||
重新加载
|
||||
</Button>
|
||||
</div>
|
||||
<PageHeader />
|
||||
<ErrorState message={error?.message} onRetry={() => refetch()} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const totalCount = tabCounts[activeTab] ?? paginatedTasks.length
|
||||
|
||||
return (
|
||||
<div className="xx-history-page">
|
||||
{/* ── 页面头部 ──────────────────────────────────────────── */}
|
||||
<div className="xx-history-header">
|
||||
<h2>任务历史</h2>
|
||||
<p>查看和管理所有生成任务</p>
|
||||
</div>
|
||||
<PageHeader />
|
||||
|
||||
{/* ── Tab 切换 ──────────────────────────────────────────── */}
|
||||
<div className="xx-history-tabs">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
className={`xx-history-tab${activeTab === tab.key ? " active" : ""}`}
|
||||
onClick={() => handleTabChange(tab.key)}
|
||||
>
|
||||
{tab.label}
|
||||
<span className="xx-history-tab-count">{tabCounts[tab.key]}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<HistoryTabs
|
||||
tabs={tabs}
|
||||
activeTab={activeTab}
|
||||
tabCounts={tabCounts}
|
||||
onChange={handleTabChange}
|
||||
/>
|
||||
|
||||
{/* ── 任务列表 ──────────────────────────────────────────── */}
|
||||
{/* 任务列表 */}
|
||||
{paginatedTasks.length === 0 ? (
|
||||
<div className="xx-history-empty">
|
||||
<div className="xx-history-empty-icon">📭</div>
|
||||
<h3>暂无任务记录</h3>
|
||||
<p>{activeTab === "all" ? "点击上方按钮开始创建任务" : "当前分类下没有任务"}</p>
|
||||
</div>
|
||||
<EmptyState activeTab={activeTab} />
|
||||
) : (
|
||||
<div className="xx-history-task-list">
|
||||
{paginatedTasks.map((task) => (
|
||||
<div key={task.id} className="xx-history-task-item">
|
||||
{/* 任务信息 */}
|
||||
<div className="xx-history-task-info">
|
||||
<h4>{task.name}</h4>
|
||||
<span>
|
||||
{task.type} · 模板:{task.template}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 状态标签 */}
|
||||
<span className={`xx-history-status xx-history-status--${task.status}`}>
|
||||
{statusLabel[task.status]}
|
||||
</span>
|
||||
|
||||
{/* 时间区 */}
|
||||
<div className="xx-history-task-time">
|
||||
<span>{task.date}</span>
|
||||
{task.status === "completed" ? (
|
||||
<span>完成</span>
|
||||
) : task.status === "processing" ? (
|
||||
<span>进度 {task.progress}%</span>
|
||||
) : task.status === "failed" ? (
|
||||
<span>{task.errorMessage || "请重试"}</span>
|
||||
) : (
|
||||
<span>等待中</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="xx-history-task-action">
|
||||
{task.status === "failed" && task.retryable ? (
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
buttonSize="sm"
|
||||
onClick={() => handleRetry(task.id)}
|
||||
disabled={retryMutation.isPending}
|
||||
>
|
||||
{retryMutation.isPending ? "重试中..." : "重试"}
|
||||
</Button>
|
||||
) : (
|
||||
<Button buttonType="ghost" buttonSize="sm" onClick={() => handleView(task.id)}>
|
||||
查看
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<TaskItem
|
||||
key={task.id}
|
||||
task={task}
|
||||
onRetry={handleRetry}
|
||||
onView={handleView}
|
||||
retryLoading={retryLoading}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── 分页 ──────────────────────────────────────────────── */}
|
||||
{totalPages > 1 && (
|
||||
<div className="xx-history-pagination">
|
||||
<button
|
||||
className="xx-history-page-btn"
|
||||
disabled={currentPage === 1}
|
||||
onClick={() => setCurrentPage(currentPage - 1)}
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
{Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => (
|
||||
<button
|
||||
key={page}
|
||||
className={`xx-history-page-btn${currentPage === page ? " active" : ""}`}
|
||||
onClick={() => setCurrentPage(page)}
|
||||
>
|
||||
{page}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
className="xx-history-page-btn"
|
||||
disabled={currentPage === totalPages}
|
||||
onClick={() => setCurrentPage(currentPage + 1)}
|
||||
>
|
||||
›
|
||||
</button>
|
||||
<span className="xx-history-page-info">共 {filteredTasks.length} 条</span>
|
||||
</div>
|
||||
)}
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
total={totalCount}
|
||||
onChange={setCurrentPage}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import React from "react"
|
||||
import { Button } from "@/components/ui"
|
||||
|
||||
interface LoadingStateProps {
|
||||
title?: string
|
||||
}
|
||||
|
||||
/** 加载状态 */
|
||||
export const LoadingState: React.FC<LoadingStateProps> = ({ title = "加载中..." }) => (
|
||||
<div className="xx-history-empty">
|
||||
<div className="xx-history-empty-icon">⏳</div>
|
||||
<h3>{title}</h3>
|
||||
</div>
|
||||
)
|
||||
|
||||
interface ErrorStateProps {
|
||||
message?: string
|
||||
onRetry: () => void
|
||||
}
|
||||
|
||||
/** 错误状态 */
|
||||
export const ErrorState: React.FC<ErrorStateProps> = ({ message, onRetry }) => (
|
||||
<div className="xx-history-empty">
|
||||
<div className="xx-history-empty-icon">❌</div>
|
||||
<h3>加载失败</h3>
|
||||
<p>{message || "网络异常,请稍后重试"}</p>
|
||||
<Button buttonType="primary" buttonSize="md" onClick={onRetry}>
|
||||
重新加载
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
|
||||
interface EmptyStateProps {
|
||||
activeTab?: string
|
||||
}
|
||||
|
||||
/** 空状态 */
|
||||
export const EmptyState: React.FC<EmptyStateProps> = ({ activeTab = "all" }) => (
|
||||
<div className="xx-history-empty">
|
||||
<div className="xx-history-empty-icon">📭</div>
|
||||
<h3>暂无任务记录</h3>
|
||||
<p>{activeTab === "all" ? "点击上方按钮开始创建任务" : "当前分类下没有任务"}</p>
|
||||
</div>
|
||||
)
|
||||
|
||||
/** 页面头部 */
|
||||
export const PageHeader: React.FC = () => (
|
||||
<div className="xx-history-header">
|
||||
<h2>任务历史</h2>
|
||||
<p>查看和管理所有生成任务</p>
|
||||
</div>
|
||||
)
|
||||
@@ -0,0 +1,142 @@
|
||||
import React from "react"
|
||||
import { Button } from "@/components/ui"
|
||||
import type { TabConfig } from "../constants"
|
||||
import type { MappedTask } from "../hooks/useTaskHistory"
|
||||
import { statusLabel } from "../constants"
|
||||
|
||||
interface HistoryTabsProps {
|
||||
tabs: TabConfig[]
|
||||
activeTab: string
|
||||
tabCounts: Record<string, number>
|
||||
onChange: (key: string) => void
|
||||
}
|
||||
|
||||
/** Tab 切换栏 */
|
||||
export const HistoryTabs: React.FC<HistoryTabsProps> = ({
|
||||
tabs,
|
||||
activeTab,
|
||||
tabCounts,
|
||||
onChange,
|
||||
}) => (
|
||||
<div className="xx-history-tabs">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
className={`xx-history-tab${activeTab === tab.key ? " active" : ""}`}
|
||||
onClick={() => onChange(tab.key)}
|
||||
>
|
||||
{tab.label}
|
||||
<span className="xx-history-tab-count">{tabCounts[tab.key]}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
interface TaskItemProps {
|
||||
task: MappedTask
|
||||
onRetry: (id: string) => void
|
||||
onView: (id: string) => void
|
||||
retryLoading?: boolean
|
||||
}
|
||||
|
||||
/** 单个任务卡片 */
|
||||
export const TaskItem: React.FC<TaskItemProps> = ({ task, onRetry, onView, retryLoading }) => {
|
||||
const getSubText = () => {
|
||||
switch (task.status) {
|
||||
case "completed":
|
||||
return "完成"
|
||||
case "processing":
|
||||
return `进度 ${task.progress}%`
|
||||
case "failed":
|
||||
return task.errorMessage || "请重试"
|
||||
default:
|
||||
return "等待中"
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="xx-history-task-item">
|
||||
{/* 任务信息 */}
|
||||
<div className="xx-history-task-info">
|
||||
<h4>{task.name}</h4>
|
||||
<span>
|
||||
{task.type} · 模板:{task.template}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 状态标签 */}
|
||||
<span className={`xx-history-status xx-history-status--${task.status}`}>
|
||||
{statusLabel[task.status]}
|
||||
</span>
|
||||
|
||||
{/* 时间区 */}
|
||||
<div className="xx-history-task-time">
|
||||
<span>{task.date}</span>
|
||||
<span>{getSubText()}</span>
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="xx-history-task-action">
|
||||
{task.status === "failed" && task.retryable ? (
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
buttonSize="sm"
|
||||
onClick={() => onRetry(task.id)}
|
||||
disabled={retryLoading}
|
||||
>
|
||||
{retryLoading ? "重试中..." : "重试"}
|
||||
</Button>
|
||||
) : (
|
||||
<Button buttonType="ghost" buttonSize="sm" onClick={() => onView(task.id)}>
|
||||
查看
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface PaginationProps {
|
||||
currentPage: number
|
||||
totalPages: number
|
||||
total: number
|
||||
onChange: (page: number) => void
|
||||
}
|
||||
|
||||
/** 分页组件 */
|
||||
export const Pagination: React.FC<PaginationProps> = ({
|
||||
currentPage,
|
||||
totalPages,
|
||||
total,
|
||||
onChange,
|
||||
}) => {
|
||||
if (totalPages <= 1) return null
|
||||
return (
|
||||
<div className="xx-history-pagination">
|
||||
<button
|
||||
className="xx-history-page-btn"
|
||||
disabled={currentPage === 1}
|
||||
onClick={() => onChange(currentPage - 1)}
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
{Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => (
|
||||
<button
|
||||
key={page}
|
||||
className={`xx-history-page-btn${currentPage === page ? " active" : ""}`}
|
||||
onClick={() => onChange(page)}
|
||||
>
|
||||
{page}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
className="xx-history-page-btn"
|
||||
disabled={currentPage === totalPages}
|
||||
onClick={() => onChange(currentPage + 1)}
|
||||
>
|
||||
›
|
||||
</button>
|
||||
<span className="xx-history-page-info">共 {total} 条</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/** 任务状态 */
|
||||
export type TaskStatus = "completed" | "processing" | "pending" | "failed"
|
||||
|
||||
/** 状态标签 */
|
||||
export const statusLabel: Record<TaskStatus, string> = {
|
||||
completed: "已完成",
|
||||
processing: "进行中",
|
||||
pending: "排队中",
|
||||
failed: "失败",
|
||||
}
|
||||
|
||||
/** Tab 配置 */
|
||||
export interface TabConfig {
|
||||
key: string
|
||||
label: string
|
||||
statusFilter?: TaskStatus
|
||||
}
|
||||
|
||||
/** 默认 Tab 列表 */
|
||||
export const TABS: TabConfig[] = [
|
||||
{ key: "all", label: "全部" },
|
||||
{ key: "processing", label: "进行中", statusFilter: "processing" },
|
||||
{ key: "completed", label: "已完成", statusFilter: "completed" },
|
||||
{ key: "failed", label: "失败", statusFilter: "failed" },
|
||||
]
|
||||
|
||||
/** 每页数量 */
|
||||
export const PAGE_SIZE = 10
|
||||
@@ -0,0 +1,131 @@
|
||||
import { useState, useMemo, useCallback } from "react"
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { getUserTasks, retryTask, type TaskItem } from "@/api/tasks"
|
||||
import { TABS, PAGE_SIZE, type TabConfig } from "../constants"
|
||||
import { normalizeStatus, formatDate } from "../utils"
|
||||
|
||||
/** 映射后的任务列表项 */
|
||||
export interface MappedTask {
|
||||
id: string
|
||||
name: string
|
||||
type: string
|
||||
template?: string
|
||||
status: "completed" | "processing" | "pending" | "failed"
|
||||
date: string
|
||||
progress?: number
|
||||
retryable?: boolean
|
||||
errorMessage?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 任务历史业务 Hook
|
||||
*/
|
||||
export const useTaskHistory = () => {
|
||||
const [activeTab, setActiveTab] = useState("all")
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
// 获取任务列表
|
||||
const {
|
||||
data: tasks = [],
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
refetch,
|
||||
} = useQuery<TaskItem[], Error>({
|
||||
queryKey: ["tasks"],
|
||||
queryFn: getUserTasks,
|
||||
staleTime: 30_000,
|
||||
})
|
||||
|
||||
// 重试 mutation
|
||||
const retryMutation = useMutation({
|
||||
mutationFn: retryTask,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["tasks"] })
|
||||
},
|
||||
})
|
||||
|
||||
// 映射后端数据
|
||||
const mappedTasks: MappedTask[] = useMemo(
|
||||
() =>
|
||||
tasks.map((t) => ({
|
||||
id: t.id,
|
||||
name: t.user_message || t.task_type,
|
||||
type: t.task_type,
|
||||
template: t.template_id,
|
||||
status: normalizeStatus(t.status),
|
||||
date: formatDate(t.created_at),
|
||||
progress: t.progress,
|
||||
retryable: t.retryable,
|
||||
errorMessage: t.error_message,
|
||||
})),
|
||||
[tasks],
|
||||
)
|
||||
|
||||
// 当前 Tab 筛选
|
||||
const currentTabConfig: TabConfig | undefined = TABS.find((t) => t.key === activeTab)
|
||||
const statusFilter = currentTabConfig?.statusFilter
|
||||
|
||||
// 过滤任务
|
||||
const filteredTasks = useMemo(
|
||||
() => (statusFilter ? mappedTasks.filter((t) => t.status === statusFilter) : mappedTasks),
|
||||
[mappedTasks, statusFilter],
|
||||
)
|
||||
|
||||
// 各 Tab 计数
|
||||
const tabCounts: Record<string, number> = useMemo(
|
||||
() => ({
|
||||
all: mappedTasks.length,
|
||||
processing: mappedTasks.filter((t) => t.status === "processing").length,
|
||||
completed: mappedTasks.filter((t) => t.status === "completed").length,
|
||||
failed: mappedTasks.filter((t) => t.status === "failed").length,
|
||||
}),
|
||||
[mappedTasks],
|
||||
)
|
||||
|
||||
// 分页
|
||||
const totalPages = Math.ceil(filteredTasks.length / PAGE_SIZE)
|
||||
const paginatedTasks = filteredTasks.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE)
|
||||
|
||||
// 切换 Tab
|
||||
const handleTabChange = useCallback((key: string) => {
|
||||
setActiveTab(key)
|
||||
setCurrentPage(1)
|
||||
}, [])
|
||||
|
||||
// 重试
|
||||
const handleRetry = useCallback(
|
||||
(taskId: string) => {
|
||||
retryMutation.mutate(taskId)
|
||||
},
|
||||
[retryMutation],
|
||||
)
|
||||
|
||||
// 查看详情
|
||||
const handleView = useCallback((taskId: string) => {
|
||||
// TODO: 跳转到任务详情页(待路由实现)
|
||||
console.log("查看任务:", taskId)
|
||||
}, [])
|
||||
|
||||
return {
|
||||
// 状态
|
||||
activeTab,
|
||||
currentPage,
|
||||
totalPages,
|
||||
// 数据
|
||||
tabs: TABS,
|
||||
tabCounts,
|
||||
paginatedTasks,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
retryLoading: retryMutation.isPending,
|
||||
// 操作
|
||||
setCurrentPage,
|
||||
handleTabChange,
|
||||
handleRetry,
|
||||
handleView,
|
||||
refetch,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { TaskStatus } from "./constants"
|
||||
|
||||
/** 将后端 status 字符串映射为前端 TaskStatus */
|
||||
export const normalizeStatus = (s: string): TaskStatus => {
|
||||
const map: Record<string, TaskStatus> = {
|
||||
completed: "completed",
|
||||
succeeded: "completed",
|
||||
success: "completed",
|
||||
processing: "processing",
|
||||
running: "processing",
|
||||
pending: "pending",
|
||||
queued: "pending",
|
||||
failed: "failed",
|
||||
error: "failed",
|
||||
}
|
||||
return map[s] ?? "pending"
|
||||
}
|
||||
|
||||
/** 格式化日期 */
|
||||
export const formatDate = (iso?: string | null): string => {
|
||||
if (!iso) return "—"
|
||||
const d = new Date(iso)
|
||||
if (isNaN(d.getTime())) return "—"
|
||||
const pad = (n: number) => String(n).padStart(2, "0")
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||
}
|
||||
Reference in New Issue
Block a user