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

This commit is contained in:
2026-07-27 12:34:17 +08:00
99 changed files with 11796 additions and 4677 deletions
@@ -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,
}
}
@@ -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,
}
}
-550
View File
@@ -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
}
+1 -1
View File
@@ -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) => {
+24 -167
View File
@@ -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,
}
}
+44 -197
View File
@@ -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,
}
}