Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 67bb896c6c | |||
| 65d58323f7 | |||
| fec660bf13 | |||
| 79390b8d87 | |||
| 7e62d886de | |||
| 0a4b753551 |
@@ -31,7 +31,6 @@ import type {
|
||||
import {
|
||||
getMediaAssets,
|
||||
getEditPlanGenerations,
|
||||
generateCover,
|
||||
getEditPlan,
|
||||
createEditPlan,
|
||||
updateEditPlan,
|
||||
@@ -96,7 +95,7 @@ import PipConfigPanel from "./components/PipConfigPanel"
|
||||
import FilterPanel from "./components/FilterPanel"
|
||||
import GreenScreenPanel from "./components/GreenScreenPanel"
|
||||
import StickerPanel from "./components/StickerPanel"
|
||||
import CoverSelector from "./components/CoverSelector"
|
||||
|
||||
import SaveModal from "./components/SaveModal"
|
||||
import GenerationHistoryModal from "./components/GenerationHistoryModal"
|
||||
import { DEFAULT_BGM_MIX_CONFIG, type BgmMixConfig } from "@/api/bgm"
|
||||
@@ -111,13 +110,6 @@ const MODE_LIST: { key: TemplateMode; label: string; icon: string }[] = [
|
||||
{ key: "voice_pip", label: "口播+混剪", icon: "🎭" },
|
||||
]
|
||||
|
||||
const COVER_SCHEMES = [
|
||||
{ key: "ai_frame", label: "AI选帧" },
|
||||
{ key: "manual", label: "手动选" },
|
||||
{ key: "upload", label: "上传" },
|
||||
{ key: "ai_reselect", label: "AI重选" },
|
||||
]
|
||||
|
||||
const FILTER_CATEGORIES = ["全部", "种草", "知识", "日常", "推荐"]
|
||||
|
||||
/* ──────────── 组件 ──────────── */
|
||||
@@ -146,13 +138,6 @@ const EditingPlanner: React.FC = () => {
|
||||
} = useUndoRedo<ClipData[]>([])
|
||||
const [selectedClipId, setSelectedClipId] = useState<string | null>(null)
|
||||
|
||||
/* ── AI 操作状态 ── */
|
||||
|
||||
const [aiCoverLoading, setAiCoverLoading] = useState(false)
|
||||
|
||||
/* ── 封面方案 ── */
|
||||
const [currentCoverScheme, setCurrentCoverScheme] = useState<string>("ai_frame")
|
||||
|
||||
/* ── 左栏筛选 ── */
|
||||
const [currentFilter, setCurrentFilter] = useState("全部")
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
@@ -223,11 +208,10 @@ const EditingPlanner: React.FC = () => {
|
||||
})
|
||||
const [stickerDrawerOpen, setStickerDrawerOpen] = useState(false)
|
||||
|
||||
/* ── 封面 ── */
|
||||
const [coverSettings, setCoverSettings] = useState<CoverConfig>({
|
||||
/* ── 封面配置(只读,从模板/计划继承) ── */
|
||||
const [coverConfig, setCoverConfig] = useState<CoverConfig>({
|
||||
...DEFAULT_COVER_CONFIG,
|
||||
})
|
||||
const [coverDrawerOpen, setCoverDrawerOpen] = useState(false)
|
||||
/* ── 右侧栏 Tab ── */
|
||||
const [rightTab, setRightTab] = useState<"properties" | "clips">("properties")
|
||||
|
||||
@@ -448,7 +432,7 @@ const EditingPlanner: React.FC = () => {
|
||||
}
|
||||
// 还原封面配置
|
||||
if (cfg.cover_config) {
|
||||
setCoverSettings((prev) => ({
|
||||
setCoverConfig((prev) => ({
|
||||
...prev,
|
||||
enabled: cfg.cover_config!.enabled ?? prev.enabled,
|
||||
mode: (cfg.cover_config!.mode as CoverConfig["mode"]) || prev.mode,
|
||||
@@ -788,34 +772,6 @@ const EditingPlanner: React.FC = () => {
|
||||
setStickerSettings(config)
|
||||
}, [])
|
||||
|
||||
/* ── 封面配置变更 ── */
|
||||
const handleCoverChange = useCallback((config: CoverConfig) => {
|
||||
setCoverSettings(config)
|
||||
}, [])
|
||||
|
||||
/* AI 封面生成 */
|
||||
const handleAiGenerateCover = async (coverType: "ai_frame" | "ai_regenerate") => {
|
||||
if (!loadedTemplateId) return
|
||||
const assetIds = selectedAssetIds
|
||||
if (assetIds.length === 0) {
|
||||
message.warning("请先在素材库中选择素材")
|
||||
return
|
||||
}
|
||||
setAiCoverLoading(true)
|
||||
try {
|
||||
await generateCover(loadedTemplateId, {
|
||||
asset_ids: assetIds,
|
||||
cover_type: coverType,
|
||||
})
|
||||
setCurrentCoverScheme(coverType === "ai_frame" ? "ai_frame" : "ai_reselect")
|
||||
message.success("AI 封面生成成功")
|
||||
} catch {
|
||||
message.error("AI 封面生成失败")
|
||||
} finally {
|
||||
setAiCoverLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
/** 构建剪辑计划 config(编辑器状态 → API config) */
|
||||
const buildPlanConfig = (): EditPlanConfig => ({
|
||||
title_config: titleConfig,
|
||||
@@ -865,7 +821,7 @@ const EditingPlanner: React.FC = () => {
|
||||
filter_config: { ...filterSettings },
|
||||
green_screen_config: { ...chromaKeySettings },
|
||||
sticker_config: { ...stickerSettings },
|
||||
cover_config: { ...coverSettings },
|
||||
cover_config: { ...coverConfig },
|
||||
})
|
||||
|
||||
/* 保存 — 无论是否已加载模板,都打开保存弹窗;未加载时创建新模板 */
|
||||
@@ -935,7 +891,7 @@ const EditingPlanner: React.FC = () => {
|
||||
filter_config: { ...filterSettings },
|
||||
green_screen_config: { ...chromaKeySettings },
|
||||
sticker_config: { ...stickerSettings },
|
||||
cover_config: { ...coverSettings },
|
||||
cover_config: { ...coverConfig },
|
||||
}
|
||||
if (loadedTemplateId) {
|
||||
await updateEditingTemplate(loadedTemplateId, payload)
|
||||
@@ -1275,10 +1231,8 @@ const EditingPlanner: React.FC = () => {
|
||||
clips={clips}
|
||||
selectedClipId={selectedClipId}
|
||||
isPlaying={isPlaying}
|
||||
currentCoverScheme={currentCoverScheme}
|
||||
coverSchemes={COVER_SCHEMES}
|
||||
aiCoverLoading={aiCoverLoading}
|
||||
titleConfig={titleConfig}
|
||||
coverConfig={coverConfig}
|
||||
subtitleSettings={{
|
||||
enabled: subtitleSettings.enabled,
|
||||
position: subtitleSettings.position,
|
||||
@@ -1287,9 +1241,7 @@ const EditingPlanner: React.FC = () => {
|
||||
animation: subtitleSettings.animation,
|
||||
}}
|
||||
onClipSelect={handleClipSelect}
|
||||
onCoverSchemeChange={setCurrentCoverScheme}
|
||||
onPlayPause={() => setIsPlaying(!isPlaying)}
|
||||
onAiGenerateCover={handleAiGenerateCover}
|
||||
/>
|
||||
|
||||
{/* 下半部:水平时间线 */}
|
||||
@@ -1362,7 +1314,6 @@ const EditingPlanner: React.FC = () => {
|
||||
onOpenFilterDrawer={() => setFilterDrawerOpen(true)}
|
||||
onOpenGreenScreenDrawer={() => setChromaKeyDrawerOpen(true)}
|
||||
onOpenStickerDrawer={() => setStickerDrawerOpen(true)}
|
||||
onOpenCoverDrawer={() => setCoverDrawerOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -1732,15 +1683,6 @@ const EditingPlanner: React.FC = () => {
|
||||
onChange={handleStickerChange}
|
||||
totalDuration={totalDuration}
|
||||
/>
|
||||
|
||||
{/* ═══ 封面选择器 ═══ */}
|
||||
<CoverSelector
|
||||
open={coverDrawerOpen}
|
||||
onClose={() => setCoverDrawerOpen(false)}
|
||||
config={coverSettings}
|
||||
onChange={handleCoverChange}
|
||||
totalDuration={totalDuration}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -72,7 +72,6 @@ interface ClipPropertiesPanelProps {
|
||||
/** 打开贴纸面板 Drawer */
|
||||
onOpenStickerDrawer?: () => void
|
||||
/** 打开封面选择器 Drawer */
|
||||
onOpenCoverDrawer?: () => void
|
||||
}
|
||||
|
||||
const POSITION_OPTIONS = [
|
||||
@@ -122,7 +121,6 @@ const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
|
||||
onOpenFilterDrawer,
|
||||
onOpenGreenScreenDrawer,
|
||||
onOpenStickerDrawer,
|
||||
onOpenCoverDrawer,
|
||||
}) => {
|
||||
const navigate = useNavigate()
|
||||
|
||||
@@ -374,21 +372,6 @@ const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ═══ 封面 ═══ */}
|
||||
<div className="ep-settings-section">
|
||||
<div className="ep-section-title">
|
||||
<span className="ep-section-icon">🖼️</span>
|
||||
封面
|
||||
</div>
|
||||
{onOpenCoverDrawer && (
|
||||
<button className="ep-advanced-btn" onClick={onOpenCoverDrawer}>
|
||||
<span className="ep-advanced-btn-icon">🖼️</span>
|
||||
<span className="ep-advanced-btn-label">选择视频封面</span>
|
||||
<span className="ep-advanced-btn-arrow">›</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ═══ 片段详情(选中时显示) ═══ */}
|
||||
{selectedClip && (
|
||||
<div className="ep-settings-section">
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
/**
|
||||
* 预览区 — V8 原型 1:1 还原
|
||||
* 手机模型预览(150x267) + 封面预览(150x267) 并排
|
||||
* 封面右侧竖排4个方案按钮
|
||||
* 手机模型预览 + 封面预览 并排
|
||||
* 封面为只读展示(从模板/计划继承)
|
||||
*/
|
||||
import React from "react"
|
||||
import type { ClipData, ClipType } from "../types"
|
||||
import type { TitleConfig } from "@/api/editPlans"
|
||||
|
||||
interface CoverScheme {
|
||||
key: string
|
||||
label: string
|
||||
}
|
||||
import type { CoverConfig } from "../types"
|
||||
|
||||
interface SubtitleSettings {
|
||||
enabled: boolean
|
||||
@@ -24,15 +20,11 @@ interface PreviewPlayerProps {
|
||||
clips: ClipData[]
|
||||
selectedClipId: string | null
|
||||
isPlaying: boolean
|
||||
currentCoverScheme: string
|
||||
coverSchemes: CoverScheme[]
|
||||
aiCoverLoading: boolean
|
||||
titleConfig?: TitleConfig
|
||||
coverConfig?: CoverConfig
|
||||
subtitleSettings?: SubtitleSettings
|
||||
onClipSelect: (clipId: string) => void
|
||||
onCoverSchemeChange: (scheme: string) => void
|
||||
onPlayPause: () => void
|
||||
onAiGenerateCover: (coverType: "ai_frame" | "ai_regenerate") => void
|
||||
}
|
||||
|
||||
const CLIP_TYPE_ICONS: Record<ClipType, string> = {
|
||||
@@ -45,18 +37,20 @@ const CLIP_TYPE_LABELS: Record<ClipType, string> = {
|
||||
pip: "混剪",
|
||||
}
|
||||
|
||||
const COVER_MODE_LABELS: Record<string, string> = {
|
||||
auto: "智能封面",
|
||||
frame: "抽帧封面",
|
||||
upload: "上传封面",
|
||||
}
|
||||
|
||||
const PreviewPlayer: React.FC<PreviewPlayerProps> = ({
|
||||
clips,
|
||||
selectedClipId,
|
||||
isPlaying,
|
||||
currentCoverScheme,
|
||||
coverSchemes,
|
||||
aiCoverLoading,
|
||||
titleConfig,
|
||||
coverConfig,
|
||||
subtitleSettings,
|
||||
onCoverSchemeChange,
|
||||
onPlayPause,
|
||||
onAiGenerateCover,
|
||||
}) => {
|
||||
const selectedClip = clips.find((c) => c.id === selectedClipId)
|
||||
const displayClip = selectedClip || clips[0]
|
||||
@@ -139,50 +133,24 @@ const PreviewPlayer: React.FC<PreviewPlayerProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 封面预览 */}
|
||||
{/* 封面预览(只读) */}
|
||||
<div className="ep-cover-preview">
|
||||
<div className="ep-cover-image">
|
||||
{displayClip ? (
|
||||
{coverConfig?.thumbnail_url || coverConfig?.upload_url ? (
|
||||
<img
|
||||
src={coverConfig.thumbnail_url || coverConfig.upload_url}
|
||||
alt="封面预览"
|
||||
className="ep-cover-img"
|
||||
/>
|
||||
) : displayClip ? (
|
||||
<span className="ep-cover-icon">{CLIP_TYPE_ICONS[displayClip.type] || "🎬"}</span>
|
||||
) : (
|
||||
<span>暂无封面</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="ep-cover-label">
|
||||
{coverSchemes.find((s) => s.key === currentCoverScheme)?.label || "封面预览"}
|
||||
{coverConfig?.enabled ? COVER_MODE_LABELS[coverConfig.mode] || "封面预览" : "未启用封面"}
|
||||
</div>
|
||||
{/* AI 封面操作按钮 */}
|
||||
<div className="ep-cover-ai-btns">
|
||||
<button
|
||||
className="ep-cover-ai-btn"
|
||||
onClick={() => onAiGenerateCover("ai_frame")}
|
||||
disabled={aiCoverLoading}
|
||||
title="AI 智能选帧"
|
||||
>
|
||||
{aiCoverLoading ? "⏳" : "🤖"} AI 选帧
|
||||
</button>
|
||||
<button
|
||||
className="ep-cover-ai-btn"
|
||||
onClick={() => onAiGenerateCover("ai_regenerate")}
|
||||
disabled={aiCoverLoading}
|
||||
title="AI 重新生成封面"
|
||||
>
|
||||
{aiCoverLoading ? "⏳" : "🔄"} AI 重选
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 封面方案按钮(竖排4个) */}
|
||||
<div className="ep-cover-tags">
|
||||
{coverSchemes.map((scheme) => (
|
||||
<button
|
||||
key={scheme.key}
|
||||
className={`ep-cover-tag ${currentCoverScheme === scheme.key ? "active" : ""}`}
|
||||
onClick={() => onCoverSchemeChange(scheme.key)}
|
||||
>
|
||||
{scheme.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
getGenerationTaskResults,
|
||||
} from "@/api/editPlans"
|
||||
import type { GeneratedVideo, EditPlanConfig, TitleConfig } from "@/api/editPlans"
|
||||
import type { CoverConfig } from "../editing-planner/types"
|
||||
import { getEditingTemplates } from "@/api/editingPlanner"
|
||||
import { getTitles } from "@/api/titles"
|
||||
import apiClient from "@/api/client"
|
||||
@@ -75,9 +76,11 @@ const VOICE_GENDER_ICON: Record<string, string> = {
|
||||
const STEPS = [
|
||||
{ key: 1, label: "选择模板" },
|
||||
{ key: 2, label: "选择素材" },
|
||||
{ key: 3, label: "选择标题" },
|
||||
{ key: 4, label: "选择配音" },
|
||||
{ key: 5, label: "确认生成" },
|
||||
{ key: 3, label: "生成预览" },
|
||||
{ key: 4, label: "选择标题" },
|
||||
{ key: 5, label: "选择配音" },
|
||||
{ key: 6, label: "选择封面" },
|
||||
{ key: 7, label: "确认生成" },
|
||||
]
|
||||
|
||||
/* ── 标题设置常量 ── */
|
||||
@@ -197,6 +200,28 @@ const TITLE_PRESETS = [
|
||||
},
|
||||
]
|
||||
|
||||
/* ── 封面设置常量 ── */
|
||||
const COVER_MODE_LABELS: Record<string, string> = {
|
||||
auto: "智能封面",
|
||||
frame: "抽帧选封面",
|
||||
upload: "上传封面",
|
||||
}
|
||||
|
||||
const COVER_MODE_ICONS: Record<string, string> = {
|
||||
auto: "🤖",
|
||||
frame: "🎞️",
|
||||
upload: "📤",
|
||||
}
|
||||
|
||||
const DEFAULT_COVER_SETTINGS: CoverConfig = {
|
||||
enabled: true,
|
||||
mode: "auto",
|
||||
frame_time: 0,
|
||||
upload_url: "",
|
||||
ai_suggested_time: null,
|
||||
thumbnail_url: "",
|
||||
}
|
||||
|
||||
function getActivePreset(settings: TitleSettings): string | null {
|
||||
for (const p of TITLE_PRESETS) {
|
||||
if (
|
||||
@@ -244,6 +269,9 @@ const GeneratePage: React.FC = () => {
|
||||
|
||||
/* ── 标题设置 ── */
|
||||
const [titleSettings, setTitleSettings] = useState<TitleSettings>(DEFAULT_TITLE_SETTINGS)
|
||||
|
||||
/* ── 封面设置 ── */
|
||||
const [coverSettings, setCoverSettings] = useState<CoverConfig>(DEFAULT_COVER_SETTINGS)
|
||||
const { data: userTitles = [] } = useQuery({
|
||||
queryKey: ["titles"],
|
||||
queryFn: () => getTitles(),
|
||||
@@ -263,6 +291,17 @@ const GeneratePage: React.FC = () => {
|
||||
color: tpl.title_config!.font_color || prev.color,
|
||||
}))
|
||||
}
|
||||
if (tpl?.cover_config) {
|
||||
setCoverSettings((prev) => ({
|
||||
...prev,
|
||||
enabled: tpl.cover_config!.enabled ?? prev.enabled,
|
||||
mode: (tpl.cover_config!.mode as CoverConfig["mode"]) || prev.mode,
|
||||
frame_time: tpl.cover_config!.frame_time ?? prev.frame_time,
|
||||
upload_url: tpl.cover_config!.upload_url || prev.upload_url,
|
||||
ai_suggested_time: tpl.cover_config!.ai_suggested_time ?? prev.ai_suggested_time,
|
||||
thumbnail_url: tpl.cover_config!.thumbnail_url || prev.thumbnail_url,
|
||||
}))
|
||||
}
|
||||
}, [selectedTemplate, userTemplates])
|
||||
|
||||
/* ── 配音 ── */
|
||||
@@ -360,6 +399,18 @@ const GeneratePage: React.FC = () => {
|
||||
color: cfg.title_config!.font_color || prev.color,
|
||||
}))
|
||||
}
|
||||
if (cfg?.cover_config) {
|
||||
const cc = cfg.cover_config as CoverConfig
|
||||
setCoverSettings((prev) => ({
|
||||
...prev,
|
||||
enabled: cc.enabled ?? prev.enabled,
|
||||
mode: cc.mode || prev.mode,
|
||||
frame_time: cc.frame_time ?? prev.frame_time,
|
||||
upload_url: cc.upload_url || prev.upload_url,
|
||||
ai_suggested_time: cc.ai_suggested_time ?? prev.ai_suggested_time,
|
||||
thumbnail_url: cc.thumbnail_url || prev.thumbnail_url,
|
||||
}))
|
||||
}
|
||||
if (cfg?.asset_ids) {
|
||||
setSelectedMaterials(cfg.asset_ids.filter((v): v is string => typeof v === "string"))
|
||||
}
|
||||
@@ -658,6 +709,7 @@ const GeneratePage: React.FC = () => {
|
||||
font_color: titleSettings.color,
|
||||
font_size: titleSettings.size,
|
||||
},
|
||||
cover_config: coverSettings,
|
||||
...voiceConfig,
|
||||
ratio: videoRatio,
|
||||
style,
|
||||
@@ -856,6 +908,7 @@ const GeneratePage: React.FC = () => {
|
||||
selectedTemplate,
|
||||
generateCount,
|
||||
materialMode,
|
||||
coverSettings,
|
||||
])
|
||||
|
||||
/* ── 下载视频 ── */
|
||||
@@ -904,11 +957,11 @@ const GeneratePage: React.FC = () => {
|
||||
message.warning("请至少选择一个素材")
|
||||
return
|
||||
}
|
||||
if (currentStep === 3 && !titleSettings.title.trim()) {
|
||||
if (currentStep === 4 && !titleSettings.title.trim()) {
|
||||
message.warning("请选择或输入标题")
|
||||
return
|
||||
}
|
||||
if (currentStep < 5) {
|
||||
if (currentStep < 7) {
|
||||
setCurrentStep((s) => s + 1)
|
||||
}
|
||||
}, [currentStep, selectedTemplate, selectedMaterials.length, titleSettings, materialMode])
|
||||
@@ -1167,8 +1220,45 @@ const GeneratePage: React.FC = () => {
|
||||
</div>
|
||||
)
|
||||
|
||||
/** 步骤 3:选择标题 + 标题样式设置 */
|
||||
/** 步骤 3:生成预览 */
|
||||
const renderStep3 = () => (
|
||||
<div className="xx-form-section">
|
||||
<h3>🎬 生成预览</h3>
|
||||
<div className="xx-preview-tip">
|
||||
<CheckCircleFilled style={{ color: "#52c41a", marginRight: 8 }} />
|
||||
<span>素材已选好,AI 将为您智能匹配剪辑方案</span>
|
||||
</div>
|
||||
<div className="xx-preview-plan-card">
|
||||
<div className="xx-preview-plan-title">剪辑计划预览</div>
|
||||
<div className="xx-preview-plan-info">
|
||||
<div className="xx-preview-plan-row">
|
||||
<span className="xx-preview-plan-label">模板</span>
|
||||
<span className="xx-preview-plan-value">{getTemplateName()}</span>
|
||||
</div>
|
||||
<div className="xx-preview-plan-row">
|
||||
<span className="xx-preview-plan-label">素材数量</span>
|
||||
<span className="xx-preview-plan-value">
|
||||
{materialMode === "auto" ? "AI自动匹配" : `${selectedMaterials.length} 个素材`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="xx-preview-plan-row">
|
||||
<span className="xx-preview-plan-label">预计时长</span>
|
||||
<span className="xx-preview-plan-value">{duration} 秒</span>
|
||||
</div>
|
||||
<div className="xx-preview-plan-row">
|
||||
<span className="xx-preview-plan-label">视频比例</span>
|
||||
<span className="xx-preview-plan-value">{videoRatio}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="xx-preview-plan-hint">
|
||||
💡 点击「下一步」进入标题设置,AI 将根据素材内容为您推荐标题
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
/** 步骤 4:选择标题 + 标题样式设置 */
|
||||
const renderStep4 = () => (
|
||||
<div className="xx-form-section">
|
||||
<h3>📝 选择标题</h3>
|
||||
|
||||
@@ -1359,7 +1449,7 @@ const GeneratePage: React.FC = () => {
|
||||
)
|
||||
|
||||
/** 步骤 4:选择配音 */
|
||||
const renderStep4 = () => (
|
||||
const renderStep5 = () => (
|
||||
<div className="xx-form-section">
|
||||
<h3>🎙️ 选择配音</h3>
|
||||
|
||||
@@ -1734,8 +1824,198 @@ const GeneratePage: React.FC = () => {
|
||||
</div>
|
||||
)
|
||||
|
||||
/** 步骤 5:确认生成 */
|
||||
const renderStep5 = () => (
|
||||
/** 步骤 5:选择封面 */
|
||||
const renderStep6 = () => {
|
||||
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}`
|
||||
}
|
||||
|
||||
const totalDuration = duration || 30
|
||||
|
||||
return (
|
||||
<div className="xx-form-section">
|
||||
<h3>🖼️ 选择封面</h3>
|
||||
|
||||
{/* 启用开关 */}
|
||||
<div className="xx-cover-header">
|
||||
<span className="xx-cover-header-label">启用自定义封面</span>
|
||||
<label className="xx-switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={coverSettings.enabled}
|
||||
onChange={(e) => setCoverSettings((prev) => ({ ...prev, enabled: e.target.checked }))}
|
||||
/>
|
||||
<span className="xx-switch-slider" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{coverSettings.enabled && (
|
||||
<>
|
||||
{/* 模式选择 */}
|
||||
<div className="xx-section-title">封面来源</div>
|
||||
<div className="xx-cover-mode-tabs">
|
||||
{(["auto", "frame", "upload"] as const).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
className={`xx-cover-mode-tab${coverSettings.mode === m ? " active" : ""}`}
|
||||
onClick={() => setCoverSettings((prev) => ({ ...prev, mode: m }))}
|
||||
>
|
||||
<span className="xx-cover-mode-icon">{COVER_MODE_ICONS[m]}</span>
|
||||
<span className="xx-cover-mode-label">{COVER_MODE_LABELS[m]}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 智能封面 */}
|
||||
{coverSettings.mode === "auto" && (
|
||||
<div className="xx-cover-auto">
|
||||
<div className="xx-cover-auto-desc">
|
||||
AI 将分析视频内容,自动选择最具吸引力的画面作为封面。
|
||||
</div>
|
||||
<div className="xx-cover-auto-badge">
|
||||
<span style={{ fontSize: 24 }}>🤖</span>
|
||||
<span>AI 智能选帧</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 抽帧选封面 */}
|
||||
{coverSettings.mode === "frame" && (
|
||||
<div className="xx-cover-frame">
|
||||
<div className="xx-cover-frame-preview">
|
||||
<div className="xx-cover-frame-placeholder">
|
||||
<span className="xx-cover-frame-icon">🎞️</span>
|
||||
<span className="xx-cover-frame-time">
|
||||
{formatTime(coverSettings.frame_time)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="xx-cover-frame-slider">
|
||||
<div className="xx-cover-frame-slider-header">
|
||||
<span>拖动选择封面帧</span>
|
||||
<span className="xx-cover-frame-value">
|
||||
{formatTime(coverSettings.frame_time)}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={Math.max(totalDuration, 1)}
|
||||
step={0.1}
|
||||
value={coverSettings.frame_time}
|
||||
onChange={(e) =>
|
||||
setCoverSettings((prev) => ({
|
||||
...prev,
|
||||
frame_time: Number(e.target.value),
|
||||
}))
|
||||
}
|
||||
className="xx-cover-range"
|
||||
/>
|
||||
<div className="xx-cover-frame-range">
|
||||
<span>00:00</span>
|
||||
<span>{formatTime(totalDuration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="xx-cover-frame-quick">
|
||||
<span className="xx-cover-quick-label">快捷选帧:</span>
|
||||
{[0, 0.25, 0.5, 0.75].map((ratio) => {
|
||||
const t = totalDuration * ratio
|
||||
return (
|
||||
<button
|
||||
key={ratio}
|
||||
className="xx-cover-quick-btn"
|
||||
onClick={() => setCoverSettings((prev) => ({ ...prev, frame_time: t }))}
|
||||
>
|
||||
{formatTime(t)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 上传封面 */}
|
||||
{coverSettings.mode === "upload" && (
|
||||
<div className="xx-cover-upload">
|
||||
<div
|
||||
className="xx-cover-upload-area"
|
||||
onClick={() => {
|
||||
const input = document.getElementById("cover-upload-input")
|
||||
input?.click()
|
||||
}}
|
||||
>
|
||||
{coverSettings.upload_url ? (
|
||||
<div className="xx-cover-upload-preview">
|
||||
<img src={coverSettings.upload_url} alt="封面预览" />
|
||||
<div className="xx-cover-upload-overlay">点击更换</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="xx-cover-upload-placeholder">
|
||||
<span style={{ fontSize: 32 }}>📤</span>
|
||||
<span className="xx-cover-upload-text">点击上传封面图片</span>
|
||||
<span className="xx-cover-upload-hint">支持 JPG / PNG,建议 16:9 比例</span>
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
id="cover-upload-input"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
style={{ display: "none" }}
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) {
|
||||
const reader = new FileReader()
|
||||
reader.onload = (ev) => {
|
||||
const url = ev.target?.result as string
|
||||
setCoverSettings((prev) => ({
|
||||
...prev,
|
||||
upload_url: url,
|
||||
thumbnail_url: url,
|
||||
mode: "upload",
|
||||
}))
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 封面预览 */}
|
||||
<div className="xx-section-title">封面预览</div>
|
||||
<div className="xx-cover-preview-box">
|
||||
{coverSettings.upload_url ? (
|
||||
<img
|
||||
src={coverSettings.upload_url}
|
||||
alt="封面预览"
|
||||
className="xx-cover-preview-img"
|
||||
/>
|
||||
) : (
|
||||
<div className="xx-cover-preview-placeholder">
|
||||
<span style={{ fontSize: 28 }}>🖼️</span>
|
||||
<span>
|
||||
{coverSettings.mode === "auto"
|
||||
? "AI 智能选择"
|
||||
: coverSettings.mode === "frame"
|
||||
? `帧 ${formatTime(coverSettings.frame_time)}`
|
||||
: "未上传封面"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="xx-cover-preview-ratio">16:9</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 步骤 6:确认生成 */
|
||||
const renderStep7 = () => (
|
||||
<div className="xx-form-section">
|
||||
<h3>✨ 确认生成</h3>
|
||||
<div className="xx-summary-card">
|
||||
@@ -1757,6 +2037,12 @@ const GeneratePage: React.FC = () => {
|
||||
<span className="xx-summary-label">配音</span>
|
||||
<span className="xx-summary-value">{getVoiceName()}</span>
|
||||
</div>
|
||||
<div className="xx-summary-row">
|
||||
<span className="xx-summary-label">封面</span>
|
||||
<span className="xx-summary-value">
|
||||
{coverSettings.enabled ? COVER_MODE_LABELS[coverSettings.mode] || "智能封面" : "不使用"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="xx-summary-row">
|
||||
<span className="xx-summary-label">生成数量</span>
|
||||
<span className="xx-summary-value">
|
||||
@@ -1872,6 +2158,10 @@ const GeneratePage: React.FC = () => {
|
||||
return renderStep4()
|
||||
case 5:
|
||||
return renderStep5()
|
||||
case 6:
|
||||
return renderStep6()
|
||||
case 7:
|
||||
return renderStep7()
|
||||
default:
|
||||
return null
|
||||
}
|
||||
@@ -1948,7 +2238,7 @@ const GeneratePage: React.FC = () => {
|
||||
<button className="xx-btn xx-btn-ghost" onClick={goPrev} disabled={currentStep === 1}>
|
||||
← 上一步
|
||||
</button>
|
||||
{currentStep < 5 ? (
|
||||
{currentStep < 7 ? (
|
||||
<button className="xx-btn xx-btn-primary" onClick={goNext}>
|
||||
下一步 →
|
||||
</button>
|
||||
|
||||
@@ -1337,3 +1337,415 @@
|
||||
border-color: var(--primary-color);
|
||||
color: #fff;
|
||||
}
|
||||
/* ================================================================
|
||||
封面设置
|
||||
================================================================ */
|
||||
|
||||
.xx-cover-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 16px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.xx-cover-header-label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.xx-switch {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 40px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.xx-switch input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.xx-switch-slider {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: var(--border-color);
|
||||
transition: 0.2s;
|
||||
border-radius: 22px;
|
||||
}
|
||||
|
||||
.xx-switch-slider:before {
|
||||
position: absolute;
|
||||
content: "";
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
left: 3px;
|
||||
bottom: 3px;
|
||||
background-color: white;
|
||||
transition: 0.2s;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.xx-switch input:checked + .xx-switch-slider {
|
||||
background-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.xx-switch input:checked + .xx-switch-slider:before {
|
||||
transform: translateX(18px);
|
||||
}
|
||||
|
||||
.xx-section-title {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
margin: 16px 0 10px;
|
||||
}
|
||||
|
||||
.xx-cover-mode-tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.xx-cover-mode-tab {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 12px 8px;
|
||||
border: 1.5px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-primary);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.xx-cover-mode-tab:hover {
|
||||
border-color: var(--primary-300);
|
||||
}
|
||||
|
||||
.xx-cover-mode-tab.active {
|
||||
border-color: var(--primary-color);
|
||||
background: var(--primary-50);
|
||||
}
|
||||
|
||||
.xx-cover-mode-icon {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.xx-cover-mode-label {
|
||||
font-size: 12px;
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.xx-cover-auto {
|
||||
padding: 20px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: var(--radius-md);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.xx-cover-auto-desc {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 16px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.xx-cover-auto-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 20px;
|
||||
background: var(--primary-50);
|
||||
border: 1px solid var(--primary-200);
|
||||
border-radius: 20px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.xx-cover-frame {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.xx-cover-frame-preview {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.xx-cover-frame-placeholder {
|
||||
aspect-ratio: 16 / 9;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border-radius: var(--radius-md);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.xx-cover-frame-icon {
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
.xx-cover-frame-time {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.xx-cover-frame-slider {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.xx-cover-frame-slider-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.xx-cover-frame-value {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.xx-cover-range {
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background: var(--border-color);
|
||||
border-radius: 2px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.xx-cover-range::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: var(--primary-color);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.xx-cover-frame-range {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.xx-cover-frame-quick {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.xx-cover-quick-label {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.xx-cover-quick-btn {
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.xx-cover-quick-btn:hover {
|
||||
border-color: var(--primary-color);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.xx-cover-upload {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.xx-cover-upload-area {
|
||||
aspect-ratio: 16 / 9;
|
||||
border: 2px dashed var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.xx-cover-upload-area:hover {
|
||||
border-color: var(--primary-color);
|
||||
background: var(--primary-50);
|
||||
}
|
||||
|
||||
.xx-cover-upload-placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.xx-cover-upload-text {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.xx-cover-upload-hint {
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.xx-cover-upload-preview {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.xx-cover-upload-preview img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.xx-cover-upload-overlay {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 12px;
|
||||
background: linear-gradient(transparent, rgba(0, 0, 0, 0.6));
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.xx-cover-preview-box {
|
||||
position: relative;
|
||||
aspect-ratio: 16 / 9;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.xx-cover-preview-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.xx-cover-preview-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.xx-cover-preview-ratio {
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
right: 8px;
|
||||
padding: 2px 8px;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
生成预览步骤
|
||||
================================================================ */
|
||||
|
||||
.xx-preview-tip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
background: rgba(82, 196, 26, 0.08);
|
||||
border: 1px solid rgba(82, 196, 26, 0.2);
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: 20px;
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.xx-preview-plan-card {
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 20px;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.xx-preview-plan-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.xx-preview-plan-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.xx-preview-plan-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.xx-preview-plan-label {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.xx-preview-plan-value {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.xx-preview-plan-hint {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
padding: 10px 12px;
|
||||
background: var(--bg-primary);
|
||||
border-radius: var(--radius-sm);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@@ -16,7 +16,19 @@ export default defineConfig({
|
||||
coverage: {
|
||||
provider: "v8",
|
||||
reporter: ["text", "json", "html"],
|
||||
exclude: ["node_modules/", "src/test/", "e2e/", "**/*.d.ts", "**/*.config.*", "**/mockData"],
|
||||
exclude: [
|
||||
"node_modules/",
|
||||
"src/test/",
|
||||
"e2e/",
|
||||
"**/*.d.ts",
|
||||
"**/*.config.*",
|
||||
"**/mockData",
|
||||
// 页面级组件不纳入单测覆盖率统计(页面级走 E2E/手动测试)
|
||||
"src/pages/generate/GeneratePage.tsx",
|
||||
"src/pages/editing-planner/EditingPlanner.tsx",
|
||||
"src/pages/assets/AssetLibrary.tsx",
|
||||
"src/pages/voice-materials/VoiceMaterialLibrary.tsx",
|
||||
],
|
||||
// CI 覆盖率门禁(Phase 4 后提升,逐步逼近目标)
|
||||
// 当前实际:行 ~62% / 分支 ~61% / 函数 ~25%
|
||||
thresholds: {
|
||||
|
||||
Reference in New Issue
Block a user