diff --git a/apps/web/src/pages/generate/GeneratePage.tsx b/apps/web/src/pages/generate/GeneratePage.tsx index d655967cf..c98632d25 100644 --- a/apps/web/src/pages/generate/GeneratePage.tsx +++ b/apps/web/src/pages/generate/GeneratePage.tsx @@ -1,367 +1,869 @@ /** - * 一键生成页面 - * 流程:选择模板 → 选择素材 → 选择标题 → 选择配音 → 批量生成 + * 一键生成页面(V21 改造) + * 功能:标题/描述输入 → 素材选择 → 配音选择 → 标题/文案 → 高级设置 → 生成 → 结果 + * 使用 Task 1.5 UI 组件 + V21 CSS 变量,Mock 数据 */ -import React, { useState } from "react"; -import { useNavigate } from "react-router-dom"; -import { useQuery, useMutation } from "@tanstack/react-query"; +import React, { useState, useRef, useCallback } from "react"; import { - Card, - Button, - Typography, - Space, - Steps, - Checkbox, - message, - Result, - Spin, Row, Col, - Tag, - Alert, + Typography, + Collapse, + Progress, + Slider, + Radio, + Switch, + Upload, + message, } from "antd"; +import type { UploadFile } from "antd"; import { - CheckCircleOutlined, - VideoCameraOutlined, FileTextOutlined, - AudioOutlined, PictureOutlined, + AudioOutlined, + SettingOutlined, + ThunderboltOutlined, + CheckCircleFilled, + PlayCircleOutlined, + PauseCircleOutlined, + DownloadOutlined, + ShareAltOutlined, + ReloadOutlined, + InboxOutlined, + SoundOutlined, + FormOutlined, } from "@ant-design/icons"; -import { getTemplates } from "@/api/templates"; -import { getAssetLibraries, getAssets, type AssetItem } from "@/api/assets"; -import { getTitles } from "@/api/titles"; -import { getVoices } from "@/api/voices"; -import { createGenerationTask } from "@/api/tasks"; -import PageHead from "@/components/layout/PageHead"; +import Button from "@/components/ui/Button"; +import Input from "@/components/ui/Input"; +import Card from "@/components/ui/Card"; +import Tag from "@/components/ui/Tag"; +import Form, { FormItem } from "@/components/ui/Form"; +import type { AssetItem } from "@/api/assets"; +import type { VoiceItem } from "@/api/voices"; +import "./generate.css"; +const { TextArea } = Input; const { Text } = Typography; +/* ================================================================ + Mock 数据 + ================================================================ */ + +const MOCK_MATERIALS: AssetItem[] = [ + { + id: "m1", + library_id: "lib1", + name: "产品展示视频.mp4", + storage_key: "", + mime_type: "video/mp4", + metadata: { duration: 30 }, + file_size: 15_000_000, + status: "active", + }, + { + id: "m2", + library_id: "lib1", + name: "品牌宣传片.mp4", + storage_key: "", + mime_type: "video/mp4", + metadata: { duration: 60 }, + file_size: 42_000_000, + status: "active", + }, + { + id: "m3", + library_id: "lib1", + name: "用户访谈片段.mp4", + storage_key: "", + mime_type: "video/mp4", + metadata: { duration: 45 }, + file_size: 28_000_000, + status: "active", + }, + { + id: "m4", + library_id: "lib2", + name: "Logo 动画.png", + storage_key: "", + mime_type: "image/png", + metadata: {}, + file_size: 500_000, + status: "active", + }, + { + id: "m5", + library_id: "lib2", + name: "背景音乐.mp3", + storage_key: "", + mime_type: "audio/mpeg", + metadata: { duration: 120 }, + file_size: 3_500_000, + status: "active", + }, + { + id: "m6", + library_id: "lib1", + name: "功能演示录屏.mp4", + storage_key: "", + mime_type: "video/mp4", + metadata: { duration: 90 }, + file_size: 55_000_000, + status: "active", + }, +]; + +const MOCK_VOICES: VoiceItem[] = [ + { + id: "v1", + name: "小晓 · 温柔女声", + text: "", + voice_type: "female_gentle", + duration_seconds: 0, + audio_url: "", + }, + { + id: "v2", + name: "小宇 · 磁性男声", + text: "", + voice_type: "male_magnetic", + duration_seconds: 0, + audio_url: "", + }, + { + id: "v3", + name: "小雅 · 专业播音", + text: "", + voice_type: "female_professional", + duration_seconds: 0, + audio_url: "", + }, + { + id: "v4", + name: "小杰 · 活力男声", + text: "", + voice_type: "male_energetic", + duration_seconds: 0, + audio_url: "", + }, +]; + +/* ── 常量 ── */ + +interface RatioOption { + label: string; + value: string; + desc: string; +} + +const RATIO_OPTIONS: RatioOption[] = [ + { label: "16:9", value: "16:9", desc: "横屏" }, + { label: "9:16", value: "9:16", desc: "竖屏" }, + { label: "1:1", value: "1:1", desc: "方形" }, + { label: "4:3", value: "4:3", desc: "经典" }, +]; + +interface StyleOption { + label: string; + value: string; + icon: React.ReactNode; + desc: string; +} + +const STYLE_OPTIONS: StyleOption[] = [ + { + label: "商务", + value: "business", + icon: , + desc: "专业正式", + }, + { label: "活泼", value: "lively", icon: , desc: "轻松有趣" }, + { + label: "温馨", + value: "warm", + icon: , + desc: "温暖感人", + }, + { + label: "科技", + value: "tech", + icon: , + desc: "未来科技", + }, + { + label: "简约", + value: "minimal", + icon: , + desc: "简洁大气", + }, + { + label: "国潮", + value: "chinese", + icon: , + desc: "中国风", + }, +]; + +/* ================================================================ + 组件 + ================================================================ */ + const GeneratePage: React.FC = () => { - const navigate = useNavigate(); - const [currentStep, setCurrentStep] = useState(0); - const [selectedTemplate, setSelectedTemplate] = useState(""); - const [selectedAssets, setSelectedAssets] = useState([]); - const [selectedTitles, setSelectedTitles] = useState([]); - const [selectedVoices, setSelectedVoices] = useState([]); + /* ── 基本信息 ── */ + const [title, setTitle] = useState(""); + const [description, setDescription] = useState(""); + + /* ── 素材 ── */ + const [selectedMaterials, setSelectedMaterials] = useState([]); + + /* ── 配音 ── */ + const [selectedVoice, setSelectedVoice] = useState(""); + const [playingVoice, setPlayingVoice] = useState(null); + const [voiceMode, setVoiceMode] = useState<"preset" | "custom">("preset"); + const [customVoiceText, setCustomVoiceText] = useState(""); + + /* ── 标题/文案 ── */ + const [titleMode, setTitleMode] = useState<"ai" | "manual">("ai"); + const [manualCopy, setManualCopy] = useState(""); + + /* ── 高级设置 ── */ + const [videoRatio, setVideoRatio] = useState("16:9"); + const [duration, setDuration] = useState(30); + const [style, setStyle] = useState("business"); + const [autoSubtitles, setAutoSubtitles] = useState(true); + const [bgm, setBgm] = useState(true); + + /* ── 生成状态 ── */ const [generating, setGenerating] = useState(false); + const [progress, setProgress] = useState(0); const [generated, setGenerated] = useState(false); + const [fileList, setFileList] = useState([]); - // 获取模板列表 - const { - data: templates = [], - isLoading: tplLoading, - isError: tplError, - } = useQuery({ - queryKey: ["templates"], - queryFn: getTemplates, - }); + const progressTimer = useRef>(undefined); - // 获取素材库和素材 - const { - data: libraries = [], - isLoading: libLoading, - isError: libError, - } = useQuery({ - queryKey: ["asset-libraries"], - queryFn: getAssetLibraries, - }); + /* ── 事件 ── */ - // 获取标题 - const { - data: titles = [], - isLoading: titleLoading, - isError: titleError, - } = useQuery({ - queryKey: ["titles"], - queryFn: getTitles, - }); + const toggleMaterial = useCallback((id: string) => { + setSelectedMaterials((prev) => + prev.includes(id) ? prev.filter((m) => m !== id) : [...prev, id], + ); + }, []); - // 获取配音 - const { - data: voices = [], - isLoading: voiceLoading, - isError: voiceError, - } = useQuery({ - queryKey: ["voices"], - queryFn: getVoices, - }); - - // 获取所有素材(跨库) - const { - data: allAssets = [], - isLoading: assetsLoading, - isError: assetsError, - } = useQuery({ - queryKey: ["all-assets"], - queryFn: async () => { - const all: AssetItem[] = []; - for (const lib of libraries) { - const items = await getAssets(lib.id); - all.push(...items); + const toggleVoicePlay = useCallback((id: string) => { + setPlayingVoice((prev) => { + if (prev === id) { + message.info("Mock:停止播放"); + return null; } - return all; - }, - enabled: libraries.length > 0, - }); + message.info("Mock:开始播放配音预览"); + return id; + }); + }, []); - const pageLoading = - tplLoading || libLoading || titleLoading || voiceLoading || assetsLoading; - const pageError = - tplError || libError || titleError || voiceError || assetsError; - - // 创建生成任务 - const generateMutation = useMutation({ - mutationFn: createGenerationTask, - onSuccess: () => { - message.success("生成任务已提交"); - setGenerated(true); - setGenerating(false); - }, - onError: (err: unknown) => { - if (!(err as { __msgShown?: boolean })?.__msgShown) - message.error("生成失败"); - setGenerating(false); - }, - }); - - /** 开始生成 */ - const handleGenerate = async () => { - if (!selectedTemplate) { - message.warning("请选择模板"); + const handleGenerate = useCallback(() => { + if (!title.trim()) { + message.warning("请输入视频标题"); return; } + if (selectedMaterials.length === 0) { + message.warning("请至少选择一个素材"); + return; + } + setGenerating(true); - generateMutation.mutate({ - template_id: selectedTemplate, - asset_ids: selectedAssets, - title_ids: selectedTitles, - voice_ids: selectedVoices, - }); + setProgress(0); + setGenerated(false); + + progressTimer.current = setInterval(() => { + setProgress((prev) => { + if (prev >= 100) { + clearInterval(progressTimer.current); + setGenerating(false); + setGenerated(true); + message.success("视频生成完成!"); + return 100; + } + return prev + Math.random() * 12 + 3; + }); + }, 600); + }, [title, selectedMaterials]); + + /* ── 字数统计 ── */ + const titleCount = title.length; + const descCount = description.length; + const TITLE_MAX = 50; + const DESC_MAX = 300; + + /** 字数颜色 */ + const countClass = (count: number, max: number) => { + if (count > max) return "xx-word-count-over"; + if (count > max * 0.8) return "xx-word-count-warn"; + return ""; }; - const steps = [ - { - title: "选择模板", - icon: , - content: ( - - {templates.length === 0 ? ( - - - - ) : ( - templates.map((t) => ( - - setSelectedTemplate(t.id)} - style={{ - border: - selectedTemplate === t.id - ? "2px solid #1890ff" - : undefined, - }} - > - - {t.category && {t.category}} - {t.target_duration && {t.target_duration}s} - - } - /> - - - )) - )} - - ), - }, - { - title: "选择素材", - icon: , - content: ( -
- {allAssets.length === 0 ? ( - - ) : ( - setSelectedAssets(vals as string[])} - style={{ width: "100%" }} - > - - {allAssets.map((a) => ( - - - {a.name} - - - ))} - - - )} -
- ), - }, - { - title: "选择标题", - icon: , - content: ( -
- {titles.length === 0 ? ( - - ) : ( - setSelectedTitles(vals as string[])} - style={{ width: "100%" }} - > - - {titles.map((t) => ( - - - {t.content} - - - ))} - - - )} -
- ), - }, - { - title: "选择配音", - icon: , - content: ( -
- {voices.length === 0 ? ( - - ) : ( - setSelectedVoices(vals as string[])} - style={{ width: "100%" }} - > - - {voices.map((v) => ( - - - {v.name} - - - ))} - - - )} -
- ), - }, - { - title: "生成", - icon: , - content: generated ? ( - navigate("/history")}> - 查看任务 - - } - /> - ) : ( -
- {generating ? ( - - ) : ( - - - 已选择:{selectedAssets.length} 个素材,{selectedTitles.length}{" "} - 个标题,{selectedVoices.length} 个配音 - - - - )} -
- ), - }, - ]; + /* ── 素材图标 ── */ + const materialIcon = (mime: string) => { + if (mime.startsWith("video/")) return ; + if (mime.startsWith("image/")) return ; + if (mime.startsWith("audio/")) return ; + return ; + }; - if (pageLoading) { - return ( -
- -
- ); - } + /** 文件大小格式化 */ + const formatSize = (bytes?: number) => { + if (!bytes) return ""; + if (bytes < 1024) return `${bytes}B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)}MB`; + }; - if (pageError) { - return ( -
- window.location.reload()}>刷新页面 - } - /> -
- ); - } + /* ================================================================ + 渲染 + ================================================================ */ return ( -
- +
+ {/* ── 页头 ── */} +
+ + + 一键生成 + + + 快速生成短视频,支持多种风格和素材组合 + +
- ({ title: s.title, icon: s.icon }))} - style={{ marginBottom: 32 }} - responsive - /> +
+ {/* ════════════ 左侧:主内容 ════════════ */} +
+ {/* ── 1. 基本信息 ── */} +
+
+
+ +
+ + 基本信息 + +
+
+
+ TITLE_MAX ? "error" : undefined + } + > + setTitle(e.target.value)} + error={titleCount > TITLE_MAX} + /> + - {steps[currentStep].content} + +