Merge branch 'develop' into refactor/layer-config-split
CI/CD Pipeline / Check if frontend-only change (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Lint (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / PR Build API Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Web Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
AI Code Review / AI Code Review (pull_request) Has been cancelled
Preview Deploy / Deploy Preview Environment (pull_request) Has been cancelled
PR Automation / Auto Approve on CI Green (pull_request) Successful in 1m3s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 58s
CI/CD Pipeline / Check if frontend-only change (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Lint (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / PR Build API Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Web Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
AI Code Review / AI Code Review (pull_request) Has been cancelled
Preview Deploy / Deploy Preview Environment (pull_request) Has been cancelled
PR Automation / Auto Approve on CI Green (pull_request) Successful in 1m3s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 58s
This commit is contained in:
@@ -1,15 +1,21 @@
|
||||
/**
|
||||
* 模板编辑器 — 制作/编辑剪辑模板
|
||||
* 四行布局:顶栏(42px) → 模式栏(48px) → 三栏主体 → 底栏(40px)
|
||||
*
|
||||
* 主组件仅保留 Hook 组装与整体布局
|
||||
* 全局配置 → hooks/useGlobalSettings
|
||||
* 配音素材 → hooks/useVoiceMaterials
|
||||
* 撤销重做 → hooks/useUndoRedo
|
||||
* 抽屉管理 → hooks/useEditorDrawers
|
||||
* 播放控制 → hooks/usePlaybackControl
|
||||
* 片段操作 → hooks/useClipOperations
|
||||
* 模板管理 → hooks/useTemplateManagement
|
||||
*/
|
||||
import React, { useState } from "react"
|
||||
import { useSearchParams } from "react-router-dom"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { MODE_LABELS } from "@/api/editing-planner"
|
||||
import { MODE_LIST } from "./constants"
|
||||
import type { MediaAsset, TitleConfig } from "@/api/template-editor"
|
||||
import { ensureDefaultLibrary, getAssetsByKind, type AssetItem } from "@/api/assets"
|
||||
import { getOrCreateDefaultProject } from "@/api/projects"
|
||||
import type { MediaAsset } from "@/api/template-editor"
|
||||
|
||||
import MediaPanel from "./components/MediaPanel"
|
||||
import PreviewPlayer from "./components/PreviewPlayer"
|
||||
@@ -26,36 +32,12 @@ import { useEditorDrawers } from "./hooks/useEditorDrawers"
|
||||
import { usePlaybackControl } from "./hooks/usePlaybackControl"
|
||||
import { useClipOperations } from "./hooks/useClipOperations"
|
||||
import { useTemplateManagement, FILTER_CATEGORIES } from "./hooks/useTemplateManagement"
|
||||
import { useGlobalSettings } from "./hooks/useGlobalSettings"
|
||||
import { useVoiceMaterials } from "./hooks/useVoiceMaterials"
|
||||
|
||||
import type {
|
||||
ClipData,
|
||||
WatermarkConfig,
|
||||
IntroOutroConfig,
|
||||
PipConfig,
|
||||
FilterConfig,
|
||||
ChromaKeyConfig,
|
||||
StickerConfig,
|
||||
CoverConfig,
|
||||
} from "./types"
|
||||
import {
|
||||
DEFAULT_WATERMARK,
|
||||
DEFAULT_INTRO_OUTRO,
|
||||
DEFAULT_PIP_CONFIG,
|
||||
DEFAULT_FILTER_CONFIG,
|
||||
DEFAULT_CHROMA_KEY_CONFIG,
|
||||
DEFAULT_STICKER_CONFIG,
|
||||
DEFAULT_COVER_CONFIG,
|
||||
} from "./types"
|
||||
|
||||
import type { SubtitleStyleConfig } from "./types/subtitle"
|
||||
import { DEFAULT_SUBTITLE_STYLE } from "./types/subtitle"
|
||||
import { DEFAULT_BGM_MIX_CONFIG, type BgmMixConfig } from "@/api/bgm"
|
||||
import type { ClipData } from "./types"
|
||||
import "./EditingPlanner.css"
|
||||
|
||||
/* ──────────── 常量 ──────────── */
|
||||
|
||||
/* ──────────── 组件 ──────────── */
|
||||
|
||||
const EditingPlanner: React.FC = () => {
|
||||
const [searchParams] = useSearchParams()
|
||||
const urlTemplateId = searchParams.get("templateId") || ""
|
||||
@@ -72,50 +54,29 @@ const EditingPlanner: React.FC = () => {
|
||||
reset: resetClips,
|
||||
} = useUndoRedo<ClipData[]>([])
|
||||
|
||||
/* ── 全局配置 state ── */
|
||||
const [titleConfig, setTitleConfig] = useState<TitleConfig>({
|
||||
ai_auto_select: false,
|
||||
content: "",
|
||||
position: "bottom",
|
||||
font_preset: "思源黑体",
|
||||
font_size: 28,
|
||||
font_color: "#ffffff",
|
||||
})
|
||||
|
||||
const [subtitleSettings, setSubtitleSettings] = useState<SubtitleStyleConfig>({
|
||||
...DEFAULT_SUBTITLE_STYLE,
|
||||
})
|
||||
|
||||
const [bgmSettings, setBgmSettings] = useState<BgmMixConfig>({
|
||||
...DEFAULT_BGM_MIX_CONFIG,
|
||||
})
|
||||
|
||||
const [watermarkSettings, setWatermarkSettings] = useState<WatermarkConfig>({
|
||||
...DEFAULT_WATERMARK,
|
||||
})
|
||||
const [introOutroSettings, setIntroOutroSettings] = useState<IntroOutroConfig>({
|
||||
...DEFAULT_INTRO_OUTRO,
|
||||
})
|
||||
|
||||
const [pipSettings, setPipSettings] = useState<PipConfig>({
|
||||
...DEFAULT_PIP_CONFIG,
|
||||
})
|
||||
|
||||
const [filterSettings, setFilterSettings] = useState<FilterConfig>({
|
||||
...DEFAULT_FILTER_CONFIG,
|
||||
})
|
||||
|
||||
const [chromaKeySettings, setChromaKeySettings] = useState<ChromaKeyConfig>({
|
||||
...DEFAULT_CHROMA_KEY_CONFIG,
|
||||
})
|
||||
|
||||
const [stickerSettings, setStickerSettings] = useState<StickerConfig>({
|
||||
...DEFAULT_STICKER_CONFIG,
|
||||
})
|
||||
|
||||
const [coverConfig, setCoverConfig] = useState<CoverConfig>({
|
||||
...DEFAULT_COVER_CONFIG,
|
||||
})
|
||||
/* ── 全局配置 ── */
|
||||
const {
|
||||
titleConfig,
|
||||
setTitleConfig,
|
||||
subtitleSettings,
|
||||
setSubtitleSettings,
|
||||
bgmSettings,
|
||||
setBgmSettings,
|
||||
watermarkSettings,
|
||||
setWatermarkSettings,
|
||||
introOutroSettings,
|
||||
setIntroOutroSettings,
|
||||
pipSettings,
|
||||
setPipSettings,
|
||||
filterSettings,
|
||||
setFilterSettings,
|
||||
chromaKeySettings,
|
||||
setChromaKeySettings,
|
||||
stickerSettings,
|
||||
setStickerSettings,
|
||||
coverConfig,
|
||||
setCoverConfig,
|
||||
} = useGlobalSettings()
|
||||
|
||||
/* ── 右侧栏 Tab ── */
|
||||
const [rightTab, setRightTab] = useState<"properties" | "clips">("properties")
|
||||
@@ -128,18 +89,12 @@ const EditingPlanner: React.FC = () => {
|
||||
setSelectedAssetIds(ids)
|
||||
}
|
||||
|
||||
/* ── 配音素材(queryKey 与 VoiceMaterialLibrary 共享缓存) ── */
|
||||
const voiceMaterialsQuery = useQuery({
|
||||
queryKey: ["assets", "voice"],
|
||||
queryFn: async () => {
|
||||
const project = await getOrCreateDefaultProject()
|
||||
await ensureDefaultLibrary({ project_id: project.id, kind: "voice" })
|
||||
const assets = await getAssetsByKind("voice")
|
||||
return assets
|
||||
},
|
||||
staleTime: 30_000,
|
||||
})
|
||||
const voiceMaterials: AssetItem[] = voiceMaterialsQuery.data ?? []
|
||||
/* ── 配音素材 ── */
|
||||
const {
|
||||
voiceMaterials,
|
||||
loading: voiceMaterialsLoading,
|
||||
refetch: refetchVoiceMaterials,
|
||||
} = useVoiceMaterials()
|
||||
|
||||
/* ── 派生计算 ── */
|
||||
const totalDuration = clips.reduce((sum, c) => sum + c.duration, 0)
|
||||
@@ -179,31 +134,6 @@ const EditingPlanner: React.FC = () => {
|
||||
coverConfig,
|
||||
})
|
||||
|
||||
/* ── 配置变更 handlers ── */
|
||||
const handleWatermarkChange = (config: WatermarkConfig) => {
|
||||
setWatermarkSettings(config)
|
||||
}
|
||||
|
||||
const handleIntroOutroChange = (config: IntroOutroConfig) => {
|
||||
setIntroOutroSettings(config)
|
||||
}
|
||||
|
||||
const handlePipChange = (config: PipConfig) => {
|
||||
setPipSettings(config)
|
||||
}
|
||||
|
||||
const handleFilterChange = (config: FilterConfig) => {
|
||||
setFilterSettings(config)
|
||||
}
|
||||
|
||||
const handleChromaKeyChange = (config: ChromaKeyConfig) => {
|
||||
setChromaKeySettings(config)
|
||||
}
|
||||
|
||||
const handleStickerChange = (config: StickerConfig) => {
|
||||
setStickerSettings(config)
|
||||
}
|
||||
|
||||
/* ──────────── 渲染 ──────────── */
|
||||
|
||||
return (
|
||||
@@ -294,15 +224,15 @@ const EditingPlanner: React.FC = () => {
|
||||
totalDuration={totalDuration}
|
||||
currentMode={tpl.currentMode}
|
||||
onSubtitleSettingsChange={(partial) =>
|
||||
setSubtitleSettings((prev) => ({ ...prev, ...partial }) as SubtitleStyleConfig)
|
||||
setSubtitleSettings((prev) => ({ ...prev, ...partial }))
|
||||
}
|
||||
onBgmSettingsChange={(partial) => setBgmSettings((prev) => ({ ...prev, ...partial }))}
|
||||
onClipUpdate={clipOps.handleClipUpdate}
|
||||
onOpenBgmDrawer={() => drawers.setBgmDrawerOpen(true)}
|
||||
onOpenSubtitleDrawer={() => drawers.setSubtitleDrawerOpen(true)}
|
||||
voiceMaterials={voiceMaterials}
|
||||
voiceMaterialsLoading={voiceMaterialsQuery.isLoading}
|
||||
onRefreshVoiceMaterials={() => voiceMaterialsQuery.refetch()}
|
||||
voiceMaterialsLoading={voiceMaterialsLoading}
|
||||
onRefreshVoiceMaterials={refetchVoiceMaterials}
|
||||
onClipVoiceSelect={clipOps.handleClipVoiceSelect}
|
||||
onOpenTransitionDrawer={drawers.openTransitionDrawer}
|
||||
onOpenSpeedDrawer={drawers.openSpeedDrawer}
|
||||
@@ -382,28 +312,28 @@ const EditingPlanner: React.FC = () => {
|
||||
onCloseTtsDrawer={() => drawers.setTtsDrawerOpen(false)}
|
||||
watermarkDrawerOpen={drawers.watermarkDrawerOpen}
|
||||
watermarkSettings={watermarkSettings}
|
||||
onWatermarkChange={handleWatermarkChange}
|
||||
onWatermarkChange={setWatermarkSettings}
|
||||
onCloseWatermarkDrawer={() => drawers.setWatermarkDrawerOpen(false)}
|
||||
introOutroDrawerOpen={drawers.introOutroDrawerOpen}
|
||||
introOutroSettings={introOutroSettings}
|
||||
onIntroOutroChange={handleIntroOutroChange}
|
||||
onIntroOutroChange={setIntroOutroSettings}
|
||||
onCloseIntroOutroDrawer={() => drawers.setIntroOutroDrawerOpen(false)}
|
||||
pipDrawerOpen={drawers.pipDrawerOpen}
|
||||
pipSettings={pipSettings}
|
||||
totalDuration={totalDuration}
|
||||
onPipChange={handlePipChange}
|
||||
onPipChange={setPipSettings}
|
||||
onClosePipDrawer={() => drawers.setPipDrawerOpen(false)}
|
||||
filterDrawerOpen={drawers.filterDrawerOpen}
|
||||
filterSettings={filterSettings}
|
||||
onFilterChange={handleFilterChange}
|
||||
onFilterChange={setFilterSettings}
|
||||
onCloseFilterDrawer={() => drawers.setFilterDrawerOpen(false)}
|
||||
chromaKeyDrawerOpen={drawers.chromaKeyDrawerOpen}
|
||||
chromaKeySettings={chromaKeySettings}
|
||||
onChromaKeyChange={handleChromaKeyChange}
|
||||
onChromaKeyChange={setChromaKeySettings}
|
||||
onCloseChromaKeyDrawer={() => drawers.setChromaKeyDrawerOpen(false)}
|
||||
stickerDrawerOpen={drawers.stickerDrawerOpen}
|
||||
stickerSettings={stickerSettings}
|
||||
onStickerChange={handleStickerChange}
|
||||
onStickerChange={setStickerSettings}
|
||||
onCloseStickerDrawer={() => drawers.setStickerDrawerOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -3,189 +3,32 @@
|
||||
* 纯渲染层,业务逻辑和 state 留在父组件
|
||||
*/
|
||||
import React from "react"
|
||||
import type { TemplateCategory } from "@/api/editing-planner"
|
||||
import type {
|
||||
ClipData,
|
||||
TransitionConfig,
|
||||
SpeedConfig,
|
||||
TtsConfig,
|
||||
WatermarkConfig,
|
||||
IntroOutroConfig,
|
||||
PipConfig,
|
||||
FilterConfig,
|
||||
ChromaKeyConfig,
|
||||
StickerConfig,
|
||||
} from "../types"
|
||||
import { DEFAULT_TRANSITION, DEFAULT_SPEED, DEFAULT_TTS_CONFIG } from "../types"
|
||||
import type { SubtitleStyleConfig } from "../types/subtitle"
|
||||
import type { BgmMixConfig } from "@/api/bgm"
|
||||
import SaveModal from "./SaveModal"
|
||||
import BgmSelector from "./BgmSelector"
|
||||
import SubtitleStylePanel from "./SubtitleStylePanel"
|
||||
import TransitionSelector from "./TransitionSelector"
|
||||
import SpeedPanel from "./SpeedPanel"
|
||||
import TtsPanel from "./TtsPanel"
|
||||
import WatermarkPanel from "./WatermarkPanel"
|
||||
import IntroOutroPanel from "./IntroOutroPanel"
|
||||
import PipConfigPanel from "./PipConfigPanel"
|
||||
import FilterPanel from "./FilterPanel"
|
||||
import GreenScreenPanel from "./GreenScreenPanel"
|
||||
import StickerPanel from "./StickerPanel"
|
||||
import { ClipLevelDrawers } from "./editing-drawers/ClipLevelDrawers"
|
||||
import { GlobalDrawers } from "./editing-drawers/GlobalDrawers"
|
||||
import type { EditingDrawersProps } from "./editing-drawers/types"
|
||||
|
||||
interface EditingDrawersProps {
|
||||
/* 保存弹窗 */
|
||||
saveModalOpen: boolean
|
||||
saveLoading: boolean
|
||||
isUpdate: boolean
|
||||
draftName: string
|
||||
draftCategory: string
|
||||
draftTags: string
|
||||
categories: TemplateCategory[]
|
||||
estimatedDuration: number
|
||||
onNameChange: (name: string) => void
|
||||
onCategoryChange: (cat: string) => void
|
||||
onTagsChange: (tags: string) => void
|
||||
onSave: () => Promise<void>
|
||||
onCancelSave: () => void
|
||||
/* BGM */
|
||||
bgmDrawerOpen: boolean
|
||||
bgmSettings: BgmMixConfig
|
||||
onCloseBgmDrawer: () => void
|
||||
onChangeBgmSettings: (config: BgmMixConfig) => void
|
||||
/* 字幕 */
|
||||
subtitleDrawerOpen: boolean
|
||||
subtitleSettings: SubtitleStyleConfig
|
||||
onCloseSubtitleDrawer: () => void
|
||||
onChangeSubtitleSettings: (config: SubtitleStyleConfig) => void
|
||||
/* 转场 */
|
||||
transitionDrawerOpen: boolean
|
||||
transitionTargetClipId: string | null
|
||||
onCloseTransitionDrawer: () => void
|
||||
onTransitionChange: (config: TransitionConfig) => void
|
||||
/* 调速 */
|
||||
speedDrawerOpen: boolean
|
||||
speedTargetClipId: string | null
|
||||
onCloseSpeedDrawer: () => void
|
||||
onSpeedChange: (config: SpeedConfig) => void
|
||||
onApplySpeedAll: (config: SpeedConfig) => void
|
||||
/* TTS 配音 */
|
||||
ttsDrawerOpen: boolean
|
||||
ttsTargetClipId: string | null
|
||||
onCloseTtsDrawer: () => void
|
||||
onTtsChange: (config: TtsConfig) => void
|
||||
/* 水印 */
|
||||
watermarkDrawerOpen: boolean
|
||||
watermarkSettings: WatermarkConfig
|
||||
onCloseWatermarkDrawer: () => void
|
||||
onWatermarkChange: (config: WatermarkConfig) => void
|
||||
/* 片头片尾 */
|
||||
introOutroDrawerOpen: boolean
|
||||
introOutroSettings: IntroOutroConfig
|
||||
onCloseIntroOutroDrawer: () => void
|
||||
onIntroOutroChange: (config: IntroOutroConfig) => void
|
||||
/* 混剪 */
|
||||
pipDrawerOpen: boolean
|
||||
pipSettings: PipConfig
|
||||
onClosePipDrawer: () => void
|
||||
onPipChange: (config: PipConfig) => void
|
||||
/* 滤镜调色 */
|
||||
filterDrawerOpen: boolean
|
||||
filterSettings: FilterConfig
|
||||
onCloseFilterDrawer: () => void
|
||||
onFilterChange: (config: FilterConfig) => void
|
||||
/* 绿幕抠像 */
|
||||
chromaKeyDrawerOpen: boolean
|
||||
chromaKeySettings: ChromaKeyConfig
|
||||
onCloseChromaKeyDrawer: () => void
|
||||
onChromaKeyChange: (config: ChromaKeyConfig) => void
|
||||
/* 贴纸 */
|
||||
stickerDrawerOpen: boolean
|
||||
stickerSettings: StickerConfig
|
||||
onCloseStickerDrawer: () => void
|
||||
onStickerChange: (config: StickerConfig) => void
|
||||
/* 共享数据 */
|
||||
clips: ClipData[]
|
||||
totalDuration: number
|
||||
}
|
||||
|
||||
const EditingDrawers: React.FC<EditingDrawersProps> = ({
|
||||
saveModalOpen,
|
||||
saveLoading,
|
||||
isUpdate,
|
||||
draftName,
|
||||
draftCategory,
|
||||
draftTags,
|
||||
categories,
|
||||
estimatedDuration,
|
||||
onNameChange,
|
||||
onCategoryChange,
|
||||
onTagsChange,
|
||||
onSave,
|
||||
onCancelSave,
|
||||
bgmDrawerOpen,
|
||||
bgmSettings,
|
||||
onCloseBgmDrawer,
|
||||
onChangeBgmSettings,
|
||||
subtitleDrawerOpen,
|
||||
subtitleSettings,
|
||||
onCloseSubtitleDrawer,
|
||||
onChangeSubtitleSettings,
|
||||
transitionDrawerOpen,
|
||||
transitionTargetClipId,
|
||||
onCloseTransitionDrawer,
|
||||
onTransitionChange,
|
||||
speedDrawerOpen,
|
||||
speedTargetClipId,
|
||||
onCloseSpeedDrawer,
|
||||
onSpeedChange,
|
||||
onApplySpeedAll,
|
||||
ttsDrawerOpen,
|
||||
ttsTargetClipId,
|
||||
onCloseTtsDrawer,
|
||||
onTtsChange,
|
||||
watermarkDrawerOpen,
|
||||
watermarkSettings,
|
||||
onCloseWatermarkDrawer,
|
||||
onWatermarkChange,
|
||||
introOutroDrawerOpen,
|
||||
introOutroSettings,
|
||||
onCloseIntroOutroDrawer,
|
||||
onIntroOutroChange,
|
||||
pipDrawerOpen,
|
||||
pipSettings,
|
||||
onClosePipDrawer,
|
||||
onPipChange,
|
||||
filterDrawerOpen,
|
||||
filterSettings,
|
||||
onCloseFilterDrawer,
|
||||
onFilterChange,
|
||||
chromaKeyDrawerOpen,
|
||||
chromaKeySettings,
|
||||
onCloseChromaKeyDrawer,
|
||||
onChromaKeyChange,
|
||||
stickerDrawerOpen,
|
||||
stickerSettings,
|
||||
onCloseStickerDrawer,
|
||||
onStickerChange,
|
||||
clips,
|
||||
totalDuration,
|
||||
}) => {
|
||||
const transitionConfig = transitionTargetClipId
|
||||
? (clips.find((c) => c.id === transitionTargetClipId)?.transition ?? DEFAULT_TRANSITION)
|
||||
: DEFAULT_TRANSITION
|
||||
const transitionTitle = transitionTargetClipId ? "片段转场设置" : "全局默认转场"
|
||||
|
||||
const speedConfig = speedTargetClipId
|
||||
? (clips.find((c) => c.id === speedTargetClipId)?.speed ?? DEFAULT_SPEED)
|
||||
: DEFAULT_SPEED
|
||||
|
||||
const ttsConfig = ttsTargetClipId
|
||||
? (clips.find((c) => c.id === ttsTargetClipId)?.tts_config ?? DEFAULT_TTS_CONFIG)
|
||||
: DEFAULT_TTS_CONFIG
|
||||
const EditingDrawers: React.FC<EditingDrawersProps> = (props) => {
|
||||
const {
|
||||
saveModalOpen,
|
||||
saveLoading,
|
||||
isUpdate,
|
||||
draftName,
|
||||
draftCategory,
|
||||
draftTags,
|
||||
categories,
|
||||
estimatedDuration,
|
||||
onNameChange,
|
||||
onCategoryChange,
|
||||
onTagsChange,
|
||||
onSave,
|
||||
onCancelSave,
|
||||
clips,
|
||||
} = props
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* ═══ 保存弹窗 ═══ */}
|
||||
{/* 保存弹窗 */}
|
||||
<SaveModal
|
||||
open={saveModalOpen}
|
||||
loading={saveLoading}
|
||||
@@ -202,100 +45,59 @@ const EditingDrawers: React.FC<EditingDrawersProps> = ({
|
||||
onCancel={onCancelSave}
|
||||
/>
|
||||
|
||||
{/* ═══ BGM 选择器 Drawer ═══ */}
|
||||
<BgmSelector
|
||||
open={bgmDrawerOpen}
|
||||
onClose={onCloseBgmDrawer}
|
||||
config={bgmSettings}
|
||||
onChange={onChangeBgmSettings}
|
||||
{/* 片段级抽屉(转场/调速/TTS) */}
|
||||
<ClipLevelDrawers
|
||||
clips={clips}
|
||||
transitionDrawerOpen={props.transitionDrawerOpen}
|
||||
transitionTargetClipId={props.transitionTargetClipId}
|
||||
onCloseTransitionDrawer={props.onCloseTransitionDrawer}
|
||||
onTransitionChange={props.onTransitionChange}
|
||||
speedDrawerOpen={props.speedDrawerOpen}
|
||||
speedTargetClipId={props.speedTargetClipId}
|
||||
onCloseSpeedDrawer={props.onCloseSpeedDrawer}
|
||||
onSpeedChange={props.onSpeedChange}
|
||||
onApplySpeedAll={props.onApplySpeedAll}
|
||||
ttsDrawerOpen={props.ttsDrawerOpen}
|
||||
ttsTargetClipId={props.ttsTargetClipId}
|
||||
onCloseTtsDrawer={props.onCloseTtsDrawer}
|
||||
onTtsChange={props.onTtsChange}
|
||||
/>
|
||||
|
||||
{/* ═══ 字幕样式配置 Drawer ═══ */}
|
||||
<SubtitleStylePanel
|
||||
open={subtitleDrawerOpen}
|
||||
onClose={onCloseSubtitleDrawer}
|
||||
config={subtitleSettings}
|
||||
onChange={onChangeSubtitleSettings}
|
||||
/>
|
||||
|
||||
{/* ═══ 转场特效选择器 Drawer ═══ */}
|
||||
<TransitionSelector
|
||||
open={transitionDrawerOpen}
|
||||
onClose={onCloseTransitionDrawer}
|
||||
config={transitionConfig}
|
||||
onChange={onTransitionChange}
|
||||
title={transitionTitle}
|
||||
/>
|
||||
|
||||
{/* ═══ 片段调速面板 Drawer ═══ */}
|
||||
{speedTargetClipId && (
|
||||
<SpeedPanel
|
||||
open={speedDrawerOpen}
|
||||
onClose={onCloseSpeedDrawer}
|
||||
config={speedConfig}
|
||||
onChange={onSpeedChange}
|
||||
onApplyAll={onApplySpeedAll}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ═══ TTS 配音面板 Drawer ═══ */}
|
||||
{ttsTargetClipId && (
|
||||
<TtsPanel
|
||||
open={ttsDrawerOpen}
|
||||
onClose={onCloseTtsDrawer}
|
||||
config={ttsConfig}
|
||||
onChange={onTtsChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ═══ 水印配置面板 ═══ */}
|
||||
<WatermarkPanel
|
||||
open={watermarkDrawerOpen}
|
||||
onClose={onCloseWatermarkDrawer}
|
||||
config={watermarkSettings}
|
||||
onChange={onWatermarkChange}
|
||||
/>
|
||||
|
||||
{/* ═══ 片头片尾配置面板 ═══ */}
|
||||
<IntroOutroPanel
|
||||
open={introOutroDrawerOpen}
|
||||
onClose={onCloseIntroOutroDrawer}
|
||||
config={introOutroSettings}
|
||||
onChange={onIntroOutroChange}
|
||||
/>
|
||||
|
||||
{/* ═══ 混剪配置面板 ═══ */}
|
||||
<PipConfigPanel
|
||||
open={pipDrawerOpen}
|
||||
onClose={onClosePipDrawer}
|
||||
config={pipSettings}
|
||||
onChange={onPipChange}
|
||||
totalDuration={totalDuration}
|
||||
/>
|
||||
|
||||
{/* ═══ 滤镜调色面板 ═══ */}
|
||||
<FilterPanel
|
||||
open={filterDrawerOpen}
|
||||
onClose={onCloseFilterDrawer}
|
||||
config={filterSettings}
|
||||
onChange={onFilterChange}
|
||||
/>
|
||||
|
||||
{/* ═══ 绿幕抠像面板 ═══ */}
|
||||
<GreenScreenPanel
|
||||
open={chromaKeyDrawerOpen}
|
||||
onClose={onCloseChromaKeyDrawer}
|
||||
config={chromaKeySettings}
|
||||
onChange={onChromaKeyChange}
|
||||
/>
|
||||
|
||||
{/* ═══ 贴纸面板 ═══ */}
|
||||
<StickerPanel
|
||||
open={stickerDrawerOpen}
|
||||
onClose={onCloseStickerDrawer}
|
||||
config={stickerSettings}
|
||||
onChange={onStickerChange}
|
||||
totalDuration={totalDuration}
|
||||
{/* 全局设置抽屉(BGM/字幕/水印/片头片尾/混剪/滤镜/绿幕/贴纸) */}
|
||||
<GlobalDrawers
|
||||
bgmDrawerOpen={props.bgmDrawerOpen}
|
||||
bgmSettings={props.bgmSettings}
|
||||
onCloseBgmDrawer={props.onCloseBgmDrawer}
|
||||
onChangeBgmSettings={props.onChangeBgmSettings}
|
||||
subtitleDrawerOpen={props.subtitleDrawerOpen}
|
||||
subtitleSettings={props.subtitleSettings}
|
||||
onCloseSubtitleDrawer={props.onCloseSubtitleDrawer}
|
||||
onChangeSubtitleSettings={props.onChangeSubtitleSettings}
|
||||
totalDuration={props.totalDuration}
|
||||
watermarkDrawerOpen={props.watermarkDrawerOpen}
|
||||
watermarkSettings={props.watermarkSettings}
|
||||
onCloseWatermarkDrawer={props.onCloseWatermarkDrawer}
|
||||
onWatermarkChange={props.onWatermarkChange}
|
||||
introOutroDrawerOpen={props.introOutroDrawerOpen}
|
||||
introOutroSettings={props.introOutroSettings}
|
||||
onCloseIntroOutroDrawer={props.onCloseIntroOutroDrawer}
|
||||
onIntroOutroChange={props.onIntroOutroChange}
|
||||
pipDrawerOpen={props.pipDrawerOpen}
|
||||
pipSettings={props.pipSettings}
|
||||
onClosePipDrawer={props.onClosePipDrawer}
|
||||
onPipChange={props.onPipChange}
|
||||
filterDrawerOpen={props.filterDrawerOpen}
|
||||
filterSettings={props.filterSettings}
|
||||
onCloseFilterDrawer={props.onCloseFilterDrawer}
|
||||
onFilterChange={props.onFilterChange}
|
||||
chromaKeyDrawerOpen={props.chromaKeyDrawerOpen}
|
||||
chromaKeySettings={props.chromaKeySettings}
|
||||
onCloseChromaKeyDrawer={props.onCloseChromaKeyDrawer}
|
||||
onChromaKeyChange={props.onChromaKeyChange}
|
||||
stickerDrawerOpen={props.stickerDrawerOpen}
|
||||
stickerSettings={props.stickerSettings}
|
||||
onCloseStickerDrawer={props.onCloseStickerDrawer}
|
||||
onStickerChange={props.onStickerChange}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import React from "react"
|
||||
import { DEFAULT_TRANSITION, DEFAULT_SPEED, DEFAULT_TTS_CONFIG } from "../../types"
|
||||
import TransitionSelector from "../TransitionSelector"
|
||||
import SpeedPanel from "../SpeedPanel"
|
||||
import TtsPanel from "../TtsPanel"
|
||||
import type { ClipLevelDrawersProps } from "./types"
|
||||
|
||||
/**
|
||||
* 片段级抽屉(转场/调速/TTS)
|
||||
* 这些抽屉针对特定片段,需要 targetClipId 来定位和读取当前配置
|
||||
*/
|
||||
export const ClipLevelDrawers: React.FC<ClipLevelDrawersProps> = ({
|
||||
clips,
|
||||
transitionDrawerOpen,
|
||||
transitionTargetClipId,
|
||||
onCloseTransitionDrawer,
|
||||
onTransitionChange,
|
||||
speedDrawerOpen,
|
||||
speedTargetClipId,
|
||||
onCloseSpeedDrawer,
|
||||
onSpeedChange,
|
||||
onApplySpeedAll,
|
||||
ttsDrawerOpen,
|
||||
ttsTargetClipId,
|
||||
onCloseTtsDrawer,
|
||||
onTtsChange,
|
||||
}) => {
|
||||
const transitionConfig = transitionTargetClipId
|
||||
? (clips.find((c) => c.id === transitionTargetClipId)?.transition ?? DEFAULT_TRANSITION)
|
||||
: DEFAULT_TRANSITION
|
||||
const transitionTitle = transitionTargetClipId ? "片段转场设置" : "全局默认转场"
|
||||
|
||||
const speedConfig = speedTargetClipId
|
||||
? (clips.find((c) => c.id === speedTargetClipId)?.speed ?? DEFAULT_SPEED)
|
||||
: DEFAULT_SPEED
|
||||
|
||||
const ttsConfig = ttsTargetClipId
|
||||
? (clips.find((c) => c.id === ttsTargetClipId)?.tts_config ?? DEFAULT_TTS_CONFIG)
|
||||
: DEFAULT_TTS_CONFIG
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 转场特效选择器 */}
|
||||
<TransitionSelector
|
||||
open={transitionDrawerOpen}
|
||||
onClose={onCloseTransitionDrawer}
|
||||
config={transitionConfig}
|
||||
onChange={onTransitionChange}
|
||||
title={transitionTitle}
|
||||
/>
|
||||
|
||||
{/* 片段调速面板 */}
|
||||
{speedTargetClipId && (
|
||||
<SpeedPanel
|
||||
open={speedDrawerOpen}
|
||||
onClose={onCloseSpeedDrawer}
|
||||
config={speedConfig}
|
||||
onChange={onSpeedChange}
|
||||
onApplyAll={onApplySpeedAll}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* TTS 配音面板 */}
|
||||
{ttsTargetClipId && (
|
||||
<TtsPanel
|
||||
open={ttsDrawerOpen}
|
||||
onClose={onCloseTtsDrawer}
|
||||
config={ttsConfig}
|
||||
onChange={onTtsChange}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import React from "react"
|
||||
import BgmSelector from "../BgmSelector"
|
||||
import SubtitleStylePanel from "../SubtitleStylePanel"
|
||||
import WatermarkPanel from "../WatermarkPanel"
|
||||
import IntroOutroPanel from "../IntroOutroPanel"
|
||||
import PipConfigPanel from "../PipConfigPanel"
|
||||
import FilterPanel from "../FilterPanel"
|
||||
import GreenScreenPanel from "../GreenScreenPanel"
|
||||
import StickerPanel from "../StickerPanel"
|
||||
import type { GlobalDrawersProps, BgmDrawerProps, SubtitleDrawerProps } from "./types"
|
||||
|
||||
/**
|
||||
* 全局设置抽屉(BGM/字幕/水印/片头片尾/混剪/滤镜/绿幕/贴纸)
|
||||
*/
|
||||
export const GlobalDrawers: React.FC<BgmDrawerProps & SubtitleDrawerProps & GlobalDrawersProps> = ({
|
||||
bgmDrawerOpen,
|
||||
bgmSettings,
|
||||
onCloseBgmDrawer,
|
||||
onChangeBgmSettings,
|
||||
subtitleDrawerOpen,
|
||||
subtitleSettings,
|
||||
onCloseSubtitleDrawer,
|
||||
onChangeSubtitleSettings,
|
||||
totalDuration,
|
||||
watermarkDrawerOpen,
|
||||
watermarkSettings,
|
||||
onCloseWatermarkDrawer,
|
||||
onWatermarkChange,
|
||||
introOutroDrawerOpen,
|
||||
introOutroSettings,
|
||||
onCloseIntroOutroDrawer,
|
||||
onIntroOutroChange,
|
||||
pipDrawerOpen,
|
||||
pipSettings,
|
||||
onClosePipDrawer,
|
||||
onPipChange,
|
||||
filterDrawerOpen,
|
||||
filterSettings,
|
||||
onCloseFilterDrawer,
|
||||
onFilterChange,
|
||||
chromaKeyDrawerOpen,
|
||||
chromaKeySettings,
|
||||
onCloseChromaKeyDrawer,
|
||||
onChromaKeyChange,
|
||||
stickerDrawerOpen,
|
||||
stickerSettings,
|
||||
onCloseStickerDrawer,
|
||||
onStickerChange,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
{/* BGM 选择器 */}
|
||||
<BgmSelector
|
||||
open={bgmDrawerOpen}
|
||||
onClose={onCloseBgmDrawer}
|
||||
config={bgmSettings}
|
||||
onChange={onChangeBgmSettings}
|
||||
/>
|
||||
|
||||
{/* 字幕样式配置 */}
|
||||
<SubtitleStylePanel
|
||||
open={subtitleDrawerOpen}
|
||||
onClose={onCloseSubtitleDrawer}
|
||||
config={subtitleSettings}
|
||||
onChange={onChangeSubtitleSettings}
|
||||
/>
|
||||
|
||||
{/* 水印配置面板 */}
|
||||
<WatermarkPanel
|
||||
open={watermarkDrawerOpen}
|
||||
onClose={onCloseWatermarkDrawer}
|
||||
config={watermarkSettings}
|
||||
onChange={onWatermarkChange}
|
||||
/>
|
||||
|
||||
{/* 片头片尾配置面板 */}
|
||||
<IntroOutroPanel
|
||||
open={introOutroDrawerOpen}
|
||||
onClose={onCloseIntroOutroDrawer}
|
||||
config={introOutroSettings}
|
||||
onChange={onIntroOutroChange}
|
||||
/>
|
||||
|
||||
{/* 混剪配置面板 */}
|
||||
<PipConfigPanel
|
||||
open={pipDrawerOpen}
|
||||
onClose={onClosePipDrawer}
|
||||
config={pipSettings}
|
||||
onChange={onPipChange}
|
||||
totalDuration={totalDuration}
|
||||
/>
|
||||
|
||||
{/* 滤镜调色面板 */}
|
||||
<FilterPanel
|
||||
open={filterDrawerOpen}
|
||||
onClose={onCloseFilterDrawer}
|
||||
config={filterSettings}
|
||||
onChange={onFilterChange}
|
||||
/>
|
||||
|
||||
{/* 绿幕抠像面板 */}
|
||||
<GreenScreenPanel
|
||||
open={chromaKeyDrawerOpen}
|
||||
onClose={onCloseChromaKeyDrawer}
|
||||
config={chromaKeySettings}
|
||||
onChange={onChromaKeyChange}
|
||||
/>
|
||||
|
||||
{/* 贴纸面板 */}
|
||||
<StickerPanel
|
||||
open={stickerDrawerOpen}
|
||||
onClose={onCloseStickerDrawer}
|
||||
config={stickerSettings}
|
||||
onChange={onStickerChange}
|
||||
totalDuration={totalDuration}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import type { TemplateCategory } from "@/api/editing-planner"
|
||||
import type {
|
||||
ClipData,
|
||||
TransitionConfig,
|
||||
SpeedConfig,
|
||||
TtsConfig,
|
||||
WatermarkConfig,
|
||||
IntroOutroConfig,
|
||||
PipConfig,
|
||||
FilterConfig,
|
||||
ChromaKeyConfig,
|
||||
StickerConfig,
|
||||
} from "../../types"
|
||||
import type { SubtitleStyleConfig } from "../../types/subtitle"
|
||||
import type { BgmMixConfig } from "@/api/bgm"
|
||||
|
||||
/** 保存弹窗 Props */
|
||||
export interface SaveModalDrawerProps {
|
||||
saveModalOpen: boolean
|
||||
saveLoading: boolean
|
||||
isUpdate: boolean
|
||||
draftName: string
|
||||
draftCategory: string
|
||||
draftTags: string
|
||||
categories: TemplateCategory[]
|
||||
estimatedDuration: number
|
||||
onNameChange: (name: string) => void
|
||||
onCategoryChange: (cat: string) => void
|
||||
onTagsChange: (tags: string) => void
|
||||
onSave: () => Promise<void>
|
||||
onCancelSave: () => void
|
||||
}
|
||||
|
||||
/** BGM 抽屉 Props */
|
||||
export interface BgmDrawerProps {
|
||||
bgmDrawerOpen: boolean
|
||||
bgmSettings: BgmMixConfig
|
||||
onCloseBgmDrawer: () => void
|
||||
onChangeBgmSettings: (config: BgmMixConfig) => void
|
||||
}
|
||||
|
||||
/** 字幕抽屉 Props */
|
||||
export interface SubtitleDrawerProps {
|
||||
subtitleDrawerOpen: boolean
|
||||
subtitleSettings: SubtitleStyleConfig
|
||||
onCloseSubtitleDrawer: () => void
|
||||
onChangeSubtitleSettings: (config: SubtitleStyleConfig) => void
|
||||
}
|
||||
|
||||
/** 单个片段级抽屉通用 Props */
|
||||
export interface ClipLevelDrawersProps {
|
||||
clips: ClipData[]
|
||||
transitionDrawerOpen: boolean
|
||||
transitionTargetClipId: string | null
|
||||
onCloseTransitionDrawer: () => void
|
||||
onTransitionChange: (config: TransitionConfig) => void
|
||||
speedDrawerOpen: boolean
|
||||
speedTargetClipId: string | null
|
||||
onCloseSpeedDrawer: () => void
|
||||
onSpeedChange: (config: SpeedConfig) => void
|
||||
onApplySpeedAll: (config: SpeedConfig) => void
|
||||
ttsDrawerOpen: boolean
|
||||
ttsTargetClipId: string | null
|
||||
onCloseTtsDrawer: () => void
|
||||
onTtsChange: (config: TtsConfig) => void
|
||||
}
|
||||
|
||||
/** 全局设置抽屉 Props */
|
||||
export interface GlobalDrawersProps {
|
||||
totalDuration: number
|
||||
watermarkDrawerOpen: boolean
|
||||
watermarkSettings: WatermarkConfig
|
||||
onCloseWatermarkDrawer: () => void
|
||||
onWatermarkChange: (config: WatermarkConfig) => void
|
||||
introOutroDrawerOpen: boolean
|
||||
introOutroSettings: IntroOutroConfig
|
||||
onCloseIntroOutroDrawer: () => void
|
||||
onIntroOutroChange: (config: IntroOutroConfig) => void
|
||||
pipDrawerOpen: boolean
|
||||
pipSettings: PipConfig
|
||||
onClosePipDrawer: () => void
|
||||
onPipChange: (config: PipConfig) => void
|
||||
filterDrawerOpen: boolean
|
||||
filterSettings: FilterConfig
|
||||
onCloseFilterDrawer: () => void
|
||||
onFilterChange: (config: FilterConfig) => void
|
||||
chromaKeyDrawerOpen: boolean
|
||||
chromaKeySettings: ChromaKeyConfig
|
||||
onCloseChromaKeyDrawer: () => void
|
||||
onChromaKeyChange: (config: ChromaKeyConfig) => void
|
||||
stickerDrawerOpen: boolean
|
||||
stickerSettings: StickerConfig
|
||||
onCloseStickerDrawer: () => void
|
||||
onStickerChange: (config: StickerConfig) => void
|
||||
}
|
||||
|
||||
export type EditingDrawersProps = SaveModalDrawerProps &
|
||||
BgmDrawerProps &
|
||||
SubtitleDrawerProps &
|
||||
ClipLevelDrawersProps &
|
||||
GlobalDrawersProps
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useUndoRedo } from "../useUndoRedo"
|
||||
import type { EditPlanClip } from "@/api/template-editor"
|
||||
import { useEditPlanClipList } from "./useEditPlanClipList"
|
||||
import { useEditPlanClipMutations } from "./useEditPlanClipMutations"
|
||||
|
||||
/**
|
||||
* 模板片段管理 Hook
|
||||
* 对接后端 PR#389 片段 CRUD API
|
||||
*
|
||||
* 功能:
|
||||
* - 加载/刷新片段列表
|
||||
* - 单个增删改查
|
||||
* - 批量删除
|
||||
* - 拖拽重排序
|
||||
* - 从素材批量导入
|
||||
* - 乐观更新 + 撤销重做
|
||||
*/
|
||||
export function useEditPlanClips(planId: string | undefined) {
|
||||
// 列表数据 + 选中状态
|
||||
const {
|
||||
clips,
|
||||
clipsTotal,
|
||||
clipsLoading,
|
||||
refetchClips,
|
||||
selectedClipId,
|
||||
setSelectedClipId,
|
||||
selectedClip,
|
||||
} = useEditPlanClipList(planId)
|
||||
|
||||
// CRUD 操作
|
||||
const mutations = useEditPlanClipMutations({
|
||||
planId,
|
||||
selectedClipId,
|
||||
setSelectedClipId,
|
||||
clipsLength: clips.length,
|
||||
})
|
||||
|
||||
// 本地撤销重做(供拖拽等即时操作使用)
|
||||
const {
|
||||
state: localClips,
|
||||
set: setLocalClips,
|
||||
undo,
|
||||
redo,
|
||||
canUndo,
|
||||
canRedo,
|
||||
reset: resetLocalClips,
|
||||
} = useUndoRedo<EditPlanClip[]>([])
|
||||
|
||||
return {
|
||||
// 数据
|
||||
clips,
|
||||
clipsTotal,
|
||||
clipsLoading,
|
||||
selectedClipId,
|
||||
selectedClip,
|
||||
// 选中
|
||||
setSelectedClipId,
|
||||
// 操作
|
||||
addClip: mutations.addClip,
|
||||
updateClip: mutations.updateClip,
|
||||
removeClip: mutations.removeClip,
|
||||
batchRemoveClips: mutations.batchRemoveClips,
|
||||
reorderClips: mutations.reorderClips,
|
||||
importFromAssets: mutations.importFromAssets,
|
||||
refetchClips,
|
||||
// 状态
|
||||
isCreating: mutations.isCreating,
|
||||
isUpdating: mutations.isUpdating,
|
||||
isDeleting: mutations.isDeleting,
|
||||
isReordering: mutations.isReordering,
|
||||
isImporting: mutations.isImporting,
|
||||
// 本地撤销重做
|
||||
localClips,
|
||||
setLocalClips,
|
||||
undo,
|
||||
redo,
|
||||
canUndo,
|
||||
canRedo,
|
||||
resetLocalClips,
|
||||
}
|
||||
}
|
||||
|
||||
export default useEditPlanClips
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useState } from "react"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import type { EditPlanClip } from "@/api/template-editor"
|
||||
import { getEditPlanClips } from "@/api/template-editor"
|
||||
|
||||
const QUERY_KEY = "editPlanClips"
|
||||
|
||||
interface UseEditPlanClipListResult {
|
||||
clips: EditPlanClip[]
|
||||
clipsTotal: number
|
||||
clipsLoading: boolean
|
||||
refetchClips: () => void
|
||||
selectedClipId: string | null
|
||||
setSelectedClipId: (id: string | null) => void
|
||||
selectedClip: EditPlanClip | null
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑计划片段列表 Hook
|
||||
* 封装片段列表查询、选中状态
|
||||
*/
|
||||
export function useEditPlanClipList(planId: string | undefined): UseEditPlanClipListResult {
|
||||
const {
|
||||
data: clipListData,
|
||||
isLoading: clipsLoading,
|
||||
refetch: refetchClips,
|
||||
} = useQuery({
|
||||
queryKey: [QUERY_KEY, planId],
|
||||
queryFn: () => getEditPlanClips(planId!, { limit: 500 }),
|
||||
enabled: !!planId,
|
||||
staleTime: 30_000,
|
||||
})
|
||||
|
||||
const clips: EditPlanClip[] = clipListData?.items ?? []
|
||||
const clipsTotal = clipListData?.total ?? 0
|
||||
|
||||
const [selectedClipId, setSelectedClipId] = useState<string | null>(null)
|
||||
const selectedClip = clips.find((c) => c.id === selectedClipId) ?? null
|
||||
|
||||
return {
|
||||
clips,
|
||||
clipsTotal,
|
||||
clipsLoading,
|
||||
refetchClips,
|
||||
selectedClipId,
|
||||
setSelectedClipId,
|
||||
selectedClip,
|
||||
}
|
||||
}
|
||||
+34
-84
@@ -1,26 +1,12 @@
|
||||
/**
|
||||
* 模板片段管理 Hook
|
||||
* 对接后端 PR#389 片段 CRUD API,替代原来的 config.segments 模式
|
||||
*
|
||||
* 功能:
|
||||
* - 加载/刷新片段列表
|
||||
* - 单个增删改查
|
||||
* - 批量删除
|
||||
* - 拖拽重排序
|
||||
* - 从素材批量导入
|
||||
* - 乐观更新 + 撤销重做
|
||||
*/
|
||||
import { useCallback, useState } from "react"
|
||||
import { useCallback } from "react"
|
||||
import { message } from "antd"
|
||||
import { useQuery, useQueryClient, useMutation } from "@tanstack/react-query"
|
||||
import { useQueryClient, useMutation } from "@tanstack/react-query"
|
||||
import type {
|
||||
EditPlanClip,
|
||||
CreateEditPlanClipRequest,
|
||||
UpdateEditPlanClipRequest,
|
||||
ClipReorderItem,
|
||||
} from "@/api/template-editor"
|
||||
import {
|
||||
getEditPlanClips,
|
||||
createEditPlanClip,
|
||||
updateEditPlanClip,
|
||||
deleteEditPlanClip,
|
||||
@@ -28,51 +14,37 @@ import {
|
||||
batchDeleteEditPlanClips,
|
||||
createClipsFromAssets,
|
||||
} from "@/api/template-editor"
|
||||
import { useUndoRedo } from "./useUndoRedo"
|
||||
|
||||
const QUERY_KEY = "editPlanClips"
|
||||
|
||||
export function useEditPlanClips(planId: string | undefined) {
|
||||
interface UseEditPlanClipMutationsOptions {
|
||||
planId: string | undefined
|
||||
selectedClipId: string | null
|
||||
setSelectedClipId: (id: string | null) => void
|
||||
clipsLength: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑计划片段 CRUD Hook
|
||||
* 封装创建、更新、删除、批量删除、重排序、素材导入等操作
|
||||
*/
|
||||
export function useEditPlanClipMutations({
|
||||
planId,
|
||||
selectedClipId,
|
||||
setSelectedClipId,
|
||||
clipsLength,
|
||||
}: UseEditPlanClipMutationsOptions) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
/* ── 片段列表查询 ── */
|
||||
const {
|
||||
data: clipListData,
|
||||
isLoading: clipsLoading,
|
||||
refetch: refetchClips,
|
||||
} = useQuery({
|
||||
queryKey: [QUERY_KEY, planId],
|
||||
queryFn: () => getEditPlanClips(planId!, { limit: 500 }),
|
||||
enabled: !!planId,
|
||||
staleTime: 30_000,
|
||||
})
|
||||
|
||||
const clips: EditPlanClip[] = clipListData?.items ?? []
|
||||
const clipsTotal = clipListData?.total ?? 0
|
||||
|
||||
/* ── 选中片段 ── */
|
||||
const [selectedClipId, setSelectedClipId] = useState<string | null>(null)
|
||||
const selectedClip = clips.find((c) => c.id === selectedClipId) ?? null
|
||||
|
||||
/* ── 本地撤销/重做(用于拖拽等即时操作的回退) ── */
|
||||
const {
|
||||
state: localClips,
|
||||
set: setLocalClips,
|
||||
undo,
|
||||
redo,
|
||||
canUndo,
|
||||
canRedo,
|
||||
reset: resetLocalClips,
|
||||
} = useUndoRedo<EditPlanClip[]>([])
|
||||
|
||||
// 当服务端数据变化时同步本地
|
||||
// 注意:实际使用时以服务端为准,本地仅用于拖拽等临时操作
|
||||
const invalidate = () => {
|
||||
queryClient.invalidateQueries({ queryKey: [QUERY_KEY, planId] })
|
||||
}
|
||||
|
||||
/* ── 创建片段 ── */
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: CreateEditPlanClipRequest) => createEditPlanClip(planId!, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: [QUERY_KEY, planId] })
|
||||
invalidate()
|
||||
message.success("片段已添加")
|
||||
},
|
||||
onError: () => {
|
||||
@@ -83,10 +55,10 @@ export function useEditPlanClips(planId: string | undefined) {
|
||||
const addClip = useCallback(
|
||||
(data: Omit<CreateEditPlanClipRequest, "order"> & { order?: number }) => {
|
||||
if (!planId) return
|
||||
const order = data.order ?? clips.length
|
||||
const order = data.order ?? clipsLength
|
||||
createMutation.mutate({ ...data, order })
|
||||
},
|
||||
[planId, clips.length, createMutation],
|
||||
[planId, clipsLength, createMutation],
|
||||
)
|
||||
|
||||
/* ── 更新片段 ── */
|
||||
@@ -94,7 +66,7 @@ export function useEditPlanClips(planId: string | undefined) {
|
||||
mutationFn: ({ clipId, data }: { clipId: string; data: UpdateEditPlanClipRequest }) =>
|
||||
updateEditPlanClip(planId!, clipId, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: [QUERY_KEY, planId] })
|
||||
invalidate()
|
||||
},
|
||||
onError: () => {
|
||||
message.error("更新片段失败")
|
||||
@@ -113,7 +85,7 @@ export function useEditPlanClips(planId: string | undefined) {
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (clipId: string) => deleteEditPlanClip(planId!, clipId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: [QUERY_KEY, planId] })
|
||||
invalidate()
|
||||
message.success("片段已删除")
|
||||
},
|
||||
onError: () => {
|
||||
@@ -129,14 +101,14 @@ export function useEditPlanClips(planId: string | undefined) {
|
||||
}
|
||||
deleteMutation.mutate(clipId)
|
||||
},
|
||||
[planId, selectedClipId, deleteMutation],
|
||||
[planId, selectedClipId, setSelectedClipId, deleteMutation],
|
||||
)
|
||||
|
||||
/* ── 批量删除 ── */
|
||||
const batchDeleteMutation = useMutation({
|
||||
mutationFn: (clipIds: string[]) => batchDeleteEditPlanClips(planId!, clipIds),
|
||||
onSuccess: (res) => {
|
||||
queryClient.invalidateQueries({ queryKey: [QUERY_KEY, planId] })
|
||||
invalidate()
|
||||
message.success(`已删除 ${res.deleted_count} 个片段`)
|
||||
},
|
||||
onError: () => {
|
||||
@@ -152,19 +124,18 @@ export function useEditPlanClips(planId: string | undefined) {
|
||||
}
|
||||
batchDeleteMutation.mutate(clipIds)
|
||||
},
|
||||
[planId, selectedClipId, batchDeleteMutation],
|
||||
[planId, selectedClipId, setSelectedClipId, batchDeleteMutation],
|
||||
)
|
||||
|
||||
/* ── 重排序(拖拽结束后一次性提交) ── */
|
||||
/* ── 重排序 ── */
|
||||
const reorderMutation = useMutation({
|
||||
mutationFn: (items: ClipReorderItem[]) => reorderEditPlanClips(planId!, items),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: [QUERY_KEY, planId] })
|
||||
invalidate()
|
||||
},
|
||||
onError: () => {
|
||||
message.error("排序失败")
|
||||
// 失败后刷新回服务端状态
|
||||
queryClient.invalidateQueries({ queryKey: [QUERY_KEY, planId] })
|
||||
invalidate()
|
||||
},
|
||||
})
|
||||
|
||||
@@ -180,7 +151,7 @@ export function useEditPlanClips(planId: string | undefined) {
|
||||
const importFromAssetsMutation = useMutation({
|
||||
mutationFn: (assetIds: string[]) => createClipsFromAssets(planId!, assetIds),
|
||||
onSuccess: (res) => {
|
||||
queryClient.invalidateQueries({ queryKey: [QUERY_KEY, planId] })
|
||||
invalidate()
|
||||
message.success(`已导入 ${res.created_count} 个素材片段`)
|
||||
},
|
||||
onError: () => {
|
||||
@@ -197,37 +168,16 @@ export function useEditPlanClips(planId: string | undefined) {
|
||||
)
|
||||
|
||||
return {
|
||||
// 数据
|
||||
clips,
|
||||
clipsTotal,
|
||||
clipsLoading,
|
||||
selectedClipId,
|
||||
selectedClip,
|
||||
// 选中
|
||||
setSelectedClipId,
|
||||
// 操作
|
||||
addClip,
|
||||
updateClip,
|
||||
removeClip,
|
||||
batchRemoveClips,
|
||||
reorderClips,
|
||||
importFromAssets,
|
||||
refetchClips,
|
||||
// 状态
|
||||
isCreating: createMutation.isPending,
|
||||
isUpdating: updateMutation.isPending,
|
||||
isDeleting: deleteMutation.isPending,
|
||||
isReordering: reorderMutation.isPending,
|
||||
isImporting: importFromAssetsMutation.isPending,
|
||||
// 本地撤销重做(供拖拽等场景使用)
|
||||
localClips,
|
||||
setLocalClips,
|
||||
undo,
|
||||
redo,
|
||||
canUndo,
|
||||
canRedo,
|
||||
resetLocalClips,
|
||||
}
|
||||
}
|
||||
|
||||
export default useEditPlanClips
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* EditingPlanner 全局配置状态管理
|
||||
* 集中管理 9 个全局配置:标题/字幕/BGM/水印/片头片尾/画中画/滤镜/绿幕/贴纸/封面
|
||||
*/
|
||||
import { useState } from "react"
|
||||
import type { TitleConfig } from "@/api/template-editor"
|
||||
import type {
|
||||
WatermarkConfig,
|
||||
IntroOutroConfig,
|
||||
PipConfig,
|
||||
FilterConfig,
|
||||
ChromaKeyConfig,
|
||||
StickerConfig,
|
||||
CoverConfig,
|
||||
} from "../types"
|
||||
import {
|
||||
DEFAULT_WATERMARK,
|
||||
DEFAULT_INTRO_OUTRO,
|
||||
DEFAULT_PIP_CONFIG,
|
||||
DEFAULT_FILTER_CONFIG,
|
||||
DEFAULT_CHROMA_KEY_CONFIG,
|
||||
DEFAULT_STICKER_CONFIG,
|
||||
DEFAULT_COVER_CONFIG,
|
||||
} from "../types"
|
||||
import type { SubtitleStyleConfig } from "../types/subtitle"
|
||||
import { DEFAULT_SUBTITLE_STYLE } from "../types/subtitle"
|
||||
import { DEFAULT_BGM_MIX_CONFIG, type BgmMixConfig } from "@/api/bgm"
|
||||
|
||||
export interface GlobalSettings {
|
||||
titleConfig: TitleConfig
|
||||
setTitleConfig: (config: TitleConfig | ((prev: TitleConfig) => TitleConfig)) => void
|
||||
subtitleSettings: SubtitleStyleConfig
|
||||
setSubtitleSettings: (
|
||||
settings: SubtitleStyleConfig | ((prev: SubtitleStyleConfig) => SubtitleStyleConfig),
|
||||
) => void
|
||||
bgmSettings: BgmMixConfig
|
||||
setBgmSettings: (settings: BgmMixConfig | ((prev: BgmMixConfig) => BgmMixConfig)) => void
|
||||
watermarkSettings: WatermarkConfig
|
||||
setWatermarkSettings: (config: WatermarkConfig) => void
|
||||
introOutroSettings: IntroOutroConfig
|
||||
setIntroOutroSettings: (config: IntroOutroConfig) => void
|
||||
pipSettings: PipConfig
|
||||
setPipSettings: (config: PipConfig) => void
|
||||
filterSettings: FilterConfig
|
||||
setFilterSettings: (config: FilterConfig) => void
|
||||
chromaKeySettings: ChromaKeyConfig
|
||||
setChromaKeySettings: (config: ChromaKeyConfig) => void
|
||||
stickerSettings: StickerConfig
|
||||
setStickerSettings: (config: StickerConfig) => void
|
||||
coverConfig: CoverConfig
|
||||
setCoverConfig: (config: CoverConfig | ((prev: CoverConfig) => CoverConfig)) => void
|
||||
}
|
||||
|
||||
export const useGlobalSettings = (): GlobalSettings => {
|
||||
const [titleConfig, setTitleConfig] = useState<TitleConfig>({
|
||||
ai_auto_select: false,
|
||||
content: "",
|
||||
position: "bottom",
|
||||
font_preset: "思源黑体",
|
||||
font_size: 28,
|
||||
font_color: "#ffffff",
|
||||
})
|
||||
|
||||
const [subtitleSettings, setSubtitleSettings] = useState<SubtitleStyleConfig>({
|
||||
...DEFAULT_SUBTITLE_STYLE,
|
||||
})
|
||||
|
||||
const [bgmSettings, setBgmSettings] = useState<BgmMixConfig>({
|
||||
...DEFAULT_BGM_MIX_CONFIG,
|
||||
})
|
||||
|
||||
const [watermarkSettings, setWatermarkSettings] = useState<WatermarkConfig>({
|
||||
...DEFAULT_WATERMARK,
|
||||
})
|
||||
const [introOutroSettings, setIntroOutroSettings] = useState<IntroOutroConfig>({
|
||||
...DEFAULT_INTRO_OUTRO,
|
||||
})
|
||||
|
||||
const [pipSettings, setPipSettings] = useState<PipConfig>({
|
||||
...DEFAULT_PIP_CONFIG,
|
||||
})
|
||||
|
||||
const [filterSettings, setFilterSettings] = useState<FilterConfig>({
|
||||
...DEFAULT_FILTER_CONFIG,
|
||||
})
|
||||
|
||||
const [chromaKeySettings, setChromaKeySettings] = useState<ChromaKeyConfig>({
|
||||
...DEFAULT_CHROMA_KEY_CONFIG,
|
||||
})
|
||||
|
||||
const [stickerSettings, setStickerSettings] = useState<StickerConfig>({
|
||||
...DEFAULT_STICKER_CONFIG,
|
||||
})
|
||||
|
||||
const [coverConfig, setCoverConfig] = useState<CoverConfig>({
|
||||
...DEFAULT_COVER_CONFIG,
|
||||
})
|
||||
|
||||
return {
|
||||
titleConfig,
|
||||
setTitleConfig,
|
||||
subtitleSettings,
|
||||
setSubtitleSettings,
|
||||
bgmSettings,
|
||||
setBgmSettings,
|
||||
watermarkSettings,
|
||||
setWatermarkSettings,
|
||||
introOutroSettings,
|
||||
setIntroOutroSettings,
|
||||
pipSettings,
|
||||
setPipSettings,
|
||||
filterSettings,
|
||||
setFilterSettings,
|
||||
chromaKeySettings,
|
||||
setChromaKeySettings,
|
||||
stickerSettings,
|
||||
setStickerSettings,
|
||||
coverConfig,
|
||||
setCoverConfig,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* EditingPlanner 配音素材数据加载
|
||||
* queryKey 与 VoiceMaterialLibrary 共享缓存
|
||||
*/
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { ensureDefaultLibrary, getAssetsByKind, type AssetItem } from "@/api/assets"
|
||||
import { getOrCreateDefaultProject } from "@/api/projects"
|
||||
|
||||
export interface UseVoiceMaterialsReturn {
|
||||
voiceMaterials: AssetItem[]
|
||||
loading: boolean
|
||||
refetch: () => Promise<unknown>
|
||||
}
|
||||
|
||||
export const useVoiceMaterials = (): UseVoiceMaterialsReturn => {
|
||||
const query = useQuery({
|
||||
queryKey: ["assets", "voice"],
|
||||
queryFn: async () => {
|
||||
const project = await getOrCreateDefaultProject()
|
||||
await ensureDefaultLibrary({ project_id: project.id, kind: "voice" })
|
||||
const assets = await getAssetsByKind("voice")
|
||||
return assets
|
||||
},
|
||||
staleTime: 30_000,
|
||||
})
|
||||
|
||||
return {
|
||||
voiceMaterials: query.data ?? [],
|
||||
loading: query.isLoading,
|
||||
refetch: query.refetch,
|
||||
}
|
||||
}
|
||||
@@ -1,550 +0,0 @@
|
||||
/**
|
||||
* 片段(Clip)统一类型定义
|
||||
* 片段 = 时间规划 + 类型标记,不绑定任何素材
|
||||
*/
|
||||
|
||||
export type ClipType = "voice" | "pip"
|
||||
|
||||
/* ──────── 转场特效 ──────── */
|
||||
|
||||
/** 14 种转场类型 */
|
||||
export type TransitionType =
|
||||
| "none"
|
||||
| "cut"
|
||||
| "fade"
|
||||
| "dissolve"
|
||||
| "zoom"
|
||||
| "slide_left"
|
||||
| "slide_right"
|
||||
| "slide_up"
|
||||
| "slide_down"
|
||||
| "wipe_left"
|
||||
| "wipe_right"
|
||||
| "wipe_up"
|
||||
| "wipe_down"
|
||||
| "circlecrop"
|
||||
| "rectcrop"
|
||||
|
||||
/** 片段间转场配置 */
|
||||
export interface TransitionConfig {
|
||||
/** 转场类型 */
|
||||
type: TransitionType
|
||||
/** 转场时长(秒),0.3 ~ 2.0 */
|
||||
duration: number
|
||||
}
|
||||
|
||||
/** 默认转场配置 */
|
||||
export const DEFAULT_TRANSITION: TransitionConfig = {
|
||||
type: "none",
|
||||
duration: 0.5,
|
||||
}
|
||||
|
||||
/* ──────── 片段调速 ──────── */
|
||||
|
||||
/** 片段调速配置 */
|
||||
export interface SpeedConfig {
|
||||
/** 播放速度,0.25 ~ 4.0 */
|
||||
rate: number
|
||||
/** 音调修正(变速不变调) */
|
||||
pitchCorrection: boolean
|
||||
}
|
||||
|
||||
/** 默认调速配置 */
|
||||
export const DEFAULT_SPEED: SpeedConfig = {
|
||||
rate: 1.0,
|
||||
pitchCorrection: true,
|
||||
}
|
||||
|
||||
/* ──────── TTS 配音 ──────── */
|
||||
|
||||
/** 配音模式 */
|
||||
export type TtsMode = "none" | "upload" | "tts"
|
||||
|
||||
/** TTS 配音配置 */
|
||||
export interface TtsConfig {
|
||||
/** 配音模式 */
|
||||
mode: TtsMode
|
||||
/** TTS 合成文本 */
|
||||
text: string
|
||||
/** 音色 ID */
|
||||
voice_id: string
|
||||
/** 语速 0.5 ~ 2.0 */
|
||||
speed: number
|
||||
/** 语调(半音)-12 ~ +12 */
|
||||
pitch: number
|
||||
/** 音量 0 ~ 100 */
|
||||
volume: number
|
||||
/** 字幕联动 */
|
||||
subtitle_sync: boolean
|
||||
}
|
||||
|
||||
/** 默认 TTS 配置 */
|
||||
export const DEFAULT_TTS_CONFIG: TtsConfig = {
|
||||
mode: "none",
|
||||
text: "",
|
||||
voice_id: "",
|
||||
speed: 1.0,
|
||||
pitch: 0,
|
||||
volume: 100,
|
||||
subtitle_sync: true,
|
||||
}
|
||||
|
||||
/* ──────── 裁剪配置 ──────── */
|
||||
|
||||
/** 片段裁剪配置 — 定义素材的入点/出点 */
|
||||
export interface TrimConfig {
|
||||
/** 入点(秒),素材原始时间轴上的起始位置 */
|
||||
start_time: number
|
||||
/** 出点(秒),素材原始时间轴上的结束位置 */
|
||||
end_time: number
|
||||
/** 素材原始总时长(秒),用于"恢复原始长度" */
|
||||
original_duration?: number
|
||||
}
|
||||
|
||||
/* ──────── 水印配置 ──────── */
|
||||
|
||||
/** 水印类型 */
|
||||
export type WatermarkType = "none" | "image" | "text" | "scroll"
|
||||
|
||||
/** 水印位置 */
|
||||
export type WatermarkPosition = "top_left" | "top_right" | "bottom_left" | "bottom_right" | "center"
|
||||
|
||||
/** 滚动水印方向 */
|
||||
export type ScrollDirection = "horizontal" | "vertical" | "diagonal"
|
||||
|
||||
/** 水印配置 */
|
||||
export interface WatermarkConfig {
|
||||
/** 水印类型 */
|
||||
type: WatermarkType
|
||||
/** 图片水印 URL */
|
||||
image_url?: string
|
||||
/** 水印宽度(像素或百分比 0~1) */
|
||||
width?: number
|
||||
/** 水印高度(像素或百分比 0~1) */
|
||||
height?: number
|
||||
/** 水印位置 */
|
||||
position: WatermarkPosition
|
||||
/** 水印不透明度 0~1 */
|
||||
opacity: number
|
||||
/** 文字水印内容 */
|
||||
text?: string
|
||||
/** 文字水印字号 */
|
||||
font_size?: number
|
||||
/** 文字水印颜色 */
|
||||
color?: string
|
||||
/** 滚动水印方向 */
|
||||
scroll_direction?: ScrollDirection
|
||||
/** 滚动水印速度(像素/秒) */
|
||||
scroll_speed?: number
|
||||
}
|
||||
|
||||
/** 默认水印配置 */
|
||||
export const DEFAULT_WATERMARK: WatermarkConfig = {
|
||||
type: "none",
|
||||
position: "bottom_right",
|
||||
opacity: 0.7,
|
||||
}
|
||||
|
||||
/* ──────── 片头片尾配置 ──────── */
|
||||
|
||||
/** 片头片尾素材类型 */
|
||||
export type IntroOutroKind = "none" | "video" | "image"
|
||||
|
||||
/** 片头/片尾单项配置 */
|
||||
export interface IntroOutroItem {
|
||||
/** 素材类型 */
|
||||
kind: IntroOutroKind
|
||||
/** 素材 URL */
|
||||
url?: string
|
||||
/** 显示时长(秒) */
|
||||
duration: number
|
||||
/** 过渡动画 */
|
||||
transition?: TransitionType
|
||||
/** 过渡时长(秒) */
|
||||
transition_duration?: number
|
||||
}
|
||||
|
||||
/** 片头片尾完整配置 */
|
||||
export interface IntroOutroConfig {
|
||||
intro: IntroOutroItem
|
||||
outro: IntroOutroItem
|
||||
}
|
||||
|
||||
/** 默认片头片尾配置 */
|
||||
export const DEFAULT_INTRO_OUTRO: IntroOutroConfig = {
|
||||
intro: { kind: "none", duration: 3 },
|
||||
outro: { kind: "none", duration: 3 },
|
||||
}
|
||||
|
||||
/* ──────── 混剪配置 ──────── */
|
||||
|
||||
/** 九宫格位置 */
|
||||
export type PipGridPosition =
|
||||
| "top_left"
|
||||
| "top_center"
|
||||
| "top_right"
|
||||
| "center_left"
|
||||
| "center"
|
||||
| "center_right"
|
||||
| "bottom_left"
|
||||
| "bottom_center"
|
||||
| "bottom_right"
|
||||
|
||||
/** 入场动画类型 */
|
||||
export type PipAnimType = "none" | "fade_in" | "slide_in"
|
||||
|
||||
/** 入场方向 */
|
||||
export type PipSlideDirection = "left" | "right" | "up" | "down"
|
||||
|
||||
/** 混剪图层 */
|
||||
export interface PipLayer {
|
||||
id: string
|
||||
/** 图层名称(用户可编辑) */
|
||||
name: string
|
||||
/** 素材类型 */
|
||||
material_type: "image" | "video"
|
||||
/** 素材 URL */
|
||||
material_url: string
|
||||
/** 素材缩略图 */
|
||||
thumbnail_url?: string
|
||||
/** 九宫格快捷位置 */
|
||||
grid_position: PipGridPosition
|
||||
/** 精确 X 坐标(百分比 0~100) */
|
||||
x: number
|
||||
/** 精确 Y 坐标(百分比 0~100) */
|
||||
y: number
|
||||
/** 宽度(百分比 0~100,相对主画面) */
|
||||
width: number
|
||||
/** 高度(百分比 0~100,相对主画面) */
|
||||
height: number
|
||||
/** 锁定宽高比 */
|
||||
aspect_lock: boolean
|
||||
/** 圆角(百分比 0~50) */
|
||||
border_radius: number
|
||||
/** 不透明度(0~100) */
|
||||
opacity: number
|
||||
/** 开始时间(秒) */
|
||||
start_time: number
|
||||
/** 持续时长(秒) */
|
||||
duration: number
|
||||
/** 入场动画 */
|
||||
animation: PipAnimType
|
||||
/** 入场方向 */
|
||||
slide_direction: PipSlideDirection
|
||||
/** 图层顺序(z-index) */
|
||||
z_index: number
|
||||
}
|
||||
|
||||
/** 混剪配置 */
|
||||
export interface PipConfig {
|
||||
/** 是否启用混剪 */
|
||||
enabled: boolean
|
||||
/** 图层列表 */
|
||||
layers: PipLayer[]
|
||||
}
|
||||
|
||||
/** 默认 PiP 图层 */
|
||||
export const DEFAULT_PIP_LAYER: PipLayer = {
|
||||
id: "",
|
||||
name: "图层",
|
||||
material_type: "image",
|
||||
material_url: "",
|
||||
grid_position: "top_right",
|
||||
x: 70,
|
||||
y: 5,
|
||||
width: 25,
|
||||
height: 25,
|
||||
aspect_lock: true,
|
||||
border_radius: 0,
|
||||
opacity: 100,
|
||||
start_time: 0,
|
||||
duration: 5,
|
||||
animation: "none",
|
||||
slide_direction: "right",
|
||||
z_index: 1,
|
||||
}
|
||||
|
||||
/** 默认 PiP 配置 */
|
||||
export const DEFAULT_PIP_CONFIG: PipConfig = {
|
||||
enabled: false,
|
||||
layers: [],
|
||||
}
|
||||
|
||||
/* ──────── 滤镜调色 ──────── */
|
||||
|
||||
/** 预设滤镜 */
|
||||
export type FilterPreset =
|
||||
| "none"
|
||||
| "original"
|
||||
| "fresh"
|
||||
| "warm"
|
||||
| "cool"
|
||||
| "vintage"
|
||||
| "cinema"
|
||||
| "bw"
|
||||
| "sunshine"
|
||||
| "film"
|
||||
|
||||
/** 预设滤镜标签 */
|
||||
export const FILTER_PRESET_LABELS: Record<FilterPreset, string> = {
|
||||
none: "无",
|
||||
original: "原片",
|
||||
fresh: "清新",
|
||||
warm: "暖调",
|
||||
cool: "冷色",
|
||||
vintage: "复古",
|
||||
cinema: "电影",
|
||||
bw: "黑白",
|
||||
sunshine: "暖阳",
|
||||
film: "胶片",
|
||||
}
|
||||
|
||||
/** 滤镜调色配置 */
|
||||
export interface FilterConfig {
|
||||
/** 是否启用滤镜 */
|
||||
enabled: boolean
|
||||
/** 预设滤镜 */
|
||||
preset: FilterPreset
|
||||
/** 亮度(-100 ~ 100) */
|
||||
brightness: number
|
||||
/** 对比度(-100 ~ 100) */
|
||||
contrast: number
|
||||
/** 饱和度(-100 ~ 100) */
|
||||
saturation: number
|
||||
/** 色温(-100 ~ 100,负值偏蓝,正值偏黄) */
|
||||
temperature: number
|
||||
/** 色调(-100 ~ 100,负值偏绿,正值偏品红) */
|
||||
tint: number
|
||||
/** 锐度(0 ~ 100) */
|
||||
sharpness: number
|
||||
}
|
||||
|
||||
/** 默认滤镜调色配置 */
|
||||
export const DEFAULT_FILTER_CONFIG: FilterConfig = {
|
||||
enabled: false,
|
||||
preset: "none",
|
||||
brightness: 0,
|
||||
contrast: 0,
|
||||
saturation: 0,
|
||||
temperature: 0,
|
||||
tint: 0,
|
||||
sharpness: 0,
|
||||
}
|
||||
|
||||
/* ──────── 绿幕抠像 ──────── */
|
||||
|
||||
/** 绿幕抠像颜色预设 */
|
||||
export type ChromaKeyColorPreset = "green" | "blue" | "red" | "pure_green" | "soft_green"
|
||||
|
||||
/** 颜色预设标签 */
|
||||
export const CHROMA_KEY_PRESET_LABELS: Record<ChromaKeyColorPreset, string> = {
|
||||
green: "绿",
|
||||
blue: "蓝",
|
||||
red: "红",
|
||||
pure_green: "精绿",
|
||||
soft_green: "柔绿",
|
||||
}
|
||||
|
||||
/** 颜色预设对应的默认色值 */
|
||||
export const CHROMA_KEY_PRESET_COLORS: Record<ChromaKeyColorPreset, string> = {
|
||||
green: "#00FF00",
|
||||
blue: "#0000FF",
|
||||
red: "#FF0000",
|
||||
pure_green: "#00C800",
|
||||
soft_green: "#40E040",
|
||||
}
|
||||
|
||||
/** 绿幕抠像配置 */
|
||||
export interface ChromaKeyConfig {
|
||||
/** 是否启用绿幕抠像 */
|
||||
enabled: boolean
|
||||
/** 颜色预设 */
|
||||
color_preset: ChromaKeyColorPreset
|
||||
/** 抠像目标颜色(HEX) */
|
||||
color: string
|
||||
/** 相似度(0 ~ 100,越大容忍的色差范围越广) */
|
||||
similarity: number
|
||||
/** 边缘平滑(0 ~ 100,越大边缘越柔和) */
|
||||
blend: number
|
||||
/** 溢色抑制(0 ~ 100,去除边缘颜色溢出) */
|
||||
spill: number
|
||||
}
|
||||
|
||||
/** 默认绿幕抠像配置 */
|
||||
export const DEFAULT_CHROMA_KEY_CONFIG: ChromaKeyConfig = {
|
||||
enabled: false,
|
||||
color_preset: "green",
|
||||
color: "#00FF00",
|
||||
similarity: 30,
|
||||
blend: 10,
|
||||
spill: 20,
|
||||
}
|
||||
|
||||
/* ──────── 贴纸配置 ──────── */
|
||||
|
||||
/** 贴纸类型 */
|
||||
export type StickerType = "emoji" | "image" | "text"
|
||||
|
||||
/** 文字花字预设 */
|
||||
export type TextStickerPreset =
|
||||
| "normal" // 普通
|
||||
| "highlight" // 高亮
|
||||
| "bubble" // 气泡
|
||||
| "neon" // 霓虹
|
||||
| "shadow" // 投影
|
||||
| "outline" // 描边
|
||||
| "gradient" // 渐变
|
||||
| "handwrite" // 手写
|
||||
|
||||
/** 贴纸项 */
|
||||
export interface StickerItem {
|
||||
id: string
|
||||
/** 贴纸类型 */
|
||||
type: StickerType
|
||||
/** 内容(emoji 字符 / 图片 URL / 文字内容) */
|
||||
content: string
|
||||
/** X 坐标(百分比 0~100) */
|
||||
x: number
|
||||
/** Y 坐标(百分比 0~100) */
|
||||
y: number
|
||||
/** 宽度(百分比 0~100) */
|
||||
width: number
|
||||
/** 高度(百分比 0~100) */
|
||||
height: number
|
||||
/** 旋转角度(度 -180~180) */
|
||||
rotation: number
|
||||
/** 不透明度(0~100) */
|
||||
opacity: number
|
||||
/** 开始时间(秒) */
|
||||
start_time: number
|
||||
/** 持续时长(秒,0 表示全程显示) */
|
||||
duration: number
|
||||
/** 图层顺序 */
|
||||
z_index: number
|
||||
/** 文字花字预设(仅 type=text 时有效) */
|
||||
text_preset: TextStickerPreset
|
||||
/** 文字颜色(仅 type=text 时有效) */
|
||||
text_color: string
|
||||
/** 文字大小(px,仅 type=text 时有效) */
|
||||
font_size: number
|
||||
}
|
||||
|
||||
/** 贴纸配置 */
|
||||
export interface StickerConfig {
|
||||
enabled: boolean
|
||||
items: StickerItem[]
|
||||
}
|
||||
|
||||
/** 默认贴纸项 */
|
||||
export const DEFAULT_STICKER_ITEM: StickerItem = {
|
||||
id: "",
|
||||
type: "emoji",
|
||||
content: "😀",
|
||||
x: 50,
|
||||
y: 50,
|
||||
width: 15,
|
||||
height: 15,
|
||||
rotation: 0,
|
||||
opacity: 100,
|
||||
start_time: 0,
|
||||
duration: 0,
|
||||
z_index: 1,
|
||||
text_preset: "normal",
|
||||
text_color: "#FFFFFF",
|
||||
font_size: 24,
|
||||
}
|
||||
|
||||
/** 默认贴纸配置 */
|
||||
export const DEFAULT_STICKER_CONFIG: StickerConfig = {
|
||||
enabled: false,
|
||||
items: [],
|
||||
}
|
||||
|
||||
/** 文字花字预设标签 */
|
||||
export const TEXT_STICKER_PRESET_LABELS: Record<TextStickerPreset, string> = {
|
||||
normal: "普通",
|
||||
highlight: "高亮",
|
||||
bubble: "气泡",
|
||||
neon: "霓虹",
|
||||
shadow: "投影",
|
||||
outline: "描边",
|
||||
gradient: "渐变",
|
||||
handwrite: "手写",
|
||||
}
|
||||
|
||||
/* ──────── 封面配置 ──────── */
|
||||
|
||||
/** 封面来源模式 */
|
||||
export type CoverMode = "auto" | "frame" | "upload"
|
||||
|
||||
/** 封面配置 */
|
||||
export interface CoverConfig {
|
||||
/** 是否启用自定义封面 */
|
||||
enabled: boolean
|
||||
/** 封面来源模式 */
|
||||
mode: CoverMode
|
||||
/** 抽帧时间点(秒,mode=frame 时使用) */
|
||||
frame_time: number
|
||||
/** 上传的封面 URL(mode=upload 时使用) */
|
||||
upload_url: string
|
||||
/** AI 智能推荐的抽帧时间(由后端分析得出) */
|
||||
ai_suggested_time: number | null
|
||||
/** 封面缩略图 URL */
|
||||
thumbnail_url: string
|
||||
}
|
||||
|
||||
/** 默认封面配置 */
|
||||
export const DEFAULT_COVER_CONFIG: CoverConfig = {
|
||||
enabled: false,
|
||||
mode: "auto",
|
||||
frame_time: 0,
|
||||
upload_url: "",
|
||||
ai_suggested_time: null,
|
||||
thumbnail_url: "",
|
||||
}
|
||||
|
||||
/* ──────── 片段数据 ──────── */
|
||||
|
||||
export interface ClipData {
|
||||
id: string
|
||||
type: ClipType // 片段类型:voice(口播)或 pip(混剪)
|
||||
duration: number // 时长(秒)
|
||||
startOffset: number // 仅 voice 类型:在口播素材中的起始时间(秒)
|
||||
/** 素材库素材 ID(main/pip 类型片段使用) */
|
||||
media_asset_id?: string
|
||||
// 保留兼容字段(后端序列化需要)
|
||||
template_segment_id?: string
|
||||
script_text?: string
|
||||
order?: number
|
||||
/** 配音素材 ID(voice 类型片段使用) */
|
||||
voice_asset_id?: string
|
||||
/** 配音素材文件 URL(voice 类型片段使用) */
|
||||
voice_file_url?: string
|
||||
/** 与前一片段之间的转场效果 */
|
||||
transition?: TransitionConfig
|
||||
/** 播放速度配置 */
|
||||
speed?: SpeedConfig
|
||||
/** TTS 配音配置 */
|
||||
tts_config?: TtsConfig
|
||||
/** 裁剪配置 — 定义素材入点/出点 */
|
||||
trim_config?: TrimConfig
|
||||
}
|
||||
|
||||
/* ──────── 标题设置 ──────── */
|
||||
|
||||
/**
|
||||
* 标题设置 — 对齐后端 title_config 字段
|
||||
* 前端 UI 使用 camelCase,发送到后端时映射为 snake_case
|
||||
*/
|
||||
export interface TitleSettings {
|
||||
aiAutoSelect: boolean
|
||||
title: string
|
||||
position: string
|
||||
font: string
|
||||
size: number
|
||||
bold: boolean
|
||||
italic: boolean
|
||||
stroke: boolean
|
||||
shadow: boolean
|
||||
color: string
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* 绿幕抠像类型
|
||||
*/
|
||||
|
||||
/** 绿幕抠像颜色预设 */
|
||||
export type ChromaKeyColorPreset = "green" | "blue" | "red" | "pure_green" | "soft_green"
|
||||
|
||||
/** 颜色预设标签 */
|
||||
export const CHROMA_KEY_PRESET_LABELS: Record<ChromaKeyColorPreset, string> = {
|
||||
green: "绿",
|
||||
blue: "蓝",
|
||||
red: "红",
|
||||
pure_green: "精绿",
|
||||
soft_green: "柔绿",
|
||||
}
|
||||
|
||||
/** 颜色预设对应的默认色值 */
|
||||
export const CHROMA_KEY_PRESET_COLORS: Record<ChromaKeyColorPreset, string> = {
|
||||
green: "#00FF00",
|
||||
blue: "#0000FF",
|
||||
red: "#FF0000",
|
||||
pure_green: "#00C800",
|
||||
soft_green: "#40E040",
|
||||
}
|
||||
|
||||
/** 绿幕抠像配置 */
|
||||
export interface ChromaKeyConfig {
|
||||
/** 是否启用绿幕抠像 */
|
||||
enabled: boolean
|
||||
/** 颜色预设 */
|
||||
color_preset: ChromaKeyColorPreset
|
||||
/** 抠像目标颜色(HEX) */
|
||||
color: string
|
||||
/** 相似度(0 ~ 100,越大容忍的色差范围越广) */
|
||||
similarity: number
|
||||
/** 边缘平滑(0 ~ 100,越大边缘越柔和) */
|
||||
blend: number
|
||||
/** 溢色抑制(0 ~ 100,去除边缘颜色溢出) */
|
||||
spill: number
|
||||
}
|
||||
|
||||
/** 默认绿幕抠像配置 */
|
||||
export const DEFAULT_CHROMA_KEY_CONFIG: ChromaKeyConfig = {
|
||||
enabled: false,
|
||||
color_preset: "green",
|
||||
color: "#00FF00",
|
||||
similarity: 30,
|
||||
blend: 10,
|
||||
spill: 20,
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* 片段数据类型
|
||||
*/
|
||||
import type { TransitionConfig } from "./transition"
|
||||
import type { SpeedConfig } from "./speed"
|
||||
import type { TtsConfig } from "./tts"
|
||||
import type { TrimConfig } from "./trim"
|
||||
|
||||
/** 片段类型 */
|
||||
export type ClipType = "voice" | "pip"
|
||||
|
||||
/** 片段数据 — 时间规划 + 类型标记,不绑定任何素材 */
|
||||
export interface ClipData {
|
||||
id: string
|
||||
type: ClipType // 片段类型:voice(口播)或 pip(混剪)
|
||||
duration: number // 时长(秒)
|
||||
startOffset: number // 仅 voice 类型:在口播素材中的起始时间(秒)
|
||||
/** 素材库素材 ID(main/pip 类型片段使用) */
|
||||
media_asset_id?: string
|
||||
// 保留兼容字段(后端序列化需要)
|
||||
template_segment_id?: string
|
||||
script_text?: string
|
||||
order?: number
|
||||
/** 配音素材 ID(voice 类型片段使用) */
|
||||
voice_asset_id?: string
|
||||
/** 配音素材文件 URL(voice 类型片段使用) */
|
||||
voice_file_url?: string
|
||||
/** 与前一片段之间的转场效果 */
|
||||
transition?: TransitionConfig
|
||||
/** 播放速度配置 */
|
||||
speed?: SpeedConfig
|
||||
/** TTS 配音配置 */
|
||||
tts_config?: TtsConfig
|
||||
/** 裁剪配置 — 定义素材入点/出点 */
|
||||
trim_config?: TrimConfig
|
||||
}
|
||||
Executable → Regular
+1
-1
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* ClipPropertiesPanel 相关类型定义
|
||||
*/
|
||||
import type { ClipData } from "@/pages/editing-planner/types"
|
||||
import type { ClipData } from "./clip"
|
||||
import type { TemplateMode } from "@/api/editing-planner"
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* 封面配置类型
|
||||
*/
|
||||
|
||||
/** 封面来源模式 */
|
||||
export type CoverMode = "auto" | "frame" | "upload"
|
||||
|
||||
/** 封面配置 */
|
||||
export interface CoverConfig {
|
||||
/** 是否启用自定义封面 */
|
||||
enabled: boolean
|
||||
/** 封面来源模式 */
|
||||
mode: CoverMode
|
||||
/** 抽帧时间点(秒,mode=frame 时使用) */
|
||||
frame_time: number
|
||||
/** 上传的封面 URL(mode=upload 时使用) */
|
||||
upload_url: string
|
||||
/** AI 智能推荐的抽帧时间(由后端分析得出) */
|
||||
ai_suggested_time: number | null
|
||||
/** 封面缩略图 URL */
|
||||
thumbnail_url: string
|
||||
}
|
||||
|
||||
/** 默认封面配置 */
|
||||
export const DEFAULT_COVER_CONFIG: CoverConfig = {
|
||||
enabled: false,
|
||||
mode: "auto",
|
||||
frame_time: 0,
|
||||
upload_url: "",
|
||||
ai_suggested_time: null,
|
||||
thumbnail_url: "",
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* 滤镜调色类型
|
||||
*/
|
||||
|
||||
/** 预设滤镜 */
|
||||
export type FilterPreset =
|
||||
| "none"
|
||||
| "original"
|
||||
| "fresh"
|
||||
| "warm"
|
||||
| "cool"
|
||||
| "vintage"
|
||||
| "cinema"
|
||||
| "bw"
|
||||
| "sunshine"
|
||||
| "film"
|
||||
|
||||
/** 预设滤镜标签 */
|
||||
export const FILTER_PRESET_LABELS: Record<FilterPreset, string> = {
|
||||
none: "无",
|
||||
original: "原片",
|
||||
fresh: "清新",
|
||||
warm: "暖调",
|
||||
cool: "冷色",
|
||||
vintage: "复古",
|
||||
cinema: "电影",
|
||||
bw: "黑白",
|
||||
sunshine: "暖阳",
|
||||
film: "胶片",
|
||||
}
|
||||
|
||||
/** 滤镜调色配置 */
|
||||
export interface FilterConfig {
|
||||
/** 是否启用滤镜 */
|
||||
enabled: boolean
|
||||
/** 预设滤镜 */
|
||||
preset: FilterPreset
|
||||
/** 亮度(-100 ~ 100) */
|
||||
brightness: number
|
||||
/** 对比度(-100 ~ 100) */
|
||||
contrast: number
|
||||
/** 饱和度(-100 ~ 100) */
|
||||
saturation: number
|
||||
/** 色温(-100 ~ 100,负值偏蓝,正值偏黄) */
|
||||
temperature: number
|
||||
/** 色调(-100 ~ 100,负值偏绿,正值偏品红) */
|
||||
tint: number
|
||||
/** 锐度(0 ~ 100) */
|
||||
sharpness: number
|
||||
}
|
||||
|
||||
/** 默认滤镜调色配置 */
|
||||
export const DEFAULT_FILTER_CONFIG: FilterConfig = {
|
||||
enabled: false,
|
||||
preset: "none",
|
||||
brightness: 0,
|
||||
contrast: 0,
|
||||
saturation: 0,
|
||||
temperature: 0,
|
||||
tint: 0,
|
||||
sharpness: 0,
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* EditingPlanner 类型定义入口
|
||||
* 按功能模块拆分,统一从这里导出
|
||||
*/
|
||||
|
||||
/* 转场 */
|
||||
export { type TransitionType, type TransitionConfig, DEFAULT_TRANSITION } from "./transition"
|
||||
|
||||
/* 调速 */
|
||||
export { type SpeedConfig, DEFAULT_SPEED } from "./speed"
|
||||
|
||||
/* TTS 配音 */
|
||||
export { type TtsMode, type TtsConfig, DEFAULT_TTS_CONFIG } from "./tts"
|
||||
|
||||
/* 裁剪 */
|
||||
export { type TrimConfig } from "./trim"
|
||||
|
||||
/* 水印 */
|
||||
export {
|
||||
type WatermarkType,
|
||||
type WatermarkPosition,
|
||||
type ScrollDirection,
|
||||
type WatermarkConfig,
|
||||
DEFAULT_WATERMARK,
|
||||
} from "./watermark"
|
||||
|
||||
/* 片头片尾 */
|
||||
export {
|
||||
type IntroOutroKind,
|
||||
type IntroOutroItem,
|
||||
type IntroOutroConfig,
|
||||
DEFAULT_INTRO_OUTRO,
|
||||
} from "./intro-outro"
|
||||
|
||||
/* 混剪 PiP */
|
||||
export {
|
||||
type PipGridPosition,
|
||||
type PipAnimType,
|
||||
type PipSlideDirection,
|
||||
type PipLayer,
|
||||
type PipConfig,
|
||||
DEFAULT_PIP_LAYER,
|
||||
DEFAULT_PIP_CONFIG,
|
||||
} from "./pip"
|
||||
|
||||
/* 滤镜调色 */
|
||||
export {
|
||||
type FilterPreset,
|
||||
FILTER_PRESET_LABELS,
|
||||
type FilterConfig,
|
||||
DEFAULT_FILTER_CONFIG,
|
||||
} from "./filter"
|
||||
|
||||
/* 绿幕抠像 */
|
||||
export {
|
||||
type ChromaKeyColorPreset,
|
||||
CHROMA_KEY_PRESET_LABELS,
|
||||
CHROMA_KEY_PRESET_COLORS,
|
||||
type ChromaKeyConfig,
|
||||
DEFAULT_CHROMA_KEY_CONFIG,
|
||||
} from "./chroma-key"
|
||||
|
||||
/* 贴纸 */
|
||||
export {
|
||||
type StickerType,
|
||||
type TextStickerPreset,
|
||||
type StickerItem,
|
||||
type StickerConfig,
|
||||
DEFAULT_STICKER_ITEM,
|
||||
DEFAULT_STICKER_CONFIG,
|
||||
TEXT_STICKER_PRESET_LABELS,
|
||||
} from "./sticker"
|
||||
|
||||
/* 封面 */
|
||||
export { type CoverMode, type CoverConfig, DEFAULT_COVER_CONFIG } from "./cover"
|
||||
|
||||
/* 片段数据 */
|
||||
export { type ClipType, type ClipData } from "./clip"
|
||||
|
||||
/* 标题设置 */
|
||||
export { type TitleSettings } from "./title"
|
||||
|
||||
/* 字幕样式 */
|
||||
export { type SubtitleStyleConfig, DEFAULT_SUBTITLE_STYLE } from "./subtitle"
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* 片头片尾配置类型
|
||||
*/
|
||||
import type { TransitionType } from "./transition"
|
||||
|
||||
/** 片头片尾素材类型 */
|
||||
export type IntroOutroKind = "none" | "video" | "image"
|
||||
|
||||
/** 片头/片尾单项配置 */
|
||||
export interface IntroOutroItem {
|
||||
/** 素材类型 */
|
||||
kind: IntroOutroKind
|
||||
/** 素材 URL */
|
||||
url?: string
|
||||
/** 显示时长(秒) */
|
||||
duration: number
|
||||
/** 过渡动画 */
|
||||
transition?: TransitionType
|
||||
/** 过渡时长(秒) */
|
||||
transition_duration?: number
|
||||
}
|
||||
|
||||
/** 片头片尾完整配置 */
|
||||
export interface IntroOutroConfig {
|
||||
intro: IntroOutroItem
|
||||
outro: IntroOutroItem
|
||||
}
|
||||
|
||||
/** 默认片头片尾配置 */
|
||||
export const DEFAULT_INTRO_OUTRO: IntroOutroConfig = {
|
||||
intro: { kind: "none", duration: 3 },
|
||||
outro: { kind: "none", duration: 3 },
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* 混剪(PiP)配置类型
|
||||
*/
|
||||
|
||||
/** 九宫格位置 */
|
||||
export type PipGridPosition =
|
||||
| "top_left"
|
||||
| "top_center"
|
||||
| "top_right"
|
||||
| "center_left"
|
||||
| "center"
|
||||
| "center_right"
|
||||
| "bottom_left"
|
||||
| "bottom_center"
|
||||
| "bottom_right"
|
||||
|
||||
/** 入场动画类型 */
|
||||
export type PipAnimType = "none" | "fade_in" | "slide_in"
|
||||
|
||||
/** 入场方向 */
|
||||
export type PipSlideDirection = "left" | "right" | "up" | "down"
|
||||
|
||||
/** 混剪图层 */
|
||||
export interface PipLayer {
|
||||
id: string
|
||||
/** 图层名称(用户可编辑) */
|
||||
name: string
|
||||
/** 素材类型 */
|
||||
material_type: "image" | "video"
|
||||
/** 素材 URL */
|
||||
material_url: string
|
||||
/** 素材缩略图 */
|
||||
thumbnail_url?: string
|
||||
/** 九宫格快捷位置 */
|
||||
grid_position: PipGridPosition
|
||||
/** 精确 X 坐标(百分比 0~100) */
|
||||
x: number
|
||||
/** 精确 Y 坐标(百分比 0~100) */
|
||||
y: number
|
||||
/** 宽度(百分比 0~100,相对主画面) */
|
||||
width: number
|
||||
/** 高度(百分比 0~100,相对主画面) */
|
||||
height: number
|
||||
/** 锁定宽高比 */
|
||||
aspect_lock: boolean
|
||||
/** 圆角(百分比 0~50) */
|
||||
border_radius: number
|
||||
/** 不透明度(0~100) */
|
||||
opacity: number
|
||||
/** 开始时间(秒) */
|
||||
start_time: number
|
||||
/** 持续时长(秒) */
|
||||
duration: number
|
||||
/** 入场动画 */
|
||||
animation: PipAnimType
|
||||
/** 入场方向 */
|
||||
slide_direction: PipSlideDirection
|
||||
/** 图层顺序(z-index) */
|
||||
z_index: number
|
||||
}
|
||||
|
||||
/** 混剪配置 */
|
||||
export interface PipConfig {
|
||||
/** 是否启用混剪 */
|
||||
enabled: boolean
|
||||
/** 图层列表 */
|
||||
layers: PipLayer[]
|
||||
}
|
||||
|
||||
/** 默认 PiP 图层 */
|
||||
export const DEFAULT_PIP_LAYER: PipLayer = {
|
||||
id: "",
|
||||
name: "图层",
|
||||
material_type: "image",
|
||||
material_url: "",
|
||||
grid_position: "top_right",
|
||||
x: 70,
|
||||
y: 5,
|
||||
width: 25,
|
||||
height: 25,
|
||||
aspect_lock: true,
|
||||
border_radius: 0,
|
||||
opacity: 100,
|
||||
start_time: 0,
|
||||
duration: 5,
|
||||
animation: "none",
|
||||
slide_direction: "right",
|
||||
z_index: 1,
|
||||
}
|
||||
|
||||
/** 默认 PiP 配置 */
|
||||
export const DEFAULT_PIP_CONFIG: PipConfig = {
|
||||
enabled: false,
|
||||
layers: [],
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* 片段调速类型
|
||||
*/
|
||||
|
||||
/** 片段调速配置 */
|
||||
export interface SpeedConfig {
|
||||
/** 播放速度,0.25 ~ 4.0 */
|
||||
rate: number
|
||||
/** 音调修正(变速不变调) */
|
||||
pitchCorrection: boolean
|
||||
}
|
||||
|
||||
/** 默认调速配置 */
|
||||
export const DEFAULT_SPEED: SpeedConfig = {
|
||||
rate: 1.0,
|
||||
pitchCorrection: true,
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* 贴纸配置类型
|
||||
*/
|
||||
|
||||
/** 贴纸类型 */
|
||||
export type StickerType = "emoji" | "image" | "text"
|
||||
|
||||
/** 文字花字预设 */
|
||||
export type TextStickerPreset =
|
||||
| "normal" // 普通
|
||||
| "highlight" // 高亮
|
||||
| "bubble" // 气泡
|
||||
| "neon" // 霓虹
|
||||
| "shadow" // 投影
|
||||
| "outline" // 描边
|
||||
| "gradient" // 渐变
|
||||
| "handwrite" // 手写
|
||||
|
||||
/** 贴纸项 */
|
||||
export interface StickerItem {
|
||||
id: string
|
||||
/** 贴纸类型 */
|
||||
type: StickerType
|
||||
/** 内容(emoji 字符 / 图片 URL / 文字内容) */
|
||||
content: string
|
||||
/** X 坐标(百分比 0~100) */
|
||||
x: number
|
||||
/** Y 坐标(百分比 0~100) */
|
||||
y: number
|
||||
/** 宽度(百分比 0~100) */
|
||||
width: number
|
||||
/** 高度(百分比 0~100) */
|
||||
height: number
|
||||
/** 旋转角度(度 -180~180) */
|
||||
rotation: number
|
||||
/** 不透明度(0~100) */
|
||||
opacity: number
|
||||
/** 开始时间(秒) */
|
||||
start_time: number
|
||||
/** 持续时长(秒,0 表示全程显示) */
|
||||
duration: number
|
||||
/** 图层顺序 */
|
||||
z_index: number
|
||||
/** 文字花字预设(仅 type=text 时有效) */
|
||||
text_preset: TextStickerPreset
|
||||
/** 文字颜色(仅 type=text 时有效) */
|
||||
text_color: string
|
||||
/** 文字大小(px,仅 type=text 时有效) */
|
||||
font_size: number
|
||||
}
|
||||
|
||||
/** 贴纸配置 */
|
||||
export interface StickerConfig {
|
||||
enabled: boolean
|
||||
items: StickerItem[]
|
||||
}
|
||||
|
||||
/** 默认贴纸项 */
|
||||
export const DEFAULT_STICKER_ITEM: StickerItem = {
|
||||
id: "",
|
||||
type: "emoji",
|
||||
content: "😀",
|
||||
x: 50,
|
||||
y: 50,
|
||||
width: 15,
|
||||
height: 15,
|
||||
rotation: 0,
|
||||
opacity: 100,
|
||||
start_time: 0,
|
||||
duration: 0,
|
||||
z_index: 1,
|
||||
text_preset: "normal",
|
||||
text_color: "#FFFFFF",
|
||||
font_size: 24,
|
||||
}
|
||||
|
||||
/** 默认贴纸配置 */
|
||||
export const DEFAULT_STICKER_CONFIG: StickerConfig = {
|
||||
enabled: false,
|
||||
items: [],
|
||||
}
|
||||
|
||||
/** 文字花字预设标签 */
|
||||
export const TEXT_STICKER_PRESET_LABELS: Record<TextStickerPreset, string> = {
|
||||
normal: "普通",
|
||||
highlight: "高亮",
|
||||
bubble: "气泡",
|
||||
neon: "霓虹",
|
||||
shadow: "投影",
|
||||
outline: "描边",
|
||||
gradient: "渐变",
|
||||
handwrite: "手写",
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* 标题设置类型
|
||||
*/
|
||||
|
||||
/**
|
||||
* 标题设置 — 对齐后端 title_config 字段
|
||||
* 前端 UI 使用 camelCase,发送到后端时映射为 snake_case
|
||||
*/
|
||||
export interface TitleSettings {
|
||||
aiAutoSelect: boolean
|
||||
title: string
|
||||
position: string
|
||||
font: string
|
||||
size: number
|
||||
bold: boolean
|
||||
italic: boolean
|
||||
stroke: boolean
|
||||
shadow: boolean
|
||||
color: string
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* 转场特效类型
|
||||
*/
|
||||
|
||||
/** 14 种转场类型 */
|
||||
export type TransitionType =
|
||||
| "none"
|
||||
| "cut"
|
||||
| "fade"
|
||||
| "dissolve"
|
||||
| "zoom"
|
||||
| "slide_left"
|
||||
| "slide_right"
|
||||
| "slide_up"
|
||||
| "slide_down"
|
||||
| "wipe_left"
|
||||
| "wipe_right"
|
||||
| "wipe_up"
|
||||
| "wipe_down"
|
||||
| "circlecrop"
|
||||
| "rectcrop"
|
||||
|
||||
/** 片段间转场配置 */
|
||||
export interface TransitionConfig {
|
||||
/** 转场类型 */
|
||||
type: TransitionType
|
||||
/** 转场时长(秒),0.3 ~ 2.0 */
|
||||
duration: number
|
||||
}
|
||||
|
||||
/** 默认转场配置 */
|
||||
export const DEFAULT_TRANSITION: TransitionConfig = {
|
||||
type: "none",
|
||||
duration: 0.5,
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* 片段裁剪类型
|
||||
*/
|
||||
|
||||
/** 片段裁剪配置 — 定义素材的入点/出点 */
|
||||
export interface TrimConfig {
|
||||
/** 入点(秒),素材原始时间轴上的起始位置 */
|
||||
start_time: number
|
||||
/** 出点(秒),素材原始时间轴上的结束位置 */
|
||||
end_time: number
|
||||
/** 素材原始总时长(秒),用于"恢复原始长度" */
|
||||
original_duration?: number
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* TTS 配音类型
|
||||
*/
|
||||
|
||||
/** 配音模式 */
|
||||
export type TtsMode = "none" | "upload" | "tts"
|
||||
|
||||
/** TTS 配音配置 */
|
||||
export interface TtsConfig {
|
||||
/** 配音模式 */
|
||||
mode: TtsMode
|
||||
/** TTS 合成文本 */
|
||||
text: string
|
||||
/** 音色 ID */
|
||||
voice_id: string
|
||||
/** 语速 0.5 ~ 2.0 */
|
||||
speed: number
|
||||
/** 语调(半音)-12 ~ +12 */
|
||||
pitch: number
|
||||
/** 音量 0 ~ 100 */
|
||||
volume: number
|
||||
/** 字幕联动 */
|
||||
subtitle_sync: boolean
|
||||
}
|
||||
|
||||
/** 默认 TTS 配置 */
|
||||
export const DEFAULT_TTS_CONFIG: TtsConfig = {
|
||||
mode: "none",
|
||||
text: "",
|
||||
voice_id: "",
|
||||
speed: 1.0,
|
||||
pitch: 0,
|
||||
volume: 100,
|
||||
subtitle_sync: true,
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* 水印配置类型
|
||||
*/
|
||||
|
||||
/** 水印类型 */
|
||||
export type WatermarkType = "none" | "image" | "text" | "scroll"
|
||||
|
||||
/** 水印位置 */
|
||||
export type WatermarkPosition = "top_left" | "top_right" | "bottom_left" | "bottom_right" | "center"
|
||||
|
||||
/** 滚动水印方向 */
|
||||
export type ScrollDirection = "horizontal" | "vertical" | "diagonal"
|
||||
|
||||
/** 水印配置 */
|
||||
export interface WatermarkConfig {
|
||||
/** 水印类型 */
|
||||
type: WatermarkType
|
||||
/** 图片水印 URL */
|
||||
image_url?: string
|
||||
/** 水印宽度(像素或百分比 0~1) */
|
||||
width?: number
|
||||
/** 水印高度(像素或百分比 0~1) */
|
||||
height?: number
|
||||
/** 水印位置 */
|
||||
position: WatermarkPosition
|
||||
/** 水印不透明度 0~1 */
|
||||
opacity: number
|
||||
/** 文字水印内容 */
|
||||
text?: string
|
||||
/** 文字水印字号 */
|
||||
font_size?: number
|
||||
/** 文字水印颜色 */
|
||||
color?: string
|
||||
/** 滚动水印方向 */
|
||||
scroll_direction?: ScrollDirection
|
||||
/** 滚动水印速度(像素/秒) */
|
||||
scroll_speed?: number
|
||||
}
|
||||
|
||||
/** 默认水印配置 */
|
||||
export const DEFAULT_WATERMARK: WatermarkConfig = {
|
||||
type: "none",
|
||||
position: "bottom_right",
|
||||
opacity: 0.7,
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { useState, useCallback } from "react"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import { message } from "antd"
|
||||
import { useQuery, useMutation } from "@tanstack/react-query"
|
||||
import { saveTtsToLibrary } from "@/api/tts"
|
||||
import { getTags, createTag } from "@/api/tags"
|
||||
|
||||
/**
|
||||
* 存为素材(配音库)弹窗逻辑
|
||||
*/
|
||||
export function useSaveToLibrary(completedTtsJobId: string | null, resetTtsState: () => void) {
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [saveModalOpen, setSaveModalOpen] = useState(false)
|
||||
const [saveName, setSaveName] = useState("")
|
||||
const [saveTagIds, setSaveTagIds] = useState<string[]>([])
|
||||
const [saveNewTag, setSaveNewTag] = useState("")
|
||||
|
||||
const { data: allTags = [] } = useQuery({
|
||||
queryKey: ["generate-save-tags"],
|
||||
queryFn: getTags,
|
||||
staleTime: 30_000,
|
||||
})
|
||||
|
||||
const handleGoToLibrary = useCallback(() => {
|
||||
navigate("/app/voice-materials")
|
||||
}, [navigate])
|
||||
|
||||
const saveToLibraryMutation = useMutation({
|
||||
mutationFn: (params: { name?: string; tag_ids?: string[] }) =>
|
||||
saveTtsToLibrary(completedTtsJobId!, params),
|
||||
onSuccess: () => {
|
||||
message.success({
|
||||
content: (
|
||||
<span>
|
||||
已保存到配音库!{" "}
|
||||
<a
|
||||
onClick={handleGoToLibrary}
|
||||
style={{
|
||||
color: "var(--primary-500, #6366f1)",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
去视频库查看
|
||||
</a>
|
||||
</span>
|
||||
),
|
||||
duration: 5,
|
||||
})
|
||||
setSaveModalOpen(false)
|
||||
setSaveName("")
|
||||
setSaveTagIds([])
|
||||
setSaveNewTag("")
|
||||
resetTtsState()
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
message.error(`保存失败:${err.message || "请重试"}`)
|
||||
},
|
||||
})
|
||||
|
||||
const handleOpenSaveModal = useCallback(() => {
|
||||
setSaveName("")
|
||||
setSaveTagIds([])
|
||||
setSaveNewTag("")
|
||||
setSaveModalOpen(true)
|
||||
}, [])
|
||||
|
||||
const handleConfirmSave = useCallback(() => {
|
||||
if (!completedTtsJobId) return
|
||||
saveToLibraryMutation.mutate({
|
||||
name: saveName.trim() || undefined,
|
||||
tag_ids: saveTagIds.length > 0 ? saveTagIds : undefined,
|
||||
})
|
||||
}, [completedTtsJobId, saveName, saveTagIds, saveToLibraryMutation])
|
||||
|
||||
const handleAddTagInModal = useCallback(
|
||||
async (tagName: string) => {
|
||||
const trimmed = tagName.trim()
|
||||
if (!trimmed) return
|
||||
const existing = allTags.find((t) => t.name === trimmed)
|
||||
if (existing) {
|
||||
if (!saveTagIds.includes(existing.id)) {
|
||||
setSaveTagIds((prev) => [...prev, existing.id])
|
||||
}
|
||||
return
|
||||
}
|
||||
try {
|
||||
const created = await createTag(trimmed)
|
||||
setSaveTagIds((prev) => [...prev, created.id])
|
||||
setSaveNewTag("")
|
||||
} catch {
|
||||
message.error(`创建标签"${trimmed}"失败`)
|
||||
}
|
||||
},
|
||||
[allTags, saveTagIds],
|
||||
)
|
||||
|
||||
return {
|
||||
saveModalOpen,
|
||||
setSaveModalOpen,
|
||||
saveName,
|
||||
setSaveName,
|
||||
saveTagIds,
|
||||
setSaveTagIds,
|
||||
saveNewTag,
|
||||
setSaveNewTag,
|
||||
allTags,
|
||||
saveToLibraryMutation,
|
||||
handleOpenSaveModal,
|
||||
handleConfirmSave,
|
||||
handleAddTagInModal,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { useState, useCallback, useEffect } from "react"
|
||||
import { message } from "antd"
|
||||
import { useMutation } from "@tanstack/react-query"
|
||||
import { synthesizeSpeech, getTTSJobStatus } from "@/api/tts"
|
||||
|
||||
/**
|
||||
* TTS 自定义合成 + 轮询状态
|
||||
*/
|
||||
export function useTtsSynthesis(selectedVoice: string) {
|
||||
const [customVoiceText, setCustomVoiceText] = useState("")
|
||||
const [customAudioUrl, setCustomAudioUrl] = useState<string | null>(null)
|
||||
const [ttsError, setTtsError] = useState<string | null>(null)
|
||||
const [ttsJobId, setTtsJobId] = useState<string | null>(null)
|
||||
const [completedTtsJobId, setCompletedTtsJobId] = useState<string | null>(null)
|
||||
|
||||
const synthesizeMutation = useMutation({
|
||||
mutationFn: synthesizeSpeech,
|
||||
onSuccess: (data) => {
|
||||
setTtsJobId(data.job_id)
|
||||
message.info("语音合成已提交,等待处理…")
|
||||
},
|
||||
onError: () => {
|
||||
setTtsError("语音合成请求失败,请重试")
|
||||
},
|
||||
})
|
||||
|
||||
/* 轮询 TTS 任务状态 */
|
||||
useEffect(() => {
|
||||
if (!ttsJobId) return
|
||||
let cancelled = false
|
||||
let timer: ReturnType<typeof setTimeout>
|
||||
|
||||
const poll = async () => {
|
||||
try {
|
||||
const status = await getTTSJobStatus(ttsJobId)
|
||||
if (cancelled) return
|
||||
if (status.status === "completed") {
|
||||
setCustomAudioUrl(status.output_audio_url)
|
||||
setCompletedTtsJobId(ttsJobId)
|
||||
setTtsJobId(null)
|
||||
setTtsError(null)
|
||||
message.success("语音合成完成!")
|
||||
return
|
||||
}
|
||||
if (status.status === "failed" || status.status === "cancelled") {
|
||||
setTtsError(status.error_message || "语音合成失败")
|
||||
setTtsJobId(null)
|
||||
return
|
||||
}
|
||||
timer = setTimeout(poll, 2000)
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setTtsError("查询合成状态失败")
|
||||
setTtsJobId(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
timer = setTimeout(poll, 2000)
|
||||
return () => {
|
||||
cancelled = true
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}, [ttsJobId])
|
||||
|
||||
const handleSynthesizeVoice = useCallback(() => {
|
||||
if (!customVoiceText.trim()) {
|
||||
message.warning("请先输入配音文案")
|
||||
return
|
||||
}
|
||||
setTtsError(null)
|
||||
setCustomAudioUrl(null)
|
||||
synthesizeMutation.mutate({
|
||||
text: customVoiceText.trim(),
|
||||
voice_id: selectedVoice || undefined,
|
||||
language: "zh-CN",
|
||||
})
|
||||
}, [customVoiceText, selectedVoice, synthesizeMutation])
|
||||
|
||||
const resetTtsState = useCallback(() => {
|
||||
setCompletedTtsJobId(null)
|
||||
setCustomAudioUrl(null)
|
||||
}, [])
|
||||
|
||||
return {
|
||||
customVoiceText,
|
||||
setCustomVoiceText,
|
||||
customAudioUrl,
|
||||
ttsError,
|
||||
ttsJobId,
|
||||
completedTtsJobId,
|
||||
synthesizeMutation,
|
||||
handleSynthesizeVoice,
|
||||
resetTtsState,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useState, useRef, useCallback } from "react"
|
||||
import { message } from "antd"
|
||||
|
||||
/**
|
||||
* 音色试听播放控制
|
||||
*/
|
||||
export function useVoiceAudio() {
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||
const [playingVoice, setPlayingVoice] = useState<string | null>(null)
|
||||
|
||||
const toggleVoicePlay = useCallback(
|
||||
(voiceId: string, previewUrl: string | null) => {
|
||||
if (playingVoice === voiceId) {
|
||||
audioRef.current?.pause()
|
||||
audioRef.current = null
|
||||
setPlayingVoice(null)
|
||||
return
|
||||
}
|
||||
audioRef.current?.pause()
|
||||
if (!previewUrl) {
|
||||
message.warning("该音色暂无试听音频")
|
||||
return
|
||||
}
|
||||
const audio = new Audio(previewUrl)
|
||||
audioRef.current = audio
|
||||
audio.play().catch(() => {
|
||||
message.error("播放失败,请检查网络")
|
||||
})
|
||||
audio.onended = () => {
|
||||
setPlayingVoice(null)
|
||||
audioRef.current = null
|
||||
}
|
||||
setPlayingVoice(voiceId)
|
||||
},
|
||||
[playingVoice],
|
||||
)
|
||||
|
||||
return { playingVoice, toggleVoicePlay }
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useState, useCallback, useMemo } from "react"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { fetchPresetVoices } from "@/api/voices"
|
||||
import type { PresetVoiceItem } from "@/api/voices"
|
||||
|
||||
/**
|
||||
* 智能配音推荐
|
||||
* 根据标题内容风格模拟推荐音色
|
||||
*/
|
||||
export function useVoiceRecommend(titleText: string) {
|
||||
const { data: presetVoicesData, isLoading: presetVoicesLoading } = useQuery({
|
||||
queryKey: ["preset-voices"],
|
||||
queryFn: fetchPresetVoices,
|
||||
})
|
||||
|
||||
const presetVoices: PresetVoiceItem[] = useMemo(
|
||||
() => presetVoicesData?.items ?? [],
|
||||
[presetVoicesData],
|
||||
)
|
||||
|
||||
const [voiceRecommendLoading, setVoiceRecommendLoading] = useState(false)
|
||||
const [voiceRecommendations, setVoiceRecommendations] = useState<string[]>([])
|
||||
const [hasVoiceRecommend, setHasVoiceRecommend] = useState(false)
|
||||
|
||||
const handleVoiceRecommend = useCallback(async () => {
|
||||
if (presetVoices.length === 0) return
|
||||
setVoiceRecommendLoading(true)
|
||||
setHasVoiceRecommend(true)
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
|
||||
const title = titleText.toLowerCase()
|
||||
let recommended: string[] = []
|
||||
|
||||
const femaleVoices = presetVoices.filter((v) => v.gender === "female").map((v) => v.voice_id)
|
||||
const maleVoices = presetVoices.filter((v) => v.gender === "male").map((v) => v.voice_id)
|
||||
const childVoices = presetVoices.filter((v) => v.gender === "child").map((v) => v.voice_id)
|
||||
|
||||
if (/情感|感人|温暖|治愈|故事|回忆/.test(title)) {
|
||||
recommended = femaleVoices.slice(0, 3)
|
||||
} else if (/教程|知识|科普|干货|讲解|分析/.test(title)) {
|
||||
recommended = maleVoices.slice(0, 2).concat(femaleVoices.slice(0, 1))
|
||||
} else if (/活力|热血|运动|搞笑|有趣/.test(title)) {
|
||||
recommended = childVoices.slice(0, 1).concat(maleVoices.slice(0, 1), femaleVoices.slice(0, 1))
|
||||
} else {
|
||||
recommended = presetVoices.slice(0, 3).map((v) => v.voice_id)
|
||||
}
|
||||
|
||||
if (recommended.length < 3) {
|
||||
const others = presetVoices
|
||||
.filter((v) => !recommended.includes(v.voice_id))
|
||||
.map((v) => v.voice_id)
|
||||
recommended = recommended.concat(others.slice(0, 3 - recommended.length))
|
||||
}
|
||||
|
||||
setVoiceRecommendations(recommended)
|
||||
setVoiceRecommendLoading(false)
|
||||
}, [presetVoices, titleText])
|
||||
|
||||
return {
|
||||
presetVoices,
|
||||
presetVoicesLoading,
|
||||
voiceRecommendLoading,
|
||||
voiceRecommendations,
|
||||
hasVoiceRecommend,
|
||||
handleVoiceRecommend,
|
||||
}
|
||||
}
|
||||
@@ -1,251 +0,0 @@
|
||||
/**
|
||||
* Step 4 标题设置 Hook
|
||||
* 封装 AI 标题生成、标题样式设置等逻辑
|
||||
*/
|
||||
import { useState, useCallback, useMemo } from "react"
|
||||
import { message } from "antd"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { getTitles } from "@/api/titles"
|
||||
import { TITLE_PRESETS, AI_TITLE_TEMPLATES } from "../constants"
|
||||
import type { TitleSettings } from "../types"
|
||||
|
||||
interface UseStep4TitleProps {
|
||||
titleSettings: TitleSettings
|
||||
onTitleSettingsChange: (settings: TitleSettings) => void
|
||||
}
|
||||
|
||||
interface AiTitleItem {
|
||||
title: string
|
||||
highlight: string
|
||||
style: "catchy" | "emotional" | "informative"
|
||||
}
|
||||
|
||||
export function useStep4Title({ titleSettings, onTitleSettingsChange }: UseStep4TitleProps) {
|
||||
/* ── 标题库 API ── */
|
||||
const { data: userTitles = [] } = useQuery({
|
||||
queryKey: ["titles"],
|
||||
queryFn: () => getTitles(),
|
||||
staleTime: 30_000,
|
||||
})
|
||||
|
||||
/* ── AI 标题生成状态 ── */
|
||||
const [aiTitleInput, setAiTitleInput] = useState("")
|
||||
const [aiTitleGenerating, setAiTitleGenerating] = useState(false)
|
||||
const [aiTitleResults, setAiTitleResults] = useState<AiTitleItem[]>([])
|
||||
const [hasGeneratedTitles, setHasGeneratedTitles] = useState(false)
|
||||
|
||||
/* ── 辅助函数 ── */
|
||||
const extractTopic = (text: string): string => {
|
||||
const keywords = text
|
||||
.replace(/[,。!?、,.!?]/g, " ")
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
if (keywords.length === 0) return "这个话题"
|
||||
// 取前3个关键词组合
|
||||
return keywords.slice(0, 3).join("")
|
||||
}
|
||||
|
||||
const getActivePreset = (settings: TitleSettings): string | null => {
|
||||
for (const p of TITLE_PRESETS) {
|
||||
if (
|
||||
settings.size === p.style.size &&
|
||||
settings.color === p.style.color &&
|
||||
settings.bold === p.style.bold &&
|
||||
settings.italic === p.style.italic &&
|
||||
settings.stroke === p.style.stroke &&
|
||||
settings.shadow === p.style.shadow
|
||||
) {
|
||||
return p.key
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const activePreset = useMemo(() => getActivePreset(titleSettings), [titleSettings])
|
||||
|
||||
/* ── AI 标题生成 ── */
|
||||
const handleGenerateAiTitles = useCallback(async () => {
|
||||
if (!aiTitleInput.trim()) {
|
||||
message.warning("请先输入视频描述或关键词")
|
||||
return
|
||||
}
|
||||
setAiTitleGenerating(true)
|
||||
setHasGeneratedTitles(true)
|
||||
|
||||
// 模拟 AI 生成延迟
|
||||
await new Promise((resolve) => setTimeout(resolve, 1200))
|
||||
|
||||
const topic = extractTopic(aiTitleInput)
|
||||
const results: AiTitleItem[] = []
|
||||
|
||||
const styles: Array<"catchy" | "emotional" | "informative"> = [
|
||||
"catchy",
|
||||
"emotional",
|
||||
"informative",
|
||||
]
|
||||
styles.forEach((style) => {
|
||||
const templates = AI_TITLE_TEMPLATES[style]
|
||||
// 每种风格随机选2个
|
||||
const shuffled = [...templates].sort(() => Math.random() - 0.5).slice(0, 2)
|
||||
shuffled.forEach((tpl) => {
|
||||
const title = tpl.replace(/\{topic\}/g, topic)
|
||||
const highlights = {
|
||||
catchy: "吸睛标题",
|
||||
emotional: "情感共鸣",
|
||||
informative: "知识干货",
|
||||
}
|
||||
results.push({
|
||||
title,
|
||||
highlight: highlights[style],
|
||||
style,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// 打乱顺序
|
||||
results.sort(() => Math.random() - 0.5)
|
||||
setAiTitleResults(results)
|
||||
setAiTitleGenerating(false)
|
||||
}, [aiTitleInput])
|
||||
|
||||
const handleSelectAiTitle = useCallback(
|
||||
(title: string) => {
|
||||
onTitleSettingsChange({ ...titleSettings, title, aiAutoSelect: false })
|
||||
message.success("已选用此标题")
|
||||
},
|
||||
[titleSettings, onTitleSettingsChange],
|
||||
)
|
||||
|
||||
const handleRefreshAiTitles = useCallback(async () => {
|
||||
if (!aiTitleInput.trim()) return
|
||||
setAiTitleGenerating(true)
|
||||
await new Promise((resolve) => setTimeout(resolve, 800))
|
||||
// 重新生成一批
|
||||
const topic = extractTopic(aiTitleInput)
|
||||
const results: AiTitleItem[] = []
|
||||
const styles: Array<"catchy" | "emotional" | "informative"> = [
|
||||
"catchy",
|
||||
"emotional",
|
||||
"informative",
|
||||
]
|
||||
const highlights = { catchy: "吸睛标题", emotional: "情感共鸣", informative: "知识干货" }
|
||||
styles.forEach((style) => {
|
||||
const templates = AI_TITLE_TEMPLATES[style]
|
||||
const shuffled = [...templates].sort(() => Math.random() - 0.5).slice(0, 2)
|
||||
shuffled.forEach((tpl) => {
|
||||
results.push({
|
||||
title: tpl.replace(/\{topic\}/g, topic),
|
||||
highlight: highlights[style],
|
||||
style,
|
||||
})
|
||||
})
|
||||
})
|
||||
results.sort(() => Math.random() - 0.5)
|
||||
setAiTitleResults(results)
|
||||
setAiTitleGenerating(false)
|
||||
}, [aiTitleInput])
|
||||
|
||||
/* ── 标题设置更新 ── */
|
||||
const updateTitle = useCallback(
|
||||
(title: string) => {
|
||||
onTitleSettingsChange({ ...titleSettings, title })
|
||||
},
|
||||
[titleSettings, onTitleSettingsChange],
|
||||
)
|
||||
|
||||
const toggleAiAutoSelect = useCallback(() => {
|
||||
onTitleSettingsChange({ ...titleSettings, aiAutoSelect: !titleSettings.aiAutoSelect })
|
||||
}, [titleSettings, onTitleSettingsChange])
|
||||
|
||||
const updatePosition = useCallback(
|
||||
(position: string) => {
|
||||
onTitleSettingsChange({ ...titleSettings, position })
|
||||
},
|
||||
[titleSettings, onTitleSettingsChange],
|
||||
)
|
||||
|
||||
const updateFont = useCallback(
|
||||
(font: string) => {
|
||||
onTitleSettingsChange({ ...titleSettings, font })
|
||||
},
|
||||
[titleSettings, onTitleSettingsChange],
|
||||
)
|
||||
|
||||
const updateSize = useCallback(
|
||||
(size: number) => {
|
||||
onTitleSettingsChange({ ...titleSettings, size })
|
||||
},
|
||||
[titleSettings, onTitleSettingsChange],
|
||||
)
|
||||
|
||||
const updateColor = useCallback(
|
||||
(color: string) => {
|
||||
onTitleSettingsChange({ ...titleSettings, color })
|
||||
},
|
||||
[titleSettings, onTitleSettingsChange],
|
||||
)
|
||||
|
||||
const toggleBold = useCallback(() => {
|
||||
onTitleSettingsChange({ ...titleSettings, bold: !titleSettings.bold })
|
||||
}, [titleSettings, onTitleSettingsChange])
|
||||
|
||||
const toggleItalic = useCallback(() => {
|
||||
onTitleSettingsChange({ ...titleSettings, italic: !titleSettings.italic })
|
||||
}, [titleSettings, onTitleSettingsChange])
|
||||
|
||||
const toggleStroke = useCallback(() => {
|
||||
onTitleSettingsChange({ ...titleSettings, stroke: !titleSettings.stroke })
|
||||
}, [titleSettings, onTitleSettingsChange])
|
||||
|
||||
const toggleShadow = useCallback(() => {
|
||||
onTitleSettingsChange({ ...titleSettings, shadow: !titleSettings.shadow })
|
||||
}, [titleSettings, onTitleSettingsChange])
|
||||
|
||||
const applyPreset = useCallback(
|
||||
(presetKey: string) => {
|
||||
const preset = TITLE_PRESETS.find((p) => p.key === presetKey)
|
||||
if (!preset) return
|
||||
onTitleSettingsChange({
|
||||
...titleSettings,
|
||||
size: preset.style.size,
|
||||
color: preset.style.color,
|
||||
bold: preset.style.bold,
|
||||
italic: preset.style.italic,
|
||||
stroke: preset.style.stroke,
|
||||
shadow: preset.style.shadow,
|
||||
})
|
||||
},
|
||||
[titleSettings, onTitleSettingsChange],
|
||||
)
|
||||
|
||||
return {
|
||||
// 数据
|
||||
userTitles,
|
||||
titleSettings,
|
||||
aiTitleInput,
|
||||
setAiTitleInput,
|
||||
aiTitleGenerating,
|
||||
aiTitleResults,
|
||||
hasGeneratedTitles,
|
||||
activePreset,
|
||||
titlePresets: TITLE_PRESETS,
|
||||
// AI 标题操作
|
||||
handleGenerateAiTitles,
|
||||
handleSelectAiTitle,
|
||||
handleRefreshAiTitles,
|
||||
// 标题设置操作
|
||||
updateTitle,
|
||||
toggleAiAutoSelect,
|
||||
updatePosition,
|
||||
updateFont,
|
||||
updateSize,
|
||||
updateColor,
|
||||
toggleBold,
|
||||
toggleItalic,
|
||||
toggleStroke,
|
||||
toggleShadow,
|
||||
applyPreset,
|
||||
}
|
||||
}
|
||||
|
||||
export default useStep4Title
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { getTitles } from "@/api/titles"
|
||||
import type { TitleSettings } from "../../types"
|
||||
import { useAiTitleGenerator } from "./useAiTitleGenerator"
|
||||
import { useTitleStyleUpdaters } from "./useTitleStyleUpdaters"
|
||||
|
||||
interface UseStep4TitleProps {
|
||||
titleSettings: TitleSettings
|
||||
onTitleSettingsChange: (settings: TitleSettings) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 4 标题设置 Hook
|
||||
* 封装 AI 标题生成、标题样式设置等逻辑
|
||||
*/
|
||||
export function useStep4Title({ titleSettings, onTitleSettingsChange }: UseStep4TitleProps) {
|
||||
// 标题库数据
|
||||
const { data: userTitles = [] } = useQuery({
|
||||
queryKey: ["titles"],
|
||||
queryFn: () => getTitles(),
|
||||
staleTime: 30_000,
|
||||
})
|
||||
|
||||
// AI 标题生成
|
||||
const aiGenerator = useAiTitleGenerator({ titleSettings, onTitleSettingsChange })
|
||||
|
||||
// 样式更新
|
||||
const styleUpdaters = useTitleStyleUpdaters({ titleSettings, onTitleSettingsChange })
|
||||
|
||||
return {
|
||||
// 数据
|
||||
userTitles,
|
||||
titleSettings,
|
||||
// AI 标题状态
|
||||
aiTitleInput: aiGenerator.aiTitleInput,
|
||||
setAiTitleInput: aiGenerator.setAiTitleInput,
|
||||
aiTitleGenerating: aiGenerator.aiTitleGenerating,
|
||||
aiTitleResults: aiGenerator.aiTitleResults,
|
||||
hasGeneratedTitles: aiGenerator.hasGeneratedTitles,
|
||||
activePreset: styleUpdaters.activePreset,
|
||||
titlePresets: styleUpdaters.titlePresets,
|
||||
// AI 标题操作
|
||||
handleGenerateAiTitles: aiGenerator.handleGenerateAiTitles,
|
||||
handleSelectAiTitle: aiGenerator.handleSelectAiTitle,
|
||||
handleRefreshAiTitles: aiGenerator.handleRefreshAiTitles,
|
||||
// 标题设置操作
|
||||
updateTitle: styleUpdaters.updateTitle,
|
||||
toggleAiAutoSelect: styleUpdaters.toggleAiAutoSelect,
|
||||
updatePosition: styleUpdaters.updatePosition,
|
||||
updateFont: styleUpdaters.updateFont,
|
||||
updateSize: styleUpdaters.updateSize,
|
||||
updateColor: styleUpdaters.updateColor,
|
||||
toggleBold: styleUpdaters.toggleBold,
|
||||
toggleItalic: styleUpdaters.toggleItalic,
|
||||
toggleStroke: styleUpdaters.toggleStroke,
|
||||
toggleShadow: styleUpdaters.toggleShadow,
|
||||
applyPreset: styleUpdaters.applyPreset,
|
||||
}
|
||||
}
|
||||
|
||||
export default useStep4Title
|
||||
@@ -0,0 +1,102 @@
|
||||
import { useState, useCallback } from "react"
|
||||
import { message } from "antd"
|
||||
import { AI_TITLE_TEMPLATES } from "../../constants"
|
||||
import type { TitleSettings } from "../../types"
|
||||
|
||||
export interface AiTitleItem {
|
||||
title: string
|
||||
highlight: string
|
||||
style: "catchy" | "emotional" | "informative"
|
||||
}
|
||||
|
||||
interface UseAiTitleGeneratorOptions {
|
||||
titleSettings: TitleSettings
|
||||
onTitleSettingsChange: (settings: TitleSettings) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 标题生成 Hook
|
||||
* 封装 AI 标题生成、刷新、选择等逻辑
|
||||
*/
|
||||
export function useAiTitleGenerator({
|
||||
titleSettings,
|
||||
onTitleSettingsChange,
|
||||
}: UseAiTitleGeneratorOptions) {
|
||||
const [aiTitleInput, setAiTitleInput] = useState("")
|
||||
const [aiTitleGenerating, setAiTitleGenerating] = useState(false)
|
||||
const [aiTitleResults, setAiTitleResults] = useState<AiTitleItem[]>([])
|
||||
const [hasGeneratedTitles, setHasGeneratedTitles] = useState(false)
|
||||
|
||||
const extractTopic = (text: string): string => {
|
||||
const keywords = text
|
||||
.replace(/[,。!?、,.!?]/g, " ")
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
if (keywords.length === 0) return "这个话题"
|
||||
return keywords.slice(0, 3).join("")
|
||||
}
|
||||
|
||||
const generateTitlesFromTopic = (topic: string): AiTitleItem[] => {
|
||||
const results: AiTitleItem[] = []
|
||||
const styles: Array<"catchy" | "emotional" | "informative"> = [
|
||||
"catchy",
|
||||
"emotional",
|
||||
"informative",
|
||||
]
|
||||
const highlights = { catchy: "吸睛标题", emotional: "情感共鸣", informative: "知识干货" }
|
||||
styles.forEach((style) => {
|
||||
const templates = AI_TITLE_TEMPLATES[style]
|
||||
const shuffled = [...templates].sort(() => Math.random() - 0.5).slice(0, 2)
|
||||
shuffled.forEach((tpl) => {
|
||||
results.push({
|
||||
title: tpl.replace(/\{topic\}/g, topic),
|
||||
highlight: highlights[style],
|
||||
style,
|
||||
})
|
||||
})
|
||||
})
|
||||
results.sort(() => Math.random() - 0.5)
|
||||
return results
|
||||
}
|
||||
|
||||
const handleGenerateAiTitles = useCallback(async () => {
|
||||
if (!aiTitleInput.trim()) {
|
||||
message.warning("请先输入视频描述或关键词")
|
||||
return
|
||||
}
|
||||
setAiTitleGenerating(true)
|
||||
setHasGeneratedTitles(true)
|
||||
await new Promise((resolve) => setTimeout(resolve, 1200))
|
||||
const topic = extractTopic(aiTitleInput)
|
||||
setAiTitleResults(generateTitlesFromTopic(topic))
|
||||
setAiTitleGenerating(false)
|
||||
}, [aiTitleInput])
|
||||
|
||||
const handleSelectAiTitle = useCallback(
|
||||
(title: string) => {
|
||||
onTitleSettingsChange({ ...titleSettings, title, aiAutoSelect: false })
|
||||
message.success("已选用此标题")
|
||||
},
|
||||
[titleSettings, onTitleSettingsChange],
|
||||
)
|
||||
|
||||
const handleRefreshAiTitles = useCallback(async () => {
|
||||
if (!aiTitleInput.trim()) return
|
||||
setAiTitleGenerating(true)
|
||||
await new Promise((resolve) => setTimeout(resolve, 800))
|
||||
const topic = extractTopic(aiTitleInput)
|
||||
setAiTitleResults(generateTitlesFromTopic(topic))
|
||||
setAiTitleGenerating(false)
|
||||
}, [aiTitleInput])
|
||||
|
||||
return {
|
||||
aiTitleInput,
|
||||
setAiTitleInput,
|
||||
aiTitleGenerating,
|
||||
aiTitleResults,
|
||||
hasGeneratedTitles,
|
||||
handleGenerateAiTitles,
|
||||
handleSelectAiTitle,
|
||||
handleRefreshAiTitles,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { useCallback, useMemo } from "react"
|
||||
import { TITLE_PRESETS } from "../../constants"
|
||||
import type { TitleSettings } from "../../types"
|
||||
|
||||
interface UseTitleStyleUpdatersOptions {
|
||||
titleSettings: TitleSettings
|
||||
onTitleSettingsChange: (settings: TitleSettings) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 标题样式更新 Hook
|
||||
* 封装标题文字、位置、字体、样式等所有设置更新函数
|
||||
*/
|
||||
export function useTitleStyleUpdaters({
|
||||
titleSettings,
|
||||
onTitleSettingsChange,
|
||||
}: UseTitleStyleUpdatersOptions) {
|
||||
const getActivePreset = (settings: TitleSettings): string | null => {
|
||||
for (const p of TITLE_PRESETS) {
|
||||
if (
|
||||
settings.size === p.style.size &&
|
||||
settings.color === p.style.color &&
|
||||
settings.bold === p.style.bold &&
|
||||
settings.italic === p.style.italic &&
|
||||
settings.stroke === p.style.stroke &&
|
||||
settings.shadow === p.style.shadow
|
||||
) {
|
||||
return p.key
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const activePreset = useMemo(() => getActivePreset(titleSettings), [titleSettings])
|
||||
|
||||
const updateTitle = useCallback(
|
||||
(title: string) => {
|
||||
onTitleSettingsChange({ ...titleSettings, title })
|
||||
},
|
||||
[titleSettings, onTitleSettingsChange],
|
||||
)
|
||||
|
||||
const toggleAiAutoSelect = useCallback(() => {
|
||||
onTitleSettingsChange({ ...titleSettings, aiAutoSelect: !titleSettings.aiAutoSelect })
|
||||
}, [titleSettings, onTitleSettingsChange])
|
||||
|
||||
const updatePosition = useCallback(
|
||||
(position: string) => {
|
||||
onTitleSettingsChange({ ...titleSettings, position })
|
||||
},
|
||||
[titleSettings, onTitleSettingsChange],
|
||||
)
|
||||
|
||||
const updateFont = useCallback(
|
||||
(font: string) => {
|
||||
onTitleSettingsChange({ ...titleSettings, font })
|
||||
},
|
||||
[titleSettings, onTitleSettingsChange],
|
||||
)
|
||||
|
||||
const updateSize = useCallback(
|
||||
(size: number) => {
|
||||
onTitleSettingsChange({ ...titleSettings, size })
|
||||
},
|
||||
[titleSettings, onTitleSettingsChange],
|
||||
)
|
||||
|
||||
const updateColor = useCallback(
|
||||
(color: string) => {
|
||||
onTitleSettingsChange({ ...titleSettings, color })
|
||||
},
|
||||
[titleSettings, onTitleSettingsChange],
|
||||
)
|
||||
|
||||
const toggleBold = useCallback(() => {
|
||||
onTitleSettingsChange({ ...titleSettings, bold: !titleSettings.bold })
|
||||
}, [titleSettings, onTitleSettingsChange])
|
||||
|
||||
const toggleItalic = useCallback(() => {
|
||||
onTitleSettingsChange({ ...titleSettings, italic: !titleSettings.italic })
|
||||
}, [titleSettings, onTitleSettingsChange])
|
||||
|
||||
const toggleStroke = useCallback(() => {
|
||||
onTitleSettingsChange({ ...titleSettings, stroke: !titleSettings.stroke })
|
||||
}, [titleSettings, onTitleSettingsChange])
|
||||
|
||||
const toggleShadow = useCallback(() => {
|
||||
onTitleSettingsChange({ ...titleSettings, shadow: !titleSettings.shadow })
|
||||
}, [titleSettings, onTitleSettingsChange])
|
||||
|
||||
const applyPreset = useCallback(
|
||||
(presetKey: string) => {
|
||||
const preset = TITLE_PRESETS.find((p) => p.key === presetKey)
|
||||
if (!preset) return
|
||||
onTitleSettingsChange({
|
||||
...titleSettings,
|
||||
size: preset.style.size,
|
||||
color: preset.style.color,
|
||||
bold: preset.style.bold,
|
||||
italic: preset.style.italic,
|
||||
stroke: preset.style.stroke,
|
||||
shadow: preset.style.shadow,
|
||||
})
|
||||
},
|
||||
[titleSettings, onTitleSettingsChange],
|
||||
)
|
||||
|
||||
return {
|
||||
activePreset,
|
||||
titlePresets: TITLE_PRESETS,
|
||||
updateTitle,
|
||||
toggleAiAutoSelect,
|
||||
updatePosition,
|
||||
updateFont,
|
||||
updateSize,
|
||||
updateColor,
|
||||
toggleBold,
|
||||
toggleItalic,
|
||||
toggleStroke,
|
||||
toggleShadow,
|
||||
applyPreset,
|
||||
}
|
||||
}
|
||||
@@ -2,17 +2,15 @@
|
||||
* Step 5 配音选择 Hook
|
||||
* 封装 AI 推荐、预设音色试听、TTS 自定义合成、存为素材等逻辑
|
||||
*/
|
||||
import { useState, useRef, useCallback, useEffect, useMemo } from "react"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import { message } from "antd"
|
||||
import { useQuery, useMutation } from "@tanstack/react-query"
|
||||
import type { PresetVoiceItem } from "@/api/voices"
|
||||
import { fetchPresetVoices } from "@/api/voices"
|
||||
import { synthesizeSpeech, getTTSJobStatus, saveTtsToLibrary } from "@/api/tts"
|
||||
import { getTags, createTag } from "@/api/tags"
|
||||
import { formatDuration } from "../utils/formatDuration"
|
||||
import { useCallback } from "react"
|
||||
import type { VoiceClone } from "@/api/voice-clone"
|
||||
import { message } from "antd"
|
||||
import { VOICE_GENDER_ICON, CLONE_STATUS_CONFIG } from "../constants"
|
||||
import { formatDuration } from "../utils/formatDuration"
|
||||
import { useVoiceAudio } from "./step5-voice/useVoiceAudio"
|
||||
import { useVoiceRecommend } from "./step5-voice/useVoiceRecommend"
|
||||
import { useTtsSynthesis } from "./step5-voice/useTtsSynthesis"
|
||||
import { useSaveToLibrary } from "./step5-voice/useSaveToLibrary"
|
||||
|
||||
interface UseStep5VoiceProps {
|
||||
selectedVoice: string
|
||||
@@ -43,91 +41,47 @@ export function useStep5Voice({
|
||||
onCloneModalOpenChange,
|
||||
titleText,
|
||||
}: UseStep5VoiceProps) {
|
||||
const navigate = useNavigate()
|
||||
/* ── 预置音色 API ── */
|
||||
const { data: presetVoicesData, isLoading: presetVoicesLoading } = useQuery({
|
||||
queryKey: ["preset-voices"],
|
||||
queryFn: fetchPresetVoices,
|
||||
})
|
||||
const presetVoices: PresetVoiceItem[] = useMemo(
|
||||
() => presetVoicesData?.items ?? [],
|
||||
[presetVoicesData],
|
||||
)
|
||||
/* ── 子模块 ── */
|
||||
const { playingVoice, toggleVoicePlay } = useVoiceAudio()
|
||||
|
||||
/* ── 音频播放 ── */
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||
const [playingVoice, setPlayingVoice] = useState<string | null>(null)
|
||||
const {
|
||||
presetVoices,
|
||||
presetVoicesLoading,
|
||||
voiceRecommendLoading,
|
||||
voiceRecommendations,
|
||||
hasVoiceRecommend,
|
||||
handleVoiceRecommend,
|
||||
} = useVoiceRecommend(titleText)
|
||||
|
||||
const toggleVoicePlay = useCallback(
|
||||
(voiceId: string, previewUrl: string | null) => {
|
||||
if (playingVoice === voiceId) {
|
||||
audioRef.current?.pause()
|
||||
audioRef.current = null
|
||||
setPlayingVoice(null)
|
||||
return
|
||||
}
|
||||
audioRef.current?.pause()
|
||||
if (!previewUrl) {
|
||||
message.warning("该音色暂无试听音频")
|
||||
return
|
||||
}
|
||||
const audio = new Audio(previewUrl)
|
||||
audioRef.current = audio
|
||||
audio.play().catch(() => {
|
||||
message.error("播放失败,请检查网络")
|
||||
})
|
||||
audio.onended = () => {
|
||||
setPlayingVoice(null)
|
||||
audioRef.current = null
|
||||
}
|
||||
setPlayingVoice(voiceId)
|
||||
},
|
||||
[playingVoice],
|
||||
)
|
||||
const {
|
||||
customVoiceText,
|
||||
setCustomVoiceText,
|
||||
customAudioUrl,
|
||||
ttsError,
|
||||
ttsJobId,
|
||||
completedTtsJobId,
|
||||
synthesizeMutation,
|
||||
handleSynthesizeVoice,
|
||||
resetTtsState,
|
||||
} = useTtsSynthesis(selectedVoice)
|
||||
|
||||
/* ── 智能配音推荐 ── */
|
||||
const [voiceRecommendLoading, setVoiceRecommendLoading] = useState(false)
|
||||
const [voiceRecommendations, setVoiceRecommendations] = useState<string[]>([])
|
||||
const [hasVoiceRecommend, setHasVoiceRecommend] = useState(false)
|
||||
|
||||
const handleVoiceRecommend = useCallback(async () => {
|
||||
if (presetVoices.length === 0) return
|
||||
setVoiceRecommendLoading(true)
|
||||
setHasVoiceRecommend(true)
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
|
||||
// 根据标题内容风格模拟推荐:情感类→温柔女声,知识类→沉稳男声,活力类→阳光少年
|
||||
const title = titleText.toLowerCase()
|
||||
let recommended: string[] = []
|
||||
|
||||
const femaleVoices = presetVoices.filter((v) => v.gender === "female").map((v) => v.voice_id)
|
||||
const maleVoices = presetVoices.filter((v) => v.gender === "male").map((v) => v.voice_id)
|
||||
const childVoices = presetVoices.filter((v) => v.gender === "child").map((v) => v.voice_id)
|
||||
|
||||
if (/情感|感人|温暖|治愈|故事|回忆/.test(title)) {
|
||||
recommended = femaleVoices.slice(0, 3)
|
||||
} else if (/教程|知识|科普|干货|讲解|分析/.test(title)) {
|
||||
recommended = maleVoices.slice(0, 2).concat(femaleVoices.slice(0, 1))
|
||||
} else if (/活力|热血|运动|搞笑|有趣/.test(title)) {
|
||||
recommended = childVoices.slice(0, 1).concat(maleVoices.slice(0, 1), femaleVoices.slice(0, 1))
|
||||
} else {
|
||||
// 默认推荐前3个
|
||||
recommended = presetVoices.slice(0, 3).map((v) => v.voice_id)
|
||||
}
|
||||
|
||||
// 不足3个时补足
|
||||
if (recommended.length < 3) {
|
||||
const others = presetVoices
|
||||
.filter((v) => !recommended.includes(v.voice_id))
|
||||
.map((v) => v.voice_id)
|
||||
recommended = recommended.concat(others.slice(0, 3 - recommended.length))
|
||||
}
|
||||
|
||||
setVoiceRecommendations(recommended)
|
||||
setVoiceRecommendLoading(false)
|
||||
}, [presetVoices, titleText])
|
||||
const {
|
||||
saveModalOpen,
|
||||
setSaveModalOpen,
|
||||
saveName,
|
||||
setSaveName,
|
||||
saveTagIds,
|
||||
setSaveTagIds,
|
||||
saveNewTag,
|
||||
setSaveNewTag,
|
||||
allTags,
|
||||
saveToLibraryMutation,
|
||||
handleOpenSaveModal,
|
||||
handleConfirmSave,
|
||||
handleAddTagInModal,
|
||||
} = useSaveToLibrary(completedTtsJobId, resetTtsState)
|
||||
|
||||
/* ── 推荐音色选择 ── */
|
||||
const handleSelectRecommendedVoice = useCallback(
|
||||
(voiceId: string) => {
|
||||
onVoiceModeChange("preset")
|
||||
@@ -136,173 +90,6 @@ export function useStep5Voice({
|
||||
[onVoiceModeChange, onSelectedVoiceChange],
|
||||
)
|
||||
|
||||
/* ── TTS 自定义合成状态 ── */
|
||||
const [customVoiceText, setCustomVoiceText] = useState("")
|
||||
const [customAudioUrl, setCustomAudioUrl] = useState<string | null>(null)
|
||||
const [ttsError, setTtsError] = useState<string | null>(null)
|
||||
const [ttsJobId, setTtsJobId] = useState<string | null>(null)
|
||||
/** 合成完成后保留的 job ID,用于"存为素材" */
|
||||
const [completedTtsJobId, setCompletedTtsJobId] = useState<string | null>(null)
|
||||
|
||||
/* ── TTS mutation ── */
|
||||
const synthesizeMutation = useMutation({
|
||||
mutationFn: synthesizeSpeech,
|
||||
onSuccess: (data) => {
|
||||
setTtsJobId(data.job_id)
|
||||
message.info("语音合成已提交,等待处理…")
|
||||
},
|
||||
onError: () => {
|
||||
setTtsError("语音合成请求失败,请重试")
|
||||
},
|
||||
})
|
||||
|
||||
/** 轮询 TTS 任务状态 */
|
||||
useEffect(() => {
|
||||
if (!ttsJobId) return
|
||||
let cancelled = false
|
||||
let timer: ReturnType<typeof setTimeout>
|
||||
|
||||
const poll = async () => {
|
||||
try {
|
||||
const status = await getTTSJobStatus(ttsJobId)
|
||||
if (cancelled) return
|
||||
if (status.status === "completed") {
|
||||
setCustomAudioUrl(status.output_audio_url)
|
||||
setCompletedTtsJobId(ttsJobId)
|
||||
setTtsJobId(null)
|
||||
setTtsError(null)
|
||||
message.success("语音合成完成!")
|
||||
return
|
||||
}
|
||||
if (status.status === "failed" || status.status === "cancelled") {
|
||||
setTtsError(status.error_message || "语音合成失败")
|
||||
setTtsJobId(null)
|
||||
return
|
||||
}
|
||||
timer = setTimeout(poll, 2000)
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setTtsError("查询合成状态失败")
|
||||
setTtsJobId(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
timer = setTimeout(poll, 2000)
|
||||
return () => {
|
||||
cancelled = true
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}, [ttsJobId])
|
||||
|
||||
/** 触发自定义文本 TTS 合成 */
|
||||
const handleSynthesizeVoice = useCallback(() => {
|
||||
if (!customVoiceText.trim()) {
|
||||
message.warning("请先输入配音文案")
|
||||
return
|
||||
}
|
||||
setTtsError(null)
|
||||
setCustomAudioUrl(null)
|
||||
synthesizeMutation.mutate({
|
||||
text: customVoiceText.trim(),
|
||||
voice_id: selectedVoice || undefined,
|
||||
language: "zh-CN",
|
||||
})
|
||||
}, [customVoiceText, selectedVoice, synthesizeMutation])
|
||||
|
||||
/* ── 存为素材弹窗状态 ── */
|
||||
const [saveModalOpen, setSaveModalOpen] = useState(false)
|
||||
const [saveName, setSaveName] = useState("")
|
||||
const [saveTagIds, setSaveTagIds] = useState<string[]>([])
|
||||
const [saveNewTag, setSaveNewTag] = useState("")
|
||||
|
||||
/* ── 标签列表(用于存为素材弹窗) ── */
|
||||
const { data: allTags = [] } = useQuery({
|
||||
queryKey: ["generate-save-tags"],
|
||||
queryFn: getTags,
|
||||
staleTime: 30_000,
|
||||
})
|
||||
|
||||
/* ── 存为素材 mutation ── */
|
||||
const saveToLibraryMutation = useMutation({
|
||||
mutationFn: (params: { name?: string; tag_ids?: string[] }) =>
|
||||
saveTtsToLibrary(completedTtsJobId!, params),
|
||||
onSuccess: () => {
|
||||
message.success({
|
||||
content: (
|
||||
<span>
|
||||
已保存到配音库!{" "}
|
||||
<a
|
||||
onClick={handleGoToLibrary}
|
||||
style={{
|
||||
color: "var(--primary-500, #6366f1)",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
去视频库查看
|
||||
</a>
|
||||
</span>
|
||||
),
|
||||
duration: 5,
|
||||
})
|
||||
setSaveModalOpen(false)
|
||||
setSaveName("")
|
||||
setSaveTagIds([])
|
||||
setSaveNewTag("")
|
||||
setCompletedTtsJobId(null)
|
||||
setCustomAudioUrl(null)
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
message.error(`保存失败:${err.message || "请重试"}`)
|
||||
},
|
||||
})
|
||||
|
||||
/** 打开存为素材弹窗 */
|
||||
const handleOpenSaveModal = useCallback(() => {
|
||||
setSaveName("")
|
||||
setSaveTagIds([])
|
||||
setSaveNewTag("")
|
||||
setSaveModalOpen(true)
|
||||
}, [])
|
||||
|
||||
/** 确认保存 */
|
||||
const handleConfirmSave = useCallback(() => {
|
||||
if (!completedTtsJobId) return
|
||||
saveToLibraryMutation.mutate({
|
||||
name: saveName.trim() || undefined,
|
||||
tag_ids: saveTagIds.length > 0 ? saveTagIds : undefined,
|
||||
})
|
||||
}, [completedTtsJobId, saveName, saveTagIds, saveToLibraryMutation])
|
||||
|
||||
/** 在弹窗中新增标签(先创建再选中) */
|
||||
const handleAddTagInModal = useCallback(
|
||||
async (tagName: string) => {
|
||||
const trimmed = tagName.trim()
|
||||
if (!trimmed) return
|
||||
/* 已在选中列表则跳过 */
|
||||
const existing = allTags.find((t) => t.name === trimmed)
|
||||
if (existing) {
|
||||
if (!saveTagIds.includes(existing.id)) {
|
||||
setSaveTagIds((prev) => [...prev, existing.id])
|
||||
}
|
||||
return
|
||||
}
|
||||
try {
|
||||
const created = await createTag(trimmed)
|
||||
setSaveTagIds((prev) => [...prev, created.id])
|
||||
setSaveNewTag("")
|
||||
} catch {
|
||||
message.error(`创建标签"${trimmed}"失败`)
|
||||
}
|
||||
},
|
||||
[allTags, saveTagIds],
|
||||
)
|
||||
|
||||
/** 保存成功后跳转到视频库 */
|
||||
const handleGoToLibrary = useCallback(() => {
|
||||
navigate("/app/voice-materials")
|
||||
}, [navigate])
|
||||
|
||||
/* ── 克隆成功回调 ── */
|
||||
const handleCloneSuccess = useCallback(
|
||||
(voice: VoiceClone) => {
|
||||
|
||||
@@ -3,126 +3,39 @@
|
||||
* 卡片视图展示用户已保存的剪辑模板
|
||||
* 支持搜索、分类筛选、编辑/复制/删除/使用模板生成
|
||||
*/
|
||||
import React, { useState } from "react"
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import {
|
||||
Typography,
|
||||
Card,
|
||||
Input,
|
||||
Select,
|
||||
Tag,
|
||||
Button,
|
||||
Space,
|
||||
Empty,
|
||||
Spin,
|
||||
Tooltip,
|
||||
message,
|
||||
Popconfirm,
|
||||
Row,
|
||||
Col,
|
||||
} from "antd"
|
||||
import {
|
||||
SearchOutlined,
|
||||
EditOutlined,
|
||||
CopyOutlined,
|
||||
DeleteOutlined,
|
||||
VideoCameraOutlined,
|
||||
AppstoreOutlined,
|
||||
PlusOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import React from "react"
|
||||
import { Typography, Input, Select, Button, Empty, Spin, Row, Col } from "antd"
|
||||
import { SearchOutlined, AppstoreOutlined, PlusOutlined } from "@ant-design/icons"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import {
|
||||
getEditingTemplates,
|
||||
getTemplateCategories,
|
||||
deleteEditingTemplate,
|
||||
createEditingTemplate,
|
||||
MODE_LABELS,
|
||||
MODE_COLORS,
|
||||
type EditingTemplate,
|
||||
type TemplateMode,
|
||||
} from "@/api/editing-planner"
|
||||
import type { EditingTemplate } from "@/api/editing-planner"
|
||||
import { useMyTemplates } from "./hooks/useMyTemplates"
|
||||
import { TemplateCard } from "./components/TemplateCard"
|
||||
import "./MyTemplates.css"
|
||||
|
||||
const { Title, Text } = Typography
|
||||
|
||||
const MyTemplates: React.FC = () => {
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
const {
|
||||
searchText,
|
||||
setSearchText,
|
||||
filterCategory,
|
||||
setFilterCategory,
|
||||
templates,
|
||||
categories,
|
||||
isLoading,
|
||||
handleCopy,
|
||||
handleDelete,
|
||||
} = useMyTemplates()
|
||||
|
||||
const [searchText, setSearchText] = useState("")
|
||||
const [filterCategory, setFilterCategory] = useState("")
|
||||
|
||||
/* ── 数据查询 ── */
|
||||
const { data: templates = [], isLoading } = useQuery({
|
||||
queryKey: ["editing-templates", filterCategory, searchText],
|
||||
queryFn: () =>
|
||||
getEditingTemplates({
|
||||
category: filterCategory || undefined,
|
||||
tag: searchText || undefined,
|
||||
}),
|
||||
})
|
||||
|
||||
const { data: categories = [] } = useQuery({
|
||||
queryKey: ["template-categories"],
|
||||
queryFn: getTemplateCategories,
|
||||
})
|
||||
|
||||
/* ── Mutations ── */
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: deleteEditingTemplate,
|
||||
onSuccess: () => {
|
||||
message.success("模板已删除")
|
||||
queryClient.invalidateQueries({ queryKey: ["editing-templates"] })
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
if (!(err as { __msgShown?: boolean })?.__msgShown) message.error("删除失败")
|
||||
},
|
||||
})
|
||||
|
||||
const copyMutation = useMutation({
|
||||
mutationFn: (tpl: EditingTemplate) =>
|
||||
createEditingTemplate({
|
||||
name: `${tpl.name}(副本)`,
|
||||
mode: tpl.mode,
|
||||
category: tpl.category,
|
||||
tags: tpl.tags,
|
||||
title_config: tpl.title_config,
|
||||
subtitle_config: tpl.subtitle_config,
|
||||
bgm_config: tpl.bgm_config,
|
||||
estimated_duration:
|
||||
tpl.estimated_duration ??
|
||||
Math.round(
|
||||
tpl.segments.reduce((s, seg) => s + (seg.duration_min + seg.duration_max) / 2, 0),
|
||||
),
|
||||
segments: tpl.segments.map(({ id: _id, ...rest }) => rest),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
message.success("模板已复制")
|
||||
queryClient.invalidateQueries({ queryKey: ["editing-templates"] })
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
if (!(err as { __msgShown?: boolean })?.__msgShown) message.error("复制失败")
|
||||
},
|
||||
})
|
||||
|
||||
/* ── 操作 ── */
|
||||
const handleEdit = (tpl: EditingTemplate) => {
|
||||
navigate(`/editing-planner?template=${tpl.id}`)
|
||||
}
|
||||
|
||||
const handleGenerate = (tpl: EditingTemplate) => {
|
||||
// 跳转到智能剪辑页面,统一从智能剪辑出片
|
||||
navigate(`/generate?templateId=${tpl.id}`)
|
||||
}
|
||||
|
||||
const handleCopy = (tpl: EditingTemplate) => {
|
||||
copyMutation.mutate(tpl)
|
||||
}
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
deleteMutation.mutate(id)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-page">
|
||||
{/* 页面头部 */}
|
||||
@@ -179,69 +92,13 @@ const MyTemplates: React.FC = () => {
|
||||
<Row gutter={[16, 16]}>
|
||||
{templates.map((tpl) => (
|
||||
<Col key={tpl.id} xs={24} sm={12} md={8} lg={6}>
|
||||
<Card
|
||||
className="mt-card"
|
||||
hoverable
|
||||
actions={[
|
||||
<Tooltip title="编辑" key="edit">
|
||||
<EditOutlined onClick={() => handleEdit(tpl)} />
|
||||
</Tooltip>,
|
||||
<Tooltip title="复制" key="copy">
|
||||
<CopyOutlined onClick={() => handleCopy(tpl)} />
|
||||
</Tooltip>,
|
||||
<Tooltip title="使用模板生成" key="generate">
|
||||
<VideoCameraOutlined onClick={() => handleGenerate(tpl)} />
|
||||
</Tooltip>,
|
||||
<Popconfirm
|
||||
key="delete"
|
||||
title="确定删除此模板?"
|
||||
onConfirm={() => handleDelete(tpl.id)}
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Tooltip title="删除">
|
||||
<DeleteOutlined style={{ color: "#ff4d4f" }} />
|
||||
</Tooltip>
|
||||
</Popconfirm>,
|
||||
]}
|
||||
>
|
||||
<div className="mt-card-head">
|
||||
<Text strong ellipsis style={{ fontSize: 15 }}>
|
||||
{tpl.name}
|
||||
</Text>
|
||||
<Tag color={MODE_COLORS[tpl.mode as TemplateMode] || "default"}>
|
||||
{MODE_LABELS[tpl.mode as TemplateMode] || tpl.mode}
|
||||
</Tag>
|
||||
<Tag color="green">用户自制</Tag>
|
||||
</div>
|
||||
|
||||
<div className="mt-card-meta">
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{tpl.segments.length} 片段 · 预估 ~{tpl.estimated_duration}s
|
||||
</Text>
|
||||
{tpl.category && (
|
||||
<Tag style={{ fontSize: 11, marginTop: 4 }}>{tpl.category}</Tag>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{tpl.tags.length > 0 && (
|
||||
<div className="mt-card-tags">
|
||||
{tpl.tags.map((tag) => (
|
||||
<Tag key={tag} style={{ fontSize: 11 }}>
|
||||
{tag}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-card-config">
|
||||
<Space size={4} wrap>
|
||||
{tpl.title_config.ai_auto_select && <Tag color="cyan">AI标题</Tag>}
|
||||
{tpl.subtitle_config.enabled && <Tag color="geekblue">字幕</Tag>}
|
||||
{tpl.bgm_config.enabled && <Tag color="pink">BGM</Tag>}
|
||||
</Space>
|
||||
</div>
|
||||
</Card>
|
||||
<TemplateCard
|
||||
tpl={tpl}
|
||||
onEdit={handleEdit}
|
||||
onCopy={handleCopy}
|
||||
onGenerate={handleGenerate}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import React from "react"
|
||||
import { Card, Tag, Tooltip, Popconfirm, Space, Typography } from "antd"
|
||||
import { EditOutlined, CopyOutlined, DeleteOutlined, VideoCameraOutlined } from "@ant-design/icons"
|
||||
import {
|
||||
MODE_LABELS,
|
||||
MODE_COLORS,
|
||||
type EditingTemplate,
|
||||
type TemplateMode,
|
||||
} from "@/api/editing-planner"
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
interface TemplateCardProps {
|
||||
tpl: EditingTemplate
|
||||
onEdit: (tpl: EditingTemplate) => void
|
||||
onCopy: (tpl: EditingTemplate) => void
|
||||
onGenerate: (tpl: EditingTemplate) => void
|
||||
onDelete: (id: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 单个模板卡片组件
|
||||
*/
|
||||
export const TemplateCard: React.FC<TemplateCardProps> = ({
|
||||
tpl,
|
||||
onEdit,
|
||||
onCopy,
|
||||
onGenerate,
|
||||
onDelete,
|
||||
}) => (
|
||||
<Card
|
||||
className="mt-card"
|
||||
hoverable
|
||||
actions={[
|
||||
<Tooltip title="编辑" key="edit">
|
||||
<EditOutlined onClick={() => onEdit(tpl)} />
|
||||
</Tooltip>,
|
||||
<Tooltip title="复制" key="copy">
|
||||
<CopyOutlined onClick={() => onCopy(tpl)} />
|
||||
</Tooltip>,
|
||||
<Tooltip title="使用模板生成" key="generate">
|
||||
<VideoCameraOutlined onClick={() => onGenerate(tpl)} />
|
||||
</Tooltip>,
|
||||
<Popconfirm
|
||||
key="delete"
|
||||
title="确定删除此模板?"
|
||||
onConfirm={() => onDelete(tpl.id)}
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Tooltip title="删除">
|
||||
<DeleteOutlined style={{ color: "#ff4d4f" }} />
|
||||
</Tooltip>
|
||||
</Popconfirm>,
|
||||
]}
|
||||
>
|
||||
<div className="mt-card-head">
|
||||
<Text strong ellipsis style={{ fontSize: 15 }}>
|
||||
{tpl.name}
|
||||
</Text>
|
||||
<Tag color={MODE_COLORS[tpl.mode as TemplateMode] || "default"}>
|
||||
{MODE_LABELS[tpl.mode as TemplateMode] || tpl.mode}
|
||||
</Tag>
|
||||
<Tag color="green">用户自制</Tag>
|
||||
</div>
|
||||
|
||||
<div className="mt-card-meta">
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{tpl.segments.length} 片段 · 预估 ~{tpl.estimated_duration}s
|
||||
</Text>
|
||||
{tpl.category && <Tag style={{ fontSize: 11, marginTop: 4 }}>{tpl.category}</Tag>}
|
||||
</div>
|
||||
|
||||
{tpl.tags.length > 0 && (
|
||||
<div className="mt-card-tags">
|
||||
{tpl.tags.map((tag) => (
|
||||
<Tag key={tag} style={{ fontSize: 11 }}>
|
||||
{tag}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-card-config">
|
||||
<Space size={4} wrap>
|
||||
{tpl.title_config.ai_auto_select && <Tag color="cyan">AI标题</Tag>}
|
||||
{tpl.subtitle_config.enabled && <Tag color="geekblue">字幕</Tag>}
|
||||
{tpl.bgm_config.enabled && <Tag color="pink">BGM</Tag>}
|
||||
</Space>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
@@ -0,0 +1,100 @@
|
||||
import { useState } from "react"
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { message } from "antd"
|
||||
import {
|
||||
getEditingTemplates,
|
||||
getTemplateCategories,
|
||||
deleteEditingTemplate,
|
||||
createEditingTemplate,
|
||||
type EditingTemplate,
|
||||
} from "@/api/editing-planner"
|
||||
|
||||
/**
|
||||
* 我的模板数据 Hook
|
||||
* 封装模板列表查询、筛选、删除、复制等数据操作
|
||||
*/
|
||||
export function useMyTemplates() {
|
||||
const queryClient = useQueryClient()
|
||||
const [searchText, setSearchText] = useState("")
|
||||
const [filterCategory, setFilterCategory] = useState("")
|
||||
|
||||
/* 模板列表 */
|
||||
const { data: templates = [], isLoading } = useQuery({
|
||||
queryKey: ["editing-templates", filterCategory, searchText],
|
||||
queryFn: () =>
|
||||
getEditingTemplates({
|
||||
category: filterCategory || undefined,
|
||||
tag: searchText || undefined,
|
||||
}),
|
||||
})
|
||||
|
||||
/* 分类列表 */
|
||||
const { data: categories = [] } = useQuery({
|
||||
queryKey: ["template-categories"],
|
||||
queryFn: getTemplateCategories,
|
||||
})
|
||||
|
||||
/* 删除 mutation */
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: deleteEditingTemplate,
|
||||
onSuccess: () => {
|
||||
message.success("模板已删除")
|
||||
queryClient.invalidateQueries({ queryKey: ["editing-templates"] })
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
if (!(err as { __msgShown?: boolean })?.__msgShown) message.error("删除失败")
|
||||
},
|
||||
})
|
||||
|
||||
/* 复制 mutation */
|
||||
const copyMutation = useMutation({
|
||||
mutationFn: (tpl: EditingTemplate) =>
|
||||
createEditingTemplate({
|
||||
name: `${tpl.name}(副本)`,
|
||||
mode: tpl.mode,
|
||||
category: tpl.category,
|
||||
tags: tpl.tags,
|
||||
title_config: tpl.title_config,
|
||||
subtitle_config: tpl.subtitle_config,
|
||||
bgm_config: tpl.bgm_config,
|
||||
estimated_duration:
|
||||
tpl.estimated_duration ??
|
||||
Math.round(
|
||||
tpl.segments.reduce((s, seg) => s + (seg.duration_min + seg.duration_max) / 2, 0),
|
||||
),
|
||||
segments: tpl.segments.map(({ id: _id, ...rest }) => rest),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
message.success("模板已复制")
|
||||
queryClient.invalidateQueries({ queryKey: ["editing-templates"] })
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
if (!(err as { __msgShown?: boolean })?.__msgShown) message.error("复制失败")
|
||||
},
|
||||
})
|
||||
|
||||
const handleCopy = (tpl: EditingTemplate) => {
|
||||
copyMutation.mutate(tpl)
|
||||
}
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
deleteMutation.mutate(id)
|
||||
}
|
||||
|
||||
return {
|
||||
// 状态
|
||||
searchText,
|
||||
setSearchText,
|
||||
filterCategory,
|
||||
setFilterCategory,
|
||||
// 数据
|
||||
templates,
|
||||
categories,
|
||||
isLoading,
|
||||
// 操作
|
||||
handleCopy,
|
||||
handleDelete,
|
||||
isDeleting: deleteMutation.isPending,
|
||||
isCopying: copyMutation.isPending,
|
||||
}
|
||||
}
|
||||
Executable → Regular
+44
-197
@@ -1,38 +1,29 @@
|
||||
/**
|
||||
* 成片库页面 — V21 设计系统
|
||||
* 卡片网格布局,支持视频播放/下载/分享、批量操作、筛选
|
||||
* 使用 useQuery 对接后端真实 API(api/products.ts)
|
||||
*
|
||||
* 代码结构(三阶段重构后):
|
||||
* - types.ts: 类型定义
|
||||
* - constants.ts: 常量配置
|
||||
* - utils/index.ts: 工具函数
|
||||
* - components/ProductCard.tsx: 产品卡片组件
|
||||
* - components/VideoPlayer.tsx: 视频播放器组件
|
||||
* - hooks/useProductList.ts: 列表查询与筛选
|
||||
* - hooks/useProductActions.ts: 单个/批量操作
|
||||
* 主组件仅保留 Hook 组装与整体布局
|
||||
* 列表查询 → hooks/useProductList
|
||||
* 操作逻辑 → hooks/useProductActions
|
||||
* 筛选栏 → components/ProductFilterBar
|
||||
* 批量操作栏 → components/ProductBatchBar
|
||||
* 空状态 → components/ProductEmptyState
|
||||
* 产品卡片 → components/ProductCard
|
||||
* 视频播放 → components/VideoPlayer
|
||||
*/
|
||||
import React, { useState } from "react"
|
||||
import { Popconfirm, message } from "antd"
|
||||
import {
|
||||
SearchOutlined,
|
||||
VideoCameraOutlined,
|
||||
DownloadOutlined,
|
||||
DeleteOutlined,
|
||||
CheckOutlined,
|
||||
CloudUploadOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import { Button, Input, Select } from "@/components/ui"
|
||||
import { VideoCameraOutlined, DownloadOutlined } from "@ant-design/icons"
|
||||
import { Button } from "@/components/ui"
|
||||
import type { ProductItem } from "./types"
|
||||
import { ProductCard } from "./components/ProductCard"
|
||||
import { VideoPlayer } from "./components/VideoPlayer"
|
||||
import { ProductFilterBar } from "./components/ProductFilterBar"
|
||||
import { ProductBatchBar } from "./components/ProductBatchBar"
|
||||
import { ProductEmptyState } from "./components/ProductEmptyState"
|
||||
import { useProductList } from "./hooks/useProductList"
|
||||
import { useProductActions } from "./hooks/useProductActions"
|
||||
import "./products.css"
|
||||
|
||||
/* ============================================================
|
||||
* 主组件
|
||||
* ============================================================ */
|
||||
const ProductLibrary: React.FC = () => {
|
||||
const {
|
||||
products,
|
||||
@@ -83,58 +74,20 @@ const ProductLibrary: React.FC = () => {
|
||||
setPlayingProduct,
|
||||
})
|
||||
|
||||
// ── Loading 状态 ──
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="xx-products-page">
|
||||
<div className="xx-products-empty">
|
||||
<div className="xx-products-empty-icon">⏳</div>
|
||||
<p>加载中...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
return <ProductEmptyState type="loading" />
|
||||
}
|
||||
|
||||
// ── Error 状态 ──
|
||||
if (isError) {
|
||||
console.error("[ProductLibrary] 加载失败:", error)
|
||||
const errorMsg = error?.message || "加载失败"
|
||||
// 404 视为空数据(API 尚未就绪或无数据)
|
||||
const is404 = errorMsg.includes("404") || errorMsg.includes("Not Found")
|
||||
if (is404) {
|
||||
return (
|
||||
<div className="xx-products-page">
|
||||
<div className="xx-products-header">
|
||||
<h2>
|
||||
<VideoCameraOutlined /> 成片库
|
||||
</h2>
|
||||
</div>
|
||||
<div className="xx-products-empty">
|
||||
<div className="xx-products-empty-icon">🎬</div>
|
||||
<p>暂无成片数据</p>
|
||||
<p
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "var(--text-tertiary)",
|
||||
marginTop: 4,
|
||||
}}
|
||||
>
|
||||
完成视频生成后,成片将自动保存到这里
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
return <ProductEmptyState type="404" />
|
||||
}
|
||||
return (
|
||||
<div className="xx-products-page">
|
||||
<div className="xx-products-empty">
|
||||
<div className="xx-products-empty-icon">❌</div>
|
||||
<p>{errorMsg || "加载失败,请稍后重试"}</p>
|
||||
<Button buttonType="primary" buttonSize="sm" onClick={() => refetch()}>
|
||||
重新加载
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
return <ProductEmptyState type="error" errorMessage={errorMsg} onRetry={refetch} />
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -153,129 +106,35 @@ const ProductLibrary: React.FC = () => {
|
||||
|
||||
{/* 批量操作栏 */}
|
||||
{batchMode && (
|
||||
<div className="xx-products-batch-bar">
|
||||
<div className="xx-products-batch-bar-left">
|
||||
<div
|
||||
className={`xx-products-checkbox${allSelected ? " checked" : ""}`}
|
||||
onClick={handleSelectAll}
|
||||
>
|
||||
{allSelected && <CheckOutlined />}
|
||||
</div>
|
||||
<span className="xx-products-select-all" onClick={handleSelectAll}>
|
||||
{allSelected ? "取消全选" : "全选"}
|
||||
</span>
|
||||
<span className="xx-products-batch-count">已选择 {selectedIds.size} 项</span>
|
||||
</div>
|
||||
<div className="xx-products-batch-bar-right">
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
buttonSize="sm"
|
||||
icon={<DownloadOutlined />}
|
||||
onClick={handleBatchDownload}
|
||||
disabled={batchDownloading}
|
||||
>
|
||||
{batchDownloading ? "打包中..." : "批量下载"}
|
||||
</Button>
|
||||
<Button
|
||||
buttonType="primary"
|
||||
buttonSize="sm"
|
||||
icon={<CloudUploadOutlined />}
|
||||
onClick={handleBatchPublish}
|
||||
>
|
||||
批量发布
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title={`确定删除选中的 ${selectedIds.size} 个视频?`}
|
||||
onConfirm={handleBatchDelete}
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button buttonType="danger" buttonSize="sm" icon={<DeleteOutlined />}>
|
||||
批量删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
<Button buttonType="ghost" buttonSize="sm" onClick={clearSelection}>
|
||||
取消选择
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<ProductBatchBar
|
||||
allSelected={allSelected}
|
||||
selectedCount={selectedIds.size}
|
||||
batchDownloading={batchDownloading}
|
||||
onSelectAll={handleSelectAll}
|
||||
onBatchDownload={handleBatchDownload}
|
||||
onBatchPublish={handleBatchPublish}
|
||||
onBatchDelete={handleBatchDelete}
|
||||
onClearSelection={clearSelection}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 筛选栏 */}
|
||||
<div className="xx-products-filters">
|
||||
<div className="xx-products-filters-left">
|
||||
<Input
|
||||
placeholder="搜索成片名称..."
|
||||
prefix={<SearchOutlined />}
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
allowClear
|
||||
style={{ width: 220 }}
|
||||
/>
|
||||
<Select
|
||||
value={filterStatus}
|
||||
onChange={setFilterStatus}
|
||||
style={{ width: 120 }}
|
||||
options={[
|
||||
{ value: "all", label: "全部状态" },
|
||||
{ value: "completed", label: "已完成" },
|
||||
{ value: "processing", label: "处理中" },
|
||||
{ value: "review", label: "待复核" },
|
||||
{ value: "failed", label: "失败" },
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
value={filterTime}
|
||||
onChange={setFilterTime}
|
||||
style={{ width: 120 }}
|
||||
options={[
|
||||
{ value: "all", label: "全部时间" },
|
||||
{ value: "today", label: "今天" },
|
||||
{ value: "week", label: "近一周" },
|
||||
{ value: "month", label: "近一月" },
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
value={filterDuration}
|
||||
onChange={setFilterDuration}
|
||||
style={{ width: 120 }}
|
||||
options={[
|
||||
{ value: "all", label: "全部时长" },
|
||||
{ value: "short", label: "≤1分钟" },
|
||||
{ value: "medium", label: "1-3分钟" },
|
||||
{ value: "long", label: ">3分钟" },
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
value={filterProject}
|
||||
onChange={setFilterProject}
|
||||
style={{ width: 140 }}
|
||||
options={[{ value: "all", label: "全部项目" }, ...projectOptions]}
|
||||
/>
|
||||
<Select
|
||||
value={filterReviewStatus}
|
||||
onChange={setFilterReviewStatus}
|
||||
style={{ width: 130 }}
|
||||
options={[
|
||||
{ value: "all", label: "全部复核" },
|
||||
{ value: "none", label: "未设置" },
|
||||
{ value: "pending_review", label: "待复核" },
|
||||
{ value: "approved", label: "已通过" },
|
||||
{ value: "rejected", label: "需修改" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className="xx-products-filters-right">
|
||||
<span
|
||||
style={{
|
||||
fontSize: "var(--font-size-sm)",
|
||||
color: "var(--text-tertiary)",
|
||||
}}
|
||||
>
|
||||
共 {filteredProducts.length} 个成片
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<ProductFilterBar
|
||||
searchText={searchText}
|
||||
onSearchChange={setSearchText}
|
||||
filterStatus={filterStatus}
|
||||
onFilterStatusChange={setFilterStatus}
|
||||
filterTime={filterTime}
|
||||
onFilterTimeChange={setFilterTime}
|
||||
filterDuration={filterDuration}
|
||||
onFilterDurationChange={setFilterDuration}
|
||||
filterProject={filterProject}
|
||||
onFilterProjectChange={setFilterProject}
|
||||
filterReviewStatus={filterReviewStatus}
|
||||
onFilterReviewStatusChange={setFilterReviewStatus}
|
||||
projectOptions={projectOptions}
|
||||
resultCount={filteredProducts.length}
|
||||
/>
|
||||
|
||||
{/* 卡片网格 */}
|
||||
{filteredProducts.length > 0 ? (
|
||||
@@ -297,19 +156,7 @@ const ProductLibrary: React.FC = () => {
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="xx-products-empty">
|
||||
<div className="xx-products-empty-icon">
|
||||
<VideoCameraOutlined />
|
||||
</div>
|
||||
<p>暂无成片,去智能剪辑吧</p>
|
||||
<Button
|
||||
buttonType="primary"
|
||||
buttonSize="sm"
|
||||
onClick={() => message.info("跳转到生成页面")}
|
||||
>
|
||||
去生成
|
||||
</Button>
|
||||
</div>
|
||||
<ProductEmptyState type="empty" />
|
||||
)}
|
||||
|
||||
{/* 视频播放弹窗 */}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* ProductLibrary 批量操作栏
|
||||
*/
|
||||
import React from "react"
|
||||
import {
|
||||
CheckOutlined,
|
||||
DownloadOutlined,
|
||||
DeleteOutlined,
|
||||
CloudUploadOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import { Button } from "@/components/ui"
|
||||
import { Popconfirm } from "antd"
|
||||
|
||||
export interface ProductBatchBarProps {
|
||||
allSelected: boolean
|
||||
selectedCount: number
|
||||
batchDownloading: boolean
|
||||
onSelectAll: () => void
|
||||
onBatchDownload: () => void
|
||||
onBatchPublish: () => void
|
||||
onBatchDelete: () => void
|
||||
onClearSelection: () => void
|
||||
}
|
||||
|
||||
export const ProductBatchBar: React.FC<ProductBatchBarProps> = ({
|
||||
allSelected,
|
||||
selectedCount,
|
||||
batchDownloading,
|
||||
onSelectAll,
|
||||
onBatchDownload,
|
||||
onBatchPublish,
|
||||
onBatchDelete,
|
||||
onClearSelection,
|
||||
}) => {
|
||||
return (
|
||||
<div className="xx-products-batch-bar">
|
||||
<div className="xx-products-batch-bar-left">
|
||||
<div
|
||||
className={`xx-products-checkbox${allSelected ? " checked" : ""}`}
|
||||
onClick={onSelectAll}
|
||||
>
|
||||
{allSelected && <CheckOutlined />}
|
||||
</div>
|
||||
<span className="xx-products-select-all" onClick={onSelectAll}>
|
||||
{allSelected ? "取消全选" : "全选"}
|
||||
</span>
|
||||
<span className="xx-products-batch-count">已选择 {selectedCount} 项</span>
|
||||
</div>
|
||||
<div className="xx-products-batch-bar-right">
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
buttonSize="sm"
|
||||
icon={<DownloadOutlined />}
|
||||
onClick={onBatchDownload}
|
||||
disabled={batchDownloading}
|
||||
>
|
||||
{batchDownloading ? "打包中..." : "批量下载"}
|
||||
</Button>
|
||||
<Button
|
||||
buttonType="primary"
|
||||
buttonSize="sm"
|
||||
icon={<CloudUploadOutlined />}
|
||||
onClick={onBatchPublish}
|
||||
>
|
||||
批量发布
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title={`确定删除选中的 ${selectedCount} 个视频?`}
|
||||
onConfirm={onBatchDelete}
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button buttonType="danger" buttonSize="sm" icon={<DeleteOutlined />}>
|
||||
批量删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
<Button buttonType="ghost" buttonSize="sm" onClick={onClearSelection}>
|
||||
取消选择
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ProductBatchBar
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* ProductLibrary 空状态/加载/错误页面
|
||||
*/
|
||||
import React from "react"
|
||||
import { VideoCameraOutlined } from "@ant-design/icons"
|
||||
import { Button } from "@/components/ui"
|
||||
import { message } from "antd"
|
||||
|
||||
export type ProductEmptyStateType = "loading" | "empty" | "404" | "error"
|
||||
|
||||
export interface ProductEmptyStateProps {
|
||||
type: ProductEmptyStateType
|
||||
errorMessage?: string
|
||||
onRetry?: () => void
|
||||
}
|
||||
|
||||
export const ProductEmptyState: React.FC<ProductEmptyStateProps> = ({
|
||||
type,
|
||||
errorMessage,
|
||||
onRetry,
|
||||
}) => {
|
||||
if (type === "loading") {
|
||||
return (
|
||||
<div className="xx-products-page">
|
||||
<div className="xx-products-empty">
|
||||
<div className="xx-products-empty-icon">⏳</div>
|
||||
<p>加载中...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (type === "404") {
|
||||
return (
|
||||
<div className="xx-products-page">
|
||||
<div className="xx-products-header">
|
||||
<h2>
|
||||
<VideoCameraOutlined /> 成片库
|
||||
</h2>
|
||||
</div>
|
||||
<div className="xx-products-empty">
|
||||
<div className="xx-products-empty-icon">🎬</div>
|
||||
<p>暂无成片数据</p>
|
||||
<p
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "var(--text-tertiary)",
|
||||
marginTop: 4,
|
||||
}}
|
||||
>
|
||||
完成视频生成后,成片将自动保存到这里
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (type === "error") {
|
||||
return (
|
||||
<div className="xx-products-page">
|
||||
<div className="xx-products-empty">
|
||||
<div className="xx-products-empty-icon">❌</div>
|
||||
<p>{errorMessage || "加载失败,请稍后重试"}</p>
|
||||
{onRetry && (
|
||||
<Button buttonType="primary" buttonSize="sm" onClick={onRetry}>
|
||||
重新加载
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// empty
|
||||
return (
|
||||
<div className="xx-products-empty">
|
||||
<div className="xx-products-empty-icon">
|
||||
<VideoCameraOutlined />
|
||||
</div>
|
||||
<p>暂无成片,去智能剪辑吧</p>
|
||||
<Button buttonType="primary" buttonSize="sm" onClick={() => message.info("跳转到生成页面")}>
|
||||
去生成
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ProductEmptyState
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* ProductLibrary 筛选栏
|
||||
*/
|
||||
import React from "react"
|
||||
import { SearchOutlined } from "@ant-design/icons"
|
||||
import { Input, Select } from "@/components/ui"
|
||||
|
||||
export interface ProductFilterBarProps {
|
||||
searchText: string
|
||||
onSearchChange: (text: string) => void
|
||||
filterStatus: string
|
||||
onFilterStatusChange: (val: string) => void
|
||||
filterTime: string
|
||||
onFilterTimeChange: (val: string) => void
|
||||
filterDuration: string
|
||||
onFilterDurationChange: (val: string) => void
|
||||
filterProject: string
|
||||
onFilterProjectChange: (val: string) => void
|
||||
filterReviewStatus: string
|
||||
onFilterReviewStatusChange: (val: string) => void
|
||||
projectOptions: Array<{ value: string; label: string }>
|
||||
resultCount: number
|
||||
}
|
||||
|
||||
export const ProductFilterBar: React.FC<ProductFilterBarProps> = ({
|
||||
searchText,
|
||||
onSearchChange,
|
||||
filterStatus,
|
||||
onFilterStatusChange,
|
||||
filterTime,
|
||||
onFilterTimeChange,
|
||||
filterDuration,
|
||||
onFilterDurationChange,
|
||||
filterProject,
|
||||
onFilterProjectChange,
|
||||
filterReviewStatus,
|
||||
onFilterReviewStatusChange,
|
||||
projectOptions,
|
||||
resultCount,
|
||||
}) => {
|
||||
return (
|
||||
<div className="xx-products-filters">
|
||||
<div className="xx-products-filters-left">
|
||||
<Input
|
||||
placeholder="搜索成片名称..."
|
||||
prefix={<SearchOutlined />}
|
||||
value={searchText}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
allowClear
|
||||
style={{ width: 220 }}
|
||||
/>
|
||||
<Select
|
||||
value={filterStatus}
|
||||
onChange={onFilterStatusChange}
|
||||
style={{ width: 120 }}
|
||||
options={[
|
||||
{ value: "all", label: "全部状态" },
|
||||
{ value: "completed", label: "已完成" },
|
||||
{ value: "processing", label: "处理中" },
|
||||
{ value: "review", label: "待复核" },
|
||||
{ value: "failed", label: "失败" },
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
value={filterTime}
|
||||
onChange={onFilterTimeChange}
|
||||
style={{ width: 120 }}
|
||||
options={[
|
||||
{ value: "all", label: "全部时间" },
|
||||
{ value: "today", label: "今天" },
|
||||
{ value: "week", label: "近一周" },
|
||||
{ value: "month", label: "近一月" },
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
value={filterDuration}
|
||||
onChange={onFilterDurationChange}
|
||||
style={{ width: 120 }}
|
||||
options={[
|
||||
{ value: "all", label: "全部时长" },
|
||||
{ value: "short", label: "≤1分钟" },
|
||||
{ value: "medium", label: "1-3分钟" },
|
||||
{ value: "long", label: ">3分钟" },
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
value={filterProject}
|
||||
onChange={onFilterProjectChange}
|
||||
style={{ width: 140 }}
|
||||
options={[{ value: "all", label: "全部项目" }, ...projectOptions]}
|
||||
/>
|
||||
<Select
|
||||
value={filterReviewStatus}
|
||||
onChange={onFilterReviewStatusChange}
|
||||
style={{ width: 130 }}
|
||||
options={[
|
||||
{ value: "all", label: "全部复核" },
|
||||
{ value: "none", label: "未设置" },
|
||||
{ value: "pending_review", label: "待复核" },
|
||||
{ value: "approved", label: "已通过" },
|
||||
{ value: "rejected", label: "需修改" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className="xx-products-filters-right">
|
||||
<span
|
||||
style={{
|
||||
fontSize: "var(--font-size-sm)",
|
||||
color: "var(--text-tertiary)",
|
||||
}}
|
||||
>
|
||||
共 {resultCount} 个成片
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ProductFilterBar
|
||||
@@ -2,151 +2,59 @@
|
||||
* 升级/降级/续费页面
|
||||
* P1-3: antd Button/Modal/Radio/Spin → 自定义 UI 组件
|
||||
*/
|
||||
import React, { useState, useEffect } from "react"
|
||||
import { message } from "antd"
|
||||
import { Button, Modal } from "@/components/ui"
|
||||
import React from "react"
|
||||
import { Modal } from "@/components/ui"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import {
|
||||
getCurrentSubscription,
|
||||
changePlan,
|
||||
toggleAutoRenew,
|
||||
cancelSubscription,
|
||||
} from "@/api/subscription"
|
||||
import type { SubscriptionInfo, PlanType, BillingCycle } from "@/api/subscription"
|
||||
import type { PlanType } from "@/api/subscription"
|
||||
import PageHead from "@/components/layout/PageHead"
|
||||
import { Button } from "@/components/ui"
|
||||
import { PLANS_META, getPlanName, getPlanPrice } from "./constants"
|
||||
import { BillingCycleSwitch, Spinner } from "./components/SubscriptionUI"
|
||||
import { useSubscription } from "./hooks/useSubscription"
|
||||
import "./UpgradeSubscription.css"
|
||||
|
||||
const PLANS_META: Record<string, { name: string; price: number; yearlyPrice: number }> = {
|
||||
free: { name: "体验版", price: 0, yearlyPrice: 0 },
|
||||
standard: { name: "标准版", price: 99, yearlyPrice: 990 },
|
||||
pro: { name: "专业版", price: 299, yearlyPrice: 2990 },
|
||||
enterprise: { name: "企业版", price: 0, yearlyPrice: 0 },
|
||||
}
|
||||
|
||||
/** 自定义计费周期切换组件 */
|
||||
const BillingCycleSwitch: React.FC<{
|
||||
value: BillingCycle
|
||||
onChange: (cycle: BillingCycle) => void
|
||||
monthlyPrice: number
|
||||
yearlyPrice: number
|
||||
}> = ({ value, onChange, monthlyPrice, yearlyPrice }) => (
|
||||
<div className="xx-billing-cycle-switch">
|
||||
<button
|
||||
type="button"
|
||||
className={`xx-billing-cycle-btn ${value === "monthly" ? "active" : ""}`}
|
||||
onClick={() => onChange("monthly")}
|
||||
>
|
||||
¥{monthlyPrice}/月
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`xx-billing-cycle-btn ${value === "yearly" ? "active" : ""}`}
|
||||
onClick={() => onChange("yearly")}
|
||||
>
|
||||
¥{yearlyPrice}/年
|
||||
{yearlyPrice > 0 && monthlyPrice > 0 && (
|
||||
<span className="xx-save">省 ¥{monthlyPrice * 12 - yearlyPrice}</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
|
||||
/** 自定义 Spinner 组件 */
|
||||
const Spinner: React.FC<{ size?: "small" | "large" }> = ({ size = "large" }) => (
|
||||
<div className={`xx-spinner xx-spinner--${size}`}>
|
||||
<div className="xx-spinner-dot" />
|
||||
<div className="xx-spinner-dot" />
|
||||
<div className="xx-spinner-dot" />
|
||||
</div>
|
||||
)
|
||||
|
||||
const UpgradeSubscription: React.FC = () => {
|
||||
const navigate = useNavigate()
|
||||
const [subscription, setSubscription] = useState<SubscriptionInfo | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [selectedPlan, setSelectedPlan] = useState<PlanType>("standard")
|
||||
const [billingCycle, setBillingCycle] = useState<BillingCycle>("monthly")
|
||||
const {
|
||||
subscription,
|
||||
loading,
|
||||
submitting,
|
||||
selectedPlan,
|
||||
billingCycle,
|
||||
setSelectedPlan,
|
||||
setBillingCycle,
|
||||
executeChangePlan,
|
||||
handleToggleAutoRenew,
|
||||
handleCancel,
|
||||
} = useSubscription()
|
||||
|
||||
useEffect(() => {
|
||||
loadSubscription()
|
||||
}, [])
|
||||
|
||||
const loadSubscription = async () => {
|
||||
try {
|
||||
const data = await getCurrentSubscription()
|
||||
setSubscription(data)
|
||||
setSelectedPlan(data.plan_id)
|
||||
} catch (err: unknown) {
|
||||
if (!(err as { __msgShown?: boolean })?.__msgShown) message.error("获取订阅信息失败")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpgrade = async () => {
|
||||
const handleUpgradeClick = () => {
|
||||
if (!subscription) return
|
||||
if (selectedPlan === subscription.plan_id && billingCycle === subscription.billing_cycle) {
|
||||
message.info("当前已是该套餐")
|
||||
return
|
||||
}
|
||||
|
||||
const plan = PLANS_META[selectedPlan]
|
||||
const price = billingCycle === "yearly" ? plan.yearlyPrice : plan.price
|
||||
const price = getPlanPrice(selectedPlan, billingCycle)
|
||||
|
||||
Modal.confirm({
|
||||
title: "确认变更套餐",
|
||||
content: `即将变更为「${plan.name}」(${billingCycle === "monthly" ? "月付" : "年付"}),${price > 0 ? `费用 ¥${price}${billingCycle === "monthly" ? "/月" : "/年"}` : "免费"}。变更立即生效。`,
|
||||
okText: "确认变更",
|
||||
cancelText: "取消",
|
||||
onOk: async () => {
|
||||
try {
|
||||
setSubmitting(true)
|
||||
const res = await changePlan({
|
||||
target_plan_id: selectedPlan,
|
||||
billing_cycle: billingCycle,
|
||||
})
|
||||
if (res.success) {
|
||||
message.success(res.message)
|
||||
setSubscription(res.new_subscription ?? null)
|
||||
} else {
|
||||
message.error(res.message)
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
if (!(err as { __msgShown?: boolean })?.__msgShown) message.error("套餐变更失败,请重试")
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
},
|
||||
onOk: executeChangePlan,
|
||||
})
|
||||
}
|
||||
|
||||
const handleToggleAutoRenew = async (enabled: boolean) => {
|
||||
try {
|
||||
const res = await toggleAutoRenew(enabled)
|
||||
message.success(res.message)
|
||||
if (subscription) {
|
||||
setSubscription({ ...subscription, auto_renew: enabled })
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
if (!(err as { __msgShown?: boolean })?.__msgShown) message.error("操作失败")
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
const handleCancelClick = () => {
|
||||
Modal.confirm({
|
||||
title: "确认取消订阅",
|
||||
content: "取消后,当前周期结束前仍可正常使用,到期后降级为体验版。",
|
||||
okText: "确认取消",
|
||||
cancelText: "再想想",
|
||||
onOk: async () => {
|
||||
try {
|
||||
const res = await cancelSubscription()
|
||||
message.success(res.message)
|
||||
navigate("/app/subscription")
|
||||
} catch (err: unknown) {
|
||||
if (!(err as { __msgShown?: boolean })?.__msgShown) message.error("取消失败")
|
||||
}
|
||||
const ok = await handleCancel()
|
||||
if (ok) navigate("/app/subscription")
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -163,10 +71,7 @@ const UpgradeSubscription: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div className="xx-upgrade-page">
|
||||
<PageHead
|
||||
title="变更订阅方案"
|
||||
description={`当前套餐:${PLANS_META[currentPlan]?.name ?? "体验版"}`}
|
||||
/>
|
||||
<PageHead title="变更订阅方案" description={`当前套餐:${getPlanName(currentPlan)}`} />
|
||||
|
||||
<div className="xx-upgrade-plans">
|
||||
{(["standard", "pro", "enterprise"] as PlanType[]).map((planId) => {
|
||||
@@ -198,7 +103,7 @@ const UpgradeSubscription: React.FC = () => {
|
||||
buttonType="primary"
|
||||
buttonSize="lg"
|
||||
disabled={submitting || selectedPlan === currentPlan}
|
||||
onClick={handleUpgrade}
|
||||
onClick={handleUpgradeClick}
|
||||
>
|
||||
{submitting ? "处理中..." : "确认变更"}
|
||||
</Button>
|
||||
@@ -220,7 +125,7 @@ const UpgradeSubscription: React.FC = () => {
|
||||
<Button
|
||||
buttonType="danger"
|
||||
buttonSize="sm"
|
||||
onClick={handleCancel}
|
||||
onClick={handleCancelClick}
|
||||
className="xx-cancel-btn"
|
||||
>
|
||||
取消订阅
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import React from "react"
|
||||
import type { BillingCycle } from "@/api/subscription"
|
||||
|
||||
interface BillingCycleSwitchProps {
|
||||
value: BillingCycle
|
||||
onChange: (cycle: BillingCycle) => void
|
||||
monthlyPrice: number
|
||||
yearlyPrice: number
|
||||
}
|
||||
|
||||
/** 自定义计费周期切换组件 */
|
||||
export const BillingCycleSwitch: React.FC<BillingCycleSwitchProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
monthlyPrice,
|
||||
yearlyPrice,
|
||||
}) => (
|
||||
<div className="xx-billing-cycle-switch">
|
||||
<button
|
||||
type="button"
|
||||
className={`xx-billing-cycle-btn ${value === "monthly" ? "active" : ""}`}
|
||||
onClick={() => onChange("monthly")}
|
||||
>
|
||||
¥{monthlyPrice}/月
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`xx-billing-cycle-btn ${value === "yearly" ? "active" : ""}`}
|
||||
onClick={() => onChange("yearly")}
|
||||
>
|
||||
¥{yearlyPrice}/年
|
||||
{yearlyPrice > 0 && monthlyPrice > 0 && (
|
||||
<span className="xx-save">省 ¥{monthlyPrice * 12 - yearlyPrice}</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
|
||||
interface SpinnerProps {
|
||||
size?: "small" | "large"
|
||||
}
|
||||
|
||||
/** 自定义 Spinner 组件 */
|
||||
export const Spinner: React.FC<SpinnerProps> = ({ size = "large" }) => (
|
||||
<div className={`xx-spinner xx-spinner--${size}`}>
|
||||
<div className="xx-spinner-dot" />
|
||||
<div className="xx-spinner-dot" />
|
||||
<div className="xx-spinner-dot" />
|
||||
</div>
|
||||
)
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { PlanType, BillingCycle } from "@/api/subscription"
|
||||
|
||||
export const PLANS_META: Record<string, { name: string; price: number; yearlyPrice: number }> = {
|
||||
free: { name: "体验版", price: 0, yearlyPrice: 0 },
|
||||
standard: { name: "标准版", price: 99, yearlyPrice: 990 },
|
||||
pro: { name: "专业版", price: 299, yearlyPrice: 2990 },
|
||||
enterprise: { name: "企业版", price: 0, yearlyPrice: 0 },
|
||||
}
|
||||
|
||||
export const getPlanName = (planId: PlanType | string) => PLANS_META[planId]?.name ?? "体验版"
|
||||
|
||||
export const getPlanPrice = (planId: PlanType | string, cycle: BillingCycle) => {
|
||||
const plan = PLANS_META[planId]
|
||||
if (!plan) return 0
|
||||
return cycle === "yearly" ? plan.yearlyPrice : plan.price
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { useState, useEffect, useCallback } from "react"
|
||||
import { message } from "antd"
|
||||
import {
|
||||
getCurrentSubscription,
|
||||
changePlan,
|
||||
toggleAutoRenew,
|
||||
cancelSubscription,
|
||||
type SubscriptionInfo,
|
||||
type PlanType,
|
||||
type BillingCycle,
|
||||
} from "@/api/subscription"
|
||||
|
||||
/**
|
||||
* 订阅管理 Hook
|
||||
* 封装订阅信息查询、套餐变更、自动续费切换、取消订阅等逻辑
|
||||
*/
|
||||
export function useSubscription() {
|
||||
const [subscription, setSubscription] = useState<SubscriptionInfo | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [selectedPlan, setSelectedPlan] = useState<PlanType>("standard")
|
||||
const [billingCycle, setBillingCycle] = useState<BillingCycle>("monthly")
|
||||
|
||||
const loadSubscription = useCallback(async () => {
|
||||
try {
|
||||
const data = await getCurrentSubscription()
|
||||
setSubscription(data)
|
||||
setSelectedPlan(data.plan_id)
|
||||
} catch (err: unknown) {
|
||||
if (!(err as { __msgShown?: boolean })?.__msgShown) message.error("获取订阅信息失败")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadSubscription()
|
||||
}, [loadSubscription])
|
||||
|
||||
const handleUpgrade = useCallback(async () => {
|
||||
if (!subscription) return
|
||||
if (selectedPlan === subscription.plan_id && billingCycle === subscription.billing_cycle) {
|
||||
message.info("当前已是该套餐")
|
||||
return
|
||||
}
|
||||
// 由调用方决定是否弹确认框
|
||||
}, [subscription, selectedPlan, billingCycle])
|
||||
|
||||
const executeChangePlan = useCallback(async () => {
|
||||
try {
|
||||
setSubmitting(true)
|
||||
const res = await changePlan({
|
||||
target_plan_id: selectedPlan,
|
||||
billing_cycle: billingCycle,
|
||||
})
|
||||
if (res.success) {
|
||||
message.success(res.message)
|
||||
setSubscription(res.new_subscription ?? null)
|
||||
} else {
|
||||
message.error(res.message)
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
if (!(err as { __msgShown?: boolean })?.__msgShown) message.error("套餐变更失败,请重试")
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}, [selectedPlan, billingCycle])
|
||||
|
||||
const handleToggleAutoRenew = useCallback(
|
||||
async (enabled: boolean) => {
|
||||
try {
|
||||
const res = await toggleAutoRenew(enabled)
|
||||
message.success(res.message)
|
||||
if (subscription) {
|
||||
setSubscription({ ...subscription, auto_renew: enabled })
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
if (!(err as { __msgShown?: boolean })?.__msgShown) message.error("操作失败")
|
||||
}
|
||||
},
|
||||
[subscription],
|
||||
)
|
||||
|
||||
const handleCancel = useCallback(async () => {
|
||||
try {
|
||||
const res = await cancelSubscription()
|
||||
message.success(res.message)
|
||||
return true
|
||||
} catch (err: unknown) {
|
||||
if (!(err as { __msgShown?: boolean })?.__msgShown) message.error("取消失败")
|
||||
return false
|
||||
}
|
||||
}, [])
|
||||
|
||||
return {
|
||||
// 状态
|
||||
subscription,
|
||||
loading,
|
||||
submitting,
|
||||
selectedPlan,
|
||||
billingCycle,
|
||||
setSelectedPlan,
|
||||
setBillingCycle,
|
||||
// 操作
|
||||
loadSubscription,
|
||||
handleUpgrade,
|
||||
executeChangePlan,
|
||||
handleToggleAutoRenew,
|
||||
handleCancel,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user