refactor: GeneratePage Phase 1 — 抽离常量/类型/UI组件(3036→2682行) #796

Merged
xiaoxia merged 6 commits from refactor/generate-page-phase1 into develop 2026-07-24 11:01:42 +08:00
8 changed files with 787 additions and 431 deletions
+180 -42
View File
@@ -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
+33 -389
View File
@@ -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
+200
View File
@@ -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: "",
}
+73
View File
@@ -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()
})
})