Compare commits

..

6 Commits

Author SHA1 Message Date
xiaoxia 51a79230d5 refactor(cover-selector): 主文件改为 re-export 子目录,完成拆分
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 5s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m19s
CI/CD Pipeline / Validate - Code Quality (pull_request) Failing after 1m50s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 28s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m7s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 21s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 54s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m42s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 47s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 3m32s
AI Code Review / AI Code Review (pull_request) Successful in 3m37s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 15m16s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 23s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 21s
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1220h39m42s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 1221h14m1s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1221h14m3s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1221h14m3s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1222h58m34s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 1222h58m36s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 1222h58m37s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 1222h58m49s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 1224h32m56s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 1224h33m0s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 1221h46m48s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 1223h31m20s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 1223h31m24s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 1225h5m43s
2026-07-27 12:39:00 +08:00
xiaoxia be1a1dae60 fix(cover-selector): 修复 types 导入路径(../types → ../../types)
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 Merge on CI Green + Approved (pull_request) Successful in 45m52s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m47s
2026-07-27 12:37:58 +08:00
xiaoxia 71701e27ae fix(cover-selector): 修复 types 导入路径(../types → ../../types) 2026-07-27 12:37:57 +08:00
xiaoxia 2781663c61 fix(cover-selector): 修复 types 导入路径(../types → ../../types)
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 46s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 50s
2026-07-27 12:37:55 +08:00
xiaoxia 185431788c Merge branch 'develop' into refactor/cover-selector-split
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 24s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 21s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 1m7s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m52s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m52s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 1m15s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 45s
CI/CD Pipeline / Validate - Code Quality (pull_request) Failing after 2m39s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 53s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 21s
AI Code Review / AI Code Review (pull_request) Successful in 2m0s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 2m57s
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / PR Build API Image (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 / 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
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1226h52m57s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 1226h54m51s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 1226h52m58s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 1226h54m53s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 1226h52m59s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 1226h53m0s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 1226h53m14s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 1227h27m35s
2026-07-27 12:32:35 +08:00
xiaoxia 922e0bdc58 refactor(editing-planner): 拆分 CoverSelector 组件,抽离 Hook + 模式面板
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 49s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m29s
CI/CD Pipeline / Validate - Code Quality (pull_request) Failing after 2m41s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m5s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 37s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m46s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 2m7s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m10s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 55s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 2m58s
AI Code Review / AI Code Review (pull_request) Successful in 3m3s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 7m45s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 25s
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1230h23m6s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 1230h43m43s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1230h43m45s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1230h43m46s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1231h1m49s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 1231h1m51s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 1231h1m57s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 1231h1m59s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 1231h2m1s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 1231h2m4s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 1231h21m30s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 1231h21m32s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 1231h16m28s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 1231h54m15s
主文件 286→160 行(-44%),拆分为:
- useCoverSelector: 状态管理、文件上传、模式切换等逻辑
- CoverModePanels: 智能封面/抽帧/上传 三个模式面板
- index: 组合入口 + Drawer 布局

导入路径保持向后兼容
2026-07-27 07:37:56 +08:00
9 changed files with 739 additions and 887 deletions
@@ -1,286 +1,5 @@
/**
* 封面选择器
* 抽帧选封面 + 上传自定义封面 + 智能封面推荐
* 封面选择器入口(向后兼容)
* 实际实现位于 ./cover-selector/ 目录
*/
import React, { useCallback, useRef, useState } from "react"
import { Drawer } from "antd"
import type { CoverConfig, CoverMode } from "../types"
import { DEFAULT_COVER_CONFIG } from "../types"
interface CoverSelectorProps {
open: boolean
onClose: () => void
config: CoverConfig
onChange: (config: CoverConfig) => void
totalDuration: number
}
/** 封面模式标签 */
const MODE_LABELS: Record<CoverMode, string> = {
auto: "智能封面",
frame: "抽帧选封面",
upload: "上传封面",
}
/** 封面模式图标 */
const MODE_ICONS: Record<CoverMode, string> = {
auto: "🤖",
frame: "🎞️",
upload: "📤",
}
const CoverSelector: React.FC<CoverSelectorProps> = ({
open,
onClose,
config,
onChange,
totalDuration,
}) => {
const fileInputRef = useRef<HTMLInputElement>(null)
const [isDragging, setIsDragging] = useState(false)
const update = useCallback(
(partial: Partial<CoverConfig>) => {
onChange({ ...config, ...partial })
},
[config, onChange],
)
const handleReset = useCallback(() => {
onChange({ ...DEFAULT_COVER_CONFIG, enabled: config.enabled })
}, [config.enabled, onChange])
/** 切换模式 */
const handleModeChange = useCallback(
(mode: CoverMode) => {
update({ mode })
},
[update],
)
/** 处理文件上传 */
const handleFileUpload = useCallback(
(file: File) => {
if (!file.type.startsWith("image/")) return
const reader = new FileReader()
reader.onload = (e) => {
const url = e.target?.result as string
update({ upload_url: url, thumbnail_url: url, mode: "upload" })
}
reader.readAsDataURL(file)
},
[update],
)
/** 拖拽上传 */
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault()
setIsDragging(false)
const file = e.dataTransfer.files[0]
if (file) handleFileUpload(file)
},
[handleFileUpload],
)
/** 使用 AI 推荐时间 */
const handleUseAiSuggestion = useCallback(() => {
if (config.ai_suggested_time !== null) {
update({ frame_time: config.ai_suggested_time, mode: "frame" })
}
}, [config.ai_suggested_time, update])
/** 格式化时间 */
const formatTime = (seconds: number) => {
const m = Math.floor(seconds / 60)
const s = Math.floor(seconds % 60)
const ms = Math.floor((seconds % 1) * 10)
return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}.${ms}`
}
return (
<Drawer
title="封面选择"
placement="right"
width={440}
open={open}
onClose={onClose}
className="cover-selector-drawer"
>
{/* 顶部开关 */}
<div className="cover-header">
<span className="cover-header-label"></span>
<label className="cover-switch">
<input
type="checkbox"
checked={config.enabled}
onChange={(e) => update({ enabled: e.target.checked })}
/>
<span className="cover-switch-slider" />
</label>
</div>
{/* 模式选择 */}
<div className="cover-mode-section">
<div className="cover-section-title"></div>
<div className="cover-mode-tabs">
{(["auto", "frame", "upload"] as CoverMode[]).map((m) => (
<button
key={m}
className={`cover-mode-tab${config.mode === m ? " active" : ""}`}
onClick={() => handleModeChange(m)}
>
<span className="cover-mode-icon">{MODE_ICONS[m]}</span>
<span className="cover-mode-label">{MODE_LABELS[m]}</span>
</button>
))}
</div>
</div>
{/* 模式内容区 */}
<div className="cover-mode-content">
{/* 智能封面 */}
{config.mode === "auto" && (
<div className="cover-auto-section">
<div className="cover-auto-desc">
AI
</div>
{config.ai_suggested_time !== null ? (
<div className="cover-auto-suggestion">
<div className="cover-auto-badge">AI </div>
<div className="cover-auto-time">
{formatTime(config.ai_suggested_time)}
</div>
<button className="cover-auto-use-btn" onClick={handleUseAiSuggestion}>
使
</button>
</div>
) : (
<div className="cover-auto-pending">
<div className="cover-auto-spinner" />
<span>AI ...</span>
</div>
)}
</div>
)}
{/* 抽帧选封面 */}
{config.mode === "frame" && (
<div className="cover-frame-section">
<div className="cover-frame-preview">
<div className="cover-frame-placeholder">
<span className="cover-frame-icon">🎞</span>
<span className="cover-frame-time">{formatTime(config.frame_time)}</span>
</div>
</div>
<div className="cover-frame-timeline">
<div className="cover-frame-slider-header">
<span className="cover-frame-slider-label"></span>
<span className="cover-frame-slider-value">{formatTime(config.frame_time)}</span>
</div>
<input
type="range"
className="cover-frame-slider"
min={0}
max={Math.max(totalDuration, 1)}
step={0.1}
value={config.frame_time}
onChange={(e) => update({ frame_time: Number(e.target.value) })}
/>
<div className="cover-frame-range">
<span>00:00</span>
<span>{formatTime(totalDuration)}</span>
</div>
</div>
{/* 快捷时间点 */}
<div className="cover-frame-quick">
<span className="cover-quick-label"></span>
{[0, 0.25, 0.5, 0.75].map((ratio) => {
const t = totalDuration * ratio
return (
<button
key={ratio}
className="cover-quick-btn"
onClick={() => update({ frame_time: t })}
>
{formatTime(t)}
</button>
)
})}
</div>
</div>
)}
{/* 上传封面 */}
{config.mode === "upload" && (
<div className="cover-upload-section">
<div
className={`cover-upload-area${isDragging ? " dragging" : ""}`}
onDragOver={(e) => {
e.preventDefault()
setIsDragging(true)
}}
onDragLeave={() => setIsDragging(false)}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
>
{config.upload_url ? (
<div className="cover-upload-preview">
<img src={config.upload_url} alt="封面预览" />
<div className="cover-upload-overlay"></div>
</div>
) : (
<div className="cover-upload-placeholder">
<span className="cover-upload-icon">📤</span>
<span className="cover-upload-text"></span>
<span className="cover-upload-hint"> JPG / PNG 16:9 </span>
</div>
)}
<input
ref={fileInputRef}
type="file"
accept="image/*"
style={{ display: "none" }}
onChange={(e) => {
const file = e.target.files?.[0]
if (file) handleFileUpload(file)
}}
/>
</div>
</div>
)}
</div>
{/* 封面预览 */}
<div className="cover-preview-section">
<div className="cover-section-title"></div>
<div className="cover-preview-box">
{config.upload_url ? (
<img src={config.upload_url} alt="封面预览" className="cover-preview-img" />
) : (
<div className="cover-preview-placeholder">
<span className="cover-preview-icon">🖼</span>
<span className="cover-preview-text">
{config.mode === "auto"
? "AI 智能选择"
: config.mode === "frame"
? `${formatTime(config.frame_time)}`
: "未上传封面"}
</span>
</div>
)}
<div className="cover-preview-ratio">16:9</div>
</div>
</div>
{/* 底部 */}
<div className="cover-footer">
<button className="cover-reset-btn" onClick={handleReset}>
</button>
</div>
</Drawer>
)
}
export default CoverSelector
export { default } from "./cover-selector"
@@ -0,0 +1,143 @@
import React from "react"
import type { CoverConfig } from "../../types"
interface CoverAutoModeProps {
config: CoverConfig
formatTime: (s: number) => string
onUseAiSuggestion: () => void
}
/** 智能封面模式面板 */
export const CoverAutoMode: React.FC<CoverAutoModeProps> = ({
config,
formatTime,
onUseAiSuggestion,
}) => (
<div className="cover-auto-section">
<div className="cover-auto-desc">AI </div>
{config.ai_suggested_time !== null ? (
<div className="cover-auto-suggestion">
<div className="cover-auto-badge">AI </div>
<div className="cover-auto-time">{formatTime(config.ai_suggested_time)}</div>
<button className="cover-auto-use-btn" onClick={onUseAiSuggestion}>
使
</button>
</div>
) : (
<div className="cover-auto-pending">
<div className="cover-auto-spinner" />
<span>AI ...</span>
</div>
)}
</div>
)
interface CoverFrameModeProps {
config: CoverConfig
totalDuration: number
formatTime: (s: number) => string
onFrameTimeChange: (time: number) => void
}
/** 抽帧选封面模式面板 */
export const CoverFrameMode: React.FC<CoverFrameModeProps> = ({
config,
totalDuration,
formatTime,
onFrameTimeChange,
}) => (
<div className="cover-frame-section">
<div className="cover-frame-preview">
<div className="cover-frame-placeholder">
<span className="cover-frame-icon">🎞</span>
<span className="cover-frame-time">{formatTime(config.frame_time)}</span>
</div>
</div>
<div className="cover-frame-timeline">
<div className="cover-frame-slider-header">
<span className="cover-frame-slider-label"></span>
<span className="cover-frame-slider-value">{formatTime(config.frame_time)}</span>
</div>
<input
type="range"
className="cover-frame-slider"
min={0}
max={Math.max(totalDuration, 1)}
step={0.1}
value={config.frame_time}
onChange={(e) => onFrameTimeChange(Number(e.target.value))}
/>
<div className="cover-frame-range">
<span>00:00</span>
<span>{formatTime(totalDuration)}</span>
</div>
</div>
<div className="cover-frame-quick">
<span className="cover-quick-label"></span>
{[0, 0.25, 0.5, 0.75].map((ratio) => {
const t = totalDuration * ratio
return (
<button key={ratio} className="cover-quick-btn" onClick={() => onFrameTimeChange(t)}>
{formatTime(t)}
</button>
)
})}
</div>
</div>
)
interface CoverUploadModeProps {
config: CoverConfig
isDragging: boolean
fileInputRef: React.RefObject<HTMLInputElement>
onDragOver: (e: React.DragEvent) => void
onDragLeave: () => void
onDrop: (e: React.DragEvent) => void
onAreaClick: () => void
onFileChange: (file: File) => void
}
/** 上传封面模式面板 */
export const CoverUploadMode: React.FC<CoverUploadModeProps> = ({
config,
isDragging,
fileInputRef,
onDragOver,
onDragLeave,
onDrop,
onAreaClick,
onFileChange,
}) => (
<div className="cover-upload-section">
<div
className={`cover-upload-area${isDragging ? " dragging" : ""}`}
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onDrop={onDrop}
onClick={onAreaClick}
>
{config.upload_url ? (
<div className="cover-upload-preview">
<img src={config.upload_url} alt="封面预览" />
<div className="cover-upload-overlay"></div>
</div>
) : (
<div className="cover-upload-placeholder">
<span className="cover-upload-icon">📤</span>
<span className="cover-upload-text"></span>
<span className="cover-upload-hint"> JPG / PNG 16:9 </span>
</div>
)}
<input
ref={fileInputRef}
type="file"
accept="image/*"
style={{ display: "none" }}
onChange={(e) => {
const file = e.target.files?.[0]
if (file) onFileChange(file)
}}
/>
</div>
</div>
)
@@ -0,0 +1,146 @@
/**
* 封面选择器
* 抽帧选封面 + 上传自定义封面 + 智能封面推荐
*/
import React from "react"
import { Drawer } from "antd"
import type { CoverConfig, CoverMode } from "../../types"
import { useCoverSelector, MODE_LABELS, MODE_ICONS } from "./useCoverSelector"
import { CoverAutoMode, CoverFrameMode, CoverUploadMode } from "./CoverModePanels"
interface CoverSelectorProps {
open: boolean
onClose: () => void
config: CoverConfig
onChange: (config: CoverConfig) => void
totalDuration: number
}
const CoverSelector: React.FC<CoverSelectorProps> = ({
open,
onClose,
config,
onChange,
totalDuration,
}) => {
const {
fileInputRef,
isDragging,
setIsDragging,
update,
handleReset,
handleModeChange,
handleFileUpload,
handleDrop,
handleUseAiSuggestion,
formatTime,
} = useCoverSelector({ config, onChange })
return (
<Drawer
title="封面选择"
placement="right"
width={440}
open={open}
onClose={onClose}
className="cover-selector-drawer"
>
{/* 顶部开关 */}
<div className="cover-header">
<span className="cover-header-label"></span>
<label className="cover-switch">
<input
type="checkbox"
checked={config.enabled}
onChange={(e) => update({ enabled: e.target.checked })}
/>
<span className="cover-switch-slider" />
</label>
</div>
{/* 模式选择 */}
<div className="cover-mode-section">
<div className="cover-section-title"></div>
<div className="cover-mode-tabs">
{(["auto", "frame", "upload"] as CoverMode[]).map((m) => (
<button
key={m}
className={`cover-mode-tab${config.mode === m ? " active" : ""}`}
onClick={() => handleModeChange(m)}
>
<span className="cover-mode-icon">{MODE_ICONS[m]}</span>
<span className="cover-mode-label">{MODE_LABELS[m]}</span>
</button>
))}
</div>
</div>
{/* 模式内容区 */}
<div className="cover-mode-content">
{config.mode === "auto" && (
<CoverAutoMode
config={config}
formatTime={formatTime}
onUseAiSuggestion={handleUseAiSuggestion}
/>
)}
{config.mode === "frame" && (
<CoverFrameMode
config={config}
totalDuration={totalDuration}
formatTime={formatTime}
onFrameTimeChange={(t) => update({ frame_time: t })}
/>
)}
{config.mode === "upload" && (
<CoverUploadMode
config={config}
isDragging={isDragging}
fileInputRef={fileInputRef}
onDragOver={(e) => {
e.preventDefault()
setIsDragging(true)
}}
onDragLeave={() => setIsDragging(false)}
onDrop={handleDrop}
onAreaClick={() => fileInputRef.current?.click()}
onFileChange={handleFileUpload}
/>
)}
</div>
{/* 封面预览 */}
<div className="cover-preview-section">
<div className="cover-section-title"></div>
<div className="cover-preview-box">
{config.upload_url ? (
<img src={config.upload_url} alt="封面预览" className="cover-preview-img" />
) : (
<div className="cover-preview-placeholder">
<span className="cover-preview-icon">🖼</span>
<span className="cover-preview-text">
{config.mode === "auto"
? "AI 智能选择"
: config.mode === "frame"
? `${formatTime(config.frame_time)}`
: "未上传封面"}
</span>
</div>
)}
<div className="cover-preview-ratio">16:9</div>
</div>
</div>
{/* 底部 */}
<div className="cover-footer">
<button className="cover-reset-btn" onClick={handleReset}>
</button>
</div>
</Drawer>
)
}
export default CoverSelector
@@ -0,0 +1,98 @@
import { useCallback, useRef, useState } from "react"
import type { CoverConfig, CoverMode } from "../../types"
import { DEFAULT_COVER_CONFIG } from "../../types"
/** 封面模式标签 */
export const MODE_LABELS: Record<CoverMode, string> = {
auto: "智能封面",
frame: "抽帧选封面",
upload: "上传封面",
}
/** 封面模式图标 */
export const MODE_ICONS: Record<CoverMode, string> = {
auto: "🤖",
frame: "🎞️",
upload: "📤",
}
interface UseCoverSelectorOptions {
config: CoverConfig
onChange: (config: CoverConfig) => void
}
/**
* 封面选择器 Hook
* 封装状态管理、文件上传、模式切换等逻辑
*/
export function useCoverSelector({ config, onChange }: UseCoverSelectorOptions) {
const fileInputRef = useRef<HTMLInputElement>(null)
const [isDragging, setIsDragging] = useState(false)
const update = useCallback(
(partial: Partial<CoverConfig>) => {
onChange({ ...config, ...partial })
},
[config, onChange],
)
const handleReset = useCallback(() => {
onChange({ ...DEFAULT_COVER_CONFIG, enabled: config.enabled })
}, [config.enabled, onChange])
const handleModeChange = useCallback(
(mode: CoverMode) => {
update({ mode })
},
[update],
)
const handleFileUpload = useCallback(
(file: File) => {
if (!file.type.startsWith("image/")) return
const reader = new FileReader()
reader.onload = (e) => {
const url = e.target?.result as string
update({ upload_url: url, thumbnail_url: url, mode: "upload" })
}
reader.readAsDataURL(file)
},
[update],
)
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault()
setIsDragging(false)
const file = e.dataTransfer.files[0]
if (file) handleFileUpload(file)
},
[handleFileUpload],
)
const handleUseAiSuggestion = useCallback(() => {
if (config.ai_suggested_time !== null) {
update({ frame_time: config.ai_suggested_time, mode: "frame" })
}
}, [config.ai_suggested_time, update])
const formatTime = (seconds: number) => {
const m = Math.floor(seconds / 60)
const s = Math.floor(seconds % 60)
const ms = Math.floor((seconds % 1) * 10)
return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}.${ms}`
}
return {
fileInputRef,
isDragging,
setIsDragging,
update,
handleReset,
handleModeChange,
handleFileUpload,
handleDrop,
handleUseAiSuggestion,
formatTime,
}
}
@@ -1,157 +0,0 @@
import { useEffect, type Dispatch, type SetStateAction } from "react"
import { message } from "antd"
import { getEditPlan, getEditPlanClips } from "@/api/template-editor"
import type { ClipData, ClipType, TtsConfig, TtsMode, TrimConfig } from "../../types"
import type { SubtitleStyleConfig } from "../../types/subtitle"
import type { TitleConfig } from "@/api/template-editor"
import type { BgmMixConfig } from "@/api/bgm"
import type { TransitionEffect } from "@/api/template-editor"
import type { CoverConfig } from "../../types"
interface UsePlanLoadingOptions {
loadedPlanId: string | null
resetClips: (clips: ClipData[]) => void
setLoadedTemplateId: (id: string | null) => void
setDraftName: (name: string) => void
setTitleConfig: Dispatch<SetStateAction<TitleConfig>>
setSubtitleSettings: Dispatch<SetStateAction<SubtitleStyleConfig>>
setBgmSettings: Dispatch<SetStateAction<BgmMixConfig>>
setCoverConfig: Dispatch<SetStateAction<CoverConfig>>
}
/**
* 加载已有计划草稿数据到编辑器
* 从列表页"编辑"按钮进入时,URL 带 planId,需要还原计划配置
*/
export function usePlanLoading({
loadedPlanId,
resetClips,
setLoadedTemplateId,
setDraftName,
setTitleConfig,
setSubtitleSettings,
setBgmSettings,
setCoverConfig,
}: UsePlanLoadingOptions) {
useEffect(() => {
if (!loadedPlanId) return
Promise.all([
getEditPlan(loadedPlanId),
getEditPlanClips(loadedPlanId, { limit: 500 }).catch(() => ({
items: [],
total: 0,
})),
])
.then(([plan, clipsRes]) => {
setLoadedTemplateId(plan.template_id)
setDraftName(plan.name)
const cfg = plan.config
if (cfg.title_config) {
setTitleConfig({
ai_auto_select: cfg.title_config!.ai_auto_select,
content: cfg.title_config!.content,
position: cfg.title_config!.position,
font_preset: cfg.title_config!.font_preset,
font_size: cfg.title_config!.font_size,
font_color: cfg.title_config!.font_color || "#ffffff",
})
}
if (cfg.subtitle_config) {
setSubtitleSettings((prev) => ({
...prev,
enabled: cfg.subtitle_config!.enabled,
position: (cfg.subtitle_config!.position ||
"bottom") as SubtitleStyleConfig["position"],
font: cfg.subtitle_config!.font,
fontSize: cfg.subtitle_config!.size,
fontColor: cfg.subtitle_config!.color || "#ffffff",
animation: cfg.subtitle_config!.animation,
}))
}
if (cfg.bgm_config) {
setBgmSettings((prev) => ({
...prev,
enabled: cfg.bgm_config!.enabled,
music_id: cfg.bgm_config!.music_id || "",
}))
}
if (cfg.cover_config) {
setCoverConfig((prev: CoverConfig) => ({
...prev,
enabled: cfg.cover_config!.enabled ?? prev.enabled,
mode: (cfg.cover_config!.mode as CoverConfig["mode"]) || prev.mode,
frame_time: cfg.cover_config!.frame_time ?? prev.frame_time,
upload_url: cfg.cover_config!.upload_url || prev.upload_url,
thumbnail_url: cfg.cover_config!.thumbnail_url || prev.thumbnail_url,
ai_suggested_time: cfg.cover_config!.ai_suggested_time ?? prev.ai_suggested_time,
}))
}
/* 还原片段:优先从后端 clips 表,其次从 config.segments 兜底 */
const backendClips = clipsRes?.items || []
if (backendClips.length > 0) {
const sorted = [...backendClips].sort((a, b) => a.order - b.order)
const mapped: ClipData[] = sorted.map((clip) => ({
id: clip.id,
template_segment_id: (clip.config?.template_segment_id as string) || "",
type: (clip.clip_type === "voiceover" ? "voice" : "pip") as ClipType,
duration: clip.duration || 3,
startOffset: 0,
script_text: clip.text_content || "",
order: clip.order,
media_asset_id: clip.asset_id || undefined,
transition:
clip.transition_effect && clip.transition_effect !== "none"
? {
type: clip.transition_effect as TransitionEffect["type"],
duration: clip.transition_duration || 0.3,
}
: undefined,
speed: clip.playback_speed
? { rate: clip.playback_speed, pitchCorrection: true }
: undefined,
tts_config: (clip.config?.tts_config as TtsConfig) || undefined,
trim_config: (clip.config?.trim_config as TrimConfig) || undefined,
}))
setTimeout(() => resetClips(mapped), 100)
} else if (cfg.segments && cfg.segments.length > 0) {
/* 兜底:从 config.segments 还原(老数据兼容) */
const mapped: ClipData[] = cfg.segments.map((seg, idx) => ({
id: `seg-${idx}`,
template_segment_id: `seg-${idx}`,
type: (seg.material_type === "voiceover" ? "voice" : "pip") as ClipType,
duration: (seg.duration_min + seg.duration_max) / 2,
startOffset: 0,
script_text: "",
order: seg.segment_order,
transition: seg.transition
? {
type: seg.transition.type as TransitionEffect["type"],
duration: seg.transition.duration,
}
: undefined,
speed: seg.playback_speed
? { rate: seg.playback_speed, pitchCorrection: true }
: undefined,
tts_config: seg.tts_config
? { ...seg.tts_config, mode: seg.tts_config.mode as TtsMode }
: undefined,
trim_config: seg.trim_config || undefined,
}))
setTimeout(() => resetClips(mapped), 100)
}
})
.catch(() => message.error("加载模板草稿失败"))
}, [
loadedPlanId,
resetClips,
setLoadedTemplateId,
setDraftName,
setTitleConfig,
setSubtitleSettings,
setBgmSettings,
setCoverConfig,
])
}
@@ -1,91 +0,0 @@
import { useEffect, type Dispatch, type SetStateAction } from "react"
import { message } from "antd"
import { getEditingTemplate, type TemplateMode } from "@/api/editing-planner"
import type { TitleConfig } from "@/api/template-editor"
import type { ClipData, ClipType } from "../../types"
import type { SubtitleStyleConfig } from "../../types/subtitle"
import type { BgmMixConfig } from "@/api/bgm"
interface UseTemplateDetailOptions {
loadedTemplateId: string | null
resetClips: (clips: ClipData[]) => void
setCurrentMode: (mode: TemplateMode) => void
setTitleConfig: Dispatch<SetStateAction<TitleConfig>>
setSubtitleSettings: Dispatch<SetStateAction<SubtitleStyleConfig>>
setBgmSettings: Dispatch<SetStateAction<BgmMixConfig>>
setDraftName: (name: string) => void
setDraftCategory: (cat: string) => void
setDraftTags: (tags: string) => void
}
/**
* 加载模板详情并初始化片段列表 + 配置
*/
export function useTemplateDetail({
loadedTemplateId,
resetClips,
setCurrentMode,
setTitleConfig,
setSubtitleSettings,
setBgmSettings,
setDraftName,
setDraftCategory,
setDraftTags,
}: UseTemplateDetailOptions) {
useEffect(() => {
if (!loadedTemplateId) return
getEditingTemplate(loadedTemplateId)
.then((tpl) => {
if (!tpl) return
setCurrentMode(tpl.mode)
const mapped: ClipData[] = tpl.segments.map((seg, idx) => ({
id: seg.id || `seg-${idx}`,
template_segment_id: seg.id || `seg-${idx}`,
type: (seg.material_type === "voiceover" ? "voice" : "pip") as ClipType,
duration: (seg.duration_min + seg.duration_max) / 2,
startOffset: 0,
script_text: "",
order: seg.segment_order,
}))
resetClips(mapped)
setTitleConfig({
ai_auto_select: tpl.title_config.ai_auto_select,
content: tpl.title_config.content,
position: tpl.title_config.position,
font_preset: tpl.title_config.font_preset,
font_size: tpl.title_config.font_size,
font_color: tpl.title_config.font_color || "#ffffff",
})
setSubtitleSettings((prev) => ({
...prev,
enabled: tpl.subtitle_config.enabled,
position: (tpl.subtitle_config.position || "bottom") as SubtitleStyleConfig["position"],
font: tpl.subtitle_config.font,
fontSize: tpl.subtitle_config.size,
fontColor: tpl.subtitle_config.color || "#ffffff",
animation: tpl.subtitle_config.animation,
}))
setBgmSettings((prev) => ({
...prev,
enabled: tpl.bgm_config.enabled,
music_id: tpl.bgm_config.music_id || "",
}))
setDraftName(tpl.name)
setDraftCategory(tpl.category)
setDraftTags(tpl.tags.join(", "))
})
.catch(() => message.error("加载模板详情失败"))
}, [
loadedTemplateId,
resetClips,
setCurrentMode,
setTitleConfig,
setSubtitleSettings,
setBgmSettings,
setDraftName,
setDraftCategory,
setDraftTags,
])
}
@@ -1,82 +0,0 @@
import { useState, useCallback, useEffect, useMemo } from "react"
import { message } from "antd"
import {
getEditingTemplates,
getTemplateCategories,
type EditingTemplate,
type TemplateCategory,
} from "@/api/editing-planner"
import { getMediaAssets, type MediaAsset } from "@/api/template-editor"
import { FILTER_CATEGORIES } from "../../constants"
/**
* 模板列表 + 分类 + 筛选搜索
*/
export function useTemplateList(
setMediaAssets: (assets: MediaAsset[]) => void,
initialTemplateId: string | null,
) {
const [templates, setTemplates] = useState<EditingTemplate[]>([])
const [categories, setCategories] = useState<TemplateCategory[]>([])
const [loadingTemplates, setLoadingTemplates] = useState(false)
const [currentFilter, setCurrentFilter] = useState("全部")
const [searchQuery, setSearchQuery] = useState("")
const [loadedTemplateId, setLoadedTemplateId] = useState<string | null>(initialTemplateId)
/**
* 并行加载模板列表、分类、素材库
* 三个接口无依赖关系,用 Promise.all 并发
*/
const loadTemplates = useCallback(async () => {
setLoadingTemplates(true)
try {
const [tpls, cats, assets] = await Promise.all([
getEditingTemplates(),
getTemplateCategories(),
getMediaAssets(),
])
setTemplates(tpls)
setCategories(cats)
setMediaAssets(assets)
} catch {
message.error("加载模板失败")
} finally {
setLoadingTemplates(false)
}
}, [setMediaAssets])
useEffect(() => {
loadTemplates()
}, [loadTemplates])
const filteredTemplates = useMemo(
() =>
templates.filter((t) => {
if (currentFilter !== "全部" && t.category !== currentFilter) return false
if (searchQuery && !t.name.toLowerCase().includes(searchQuery.toLowerCase())) return false
return true
}),
[templates, currentFilter, searchQuery],
)
const currentTemplate = useMemo(
() => templates.find((t) => t.id === loadedTemplateId) || null,
[templates, loadedTemplateId],
)
return {
templates,
categories,
loadingTemplates,
loadedTemplateId,
setLoadedTemplateId,
currentFilter,
setCurrentFilter,
searchQuery,
setSearchQuery,
filteredTemplates,
currentTemplate,
loadTemplates,
FILTER_CATEGORIES,
}
}
@@ -1,184 +0,0 @@
import { useState, useCallback } from "react"
import { message } from "antd"
import {
createEditingTemplate,
updateEditingTemplate,
type SaveTemplatePayload,
type TemplateMode,
} from "@/api/editing-planner"
import type {
ClipData,
WatermarkConfig,
IntroOutroConfig,
PipConfig,
FilterConfig,
ChromaKeyConfig,
StickerConfig,
} from "../../types"
import type { SubtitleStyleConfig } from "../../types/subtitle"
import type { TitleConfig } from "@/api/template-editor"
import type { CoverConfig } from "../../types"
import type { BgmMixConfig } from "@/api/bgm"
interface UseTemplateSaveOptions {
currentMode: TemplateMode
clips: ClipData[]
totalDuration: number
titleConfig: TitleConfig
subtitleSettings: SubtitleStyleConfig
bgmSettings: BgmMixConfig
watermarkSettings: WatermarkConfig
introOutroSettings: IntroOutroConfig
pipSettings: PipConfig
filterSettings: FilterConfig
chromaKeySettings: ChromaKeyConfig
stickerSettings: StickerConfig
coverConfig: CoverConfig
loadedTemplateId: string | null
loadTemplates: () => Promise<void>
}
/**
* 模板保存(创建/更新)
*/
export function useTemplateSave(options: UseTemplateSaveOptions) {
const {
currentMode,
clips,
totalDuration,
titleConfig,
subtitleSettings,
bgmSettings,
watermarkSettings,
introOutroSettings,
pipSettings,
filterSettings,
chromaKeySettings,
stickerSettings,
coverConfig,
loadedTemplateId,
loadTemplates,
} = options
const [saveModalOpen, setSaveModalOpen] = useState(false)
const [draftName, setDraftName] = useState("")
const [draftCategory, setDraftCategory] = useState("")
const [draftTags, setDraftTags] = useState("")
const [saveLoading, setSaveLoading] = useState(false)
const handleOpenSaveModal = useCallback(() => {
setSaveModalOpen(true)
}, [])
const handleSave = useCallback(async () => {
if (!draftName.trim()) {
message.warning("请输入模板名称")
return
}
setSaveLoading(true)
try {
const payload: SaveTemplatePayload = {
name: draftName,
mode: currentMode,
category: draftCategory,
tags: draftTags
.split(",")
.map((t) => t.trim())
.filter(Boolean),
title_config: titleConfig,
subtitle_config: {
enabled: subtitleSettings.enabled,
position: subtitleSettings.position,
font: subtitleSettings.font,
color: subtitleSettings.fontColor,
size: subtitleSettings.fontSize,
animation: subtitleSettings.animation,
},
bgm_config: {
enabled: bgmSettings.enabled,
music_id: bgmSettings.music_id,
},
estimated_duration: totalDuration,
segments: clips.map((c, i) => ({
segment_order: i,
duration_min: Math.max(1, c.duration - 2),
duration_max: c.duration + 2,
material_type: c.type === "voice" ? "voiceover" : "video",
transition: c.transition
? { type: c.transition.type, duration: c.transition.duration }
: undefined,
playback_speed: c.speed ? c.speed.rate : undefined,
tts_config: c.tts_config
? {
mode: c.tts_config.mode,
text: c.tts_config.text,
voice_id: c.tts_config.voice_id,
speed: c.tts_config.speed,
pitch: c.tts_config.pitch,
volume: c.tts_config.volume,
subtitle_sync: c.tts_config.subtitle_sync,
}
: undefined,
trim_config: c.trim_config
? {
start_time: c.trim_config.start_time,
end_time: c.trim_config.end_time,
}
: undefined,
})),
watermark_config: { ...watermarkSettings },
intro_outro_config: { ...introOutroSettings },
pip_config: { ...pipSettings },
filter_config: { ...filterSettings },
green_screen_config: { ...chromaKeySettings },
sticker_config: { ...stickerSettings },
cover_config: { ...coverConfig },
}
if (loadedTemplateId) {
await updateEditingTemplate(loadedTemplateId, payload)
} else {
await createEditingTemplate(payload)
}
message.success(loadedTemplateId ? "模板保存成功" : "模板创建成功")
setSaveModalOpen(false)
loadTemplates()
} catch {
message.error("保存失败")
} finally {
setSaveLoading(false)
}
}, [
draftName,
draftCategory,
draftTags,
currentMode,
clips,
totalDuration,
titleConfig,
subtitleSettings,
bgmSettings,
watermarkSettings,
introOutroSettings,
pipSettings,
filterSettings,
chromaKeySettings,
stickerSettings,
coverConfig,
loadedTemplateId,
loadTemplates,
])
return {
saveModalOpen,
setSaveModalOpen,
draftName,
setDraftName,
draftCategory,
setDraftCategory,
draftTags,
setDraftTags,
saveLoading,
handleOpenSaveModal,
handleSave,
}
}
@@ -1,8 +1,27 @@
import { useState, useCallback, type Dispatch, type SetStateAction } from "react"
import type { TemplateMode } from "@/api/editing-planner"
import type { MediaAsset, TitleConfig } from "@/api/template-editor"
import { useState, useCallback, useEffect, type Dispatch, type SetStateAction } from "react"
import { FILTER_CATEGORIES } from "../constants"
import { message } from "antd"
import type {
EditingTemplate,
TemplateCategory,
TemplateMode,
SaveTemplatePayload,
} from "@/api/editing-planner"
import {
getEditingTemplates,
getEditingTemplate,
createEditingTemplate,
updateEditingTemplate,
getTemplateCategories,
} from "@/api/editing-planner"
import type { MediaAsset, TitleConfig, TransitionEffect } from "@/api/template-editor"
import { getMediaAssets, getEditPlan, getEditPlanClips } from "@/api/template-editor"
import type {
ClipData,
ClipType,
TtsConfig,
TtsMode,
TrimConfig,
WatermarkConfig,
IntroOutroConfig,
PipConfig,
@@ -13,11 +32,6 @@ import type {
} from "../types"
import type { SubtitleStyleConfig } from "../types/subtitle"
import type { BgmMixConfig } from "@/api/bgm"
import { useTemplateList } from "./template-management/useTemplateList"
import { useTemplateDetail } from "./template-management/useTemplateDetail"
import { usePlanLoading } from "./template-management/usePlanLoading"
import { useTemplateSave } from "./template-management/useTemplateSave"
import { FILTER_CATEGORIES } from "../constants"
interface UseTemplateManagementParams {
urlTemplateId: string
@@ -30,6 +44,7 @@ interface UseTemplateManagementParams {
setSubtitleSettings: Dispatch<SetStateAction<SubtitleStyleConfig>>
setBgmSettings: Dispatch<SetStateAction<BgmMixConfig>>
setCoverConfig: Dispatch<SetStateAction<CoverConfig>>
// 保存时需要的配置
clips: ClipData[]
totalDuration: number
titleConfig: TitleConfig
@@ -46,7 +61,7 @@ interface UseTemplateManagementParams {
/**
* 模板管理 Hook
* 模板列表/分类/加载/保存/模式切换/筛选搜索
* 模板列表/分类/加载/保存/模式切换/筛选搜索 + 3 个 useEffect
*/
export const useTemplateManagement = (params: UseTemplateManagementParams) => {
const {
@@ -74,101 +89,346 @@ export const useTemplateManagement = (params: UseTemplateManagementParams) => {
coverConfig,
} = params
/* ── 模板列表 ── */
const [templates, setTemplates] = useState<EditingTemplate[]>([])
const [categories, setCategories] = useState<TemplateCategory[]>([])
const [loadingTemplates, setLoadingTemplates] = useState(false)
const [loadedTemplateId, setLoadedTemplateId] = useState<string | null>(urlTemplateId || null)
const [currentMode, setCurrentMode] = useState<TemplateMode>("pip")
/* ── 左栏筛选 ── */
const [currentFilter, setCurrentFilter] = useState("全部")
const [searchQuery, setSearchQuery] = useState("")
/* ── 保存弹窗 ── */
const [saveModalOpen, setSaveModalOpen] = useState(false)
const [draftName, setDraftName] = useState("")
const [draftCategory, setDraftCategory] = useState("")
const [draftTags, setDraftTags] = useState("")
const [saveLoading, setSaveLoading] = useState(false)
/* ── 计划 ID(从 URL 传入,不变) ── */
const [loadedPlanId] = useState<string | null>(urlPlanId || null)
/* ── 模板列表 ── */
const {
templates,
categories,
loadingTemplates,
loadedTemplateId,
setLoadedTemplateId,
currentFilter,
setCurrentFilter,
searchQuery,
setSearchQuery,
filteredTemplates,
currentTemplate,
loadTemplates,
} = useTemplateList(setMediaAssets, urlTemplateId || null)
/* ── 保存 ── */
const {
saveModalOpen,
setSaveModalOpen,
draftName,
setDraftName,
draftCategory,
setDraftCategory,
draftTags,
setDraftTags,
saveLoading,
handleOpenSaveModal,
handleSave,
} = useTemplateSave({
currentMode,
clips,
totalDuration,
titleConfig,
subtitleSettings,
bgmSettings,
watermarkSettings,
introOutroSettings,
pipSettings,
filterSettings,
chromaKeySettings,
stickerSettings,
coverConfig,
loadedTemplateId,
loadTemplates,
/* ── 计算 ── */
const filteredTemplates = templates.filter((t) => {
if (currentFilter !== "全部" && t.category !== currentFilter) return false
if (searchQuery && !t.name.toLowerCase().includes(searchQuery.toLowerCase())) return false
return true
})
/* ── 模板详情加载 ── */
useTemplateDetail({
loadedTemplateId,
resetClips,
setCurrentMode,
setTitleConfig,
setSubtitleSettings,
setBgmSettings,
setDraftName,
setDraftCategory,
setDraftTags,
})
const currentTemplate = templates.find((t) => t.id === loadedTemplateId) || null
/* ── 计划草稿加载 ── */
usePlanLoading({
/* ──────────── 加载 ──────────── */
/**
* 并行加载模板列表、分类、素材库
* 首次挂载时调用,三个接口无依赖关系,用 Promise.all 并发
*/
const loadTemplates = useCallback(async () => {
setLoadingTemplates(true)
try {
const [tpls, cats, assets] = await Promise.all([
getEditingTemplates(),
getTemplateCategories(),
getMediaAssets(),
])
setTemplates(tpls)
setCategories(cats)
setMediaAssets(assets)
} catch {
message.error("加载模板失败")
} finally {
setLoadingTemplates(false)
}
}, [setMediaAssets])
useEffect(() => {
loadTemplates()
}, [loadTemplates])
/**
* 加载模板详情并初始化片段列表
* 将后端 segments 映射为前端 ClipData,取 duration_min/max 均值作为默认时长
* 同时还原标题/字幕/BGM 配置
*/
useEffect(() => {
if (!loadedTemplateId) return
getEditingTemplate(loadedTemplateId)
.then((tpl) => {
if (!tpl) return
setCurrentMode(tpl.mode)
const mapped: ClipData[] = tpl.segments.map((seg, idx) => ({
id: seg.id || `seg-${idx}`,
template_segment_id: seg.id || `seg-${idx}`,
type: (seg.material_type === "voiceover" ? "voice" : "pip") as ClipType,
duration: (seg.duration_min + seg.duration_max) / 2,
startOffset: 0,
script_text: "",
order: seg.segment_order,
}))
resetClips(mapped)
setTitleConfig({
ai_auto_select: tpl.title_config.ai_auto_select,
content: tpl.title_config.content,
position: tpl.title_config.position,
font_preset: tpl.title_config.font_preset,
font_size: tpl.title_config.font_size,
font_color: tpl.title_config.font_color || "#ffffff",
})
setSubtitleSettings((prev) => ({
...prev,
enabled: tpl.subtitle_config.enabled,
position: (tpl.subtitle_config.position || "bottom") as SubtitleStyleConfig["position"],
font: tpl.subtitle_config.font,
fontSize: tpl.subtitle_config.size,
fontColor: tpl.subtitle_config.color || "#ffffff",
animation: tpl.subtitle_config.animation,
}))
setBgmSettings((prev) => ({
...prev,
enabled: tpl.bgm_config.enabled,
music_id: tpl.bgm_config.music_id || "",
}))
setDraftName(tpl.name)
setDraftCategory(tpl.category)
setDraftTags(tpl.tags.join(", "))
})
.catch(() => message.error("加载模板详情失败"))
}, [loadedTemplateId, resetClips, setTitleConfig, setSubtitleSettings, setBgmSettings])
/**
* 加载已有模板草稿数据到编辑器
* 从列表页"编辑"按钮进入时,URL 带 planId,需要还原计划配置
*/
useEffect(() => {
if (!loadedPlanId) return
// 并行加载计划基本信息 + 片段列表
Promise.all([
getEditPlan(loadedPlanId),
getEditPlanClips(loadedPlanId, { limit: 500 }).catch(() => ({
items: [],
total: 0,
})),
])
.then(([plan, clipsRes]) => {
// 设置关联的模板(触发模板加载 effect)
setLoadedTemplateId(plan.template_id)
// 还原基本信息
setDraftName(plan.name)
// 还原 config 中的编辑器状态
const cfg = plan.config
if (cfg.title_config) {
setTitleConfig({
ai_auto_select: cfg.title_config!.ai_auto_select,
content: cfg.title_config!.content,
position: cfg.title_config!.position,
font_preset: cfg.title_config!.font_preset,
font_size: cfg.title_config!.font_size,
font_color: cfg.title_config!.font_color || "#ffffff",
})
}
if (cfg.subtitle_config) {
setSubtitleSettings((prev) => ({
...prev,
enabled: cfg.subtitle_config!.enabled,
position: (cfg.subtitle_config!.position ||
"bottom") as SubtitleStyleConfig["position"],
font: cfg.subtitle_config!.font,
fontSize: cfg.subtitle_config!.size,
fontColor: cfg.subtitle_config!.color || "#ffffff",
animation: cfg.subtitle_config!.animation,
}))
}
if (cfg.bgm_config) {
setBgmSettings((prev) => ({
...prev,
enabled: cfg.bgm_config!.enabled,
music_id: cfg.bgm_config!.music_id || "",
}))
}
// 还原封面配置
if (cfg.cover_config) {
setCoverConfig((prev) => ({
...prev,
enabled: cfg.cover_config!.enabled ?? prev.enabled,
mode: (cfg.cover_config!.mode as CoverConfig["mode"]) || prev.mode,
frame_time: cfg.cover_config!.frame_time ?? prev.frame_time,
upload_url: cfg.cover_config!.upload_url || prev.upload_url,
thumbnail_url: cfg.cover_config!.thumbnail_url || prev.thumbnail_url,
ai_suggested_time: cfg.cover_config!.ai_suggested_time ?? prev.ai_suggested_time,
}))
}
// 还原片段:优先从后端 clips 表,其次从 config.segments 兜底
const backendClips = clipsRes?.items || []
if (backendClips.length > 0) {
// 从后端 clips 表还原
const sorted = [...backendClips].sort((a, b) => a.order - b.order)
const mapped: ClipData[] = sorted.map((clip) => ({
id: clip.id,
template_segment_id: (clip.config?.template_segment_id as string) || "",
type: (clip.clip_type === "voiceover" ? "voice" : "pip") as ClipType,
duration: clip.duration || 3,
startOffset: 0,
script_text: clip.text_content || "",
order: clip.order,
media_asset_id: clip.asset_id || undefined,
transition:
clip.transition_effect && clip.transition_effect !== "none"
? {
type: clip.transition_effect as TransitionEffect["type"],
duration: clip.transition_duration || 0.3,
}
: undefined,
speed: clip.playback_speed
? { rate: clip.playback_speed, pitchCorrection: true }
: undefined,
tts_config: (clip.config?.tts_config as TtsConfig) || undefined,
trim_config: (clip.config?.trim_config as TrimConfig) || undefined,
}))
setTimeout(() => resetClips(mapped), 100)
} else if (cfg.segments && cfg.segments.length > 0) {
// 兜底:从 config.segments 还原(老数据兼容)
const mapped: ClipData[] = cfg.segments.map((seg, idx) => ({
id: `seg-${idx}`,
template_segment_id: `seg-${idx}`,
type: (seg.material_type === "voiceover" ? "voice" : "pip") as ClipType,
duration: (seg.duration_min + seg.duration_max) / 2,
startOffset: 0,
script_text: "",
order: seg.segment_order,
transition: seg.transition
? {
type: seg.transition.type as TransitionEffect["type"],
duration: seg.transition.duration,
}
: undefined,
speed: seg.playback_speed
? { rate: seg.playback_speed, pitchCorrection: true }
: undefined,
tts_config: seg.tts_config
? { ...seg.tts_config, mode: seg.tts_config.mode as TtsMode }
: undefined,
trim_config: seg.trim_config || undefined,
}))
setTimeout(() => resetClips(mapped), 100)
}
})
.catch(() => message.error("加载模板草稿失败"))
}, [
loadedPlanId,
resetClips,
setLoadedTemplateId,
setDraftName,
setTitleConfig,
setSubtitleSettings,
setBgmSettings,
setCoverConfig,
})
])
/* ── 事件 ── */
const handleLoadTemplate = useCallback(
(templateId: string) => {
setLoadedTemplateId(templateId)
setSelectedClipId(null)
},
[setLoadedTemplateId, setSelectedClipId],
)
/* ──────────── 事件 ──────────── */
const handleModeChange = useCallback(
(mode: TemplateMode) => {
setCurrentMode(mode)
if (mode === "voice_over") {
setClips((prev) => prev.map((c) => ({ ...c, type: "voice" as const })))
} else if (mode === "pip") {
setClips((prev) => prev.map((c) => ({ ...c, type: "pip" as const })))
const handleLoadTemplate = (templateId: string) => {
setLoadedTemplateId(templateId)
setSelectedClipId(null)
}
const handleModeChange = (mode: TemplateMode) => {
setCurrentMode(mode)
// 切换纯单类型模式时,自动转换所有已有片段的类型
if (mode === "voice_over") {
setClips((prev) => prev.map((c) => ({ ...c, type: "voice" as const })))
} else if (mode === "pip") {
setClips((prev) => prev.map((c) => ({ ...c, type: "pip" as const })))
}
// 混合模式(voice_pip)和一镜到底(one_take)不自动转换,保留原有类型
}
const handleOpenSaveModal = () => {
setSaveModalOpen(true)
}
const handleSave = async () => {
if (!draftName.trim()) {
message.warning("请输入模板名称")
return
}
setSaveLoading(true)
try {
const payload: SaveTemplatePayload = {
name: draftName,
mode: currentMode,
category: draftCategory,
tags: draftTags
.split(",")
.map((t) => t.trim())
.filter(Boolean),
title_config: titleConfig,
subtitle_config: {
enabled: subtitleSettings.enabled,
position: subtitleSettings.position,
font: subtitleSettings.font,
color: subtitleSettings.fontColor,
size: subtitleSettings.fontSize,
animation: subtitleSettings.animation,
},
bgm_config: {
enabled: bgmSettings.enabled,
music_id: bgmSettings.music_id,
},
estimated_duration: totalDuration,
segments: clips.map((c, i) => ({
segment_order: i,
duration_min: Math.max(1, c.duration - 2),
duration_max: c.duration + 2,
material_type: c.type === "voice" ? "voiceover" : "video",
transition: c.transition
? { type: c.transition.type, duration: c.transition.duration }
: undefined,
playback_speed: c.speed ? c.speed.rate : undefined,
tts_config: c.tts_config
? {
mode: c.tts_config.mode,
text: c.tts_config.text,
voice_id: c.tts_config.voice_id,
speed: c.tts_config.speed,
pitch: c.tts_config.pitch,
volume: c.tts_config.volume,
subtitle_sync: c.tts_config.subtitle_sync,
}
: undefined,
trim_config: c.trim_config
? {
start_time: c.trim_config.start_time,
end_time: c.trim_config.end_time,
}
: undefined,
})),
watermark_config: { ...watermarkSettings },
intro_outro_config: { ...introOutroSettings },
pip_config: { ...pipSettings },
filter_config: { ...filterSettings },
green_screen_config: { ...chromaKeySettings },
sticker_config: { ...stickerSettings },
cover_config: { ...coverConfig },
}
},
[setClips],
)
if (loadedTemplateId) {
await updateEditingTemplate(loadedTemplateId, payload)
} else {
await createEditingTemplate(payload)
}
message.success(loadedTemplateId ? "模板保存成功" : "模板创建成功")
setSaveModalOpen(false)
loadTemplates()
} catch {
message.error("保存失败")
} finally {
setSaveLoading(false)
}
}
return {
// state