fix(ui): 修正一键生成页面偏差,补充视频预览、时间线、克隆声音区域
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
Deploy / Deploy Staging (push) Has been cancelled
Deploy / Build Production Runtime Images (push) Has been cancelled
Deploy / Deploy Production (push) Has been cancelled
Deploy / Production Browser E2E (push) Has been cancelled

This commit is contained in:
灵应
2026-06-30 22:53:01 +08:00
parent 28cb8dd6ae
commit ce9c4f041c
2 changed files with 490 additions and 82 deletions
+242 -74
View File
@@ -5,8 +5,6 @@
*/
import React, { useState, useRef, useCallback } from "react";
import {
Row,
Col,
Typography,
Collapse,
Progress,
@@ -28,7 +26,6 @@ import {
PauseCircleOutlined,
DownloadOutlined,
ShareAltOutlined,
ReloadOutlined,
InboxOutlined,
SoundOutlined,
FormOutlined,
@@ -147,6 +144,37 @@ const MOCK_VOICES: VoiceItem[] = [
},
];
/* ── 克隆声音 Mock ── */
interface ClonedVoice {
id: string;
name: string;
createdAt: string;
duration: string;
}
const MOCK_CLONED_VOICES: ClonedVoice[] = [
{ id: "cv1", name: "我的声音 v1", createdAt: "2026-06-20", duration: "45s" },
{ id: "cv2", name: "我的声音 v2", createdAt: "2026-06-25", duration: "30s" },
];
/* ── 时间线 Mock ── */
interface TimelineScene {
scene: string;
time: string;
duration: number;
color: string;
}
const MOCK_TIMELINE: TimelineScene[] = [
{ scene: "开场 - 品牌 Logo 展示", time: "0:00 - 0:05", duration: 5, color: "#6366f1" },
{ scene: "产品特写 - 细节展示", time: "0:05 - 0:15", duration: 10, color: "#818cf8" },
{ scene: "使用场景 - 生活化演绎", time: "0:15 - 0:30", duration: 15, color: "#10b981" },
{ scene: "卖点字幕 - 核心优势", time: "0:30 - 0:40", duration: 10, color: "#f59e0b" },
{ scene: "结尾 CTA - 引导关注", time: "0:40 - 0:50", duration: 10, color: "#ef4444" },
];
const TOTAL_DURATION = MOCK_TIMELINE.reduce((sum, t) => sum + t.duration, 0);
/* ── 常量 ── */
interface RatioOption {
@@ -218,9 +246,14 @@ const GeneratePage: React.FC = () => {
/* ── 配音 ── */
const [selectedVoice, setSelectedVoice] = useState<string>("");
const [playingVoice, setPlayingVoice] = useState<string | null>(null);
const [voiceMode, setVoiceMode] = useState<"preset" | "custom">("preset");
const [voiceMode, setVoiceMode] = useState<"preset" | "custom" | "clone">("preset");
const [customVoiceText, setCustomVoiceText] = useState("");
/* ── 克隆声音 ── */
const [cloneFileList, setCloneFileList] = useState<UploadFile[]>([]);
const [selectedClonedVoice, setSelectedClonedVoice] = useState<string>("");
const [cloning, setCloning] = useState(false);
/* ── 标题/文案 ── */
const [titleMode, setTitleMode] = useState<"ai" | "manual">("ai");
const [manualCopy, setManualCopy] = useState("");
@@ -473,6 +506,7 @@ const GeneratePage: React.FC = () => {
>
<Radio.Button value="preset">系统预设</Radio.Button>
<Radio.Button value="custom">自定义录制</Radio.Button>
<Radio.Button value="clone">克隆我的声音</Radio.Button>
</Radio.Group>
{voiceMode === "preset" ? (
@@ -528,7 +562,7 @@ const GeneratePage: React.FC = () => {
);
})}
</div>
) : (
) : voiceMode === "custom" ? (
<div>
<TextArea
placeholder="输入配音文案,系统将自动合成语音…"
@@ -544,6 +578,104 @@ const GeneratePage: React.FC = () => {
</Button>
</div>
</div>
) : (
/* ── 克隆我的声音 ── */
<div className="xx-clone-voice-section">
<Typography.Title level={5} className="xx-clone-voice-title">
克隆我的声音
</Typography.Title>
<Typography.Paragraph className="xx-clone-voice-desc">
上传一段您的语音样本,AI 将克隆您的声音用于视频配音
</Typography.Paragraph>
{/* 上传区域 */}
<Upload.Dragger
accept=".wav,.mp3,audio/*"
fileList={cloneFileList}
onChange={({ fileList: fl }) => setCloneFileList(fl)}
beforeUpload={() => false}
maxCount={1}
className="xx-clone-upload-area"
>
<div className="xx-clone-upload-icon">
<AudioOutlined />
</div>
<Typography.Paragraph className="xx-clone-upload-text">
点击或拖拽音频文件到此区域
</Typography.Paragraph>
<Typography.Paragraph className="xx-clone-upload-hint">
支持 WAV / MP3 格式,建议时长 30 秒以上
</Typography.Paragraph>
</Upload.Dragger>
{/* 开始克隆按钮 */}
<div style={{ marginTop: 16, textAlign: "center" }}>
<Button
buttonType="primary"
icon={<ThunderboltOutlined />}
loading={cloning}
onClick={() => {
if (cloneFileList.length === 0) {
message.warning("请先上传音频文件");
return;
}
setCloning(true);
setTimeout(() => {
setCloning(false);
message.success("声音克隆完成!");
}, 2000);
}}
>
开始克隆
</Button>
</div>
{/* 已克隆声音列表 */}
{MOCK_CLONED_VOICES.length > 0 && (
<div className="xx-clone-voices-list">
<Typography.Paragraph className="xx-clone-voices-label">
已克隆的声音
</Typography.Paragraph>
<div className="xx-voice-grid">
{MOCK_CLONED_VOICES.map((cv) => (
<div
key={cv.id}
className={`xx-voice-card ${selectedClonedVoice === cv.id ? "xx-voice-card-selected" : ""}`}
onClick={() => setSelectedClonedVoice(cv.id)}
role="button"
tabIndex={0}
aria-pressed={selectedClonedVoice === cv.id}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setSelectedClonedVoice(cv.id);
}
}}
>
<div className="xx-voice-header">
<div className="xx-voice-avatar">
<AudioOutlined />
</div>
<div>
<Typography.Paragraph className="xx-voice-name">
{cv.name}
</Typography.Paragraph>
<Typography.Paragraph className="xx-voice-desc">
{cv.createdAt} · {cv.duration}
</Typography.Paragraph>
</div>
</div>
</div>
))}
</div>
</div>
)}
{/* 提示 */}
<Typography.Paragraph className="xx-clone-tip">
💡 提示:录音环境越安静,克隆效果越好。建议使用专业麦克风录制。
</Typography.Paragraph>
</div>
)}
</div>
</div>
@@ -773,91 +905,127 @@ const GeneratePage: React.FC = () => {
)}
</div>
{/* ════════════ 右侧:结果面板 ════════════ */}
{/* ════════════ 右侧:预览面板 ════════════ */}
<div>
<Card className="xx-generate-section" style={{ marginBottom: 0 }}>
<Card className="xx-generate-section" style={{ marginBottom: 0, position: "sticky", top: 24 }}>
<div
className="xx-section-header"
style={{ padding: "16px 24px" }}
>
<Typography.Title level={5} className="xx-section-title">
生成结果
预览
</Typography.Title>
{generated && <Tag variant="success">已完成</Tag>}
</div>
<div className="xx-section-body">
{generated ? (
<>
<div className="xx-result-preview">
<PlayCircleOutlined />
{/* ── 视频预览播放器 ── */}
<div className="xx-video-preview">
<div className="xx-video-preview-overlay" />
{generated ? (
<video
src=""
controls
preload="none"
poster=""
style={{ width: "100%", height: "100%", objectFit: "cover" }}
/>
) : (
<div className="xx-video-preview-placeholder">
<button className="xx-video-play-btn" type="button">
<PlayCircleOutlined />
</button>
<Text style={{ color: "rgba(255,255,255,0.6)", fontSize: "var(--font-size-sm)" }}>
预览视频
</Text>
</div>
)}
</div>
<Row gutter={[8, 8]} style={{ marginBottom: 16 }}>
<Col span={12}>
<Text type="secondary">标题</Text>
<br />
<Text strong>{title || "未设置"}</Text>
</Col>
<Col span={12}>
<Text type="secondary">比例</Text>
<br />
<Text strong>{videoRatio}</Text>
</Col>
<Col span={12}>
<Text type="secondary">时长</Text>
<br />
<Text strong>{duration}s</Text>
</Col>
<Col span={12}>
<Text type="secondary">素材</Text>
<br />
<Text strong>{selectedMaterials.length} 个</Text>
</Col>
</Row>
{/* ── 标题预览 ── */}
<div
style={{
background: "var(--bg-secondary)",
color: "var(--text-primary)",
borderRadius: "var(--radius-sm)",
padding: "10px",
textAlign: "center",
fontWeight: 600,
fontSize: "var(--font-size-base)",
marginBottom: 14,
}}
>
{title || "视频标题预览"}
</div>
<div className="xx-result-actions">
<Button
buttonType="primary"
icon={<DownloadOutlined />}
block
>
下载视频
</Button>
<Button
buttonType="ghost"
icon={<ShareAltOutlined />}
block
>
分享
</Button>
<Button
buttonType="text"
icon={<ReloadOutlined />}
block
onClick={() => {
setGenerated(false);
setProgress(0);
{/* ── 时间线预览 ── */}
<div className="xx-timeline">
<div className="xx-timeline-header">
<Typography.Paragraph className="xx-timeline-title">
剪辑计划预览
</Typography.Paragraph>
<Typography.Paragraph className="xx-timeline-duration">
总时长 {TOTAL_DURATION}s
</Typography.Paragraph>
</div>
{/* 时间线轨道 */}
<div className="xx-timeline-track">
{MOCK_TIMELINE.map((item, idx) => (
<div
key={idx}
className="xx-timeline-clip"
style={{
width: `${(item.duration / TOTAL_DURATION) * 100}%`,
background: item.color,
}}
>
重新生成
</Button>
</div>
</>
) : (
<div
style={{
textAlign: "center",
padding: "40px 0",
color: "var(--text-tertiary)",
}}
{item.duration >= 10 ? `${item.duration}s` : ""}
</div>
))}
{/* 播放头 */}
<div className="xx-timeline-playhead" style={{ left: "20%" }} />
</div>
{/* 场景列表 */}
<div className="xx-timeline-list">
{MOCK_TIMELINE.map((item, idx) => (
<div key={idx} className="xx-timeline-item">
<div className="xx-timeline-item-index">{idx + 1}</div>
<span className="xx-timeline-item-scene">{item.scene}</span>
<span className="xx-timeline-item-time">{item.time}</span>
</div>
))}
</div>
</div>
{/* ── 操作按钮 ── */}
<div className="xx-result-actions">
<Button
buttonType="primary"
icon={<ThunderboltOutlined />}
block
onClick={handleGenerate}
disabled={generating}
>
<ThunderboltOutlined
style={{ fontSize: 48, marginBottom: 16, opacity: 0.4 }}
/>
<br />
<Text type="secondary">
{generating ? "视频生成中…" : "配置参数后点击生成"}
</Text>
{generated ? "重新生成" : "开始生成"}
</Button>
</div>
{generated && (
<div className="xx-result-actions">
<Button
buttonType="ghost"
icon={<DownloadOutlined />}
block
>
下载视频
</Button>
<Button
buttonType="ghost"
icon={<ShareAltOutlined />}
block
>
分享
</Button>
</div>
)}
</div>
+248 -8
View File
@@ -35,7 +35,7 @@
============================================================ */
.xx-generate-layout {
display: grid;
grid-template-columns: 1fr 380px;
grid-template-columns: 1fr 320px;
gap: var(--space-xl);
align-items: start;
}
@@ -359,24 +359,264 @@
}
/* ============================================================
结果区域
视频预览播放器
============================================================ */
.xx-result-preview {
border-radius: var(--radius-lg);
.xx-video-preview {
aspect-ratio: 9 / 16;
max-height: 400px;
border-radius: var(--radius-md);
background: linear-gradient(135deg, #111827, #312e81);
display: grid;
place-items: center;
position: relative;
overflow: hidden;
margin-bottom: var(--space-md);
}
.xx-video-preview video {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: var(--radius-md);
}
.xx-video-preview-overlay {
position: absolute;
inset: 0;
background: radial-gradient(circle at 72% 28%, rgba(255, 255, 255, 0.2), transparent 40%);
pointer-events: none;
}
.xx-video-preview-placeholder {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-sm);
color: rgba(255, 255, 255, 0.7);
}
.xx-video-play-btn {
width: 60px;
height: 60px;
border-radius: var(--radius-full);
background: rgba(255, 255, 255, 0.2);
backdrop-filter: blur(8px);
display: grid;
place-items: center;
color: #fff;
font-size: var(--font-size-2xl);
cursor: pointer;
transition: var(--transition-all);
border: none;
}
.xx-video-play-btn:hover {
background: rgba(255, 255, 255, 0.3);
transform: scale(1.05);
}
/* ============================================================
时间线预览
============================================================ */
.xx-timeline {
margin-bottom: var(--space-md);
}
.xx-timeline-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-sm);
}
.xx-timeline-title {
font-size: var(--font-size-sm) !important;
font-weight: var(--font-weight-semibold) !important;
color: var(--text-primary) !important;
margin: 0 !important;
}
.xx-timeline-duration {
font-size: var(--font-size-xs) !important;
color: var(--text-tertiary) !important;
margin: 0 !important;
}
.xx-timeline-track {
display: flex;
height: 32px;
border-radius: var(--radius-sm);
overflow: hidden;
background: var(--bg-tertiary);
height: 200px;
margin-bottom: var(--space-sm);
position: relative;
}
.xx-timeline-clip {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-tertiary);
font-size: var(--font-size-4xl);
margin-bottom: var(--space-md);
font-size: var(--font-size-xs);
color: #fff;
font-weight: var(--font-weight-medium);
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
padding: 0 4px;
border-right: 1px solid rgba(255, 255, 255, 0.3);
}
.xx-timeline-clip:last-child {
border-right: none;
}
.xx-timeline-playhead {
position: absolute;
top: 0;
bottom: 0;
width: 2px;
background: #ef4444;
z-index: 2;
}
.xx-timeline-playhead::before {
content: "";
position: absolute;
top: -4px;
left: -4px;
width: 10px;
height: 10px;
border-radius: var(--radius-full);
background: #ef4444;
}
.xx-timeline-list {
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
.xx-timeline-item {
display: flex;
align-items: center;
gap: var(--space-sm);
padding: var(--space-sm) var(--space-md);
background: var(--bg-secondary);
border-radius: var(--radius-sm);
font-size: var(--font-size-sm);
}
.xx-timeline-item-index {
width: 24px;
height: 24px;
border-radius: var(--radius-xs);
background: var(--primary-soft);
color: var(--primary-color);
display: flex;
align-items: center;
justify-content: center;
font-weight: var(--font-weight-bold);
font-size: var(--font-size-xs);
flex-shrink: 0;
}
.xx-timeline-item-scene {
color: var(--text-primary);
flex: 1;
}
.xx-timeline-item-time {
color: var(--text-tertiary);
font-size: var(--font-size-xs);
flex-shrink: 0;
}
/* ============================================================
克隆声音区域
============================================================ */
.xx-clone-voice-section {
margin-top: var(--space-md);
padding: var(--space-lg);
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
}
.xx-clone-voice-title {
font-size: var(--font-size-base) !important;
font-weight: var(--font-weight-semibold) !important;
color: var(--text-primary) !important;
margin: 0 0 var(--space-sm) !important;
}
.xx-clone-voice-desc {
font-size: var(--font-size-sm) !important;
color: var(--text-secondary) !important;
margin: 0 0 var(--space-md) !important;
}
.xx-clone-upload-area {
border: 2px dashed var(--border-color);
border-radius: var(--radius-sm);
padding: var(--space-xl) var(--space-md);
text-align: center;
background: var(--bg-secondary);
cursor: pointer;
transition: var(--transition-all);
}
.xx-clone-upload-area:hover {
border-color: var(--primary-color);
background: var(--primary-soft);
}
.xx-clone-upload-icon {
font-size: 28px;
margin-bottom: var(--space-sm);
}
.xx-clone-upload-text {
font-size: var(--font-size-sm) !important;
color: var(--text-primary) !important;
margin: 0 !important;
}
.xx-clone-upload-hint {
font-size: var(--font-size-xs) !important;
color: var(--text-tertiary) !important;
margin: var(--space-xs) 0 0 !important;
}
.xx-clone-tip {
font-size: var(--font-size-xs) !important;
color: var(--text-tertiary) !important;
background: var(--bg-secondary);
padding: var(--space-sm) var(--space-md);
border-radius: var(--radius-sm);
margin: var(--space-md) 0 0 !important;
}
.xx-clone-voices-list {
margin-top: var(--space-md);
}
.xx-clone-voices-label {
font-size: var(--font-size-sm) !important;
font-weight: var(--font-weight-semibold) !important;
color: var(--text-secondary) !important;
margin: 0 0 var(--space-sm) !important;
}
/* ============================================================
结果区域
============================================================ */
.xx-result-actions {
display: flex;
gap: var(--space-sm);
margin-top: var(--space-md);
}
/* ============================================================