refactor: GeneratePage Phase 1 — 抽离常量/类型/UI组件(3036→2682行) #796
+180
-42
@@ -1,60 +1,198 @@
|
||||
# 小虾 SaaS 环境变量配置
|
||||
# ============================================================
|
||||
# 小虾 SaaS 环境变量完整配置
|
||||
# ============================================================
|
||||
# 本文件列出所有可配置的环境变量及默认值。
|
||||
# 复制为 .env 后按需修改;生产环境务必覆盖所有密钥类配置。
|
||||
#
|
||||
# 配置读取规则(pydantic-settings,大小写不敏感):
|
||||
# 1. 系统环境变量(最高优先级)
|
||||
# 2. .env.{APP_ENV} 文件(如 .env.staging)
|
||||
# 3. .env 文件
|
||||
# 4. 代码中的默认值(最低优先级)
|
||||
# ============================================================
|
||||
|
||||
# ==================== 应用配置 ====================
|
||||
APP_NAME=小虾 SaaS
|
||||
APP_BASE_URL=http://localhost:3000
|
||||
|
||||
# ==================== 应用基本配置 ====================
|
||||
|
||||
# 应用名称
|
||||
APP_NAME=xiaoxia-saas
|
||||
|
||||
# 应用版本号(展示用,代码中已内置默认)
|
||||
APP_VERSION=0.1.61
|
||||
|
||||
# 环境标识:development / staging / production
|
||||
# 决定读取 .env.{APP_ENV} 还是 .env,也影响部分配置的严格校验
|
||||
APP_ENV=development
|
||||
|
||||
# ==================== 数据库配置 ====================
|
||||
DATABASE_URL=postgresql://xiaoxia_user:your_password@localhost:5432/xiaoxia_saas
|
||||
|
||||
# 开发环境:使用内存数据库(不需要 PostgreSQL)
|
||||
USE_IN_MEMORY_DB=true
|
||||
|
||||
# 生产环境:使用 PostgreSQL
|
||||
# USE_IN_MEMORY_DB=false
|
||||
|
||||
# ==================== Redis 配置 ====================
|
||||
REDIS_URL=redis://localhost:6379/0
|
||||
|
||||
# ==================== JWT 配置 ====================
|
||||
JWT_SECRET_KEY=your-super-secret-key-change-this-in-production-min-32-chars
|
||||
JWT_ALGORITHM=HS256
|
||||
JWT_ACCESS_TOKEN_EXPIRE_MINUTES=30
|
||||
JWT_REFRESH_TOKEN_EXPIRE_DAYS=30
|
||||
|
||||
# ==================== 邮件配置 ====================
|
||||
SMTP_HOST=smtp.gmail.com
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=your-email@gmail.com
|
||||
SMTP_PASSWORD=your-app-specific-password
|
||||
SMTP_FROM_EMAIL=noreply@xiaoxia-saas.com
|
||||
SMTP_FROM_NAME=小虾 SaaS
|
||||
|
||||
# ==================== 环境配置 ====================
|
||||
ENVIRONMENT=development
|
||||
# 是否开启 Debug 模式(开发环境 true,生产环境 false)
|
||||
DEBUG=true
|
||||
|
||||
# ==================== CORS 配置 ====================
|
||||
# 逗号分隔的域名列表(Settings 读取 CORS_ORIGINS_RAW)
|
||||
CORS_ORIGINS_RAW=http://localhost:3000,http://localhost:5173
|
||||
# 应用基础 URL,用于生成认证邮件、回调链接等
|
||||
APP_BASE_URL=http://localhost:3000
|
||||
|
||||
# API 服务监听地址(容器内绑定,外部暴露由 Docker/Nginx 控制)
|
||||
API_HOST=0.0.0.0
|
||||
|
||||
# API 服务监听端口
|
||||
API_PORT=8000
|
||||
|
||||
# 是否自动创建数据库表结构(开发环境可开启,生产环境用 alembic migration)
|
||||
AUTO_CREATE_SCHEMA=false
|
||||
|
||||
|
||||
# ==================== 数据库配置 ====================
|
||||
|
||||
# 数据库连接串(格式:postgresql+psycopg://user:password@host:port/dbname)
|
||||
DATABASE_URL=postgresql+psycopg://postgres:postgres@localhost:5432/xiaoxia_saas
|
||||
|
||||
# 连接池大小(常驻连接数)
|
||||
DATABASE_POOL_SIZE=20
|
||||
|
||||
# 连接池最大溢出连接数(pool_size + max_overflow = 最大并发连接数)
|
||||
DATABASE_MAX_OVERFLOW=10
|
||||
|
||||
# 获取连接超时时间(秒)
|
||||
DATABASE_POOL_TIMEOUT=30
|
||||
|
||||
# 连接回收时间(秒),防止数据库端主动断开导致的死连接
|
||||
DATABASE_POOL_RECYCLE=3600
|
||||
|
||||
# 是否使用内存数据库(SQLite,仅开发/测试可用;生产务必 false)
|
||||
USE_IN_MEMORY_DB=false
|
||||
|
||||
|
||||
# ==================== Redis 配置 ====================
|
||||
|
||||
# Redis 连接 URL(格式:redis://[:password@]host:port/db)
|
||||
REDIS_URL=redis://localhost:6379/0
|
||||
|
||||
# 是否使用 Redis 存储 Session(多实例部署时必须开启;开发可用内存存储)
|
||||
ENABLE_REDIS_SESSIONS=false
|
||||
|
||||
|
||||
# ==================== Celery 任务队列 ====================
|
||||
|
||||
# Celery Broker(任务分发),默认用 Redis db0
|
||||
CELERY_BROKER_URL=redis://localhost:6379/0
|
||||
|
||||
# Celery Result Backend(任务结果存储),默认用 Redis db1
|
||||
CELERY_RESULT_BACKEND=redis://localhost:6379/1
|
||||
|
||||
|
||||
# ==================== Worker 配置 ====================
|
||||
|
||||
# Worker 进程名称
|
||||
WORKER_NAME=xiaoxia-saas-worker
|
||||
|
||||
# Worker 并发数(同时执行的任务数)
|
||||
WORKER_CONCURRENCY=4
|
||||
|
||||
# 每个子进程最多处理多少任务后重启(防止内存泄漏)
|
||||
WORKER_MAX_TASKS_PER_CHILD=1000
|
||||
|
||||
|
||||
# ==================== JWT 认证配置 ====================
|
||||
|
||||
# JWT 签名密钥 — 生产环境必须设置为强随机字符串(至少32字符)
|
||||
# 内置不安全值会被拒绝:secret / changeme / password / your-secret-key 等
|
||||
JWT_SECRET_KEY=your-super-secret-key-change-this-in-production-min-32-chars
|
||||
|
||||
# JWT 签名算法
|
||||
JWT_ALGORITHM=HS256
|
||||
|
||||
# Access Token 过期时间(分钟)
|
||||
JWT_ACCESS_TOKEN_EXPIRE_MINUTES=30
|
||||
|
||||
# Refresh Token 过期时间(天)
|
||||
JWT_REFRESH_TOKEN_EXPIRE_DAYS=30
|
||||
|
||||
|
||||
# ==================== 邮件配置 ====================
|
||||
|
||||
# 是否启用邮件投递(关闭时邮件内容打印到日志,开发调试用)
|
||||
ENABLE_EMAIL_DELIVERY=false
|
||||
|
||||
# SMTP 服务器地址
|
||||
SMTP_HOST=smtp.gmail.com
|
||||
|
||||
# SMTP 端口
|
||||
SMTP_PORT=587
|
||||
|
||||
# SMTP 用户名
|
||||
SMTP_USER=your-email@gmail.com
|
||||
|
||||
# SMTP 密码 / 应用专用密码
|
||||
SMTP_PASSWORD=your-app-specific-password
|
||||
|
||||
# 发件人邮箱
|
||||
SMTP_FROM_EMAIL=noreply@xiaoxia-saas.com
|
||||
|
||||
# 发件人显示名称
|
||||
SMTP_FROM_NAME=小虾 SaaS
|
||||
|
||||
# 是否启用 TLS
|
||||
SMTP_USE_TLS=true
|
||||
|
||||
|
||||
# ==================== 阿里云 OSS 配置 ====================
|
||||
|
||||
# OSS 区域 endpoint
|
||||
OSS_ENDPOINT=oss-cn-hangzhou.aliyuncs.com
|
||||
|
||||
# OSS Access Key ID — 非开发环境必须设置
|
||||
OSS_ACCESS_KEY_ID=your-access-key-id
|
||||
|
||||
# OSS Access Key Secret — 非开发环境必须设置
|
||||
OSS_ACCESS_KEY_SECRET=your-access-key-secret
|
||||
|
||||
# OSS Bucket 名称
|
||||
OSS_BUCKET_NAME=xiaoxia-autocut
|
||||
|
||||
# ==================== CosyVoice 语音合成配置 ====================
|
||||
# 注意:base_url 只需写到 /api/v1,具体路径由代码拼接
|
||||
# 模型: cosyvoice-v3-flash (推荐,支持系统音色,性价比高)
|
||||
# cosyvoice-v3-plus (高质量,系统音色少)
|
||||
# cosyvoice-v3.5-flash / cosyvoice-v3.5-plus (仅支持克隆/设计音色,无系统音色)
|
||||
# 音色: v3系列系统音色带 _v3 后缀,如 longxiaochun_v3, longxiaoxia_v3, longanyang (无后缀)
|
||||
# 注意:COSYVOICE_* 变量由 packages/shared/config.py 的 SharedSettings 读取
|
||||
# 直传最大文件大小(MB)
|
||||
OSS_DIRECT_UPLOAD_MAX_MB=2000
|
||||
|
||||
# 直传签名有效期(秒)
|
||||
OSS_DIRECT_UPLOAD_EXPIRE_SECONDS=900
|
||||
|
||||
|
||||
# ==================== CORS 配置 ====================
|
||||
|
||||
# 允许跨域的前端域名列表,逗号分隔
|
||||
CORS_ORIGINS_RAW=http://localhost:3000,http://localhost:5173,http://localhost:8000
|
||||
|
||||
|
||||
# ==================== 渲染引擎配置 ====================
|
||||
|
||||
# 渲染引擎选择:
|
||||
# legacy — 旧 VideoComposeService(稳定,功能完整)
|
||||
# unified — 新 UnifiedRenderService(新架构,部分场景仍在验证)
|
||||
RENDER_ENGINE=legacy
|
||||
|
||||
|
||||
# ==================== CosyVoice 语音合成 ====================
|
||||
# 阿里云百灵语音合成服务
|
||||
# 模型选择:
|
||||
# cosyvoice-v3-flash — 推荐,系统音色多,性价比高
|
||||
# cosyvoice-v3-plus — 高质量,系统音色少
|
||||
# cosyvoice-v3.5-flash / cosyvoice-v3.5-plus — 仅支持克隆/设计音色,无系统音色
|
||||
# 音色:v3 系列系统音色带 _v3 后缀,如 longxiaochun_v3 / longxiaoxia_v3 / longanyang
|
||||
|
||||
COSYVOICE_API_KEY=your-cosyvoice-api-key
|
||||
COSYVOICE_BASE_URL=https://dashscope.aliyuncs.com/api/v1
|
||||
COSYVOICE_MODEL=cosyvoice-v3-flash
|
||||
COSYVOICE_VOICE=longxiaochun_v3
|
||||
COSYVOICE_SAMPLE_RATE=22050
|
||||
COSYVOICE_FORMAT=mp3
|
||||
|
||||
# 音色克隆模型名(固定为 voice-enrollment,通常不需修改)
|
||||
COSYVOICE_CLONE_MODEL=voice-enrollment
|
||||
|
||||
|
||||
# ==================== 豆包大模型(火山引擎方舟) ====================
|
||||
# 用于 AI 文案生成、智能剪辑等需要大模型能力的场景
|
||||
|
||||
DOUBAO_API_KEY=your-doubao-api-key
|
||||
DOUBAO_MODEL=doubao-seed-1-6-250615
|
||||
DOUBAO_BASE_URL=https://ark.cn-beijing.volces.com/api/v3
|
||||
DOUBAO_TIMEOUT=30
|
||||
DOUBAO_MAX_RETRIES=2
|
||||
|
||||
@@ -15,8 +15,6 @@ import {
|
||||
LoadingOutlined,
|
||||
PlayCircleOutlined,
|
||||
PauseCircleOutlined,
|
||||
DownloadOutlined,
|
||||
ShareAltOutlined,
|
||||
SaveOutlined,
|
||||
PlusOutlined,
|
||||
MinusOutlined,
|
||||
@@ -44,65 +42,27 @@ import { synthesizeSpeech, getTTSJobStatus, saveTtsToLibrary } from "@/api/tts"
|
||||
import { getTags, createTag } from "@/api/tags"
|
||||
import { useCloneProgress } from "@/hooks/useCloneProgress"
|
||||
import { useSearchParams, useNavigate } from "react-router-dom"
|
||||
import GenerateHeader from "./components/GenerateHeader"
|
||||
import GenerateStepsBar from "./components/GenerateStepsBar"
|
||||
import GenerateResultPanel from "./components/GenerateResultPanel"
|
||||
import {
|
||||
CLONE_STATUS_CONFIG,
|
||||
MODE_GRADIENTS,
|
||||
VOICE_GENDER_ICON,
|
||||
POSITION_OPTIONS,
|
||||
FONT_OPTIONS,
|
||||
TITLE_PRESETS,
|
||||
COVER_MODE_LABELS,
|
||||
COVER_MODE_ICONS,
|
||||
DEFAULT_COVER_SETTINGS,
|
||||
SMART_MATCH_REASONS,
|
||||
AI_TITLE_TEMPLATES,
|
||||
} from "./constants"
|
||||
import type { TitleSettings } from "./types"
|
||||
import "./generate.css"
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
/* ── 克隆声音状态配置 ── */
|
||||
const CLONE_STATUS_CONFIG: Record<string, { label: string; color: string }> = {
|
||||
ready: { label: "就绪", color: "var(--secondary-color, #10b981)" },
|
||||
processing: { label: "克隆中", color: "var(--accent-color, #f59e0b)" },
|
||||
failed: { label: "失败", color: "var(--error-color, #ef4444)" },
|
||||
}
|
||||
|
||||
/* ── 模板渐变色映射(根据 mode 分配视觉样式) ── */
|
||||
const MODE_GRADIENTS: Record<string, string> = {
|
||||
pip: "linear-gradient(135deg, #fbbf24, #f59e0b)",
|
||||
one_take: "linear-gradient(135deg, #3b82f6, #1d4ed8)",
|
||||
voice_over: "linear-gradient(135deg, #6366f1, #4f46e5)",
|
||||
voice_pip: "linear-gradient(135deg, #10b981, #059669)",
|
||||
}
|
||||
/* ── 配音预设卡片:从 API 动态生成,不再硬编码 ── */
|
||||
const VOICE_GENDER_ICON: Record<string, string> = {
|
||||
female: "🎀",
|
||||
male: "🎙️",
|
||||
child: "🧒",
|
||||
neutral: "✨",
|
||||
}
|
||||
|
||||
/* ── 步骤定义 ── */
|
||||
const STEPS = [
|
||||
{ key: 1, label: "选择模板" },
|
||||
{ key: 2, label: "选择素材" },
|
||||
{ key: 3, label: "生成预览" },
|
||||
{ key: 4, label: "选择标题" },
|
||||
{ key: 5, label: "选择配音" },
|
||||
{ key: 6, label: "选择封面" },
|
||||
{ key: 7, label: "确认生成" },
|
||||
]
|
||||
|
||||
/* ── 标题设置常量 ── */
|
||||
const POSITION_OPTIONS = [
|
||||
{ value: "top", label: "顶部" },
|
||||
{ value: "center", label: "居中" },
|
||||
{ value: "bottom", label: "底部" },
|
||||
]
|
||||
|
||||
const FONT_OPTIONS = ["思源黑体", "思源宋体", "苹方", "PingFang", "微软雅黑", "楷体", "华康俪金黑"]
|
||||
|
||||
interface TitleSettings {
|
||||
aiAutoSelect: boolean
|
||||
title: string
|
||||
position: string
|
||||
font: string
|
||||
size: number
|
||||
bold: boolean
|
||||
italic: boolean
|
||||
stroke: boolean
|
||||
shadow: boolean
|
||||
color: string
|
||||
}
|
||||
|
||||
const DEFAULT_TITLE_SETTINGS: TitleSettings = {
|
||||
aiAutoSelect: false,
|
||||
title: "",
|
||||
@@ -116,110 +76,6 @@ const DEFAULT_TITLE_SETTINGS: TitleSettings = {
|
||||
color: "#ffffff",
|
||||
}
|
||||
|
||||
const TITLE_PRESETS = [
|
||||
{
|
||||
key: "classic_white",
|
||||
label: "经典白字",
|
||||
style: { size: 28, color: "#ffffff", bold: true, italic: false, stroke: true, shadow: false },
|
||||
previewStyle: {
|
||||
fontWeight: 700,
|
||||
color: "#ffffff",
|
||||
WebkitTextStroke: "1px #000000",
|
||||
fontSize: "20px",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "black_gold",
|
||||
label: "黑金质感",
|
||||
style: { size: 32, color: "#d4a843", bold: true, italic: false, stroke: false, shadow: true },
|
||||
previewStyle: {
|
||||
fontWeight: 700,
|
||||
color: "#d4a843",
|
||||
textShadow: "1px 1px 3px rgba(0,0,0,0.8)",
|
||||
fontSize: "20px",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "fresh_minimal",
|
||||
label: "清新简约",
|
||||
style: { size: 24, color: "#333333", bold: false, italic: false, stroke: false, shadow: false },
|
||||
previewStyle: { fontWeight: 400, color: "#333333", fontSize: "18px" },
|
||||
},
|
||||
{
|
||||
key: "variety_show",
|
||||
label: "综艺花字",
|
||||
style: { size: 36, color: "#ff4081", bold: true, italic: false, stroke: true, shadow: true },
|
||||
previewStyle: {
|
||||
fontWeight: 900,
|
||||
color: "#ff4081",
|
||||
WebkitTextStroke: "1.5px #ffffff",
|
||||
textShadow: "2px 2px 4px rgba(0,0,0,0.5)",
|
||||
fontSize: "22px",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "business",
|
||||
label: "商务极简",
|
||||
style: { size: 24, color: "#1a1a1a", bold: false, italic: false, stroke: false, shadow: false },
|
||||
previewStyle: { fontWeight: 400, color: "#1a1a1a", fontSize: "17px" },
|
||||
},
|
||||
{
|
||||
key: "retro_film",
|
||||
label: "复古胶片",
|
||||
style: { size: 28, color: "#e8d5b7", bold: false, italic: false, stroke: false, shadow: true },
|
||||
previewStyle: {
|
||||
fontWeight: 400,
|
||||
color: "#e8d5b7",
|
||||
textShadow: "2px 2px 6px rgba(0,0,0,0.7)",
|
||||
fontSize: "18px",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "neon_glow",
|
||||
label: "霓虹发光",
|
||||
style: { size: 32, color: "#00e5ff", bold: true, italic: false, stroke: false, shadow: true },
|
||||
previewStyle: {
|
||||
fontWeight: 700,
|
||||
color: "#00e5ff",
|
||||
textShadow: "0 0 4px #00e5ff, 0 0 8px #00e5ff, 0 0 16px rgba(0,229,255,0.5)",
|
||||
fontSize: "20px",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "handwriting",
|
||||
label: "手写字",
|
||||
style: { size: 28, color: "#333333", bold: false, italic: false, stroke: false, shadow: true },
|
||||
previewStyle: {
|
||||
fontWeight: 400,
|
||||
color: "#333333",
|
||||
textShadow: "1px 1px 2px rgba(0,0,0,0.3)",
|
||||
fontSize: "20px",
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
/* ── 封面设置常量 ── */
|
||||
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 (
|
||||
@@ -236,45 +92,6 @@ function getActivePreset(settings: TitleSettings): string | null {
|
||||
return null
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
常量
|
||||
================================================================ */
|
||||
|
||||
const SMART_MATCH_REASONS = [
|
||||
"画面清晰度高,构图专业",
|
||||
"与描述场景高度契合",
|
||||
"时长适中,适合剪辑节奏",
|
||||
"色彩风格统一",
|
||||
"包含关键动作镜头",
|
||||
"镜头运动流畅自然",
|
||||
"光影效果出色",
|
||||
"人物表情生动",
|
||||
]
|
||||
|
||||
const AI_TITLE_TEMPLATES: Record<string, string[]> = {
|
||||
catchy: [
|
||||
"震惊!{topic}居然还能这样操作",
|
||||
"99%的人都不知道的{topic}秘诀",
|
||||
"{topic}的终极指南,看完直接封神",
|
||||
"别再走弯路了!{topic}看这一篇就够",
|
||||
"一个视频讲透{topic},建议收藏",
|
||||
],
|
||||
emotional: [
|
||||
"致每一个在{topic}路上坚持的人",
|
||||
"关于{topic},我想说句真心话",
|
||||
"{topic}背后的故事,看完沉默了",
|
||||
"为什么我劝你一定要了解{topic}",
|
||||
"这才是{topic}最动人的样子",
|
||||
],
|
||||
informative: [
|
||||
"{topic}完整科普:从入门到精通",
|
||||
"深度解析{topic}的核心原理",
|
||||
"{topic}行业趋势报告|2026最新版",
|
||||
"三分钟带你全面了解{topic}",
|
||||
"{topic}常见问题与解决方案汇总",
|
||||
],
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
组件
|
||||
================================================================ */
|
||||
@@ -2771,57 +2588,10 @@ const GeneratePage: React.FC = () => {
|
||||
return (
|
||||
<div className="xx-generate-page">
|
||||
{/* ── 页头 ── */}
|
||||
<div className="xx-generate-head">
|
||||
<div>
|
||||
<h2>
|
||||
<ThunderboltOutlined style={{ marginRight: 8 }} />
|
||||
智能剪辑
|
||||
</h2>
|
||||
<p>快速生成短视频,支持多种风格和素材组合</p>
|
||||
</div>
|
||||
{editPlanId && (
|
||||
<span
|
||||
style={{
|
||||
background: "#dbeafe",
|
||||
color: "#1d4ed8",
|
||||
fontSize: 12,
|
||||
padding: "4px 10px",
|
||||
borderRadius: 999,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
🎬 来自模板草稿
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<GenerateHeader fromEditPlan={!!editPlanId} />
|
||||
|
||||
{/* ── 步骤条 ── */}
|
||||
<div className="xx-steps-bar">
|
||||
{STEPS.map((step, idx) => {
|
||||
const isActive = currentStep === step.key
|
||||
const isDone = currentStep > step.key
|
||||
const cls = ["xx-step-item", isActive ? "active" : "", isDone ? "done" : ""]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
return (
|
||||
<React.Fragment key={step.key}>
|
||||
{idx > 0 && <span className="xx-step-arrow">→</span>}
|
||||
<div
|
||||
className={cls}
|
||||
onClick={() => {
|
||||
// 允许点击已完成的步骤回退
|
||||
if (isDone) setCurrentStep(step.key)
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<div className="xx-step-num">{isDone ? "✓" : step.key}</div>
|
||||
<span className="xx-step-label">{step.label}</span>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<GenerateStepsBar currentStep={currentStep} onStepClick={setCurrentStep} />
|
||||
|
||||
{/* ── 主布局 ── */}
|
||||
<div className="xx-generate-layout">
|
||||
@@ -2859,146 +2629,20 @@ const GeneratePage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{/* ════ 右侧:生成结果 ════ */}
|
||||
<div className="xx-generate-result">
|
||||
<div className="xx-result-header">
|
||||
<h3>生成结果</h3>
|
||||
{generated && generatedVideos.length > 0 && (
|
||||
<span className="xx-result-count">{generatedVideos.length} 个视频</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 生成中进度 */}
|
||||
{generating && (
|
||||
<div className="xx-result-progress">
|
||||
<div className="xx-progress-circle">
|
||||
<svg viewBox="0 0 80 80">
|
||||
<circle
|
||||
cx="40"
|
||||
cy="40"
|
||||
r="36"
|
||||
fill="none"
|
||||
stroke="var(--border-color)"
|
||||
strokeWidth="6"
|
||||
/>
|
||||
<circle
|
||||
cx="40"
|
||||
cy="40"
|
||||
r="36"
|
||||
fill="none"
|
||||
stroke="var(--primary-color)"
|
||||
strokeWidth="6"
|
||||
strokeDasharray={`${Math.round(progress) * 2.26} 226`}
|
||||
strokeLinecap="round"
|
||||
transform="rotate(-90 40 40)"
|
||||
/>
|
||||
</svg>
|
||||
<span className="xx-progress-percent">{Math.round(progress)}%</span>
|
||||
</div>
|
||||
<div className="xx-progress-text">
|
||||
<Text strong style={{ fontSize: 14, display: "block", marginBottom: 4 }}>
|
||||
正在生成视频
|
||||
</Text>
|
||||
<Text style={{ fontSize: 12, color: "var(--text-secondary)" }}>
|
||||
AI 正在处理素材,请稍候…
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 生成失败 */}
|
||||
{generateError && !generating && (
|
||||
<div className="xx-result-empty">
|
||||
<CloseCircleOutlined style={{ fontSize: 40, color: "#ff4d4f", marginBottom: 12 }} />
|
||||
<Text strong style={{ display: "block", marginBottom: 4 }}>
|
||||
生成失败
|
||||
</Text>
|
||||
<Text style={{ fontSize: 12, color: "var(--text-secondary)" }}>
|
||||
{typeof generateError === "string" ? generateError : "请重试"}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 空状态 */}
|
||||
{!generated && !generating && !generateError && (
|
||||
<div className="xx-result-empty">
|
||||
<PlayCircleOutlined
|
||||
style={{ fontSize: 48, color: "var(--text-tertiary)", marginBottom: 12 }}
|
||||
/>
|
||||
<Text style={{ color: "var(--text-secondary)", fontSize: 13 }}>
|
||||
完成配置后点击「确认生成」
|
||||
</Text>
|
||||
<Text style={{ color: "var(--text-tertiary)", fontSize: 12, marginTop: 4 }}>
|
||||
生成的视频将在这里展示
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 生成结果卡片列表 */}
|
||||
{generated && generatedVideos.length > 0 && (
|
||||
<div className="xx-video-grid">
|
||||
{generatedVideos.map((video, idx) => (
|
||||
<div
|
||||
key={video.id || idx}
|
||||
className="xx-video-card"
|
||||
onClick={() => {
|
||||
setPreviewVideo(video)
|
||||
setPreviewModalOpen(true)
|
||||
}}
|
||||
>
|
||||
<div className="xx-video-thumb">
|
||||
{video.thumbnail_url ? (
|
||||
<img src={video.thumbnail_url} alt="" />
|
||||
) : (
|
||||
<div className="xx-video-thumb-placeholder">
|
||||
<PlayCircleOutlined style={{ fontSize: 32, opacity: 0.5 }} />
|
||||
</div>
|
||||
)}
|
||||
<div className="xx-video-play-overlay">
|
||||
<PlayCircleOutlined style={{ fontSize: 36, color: "#fff" }} />
|
||||
</div>
|
||||
{video.duration && (
|
||||
<span className="xx-video-duration">{formatDuration(video.duration)}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="xx-video-info">
|
||||
<div className="xx-video-title">视频 {idx + 1}</div>
|
||||
<div className="xx-video-actions">
|
||||
<button
|
||||
className="xx-video-action-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleDownload()
|
||||
}}
|
||||
>
|
||||
<DownloadOutlined />
|
||||
</button>
|
||||
<button
|
||||
className="xx-video-action-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleShare()
|
||||
}}
|
||||
>
|
||||
<ShareAltOutlined />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{generated && (
|
||||
<div className="xx-result-footer">
|
||||
<button
|
||||
className="xx-btn xx-btn-ghost xx-btn-block"
|
||||
onClick={() => navigate("/app/products")}
|
||||
>
|
||||
前往成片库 →
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<GenerateResultPanel
|
||||
generated={generated}
|
||||
generating={generating}
|
||||
progress={progress}
|
||||
generateError={generateError}
|
||||
generatedVideos={generatedVideos}
|
||||
onVideoPreview={(video) => {
|
||||
setPreviewVideo(video)
|
||||
setPreviewModalOpen(true)
|
||||
}}
|
||||
onDownload={handleDownload}
|
||||
onShare={handleShare}
|
||||
onGoToLibrary={() => navigate("/app/products")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── 视频预览弹窗 ── */}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* 智能剪辑页头组件
|
||||
*/
|
||||
import React from "react"
|
||||
import { ThunderboltOutlined } from "@ant-design/icons"
|
||||
|
||||
interface GenerateHeaderProps {
|
||||
/** 是否来自模板草稿(URL 带 edit_plan_id) */
|
||||
fromEditPlan?: boolean
|
||||
}
|
||||
|
||||
const GenerateHeader: React.FC<GenerateHeaderProps> = ({ fromEditPlan }) => {
|
||||
return (
|
||||
<div className="xx-generate-head">
|
||||
<div>
|
||||
<h2>
|
||||
<ThunderboltOutlined style={{ marginRight: 8 }} />
|
||||
智能剪辑
|
||||
</h2>
|
||||
<p>快速生成短视频,支持多种风格和素材组合</p>
|
||||
</div>
|
||||
{fromEditPlan && (
|
||||
<span
|
||||
style={{
|
||||
background: "#dbeafe",
|
||||
color: "#1d4ed8",
|
||||
fontSize: 12,
|
||||
padding: "4px 10px",
|
||||
borderRadius: 999,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
🎬 来自模板草稿
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default GenerateHeader
|
||||
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* 智能剪辑右侧生成结果面板
|
||||
*/
|
||||
import React from "react"
|
||||
import { Typography } from "antd"
|
||||
import {
|
||||
PlayCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
DownloadOutlined,
|
||||
ShareAltOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import type { GeneratedVideo } from "@/api/template-editor"
|
||||
import { formatDuration } from "@/api/voice-clone"
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
interface GenerateResultPanelProps {
|
||||
/** 是否已生成完成 */
|
||||
generated: boolean
|
||||
/** 是否正在生成中 */
|
||||
generating: boolean
|
||||
/** 生成进度(0-100) */
|
||||
progress: number
|
||||
/** 生成错误信息 */
|
||||
generateError: string | null
|
||||
/** 生成的视频列表 */
|
||||
generatedVideos: GeneratedVideo[]
|
||||
/** 点击视频卡片预览回调 */
|
||||
onVideoPreview: (video: GeneratedVideo) => void
|
||||
/** 下载回调 */
|
||||
onDownload: () => void
|
||||
/** 分享回调 */
|
||||
onShare: () => void
|
||||
/** 前往成片库回调 */
|
||||
onGoToLibrary: () => void
|
||||
}
|
||||
|
||||
const GenerateResultPanel: React.FC<GenerateResultPanelProps> = ({
|
||||
generated,
|
||||
generating,
|
||||
progress,
|
||||
generateError,
|
||||
generatedVideos,
|
||||
onVideoPreview,
|
||||
onDownload,
|
||||
onShare,
|
||||
onGoToLibrary,
|
||||
}) => {
|
||||
return (
|
||||
<div className="xx-generate-result">
|
||||
<div className="xx-result-header">
|
||||
<h3>生成结果</h3>
|
||||
{generated && generatedVideos.length > 0 && (
|
||||
<span className="xx-result-count">{generatedVideos.length} 个视频</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 生成中进度 */}
|
||||
{generating && (
|
||||
<div className="xx-result-progress">
|
||||
<div className="xx-progress-circle">
|
||||
<svg viewBox="0 0 80 80">
|
||||
<circle
|
||||
cx="40"
|
||||
cy="40"
|
||||
r="36"
|
||||
fill="none"
|
||||
stroke="var(--border-color)"
|
||||
strokeWidth="6"
|
||||
/>
|
||||
<circle
|
||||
cx="40"
|
||||
cy="40"
|
||||
r="36"
|
||||
fill="none"
|
||||
stroke="var(--primary-color)"
|
||||
strokeWidth="6"
|
||||
strokeDasharray={`${Math.round(progress) * 2.26} 226`}
|
||||
strokeLinecap="round"
|
||||
transform="rotate(-90 40 40)"
|
||||
/>
|
||||
</svg>
|
||||
<span className="xx-progress-percent">{Math.round(progress)}%</span>
|
||||
</div>
|
||||
<div className="xx-progress-text">
|
||||
<Text strong style={{ fontSize: 14, display: "block", marginBottom: 4 }}>
|
||||
正在生成视频
|
||||
</Text>
|
||||
<Text style={{ fontSize: 12, color: "var(--text-secondary)" }}>
|
||||
AI 正在处理素材,请稍候…
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 生成失败 */}
|
||||
{generateError && !generating && (
|
||||
<div className="xx-result-empty">
|
||||
<CloseCircleOutlined style={{ fontSize: 40, color: "#ff4d4f", marginBottom: 12 }} />
|
||||
<Text strong style={{ display: "block", marginBottom: 4 }}>
|
||||
生成失败
|
||||
</Text>
|
||||
<Text style={{ fontSize: 12, color: "var(--text-secondary)" }}>
|
||||
{typeof generateError === "string" ? generateError : "请重试"}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 空状态 */}
|
||||
{!generated && !generating && !generateError && (
|
||||
<div className="xx-result-empty">
|
||||
<PlayCircleOutlined
|
||||
style={{ fontSize: 48, color: "var(--text-tertiary)", marginBottom: 12 }}
|
||||
/>
|
||||
<Text style={{ color: "var(--text-secondary)", fontSize: 13 }}>
|
||||
完成配置后点击「确认生成」
|
||||
</Text>
|
||||
<Text style={{ color: "var(--text-tertiary)", fontSize: 12, marginTop: 4 }}>
|
||||
生成的视频将在这里展示
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 生成结果卡片列表 */}
|
||||
{generated && generatedVideos.length > 0 && (
|
||||
<div className="xx-video-grid">
|
||||
{generatedVideos.map((video, idx) => (
|
||||
<div
|
||||
key={video.id || idx}
|
||||
className="xx-video-card"
|
||||
onClick={() => onVideoPreview(video)}
|
||||
>
|
||||
<div className="xx-video-thumb">
|
||||
{video.thumbnail_url ? (
|
||||
<img src={video.thumbnail_url} alt="" />
|
||||
) : (
|
||||
<div className="xx-video-thumb-placeholder">
|
||||
<PlayCircleOutlined style={{ fontSize: 32, opacity: 0.5 }} />
|
||||
</div>
|
||||
)}
|
||||
<div className="xx-video-play-overlay">
|
||||
<PlayCircleOutlined style={{ fontSize: 36, color: "#fff" }} />
|
||||
</div>
|
||||
{video.duration && (
|
||||
<span className="xx-video-duration">{formatDuration(video.duration)}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="xx-video-info">
|
||||
<div className="xx-video-title">视频 {idx + 1}</div>
|
||||
<div className="xx-video-actions">
|
||||
<button
|
||||
className="xx-video-action-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onDownload()
|
||||
}}
|
||||
>
|
||||
<DownloadOutlined />
|
||||
</button>
|
||||
<button
|
||||
className="xx-video-action-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onShare()
|
||||
}}
|
||||
>
|
||||
<ShareAltOutlined />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{generated && (
|
||||
<div className="xx-result-footer">
|
||||
<button className="xx-btn xx-btn-ghost xx-btn-block" onClick={onGoToLibrary}>
|
||||
前往成片库 →
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default GenerateResultPanel
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* 智能剪辑步骤条组件
|
||||
*/
|
||||
import React from "react"
|
||||
import { STEPS } from "../constants"
|
||||
|
||||
interface GenerateStepsBarProps {
|
||||
/** 当前步骤(1-based) */
|
||||
currentStep: number
|
||||
/** 点击已完成步骤的回调(用于回退) */
|
||||
onStepClick?: (step: number) => void
|
||||
}
|
||||
|
||||
const GenerateStepsBar: React.FC<GenerateStepsBarProps> = ({ currentStep, onStepClick }) => {
|
||||
return (
|
||||
<div className="xx-steps-bar">
|
||||
{STEPS.map((step, idx) => {
|
||||
const isActive = currentStep === step.key
|
||||
const isDone = currentStep > step.key
|
||||
const cls = ["xx-step-item", isActive ? "active" : "", isDone ? "done" : ""]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
return (
|
||||
<React.Fragment key={step.key}>
|
||||
{idx > 0 && <span className="xx-step-arrow">→</span>}
|
||||
<div
|
||||
className={cls}
|
||||
onClick={() => {
|
||||
// 允许点击已完成的步骤回退
|
||||
if (isDone && onStepClick) {
|
||||
onStepClick(step.key)
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<div className="xx-step-num">{isDone ? "✓" : step.key}</div>
|
||||
<span className="xx-step-label">{step.label}</span>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default GenerateStepsBar
|
||||
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* 智能剪辑页面 — 常量定义
|
||||
*/
|
||||
|
||||
import type { CoverConfig } from "../editing-planner/types"
|
||||
|
||||
/* ── 克隆声音状态配置 ── */
|
||||
export const CLONE_STATUS_CONFIG: Record<string, { label: string; color: string }> = {
|
||||
ready: { label: "就绪", color: "var(--secondary-color, #10b981)" },
|
||||
processing: { label: "克隆中", color: "var(--accent-color, #f59e0b)" },
|
||||
failed: { label: "失败", color: "var(--error-color, #ef4444)" },
|
||||
}
|
||||
|
||||
/* ── 模板渐变色映射(根据 mode 分配视觉样式) ── */
|
||||
export const MODE_GRADIENTS: Record<string, string> = {
|
||||
pip: "linear-gradient(135deg, #fbbf24, #f59e0b)",
|
||||
one_take: "linear-gradient(135deg, #3b82f6, #1d4ed8)",
|
||||
voice_over: "linear-gradient(135deg, #6366f1, #4f46e5)",
|
||||
voice_pip: "linear-gradient(135deg, #10b981, #059669)",
|
||||
}
|
||||
|
||||
/* ── 配音性别图标 ── */
|
||||
export const VOICE_GENDER_ICON: Record<string, string> = {
|
||||
female: "🎀",
|
||||
male: "🎙️",
|
||||
child: "🧒",
|
||||
neutral: "✨",
|
||||
}
|
||||
|
||||
/* ── 步骤定义 ── */
|
||||
export const STEPS = [
|
||||
{ key: 1, label: "选择模板" },
|
||||
{ key: 2, label: "选择素材" },
|
||||
{ key: 3, label: "生成预览" },
|
||||
{ key: 4, label: "选择标题" },
|
||||
{ key: 5, label: "选择配音" },
|
||||
{ key: 6, label: "选择封面" },
|
||||
{ key: 7, label: "确认生成" },
|
||||
]
|
||||
|
||||
/* ── 标题位置选项 ── */
|
||||
export const POSITION_OPTIONS = [
|
||||
{ value: "top", label: "顶部" },
|
||||
{ value: "center", label: "居中" },
|
||||
{ value: "bottom", label: "底部" },
|
||||
]
|
||||
|
||||
/* ── 标题字体选项 ── */
|
||||
export const FONT_OPTIONS = [
|
||||
"思源黑体",
|
||||
"思源宋体",
|
||||
"苹方",
|
||||
"PingFang",
|
||||
"微软雅黑",
|
||||
"楷体",
|
||||
"华康俪金黑",
|
||||
]
|
||||
|
||||
/* ── 标题样式预设 ── */
|
||||
export const TITLE_PRESETS = [
|
||||
{
|
||||
key: "classic_white",
|
||||
label: "经典白字",
|
||||
style: { size: 28, color: "#ffffff", bold: true, italic: false, stroke: true, shadow: false },
|
||||
previewStyle: {
|
||||
fontWeight: 700,
|
||||
color: "#ffffff",
|
||||
WebkitTextStroke: "1px #000000",
|
||||
fontSize: "20px",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "black_gold",
|
||||
label: "黑金质感",
|
||||
style: { size: 32, color: "#d4a843", bold: true, italic: false, stroke: false, shadow: true },
|
||||
previewStyle: {
|
||||
fontWeight: 700,
|
||||
color: "#d4a843",
|
||||
textShadow: "1px 1px 3px rgba(0,0,0,0.8)",
|
||||
fontSize: "20px",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "fresh_minimal",
|
||||
label: "清新简约",
|
||||
style: { size: 24, color: "#333333", bold: false, italic: false, stroke: false, shadow: false },
|
||||
previewStyle: { fontWeight: 400, color: "#333333", fontSize: "18px" },
|
||||
},
|
||||
{
|
||||
key: "variety_show",
|
||||
label: "综艺花字",
|
||||
style: { size: 36, color: "#ff4081", bold: true, italic: false, stroke: true, shadow: true },
|
||||
previewStyle: {
|
||||
fontWeight: 900,
|
||||
color: "#ff4081",
|
||||
WebkitTextStroke: "1.5px #ffffff",
|
||||
textShadow: "2px 2px 4px rgba(0,0,0,0.5)",
|
||||
fontSize: "22px",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "business",
|
||||
label: "商务极简",
|
||||
style: { size: 24, color: "#1a1a1a", bold: false, italic: false, stroke: false, shadow: false },
|
||||
previewStyle: { fontWeight: 400, color: "#1a1a1a", fontSize: "17px" },
|
||||
},
|
||||
{
|
||||
key: "retro_film",
|
||||
label: "复古胶片",
|
||||
style: { size: 28, color: "#e8d5b7", bold: false, italic: false, stroke: false, shadow: true },
|
||||
previewStyle: {
|
||||
fontWeight: 400,
|
||||
color: "#e8d5b7",
|
||||
textShadow: "2px 2px 6px rgba(0,0,0,0.7)",
|
||||
fontSize: "18px",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "neon_glow",
|
||||
label: "霓虹发光",
|
||||
style: { size: 32, color: "#00e5ff", bold: true, italic: false, stroke: false, shadow: true },
|
||||
previewStyle: {
|
||||
fontWeight: 700,
|
||||
color: "#00e5ff",
|
||||
textShadow: "0 0 4px #00e5ff, 0 0 8px #00e5ff, 0 0 16px rgba(0,229,255,0.5)",
|
||||
fontSize: "20px",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "handwriting",
|
||||
label: "手写字",
|
||||
style: { size: 28, color: "#333333", bold: false, italic: false, stroke: false, shadow: true },
|
||||
previewStyle: {
|
||||
fontWeight: 400,
|
||||
color: "#333333",
|
||||
textShadow: "1px 1px 2px rgba(0,0,0,0.3)",
|
||||
fontSize: "20px",
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
/* ── 封面模式 ── */
|
||||
export const COVER_MODE_LABELS: Record<string, string> = {
|
||||
auto: "智能封面",
|
||||
frame: "抽帧选封面",
|
||||
upload: "上传封面",
|
||||
}
|
||||
|
||||
export const COVER_MODE_ICONS: Record<string, string> = {
|
||||
auto: "🤖",
|
||||
frame: "🎞️",
|
||||
upload: "📤",
|
||||
}
|
||||
|
||||
/* ── 智能匹配推荐理由 ── */
|
||||
export const SMART_MATCH_REASONS = [
|
||||
"画面清晰度高,构图专业",
|
||||
"与描述场景高度契合",
|
||||
"时长适中,适合剪辑节奏",
|
||||
"色彩风格统一",
|
||||
"包含关键动作镜头",
|
||||
"镜头运动流畅自然",
|
||||
"光影效果出色",
|
||||
"人物表情生动",
|
||||
]
|
||||
|
||||
/* ── AI 标题模板 ── */
|
||||
export const AI_TITLE_TEMPLATES: Record<string, string[]> = {
|
||||
catchy: [
|
||||
"震惊!{topic}居然还能这样操作",
|
||||
"99%的人都不知道的{topic}秘诀",
|
||||
"{topic}的终极指南,看完直接封神",
|
||||
"别再走弯路了!{topic}看这一篇就够",
|
||||
"一个视频讲透{topic},建议收藏",
|
||||
],
|
||||
emotional: [
|
||||
"致每一个在{topic}路上坚持的人",
|
||||
"关于{topic},我想说句真心话",
|
||||
"{topic}背后的故事,看完沉默了",
|
||||
"为什么我劝你一定要了解{topic}",
|
||||
"这才是{topic}最动人的样子",
|
||||
],
|
||||
informative: [
|
||||
"{topic}完整科普:从入门到精通",
|
||||
"深度解析{topic}的核心原理",
|
||||
"{topic}行业趋势报告|2026最新版",
|
||||
"三分钟带你全面了解{topic}",
|
||||
"{topic}常见问题与解决方案汇总",
|
||||
],
|
||||
}
|
||||
|
||||
/* ── 默认封面设置 ── */
|
||||
export const DEFAULT_COVER_SETTINGS: CoverConfig = {
|
||||
enabled: true,
|
||||
mode: "auto",
|
||||
frame_time: 0,
|
||||
upload_url: "",
|
||||
ai_suggested_time: null,
|
||||
thumbnail_url: "",
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* 智能剪辑页面 — 类型定义
|
||||
*/
|
||||
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
|
||||
/* ── 标题设置 ── */
|
||||
export interface TitleSettings {
|
||||
aiAutoSelect: boolean
|
||||
title: string
|
||||
position: string
|
||||
font: string
|
||||
size: number
|
||||
bold: boolean
|
||||
italic: boolean
|
||||
stroke: boolean
|
||||
shadow: boolean
|
||||
color: string
|
||||
}
|
||||
|
||||
/* ── 智能匹配结果 ── */
|
||||
export interface SmartMatchResult {
|
||||
asset: AssetItem
|
||||
matchScore: number
|
||||
reasons: string[]
|
||||
}
|
||||
|
||||
/* ── AI 标题结果 ── */
|
||||
export interface AiTitleResult {
|
||||
title: string
|
||||
style: string
|
||||
styleLabel: string
|
||||
highlights: string[]
|
||||
}
|
||||
|
||||
/* ── 配音推荐结果 ── */
|
||||
export interface VoiceRecommendation {
|
||||
voiceId: string
|
||||
voiceName: string
|
||||
reason: string
|
||||
}
|
||||
|
||||
/* ── 步骤定义 ── */
|
||||
export interface StepDef {
|
||||
key: number
|
||||
label: string
|
||||
}
|
||||
|
||||
/* ── 标题预设样式 ── */
|
||||
export interface TitlePresetStyle {
|
||||
size: number
|
||||
color: string
|
||||
bold: boolean
|
||||
italic: boolean
|
||||
stroke: boolean
|
||||
shadow: boolean
|
||||
}
|
||||
|
||||
export interface TitlePreset {
|
||||
key: string
|
||||
label: string
|
||||
style: TitlePresetStyle
|
||||
previewStyle: Record<string, string | number>
|
||||
}
|
||||
|
||||
/* ── 生成结果视频 ── */
|
||||
export interface GeneratedVideoResult {
|
||||
id: string
|
||||
url: string
|
||||
thumbnail: string
|
||||
duration: number
|
||||
title: string
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* GenerateHeader 组件单元测试
|
||||
*/
|
||||
import { render, screen } from "@testing-library/react"
|
||||
import { describe, it, expect } from "vitest"
|
||||
import GenerateHeader from "@/pages/generate/components/GenerateHeader"
|
||||
|
||||
describe("GenerateHeader", () => {
|
||||
it("should render title and description", () => {
|
||||
render(<GenerateHeader />)
|
||||
|
||||
expect(screen.getByText("智能剪辑")).toBeInTheDocument()
|
||||
expect(screen.getByText("快速生成短视频,支持多种风格和素材组合")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("should not show edit plan badge by default", () => {
|
||||
render(<GenerateHeader />)
|
||||
|
||||
expect(screen.queryByText("来自模板草稿")).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("should show edit plan badge when fromEditPlan is true", () => {
|
||||
render(<GenerateHeader fromEditPlan />)
|
||||
|
||||
expect(screen.getByText("🎬 来自模板草稿")).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user