Merge branch 'develop'
Auto Merge PRs / auto-merge (push) Failing after 1m40s
CI/CD Pipeline / Frontend Lint (push) Failing after 114h52m39s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 114h52m57s
Deploy / Deploy Staging (push) Has been skipped
Deploy / Staging E2E Tests (push) Has been skipped
Deploy / Build Production Runtime Images (push) Failing after 114h50m35s
Deploy / Deploy Production (push) Failing after 114h34m27s
Deploy / Production Browser E2E (push) Failing after 114h33m48s
Auto Merge PRs / auto-merge (push) Failing after 1m40s
CI/CD Pipeline / Frontend Lint (push) Failing after 114h52m39s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 114h52m57s
Deploy / Deploy Staging (push) Has been skipped
Deploy / Staging E2E Tests (push) Has been skipped
Deploy / Build Production Runtime Images (push) Failing after 114h50m35s
Deploy / Deploy Production (push) Failing after 114h34m27s
Deploy / Production Browser E2E (push) Failing after 114h33m48s
This commit is contained in:
@@ -181,9 +181,9 @@ test.describe("Core generation flow", () => {
|
||||
const materialCard = page
|
||||
.locator(".xx-material-card")
|
||||
.filter({ hasText: sourceFileName });
|
||||
await materialCard.click();
|
||||
await materialCard.click({ force: true });
|
||||
await expect(materialCard).toHaveClass(/xx-material-card-selected/, {
|
||||
timeout: 5_000,
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
||||
// Click generate
|
||||
@@ -200,7 +200,7 @@ test.describe("Core generation flow", () => {
|
||||
!response.url().includes("/generate"),
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
await generateButton.click();
|
||||
await generateButton.click({ force: true });
|
||||
|
||||
// Verify plan creation
|
||||
try {
|
||||
|
||||
@@ -174,7 +174,7 @@ test.describe("Core media upload flow", () => {
|
||||
await page
|
||||
.locator(".xx-asset-library-item")
|
||||
.filter({ hasText: `E2E Video Library ${suffix}` })
|
||||
.click();
|
||||
.click({ force: true });
|
||||
|
||||
await page.reload();
|
||||
await expect(page.locator(".xx-assets-content")).toBeVisible({
|
||||
|
||||
@@ -77,7 +77,7 @@ async function createProject(
|
||||
|
||||
test.describe("素材库流程", () => {
|
||||
// 登录限流 10次/60s,测试可能触发限流等待,给足够超时
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
|
||||
test("创建素材库", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "lib-create");
|
||||
|
||||
@@ -52,7 +52,7 @@ async function loginWithRetry(
|
||||
|
||||
test.describe("认证流程", () => {
|
||||
// 登录限流 10次/60s,测试可能触发限流等待,给足够超时
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
|
||||
// ─── 注册 ────────────────────────────────────────────
|
||||
|
||||
@@ -192,9 +192,16 @@ test.describe("认证流程", () => {
|
||||
});
|
||||
|
||||
test("登录不存在的邮箱 - 反向", async ({ request }) => {
|
||||
const response = await request.post(`${apiBase}/auth/login`, {
|
||||
data: { email: `ghost_${Date.now()}@nonexist.com`, password: PASSWORD },
|
||||
});
|
||||
// 带限流重试的反向登录测试
|
||||
let response;
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
response = await request.post(`${apiBase}/auth/login`, {
|
||||
data: { email: `ghost_${Date.now()}@nonexist.com`, password: PASSWORD },
|
||||
});
|
||||
if (response.status() !== 429) break;
|
||||
console.log(`[反向登录测试] 触发限流,等待 65s 后重试 (${attempt + 1}/2)`);
|
||||
await new Promise((r) => setTimeout(r, 65_000));
|
||||
}
|
||||
|
||||
expect(response.status(), "不存在的用户应返回 401").toBe(401);
|
||||
});
|
||||
|
||||
@@ -62,7 +62,7 @@ async function createAuthedUser(request: any, label: string) {
|
||||
|
||||
test.describe("项目流程", () => {
|
||||
// 登录限流 10次/60s,测试可能触发限流等待,给足够超时
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
|
||||
test("创建项目", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "proj-create");
|
||||
|
||||
@@ -30,13 +30,13 @@ const Header: React.FC = () => {
|
||||
key: "profile",
|
||||
icon: <UserOutlined />,
|
||||
label: "个人设置",
|
||||
onClick: () => navigate("/profile"),
|
||||
onClick: () => navigate("/app/profile"),
|
||||
},
|
||||
{
|
||||
key: "subscription",
|
||||
icon: <SettingOutlined />,
|
||||
label: "订阅管理",
|
||||
onClick: () => navigate("/subscription"),
|
||||
onClick: () => navigate("/app/subscription"),
|
||||
},
|
||||
{ type: "divider" },
|
||||
{
|
||||
@@ -49,8 +49,9 @@ const Header: React.FC = () => {
|
||||
|
||||
/** 判断导航项是否激活 */
|
||||
const isActive = (path: string) => {
|
||||
if (path === "/dashboard") {
|
||||
return location.pathname === "/" || location.pathname === "/dashboard";
|
||||
// 首页特殊处理:/ 和 /app/dashboard 都算激活
|
||||
if (path === "/app/dashboard") {
|
||||
return location.pathname === "/" || location.pathname === "/app" || location.pathname === "/app/dashboard";
|
||||
}
|
||||
return location.pathname.startsWith(path);
|
||||
};
|
||||
@@ -61,7 +62,7 @@ const Header: React.FC = () => {
|
||||
<button
|
||||
className="xx-brand"
|
||||
type="button"
|
||||
onClick={() => navigate("/dashboard")}
|
||||
onClick={() => navigate("/app/dashboard")}
|
||||
>
|
||||
<span className="xx-logo">🦐</span>
|
||||
<span className="xx-brand-text">小虾自动剪辑</span>
|
||||
|
||||
@@ -17,8 +17,9 @@ import "./Sidebar.css";
|
||||
|
||||
/** 判断菜单项是否激活 */
|
||||
const isMenuItemActive = (pathname: string, path: string): boolean => {
|
||||
if (path === "/dashboard") {
|
||||
return pathname === "/" || pathname === "/dashboard";
|
||||
// 首页特殊处理:/ 和 /app/dashboard 都算激活
|
||||
if (path === "/app/dashboard") {
|
||||
return pathname === "/" || pathname === "/app" || pathname === "/app/dashboard";
|
||||
}
|
||||
return pathname.startsWith(path);
|
||||
};
|
||||
|
||||
@@ -1,438 +0,0 @@
|
||||
/**
|
||||
* VoiceCloneModal — 音色克隆弹窗
|
||||
*
|
||||
* 功能:上传音频文件 / 录制音频、填写音色名称、提交克隆任务
|
||||
* 进度展示:上传中 → 克隆中 → 完成(三阶段可视化)
|
||||
* 对接 API(Mock):POST /api/v1/voice-clones
|
||||
*
|
||||
* CSS 变量统一,与配音库 / 我的音色页面风格一致
|
||||
* V21 Design System — 零 antd 直接导入
|
||||
*/
|
||||
import React, { useState, useCallback, useRef, useEffect } from "react";
|
||||
import { Modal, Button } from "@/components/ui";
|
||||
import { createVoiceClone, toVoiceClone } from "@/api/voiceClone";
|
||||
import type { VoiceClone } from "@/api/voiceClone";
|
||||
import "./voice-clone-modal.css";
|
||||
|
||||
/* ── 类型定义 ───────────────────────────────────────────── */
|
||||
|
||||
/** 弹窗阶段:input=输入 | uploading=上传中 | cloning=克隆中 | done=完成 */
|
||||
type ModalPhase = "input" | "uploading" | "cloning" | "done";
|
||||
|
||||
export interface VoiceCloneModalProps {
|
||||
/** 弹窗是否可见 */
|
||||
open: boolean;
|
||||
/** 关闭弹窗回调 */
|
||||
onClose: () => void;
|
||||
/** 克隆成功回调(返回新创建的音色) */
|
||||
onSuccess?: (voice: VoiceClone) => void;
|
||||
}
|
||||
|
||||
/* ── 进度阶段配置 ─────────────────────────────────────────── */
|
||||
|
||||
const PROGRESS_STEPS: { key: ModalPhase; label: string; icon: string }[] = [
|
||||
{ key: "uploading", label: "上传中", icon: "📤" },
|
||||
{ key: "cloning", label: "克隆中", icon: "🧬" },
|
||||
{ key: "done", label: "完成", icon: "✅" },
|
||||
];
|
||||
|
||||
/* ── 默认音色名称计数器 ─────────────────────────────────── */
|
||||
|
||||
let cloneCounter = 1;
|
||||
|
||||
const getNextDefaultName = (): string => {
|
||||
const name = `我的声音 ${cloneCounter}`;
|
||||
cloneCounter += 1;
|
||||
return name;
|
||||
};
|
||||
|
||||
/* ── 支持的文件扩展名 ─────────────────────────────────── */
|
||||
|
||||
const ACCEPTED_EXTENSIONS = ["mp3", "wav", "m4a", "aac", "ogg"];
|
||||
const ACCEPTED_MIME =
|
||||
".mp3,.wav,.m4a,.aac,.ogg,audio/mpeg,audio/wav,audio/mp4,audio/aac,audio/ogg";
|
||||
|
||||
/* ── 组件 ───────────────────────────────────────────────── */
|
||||
|
||||
const VoiceCloneModal: React.FC<VoiceCloneModalProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}) => {
|
||||
const [phase, setPhase] = useState<ModalPhase>("input");
|
||||
const [voiceName, setVoiceName] = useState("");
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [dragActive, setDragActive] = useState(false);
|
||||
const [recordTime, setRecordTime] = useState(0);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const recordTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
/** 重置弹窗状态 */
|
||||
const resetState = useCallback(() => {
|
||||
setPhase("input");
|
||||
setVoiceName("");
|
||||
setSelectedFile(null);
|
||||
setIsRecording(false);
|
||||
setDragActive(false);
|
||||
setRecordTime(0);
|
||||
if (recordTimerRef.current) {
|
||||
clearInterval(recordTimerRef.current);
|
||||
recordTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
/** 关闭弹窗 */
|
||||
const handleClose = useCallback(() => {
|
||||
resetState();
|
||||
onClose();
|
||||
}, [resetState, onClose]);
|
||||
|
||||
/** 弹窗打开时初始化默认名称 */
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setVoiceName(getNextDefaultName());
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
/** 清理录制计时器 */
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (recordTimerRef.current) {
|
||||
clearInterval(recordTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
/* ── 文件上传 ──────────────────────────────────────── */
|
||||
|
||||
const handleUploadClick = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
setSelectedFile(file);
|
||||
setIsRecording(false);
|
||||
setRecordTime(0);
|
||||
}
|
||||
e.target.value = "";
|
||||
};
|
||||
|
||||
/* ── 拖拽 ──────────────────────────────────────────── */
|
||||
|
||||
const handleDrag = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (e.type === "dragenter" || e.type === "dragover") {
|
||||
setDragActive(true);
|
||||
} else if (e.type === "dragleave") {
|
||||
setDragActive(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragActive(false);
|
||||
const file = e.dataTransfer.files?.[0];
|
||||
if (file) {
|
||||
const ext = file.name.split(".").pop()?.toLowerCase();
|
||||
if (ext && ACCEPTED_EXTENSIONS.includes(ext)) {
|
||||
setSelectedFile(file);
|
||||
setIsRecording(false);
|
||||
setRecordTime(0);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/* ── 录制(mock) ──────────────────────────────────── */
|
||||
|
||||
const handleRecord = () => {
|
||||
if (isRecording) {
|
||||
// 停止录制
|
||||
setIsRecording(false);
|
||||
if (recordTimerRef.current) {
|
||||
clearInterval(recordTimerRef.current);
|
||||
recordTimerRef.current = null;
|
||||
}
|
||||
} else {
|
||||
// 开始录制
|
||||
setIsRecording(true);
|
||||
setSelectedFile(null);
|
||||
setRecordTime(0);
|
||||
recordTimerRef.current = setInterval(() => {
|
||||
setRecordTime((prev) => prev + 1);
|
||||
}, 1000);
|
||||
}
|
||||
};
|
||||
|
||||
/** 格式化录制时间 mm:ss */
|
||||
const formatRecordTime = (seconds: number): string => {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = seconds % 60;
|
||||
return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
/* ── 提交克隆 ──────────────────────────────────────── */
|
||||
|
||||
const handleStartClone = async () => {
|
||||
const name = voiceName.trim() || getNextDefaultName();
|
||||
|
||||
// 阶段 1:上传中
|
||||
setPhase("uploading");
|
||||
await new Promise((r) => setTimeout(r, 1200));
|
||||
|
||||
// 阶段 2:克隆中
|
||||
setPhase("cloning");
|
||||
try {
|
||||
const result = await createVoiceClone({
|
||||
name,
|
||||
audio_url: selectedFile
|
||||
? `mock://${selectedFile.name}`
|
||||
: "mock://recorded-audio",
|
||||
});
|
||||
|
||||
// 阶段 3:完成
|
||||
setPhase("done");
|
||||
|
||||
// 2秒后自动关闭
|
||||
setTimeout(() => {
|
||||
onSuccess?.(toVoiceClone(result));
|
||||
handleClose();
|
||||
}, 2000);
|
||||
} catch {
|
||||
setPhase("input");
|
||||
}
|
||||
};
|
||||
|
||||
/** 当前进度索引(用于进度条展示) */
|
||||
const getProgressIndex = (): number => {
|
||||
switch (phase) {
|
||||
case "uploading":
|
||||
return 0;
|
||||
case "cloning":
|
||||
return 1;
|
||||
case "done":
|
||||
return 2;
|
||||
default:
|
||||
return -1;
|
||||
}
|
||||
};
|
||||
|
||||
const canStart = selectedFile || isRecording;
|
||||
const progressIndex = getProgressIndex();
|
||||
const isProcessing = phase === "uploading" || phase === "cloning";
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onCancel={handleClose}
|
||||
title="🎤 克隆新音色"
|
||||
width={540}
|
||||
footer={null}
|
||||
destroyOnClose
|
||||
maskClosable={!isProcessing}
|
||||
keyboard={!isProcessing}
|
||||
>
|
||||
{/* ── 输入阶段 ──────────────────────────────────── */}
|
||||
{phase === "input" && (
|
||||
<div className="xx-vcmodal-body">
|
||||
{/* 音色名称 */}
|
||||
<div className="xx-vcmodal-field">
|
||||
<label className="xx-vcmodal-label">音色名称</label>
|
||||
<input
|
||||
type="text"
|
||||
className="xx-vcmodal-input"
|
||||
value={voiceName}
|
||||
onChange={(e) => setVoiceName(e.target.value)}
|
||||
placeholder="输入音色名称"
|
||||
maxLength={30}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 上传区域 */}
|
||||
<div className="xx-vcmodal-field">
|
||||
<label className="xx-vcmodal-label">上传音频</label>
|
||||
<div
|
||||
className={`xx-vcmodal-upload-zone${dragActive ? " xx-vcmodal-upload-zone--active" : ""}${selectedFile ? " xx-vcmodal-upload-zone--has-file" : ""}`}
|
||||
onClick={handleUploadClick}
|
||||
onDragEnter={handleDrag}
|
||||
onDragOver={handleDrag}
|
||||
onDragLeave={handleDrag}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<div className="xx-vcmodal-upload-icon">
|
||||
{selectedFile ? "📄" : "🎵"}
|
||||
</div>
|
||||
<p className="xx-vcmodal-upload-title">
|
||||
{selectedFile
|
||||
? selectedFile.name
|
||||
: "拖拽音频文件到此处,或点击上传"}
|
||||
</p>
|
||||
<p className="xx-vcmodal-upload-hint">
|
||||
支持 MP3、WAV、M4A、AAC、OGG 格式
|
||||
</p>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept={ACCEPTED_MIME}
|
||||
style={{ display: "none" }}
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 或分隔 */}
|
||||
<div className="xx-vcmodal-divider">
|
||||
<div className="xx-vcmodal-divider-line" />
|
||||
<span className="xx-vcmodal-divider-text">或</span>
|
||||
<div className="xx-vcmodal-divider-line" />
|
||||
</div>
|
||||
|
||||
{/* 录制区域 */}
|
||||
<div className="xx-vcmodal-field">
|
||||
<label className="xx-vcmodal-label">直接录制</label>
|
||||
<div className="xx-vcmodal-record-area">
|
||||
<div className="xx-vcmodal-record-info">
|
||||
<p className="xx-vcmodal-record-hint">
|
||||
{isRecording
|
||||
? `录制中 ${formatRecordTime(recordTime)}`
|
||||
: "点击按钮开始录制你的声音"}
|
||||
</p>
|
||||
{isRecording && (
|
||||
<div className="xx-vcmodal-record-wave">
|
||||
<span className="xx-vcmodal-record-wave-bar" />
|
||||
<span className="xx-vcmodal-record-wave-bar" />
|
||||
<span className="xx-vcmodal-record-wave-bar" />
|
||||
<span className="xx-vcmodal-record-wave-bar" />
|
||||
<span className="xx-vcmodal-record-wave-bar" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={`xx-vcmodal-record-btn${isRecording ? " xx-vcmodal-record-btn--recording" : ""}`}
|
||||
onClick={handleRecord}
|
||||
title={isRecording ? "停止录制" : "开始录制"}
|
||||
>
|
||||
{isRecording ? "⏹" : "🎙️"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 提示 */}
|
||||
<div className="xx-vcmodal-tip">
|
||||
<span className="xx-vcmodal-tip-icon">💡</span>
|
||||
<span>
|
||||
建议上传 10 秒 ~ 3 分钟的清晰语音,环境安静、语速均匀效果最佳
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 底部按钮 */}
|
||||
<div className="xx-vcmodal-footer">
|
||||
<Button buttonType="ghost" onClick={handleClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
buttonType="primary"
|
||||
disabled={!canStart}
|
||||
onClick={handleStartClone}
|
||||
>
|
||||
🎤 开始克隆
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── 进度阶段(上传中 / 克隆中 / 完成) ─────── */}
|
||||
{isProcessing && (
|
||||
<div className="xx-vcmodal-progress-body">
|
||||
{/* 步骤指示器 */}
|
||||
<div className="xx-vcmodal-steps">
|
||||
{PROGRESS_STEPS.map((step, idx) => {
|
||||
const isActive = idx === progressIndex;
|
||||
const isDone = idx < progressIndex;
|
||||
const stepClass = [
|
||||
"xx-vcmodal-step",
|
||||
isActive ? "xx-vcmodal-step--active" : "",
|
||||
isDone ? "xx-vcmodal-step--done" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
return (
|
||||
<React.Fragment key={step.key}>
|
||||
{idx > 0 && (
|
||||
<div
|
||||
className={`xx-vcmodal-step-connector${isDone ? " xx-vcmodal-step-connector--done" : ""}`}
|
||||
/>
|
||||
)}
|
||||
<div className={stepClass}>
|
||||
<div className="xx-vcmodal-step-icon">
|
||||
{isDone ? "✓" : step.icon}
|
||||
</div>
|
||||
<span className="xx-vcmodal-step-label">{step.label}</span>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 当前阶段描述 */}
|
||||
<div className="xx-vcmodal-progress-info">
|
||||
{phase === "uploading" && (
|
||||
<>
|
||||
<div className="xx-vcmodal-progress-spinner" />
|
||||
<p className="xx-vcmodal-progress-text">正在上传音频文件…</p>
|
||||
<p className="xx-vcmodal-progress-sub">
|
||||
请稍候,正在将音频上传至服务器
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
{phase === "cloning" && (
|
||||
<>
|
||||
<div className="xx-vcmodal-progress-spinner xx-vcmodal-progress-spinner--cloning" />
|
||||
<p className="xx-vcmodal-progress-text">AI 正在克隆你的声音…</p>
|
||||
<p className="xx-vcmodal-progress-sub">
|
||||
正在分析声音特征,生成专属音色模型
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── 完成阶段 ──────────────────────────────────── */}
|
||||
{phase === "done" && (
|
||||
<div className="xx-vcmodal-progress-body">
|
||||
{/* 步骤指示器(全部完成) */}
|
||||
<div className="xx-vcmodal-steps">
|
||||
{PROGRESS_STEPS.map((step, idx) => (
|
||||
<React.Fragment key={step.key}>
|
||||
{idx > 0 && (
|
||||
<div className="xx-vcmodal-step-connector xx-vcmodal-step-connector--done" />
|
||||
)}
|
||||
<div className="xx-vcmodal-step xx-vcmodal-step--done">
|
||||
<div className="xx-vcmodal-step-icon">✓</div>
|
||||
<span className="xx-vcmodal-step-label">{step.label}</span>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="xx-vcmodal-success">
|
||||
<div className="xx-vcmodal-success-icon">🎉</div>
|
||||
<h3 className="xx-vcmodal-success-title">克隆已提交</h3>
|
||||
<p className="xx-vcmodal-success-desc">
|
||||
音色正在生成中,完成后将出现在「我的音色库」列表中
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default VoiceCloneModal;
|
||||
@@ -1,512 +0,0 @@
|
||||
/**
|
||||
* VoiceCloneModal 样式 — V21 设计系统
|
||||
* CSS 变量统一,与配音库 / 我的音色页面风格一致
|
||||
* 命名规范:xx-vcmodal-*
|
||||
*/
|
||||
@import "../../styles/global.css";
|
||||
|
||||
/* ── 弹窗内容区 ───────────────────────────────────────────── */
|
||||
|
||||
.xx-vcmodal-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
padding: var(--space-sm) 0;
|
||||
}
|
||||
|
||||
/* ── 字段容器 ─────────────────────────────────────────────── */
|
||||
|
||||
.xx-vcmodal-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.xx-vcmodal-label {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--text-primary);
|
||||
letter-spacing: var(--letter-spacing-wide);
|
||||
}
|
||||
|
||||
/* ── 输入框 ───────────────────────────────────────────────── */
|
||||
|
||||
.xx-vcmodal-input {
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
padding: 0 var(--space-md);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-primary);
|
||||
font-size: var(--font-size-base);
|
||||
color: var(--text-primary);
|
||||
outline: none;
|
||||
transition:
|
||||
border-color 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.xx-vcmodal-input::placeholder {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.xx-vcmodal-input:focus {
|
||||
border-color: var(--color-primary-400);
|
||||
box-shadow: 0 0 0 3px var(--color-primary-100);
|
||||
}
|
||||
|
||||
/* ── 上传区域 ─────────────────────────────────────────────── */
|
||||
|
||||
.xx-vcmodal-upload-zone {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-lg) var(--space-md);
|
||||
border: 2px dashed var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-secondary);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 0.2s ease,
|
||||
background 0.2s ease;
|
||||
}
|
||||
|
||||
.xx-vcmodal-upload-zone:hover {
|
||||
border-color: var(--color-primary-300);
|
||||
background: var(--color-primary-50);
|
||||
}
|
||||
|
||||
.xx-vcmodal-upload-zone--active {
|
||||
border-color: var(--color-primary-500);
|
||||
background: var(--color-primary-100);
|
||||
}
|
||||
|
||||
.xx-vcmodal-upload-zone--has-file {
|
||||
border-style: solid;
|
||||
border-color: var(--color-primary-400);
|
||||
background: var(--color-primary-50);
|
||||
}
|
||||
|
||||
.xx-vcmodal-upload-icon {
|
||||
font-size: 32px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.xx-vcmodal-upload-title {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--text-primary);
|
||||
text-align: center;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.xx-vcmodal-upload-hint {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-secondary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ── 分隔线 ───────────────────────────────────────────────── */
|
||||
|
||||
.xx-vcmodal-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-md);
|
||||
margin: var(--space-xs) 0;
|
||||
}
|
||||
|
||||
.xx-vcmodal-divider-line {
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--border-color);
|
||||
}
|
||||
|
||||
.xx-vcmodal-divider-text {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-tertiary);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
/* ── 录制区域 ─────────────────────────────────────────────── */
|
||||
|
||||
.xx-vcmodal-record-area {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-md);
|
||||
padding: var(--space-md);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.xx-vcmodal-record-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.xx-vcmodal-record-hint {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* 录制波形动画 */
|
||||
.xx-vcmodal-record-wave {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 3px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.xx-vcmodal-record-wave-bar {
|
||||
width: 3px;
|
||||
background: var(--color-primary-500);
|
||||
border-radius: var(--radius-full);
|
||||
animation: vcmodal-wave 0.8s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
.xx-vcmodal-record-wave-bar:nth-child(1) {
|
||||
height: 40%;
|
||||
animation-delay: 0s;
|
||||
}
|
||||
.xx-vcmodal-record-wave-bar:nth-child(2) {
|
||||
height: 70%;
|
||||
animation-delay: 0.15s;
|
||||
}
|
||||
.xx-vcmodal-record-wave-bar:nth-child(3) {
|
||||
height: 100%;
|
||||
animation-delay: 0.3s;
|
||||
}
|
||||
.xx-vcmodal-record-wave-bar:nth-child(4) {
|
||||
height: 60%;
|
||||
animation-delay: 0.45s;
|
||||
}
|
||||
.xx-vcmodal-record-wave-bar:nth-child(5) {
|
||||
height: 30%;
|
||||
animation-delay: 0.6s;
|
||||
}
|
||||
|
||||
@keyframes vcmodal-wave {
|
||||
from {
|
||||
transform: scaleY(0.4);
|
||||
}
|
||||
to {
|
||||
transform: scaleY(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* 录制按钮 */
|
||||
.xx-vcmodal-record-btn {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: none;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--bg-primary);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.2s ease,
|
||||
transform 0.15s ease,
|
||||
box-shadow 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.xx-vcmodal-record-btn:hover {
|
||||
background: var(--color-primary-50);
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.xx-vcmodal-record-btn--recording {
|
||||
background: var(--error-color);
|
||||
color: var(--text-inverse);
|
||||
animation: vcmodal-pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.xx-vcmodal-record-btn--recording:hover {
|
||||
background: var(--error-hover);
|
||||
}
|
||||
|
||||
@keyframes vcmodal-pulse {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.4);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 0 8px rgba(239, 68, 68, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 提示 ─────────────────────────────────────────────────── */
|
||||
|
||||
.xx-vcmodal-tip {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
border-radius: var(--radius-xs);
|
||||
background: var(--info-soft);
|
||||
border: 1px solid var(--info-border);
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-secondary);
|
||||
line-height: var(--line-height-base);
|
||||
}
|
||||
|
||||
.xx-vcmodal-tip-icon {
|
||||
flex-shrink: 0;
|
||||
font-size: var(--font-size-base);
|
||||
}
|
||||
|
||||
/* ── 底部按钮 ─────────────────────────────────────────────── */
|
||||
|
||||
.xx-vcmodal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-sm);
|
||||
padding-top: var(--space-sm);
|
||||
border-top: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
/* ── 进度阶段通用 ─────────────────────────────────────────── */
|
||||
|
||||
.xx-vcmodal-progress-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-xl);
|
||||
padding: var(--space-lg) 0 var(--space-md);
|
||||
}
|
||||
|
||||
/* ── 步骤指示器 ───────────────────────────────────────────── */
|
||||
|
||||
.xx-vcmodal-steps {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.xx-vcmodal-step {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
flex: 1;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.xx-vcmodal-step-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--bg-tertiary);
|
||||
border: 2px solid var(--border-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
color: var(--text-tertiary);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.xx-vcmodal-step--active .xx-vcmodal-step-icon {
|
||||
background: var(--color-primary-50);
|
||||
border-color: var(--color-primary-500);
|
||||
color: var(--color-primary-600);
|
||||
box-shadow: 0 0 0 4px var(--color-primary-100);
|
||||
}
|
||||
|
||||
.xx-vcmodal-step--done .xx-vcmodal-step-icon {
|
||||
background: var(--color-secondary-50);
|
||||
border-color: var(--color-secondary-500);
|
||||
color: var(--color-secondary-600);
|
||||
}
|
||||
|
||||
.xx-vcmodal-step-label {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--text-tertiary);
|
||||
transition: color 0.3s ease;
|
||||
}
|
||||
|
||||
.xx-vcmodal-step--active .xx-vcmodal-step-label {
|
||||
color: var(--color-primary-600);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.xx-vcmodal-step--done .xx-vcmodal-step-label {
|
||||
color: var(--color-secondary-600);
|
||||
}
|
||||
|
||||
/* 步骤连接线 */
|
||||
.xx-vcmodal-step-connector {
|
||||
flex: 0 0 40px;
|
||||
height: 2px;
|
||||
background: var(--border-color);
|
||||
margin-bottom: 20px;
|
||||
transition: background 0.3s ease;
|
||||
}
|
||||
|
||||
.xx-vcmodal-step-connector--done {
|
||||
background: var(--color-secondary-400);
|
||||
}
|
||||
|
||||
/* ── 进度信息 ─────────────────────────────────────────────── */
|
||||
|
||||
.xx-vcmodal-progress-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 加载动画 */
|
||||
.xx-vcmodal-progress-spinner {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 3px solid var(--color-primary-100);
|
||||
border-top-color: var(--color-primary-500);
|
||||
border-radius: var(--radius-full);
|
||||
animation: vcmodal-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
.xx-vcmodal-progress-spinner--cloning {
|
||||
border-color: var(--color-secondary-100);
|
||||
border-top-color: var(--color-secondary-500);
|
||||
}
|
||||
|
||||
@keyframes vcmodal-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.xx-vcmodal-progress-text {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-md);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.xx-vcmodal-progress-sub {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* ── 完成阶段 ─────────────────────────────────────────────── */
|
||||
|
||||
.xx-vcmodal-success {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.xx-vcmodal-success-icon {
|
||||
font-size: 48px;
|
||||
line-height: 1;
|
||||
animation: vcmodal-bounce 0.6s ease-out;
|
||||
}
|
||||
|
||||
@keyframes vcmodal-bounce {
|
||||
0% {
|
||||
transform: scale(0);
|
||||
opacity: 0;
|
||||
}
|
||||
60% {
|
||||
transform: scale(1.2);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.xx-vcmodal-success-title {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: var(--font-weight-bold);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.xx-vcmodal-success-desc {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-secondary);
|
||||
max-width: 280px;
|
||||
line-height: var(--line-height-relaxed);
|
||||
}
|
||||
|
||||
/* ── 响应式 ───────────────────────────────────────────────── */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.xx-vcmodal-body {
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.xx-vcmodal-upload-zone {
|
||||
padding: var(--space-md) var(--space-sm);
|
||||
}
|
||||
|
||||
.xx-vcmodal-record-area {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.xx-vcmodal-record-btn {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.xx-vcmodal-steps {
|
||||
max-width: 280px;
|
||||
}
|
||||
|
||||
.xx-vcmodal-step-connector {
|
||||
flex: 0 0 24px;
|
||||
}
|
||||
|
||||
.xx-vcmodal-footer {
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
|
||||
.xx-vcmodal-footer > * {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.xx-vcmodal-step-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.xx-vcmodal-step-connector {
|
||||
flex: 0 0 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.xx-vcmodal-progress-spinner {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.xx-vcmodal-success-icon {
|
||||
font-size: 36px;
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,12 @@
|
||||
/**
|
||||
* CloneModal — 音色克隆弹窗
|
||||
* 任务 3.13:实现克隆 Modal,用户点击「克隆音色」按钮后弹出
|
||||
* CloneModal — 音色克隆弹窗(共享组件)
|
||||
*
|
||||
* 功能:
|
||||
* - 步骤引导:上传音频 → 填写信息 → 提交克隆
|
||||
* - 表单字段:音频文件上传(wav/mp3/m4a,≤10MB)、音色名称(必填,2-20字符)、音色描述(可选,≤100字符)
|
||||
* - 提交后调用 POST /api/v1/voice-clones 创建克隆
|
||||
* - 创建成功后关闭 Modal,刷新配音列表
|
||||
* - 错误处理:上传失败、格式错误、大小超限等提示
|
||||
* - 步骤引导:上传/录制音频 → 填写信息 → 提交克隆
|
||||
* - 两种输入方式:上传文件(wav/mp3/m4a,≤10MB)或浏览器原生录音(MediaRecorder)
|
||||
* - 提交:先 uploadAsset 获取真实 URL,再 createVoiceClone
|
||||
* - 进度展示:上传中 → 克隆中 → 完成(三阶段可视化)
|
||||
* - 错误处理:上传失败、格式错误、大小超限、录音权限拒绝等提示
|
||||
*
|
||||
* V21 Design System — 零 antd 直接导入
|
||||
*/
|
||||
@@ -20,7 +19,7 @@ import "./clone-modal.css";
|
||||
|
||||
/* ── 类型定义 ───────────────────────────────────────────── */
|
||||
|
||||
type ModalPhase = "input" | "uploading" | "success";
|
||||
type ModalPhase = "input" | "uploading" | "cloning" | "done";
|
||||
|
||||
export interface CloneModalProps {
|
||||
/** 弹窗是否可见 */
|
||||
@@ -31,12 +30,23 @@ export interface CloneModalProps {
|
||||
onSuccess?: (voice: VoiceClone) => void;
|
||||
}
|
||||
|
||||
/* ── 进度阶段配置 ─────────────────────────────────────────── */
|
||||
|
||||
const PROGRESS_STEPS: { key: string; label: string; icon: string }[] = [
|
||||
{ key: "uploading", label: "上传中", icon: "📤" },
|
||||
{ key: "cloning", label: "克隆中", icon: "🧬" },
|
||||
{ key: "done", label: "完成", icon: "✅" },
|
||||
];
|
||||
|
||||
/* ── 常量 ───────────────────────────────────────────────── */
|
||||
|
||||
const ACCEPTED_EXTENSIONS = ["mp3", "wav", "m4a"];
|
||||
const ACCEPTED_MIME = ".mp3,.wav,.m4a,audio/mpeg,audio/wav,audio/mp4";
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
|
||||
/** 最长录制时长:5 分钟(秒) */
|
||||
const MAX_RECORD_SECONDS = 5 * 60;
|
||||
|
||||
/* ── 组件 ───────────────────────────────────────────────── */
|
||||
|
||||
const CloneModal: React.FC<CloneModalProps> = ({
|
||||
@@ -50,14 +60,34 @@ const CloneModal: React.FC<CloneModalProps> = ({
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [dragActive, setDragActive] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
|
||||
// 录音状态
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [recordTime, setRecordTime] = useState(0);
|
||||
const [recordedBlob, setRecordedBlob] = useState<Blob | null>(null);
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const recordTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||
const audioChunksRef = useRef<Blob[]>([]);
|
||||
/** 默认音色名称计数器(组件级 ref,避免多实例串号) */
|
||||
const cloneCounterRef = useRef(1);
|
||||
|
||||
/** 组件卸载时清理定时器(P2-2 修复) */
|
||||
/** 生成下一个默认音色名称 */
|
||||
const getNextDefaultName = useCallback((): string => {
|
||||
const name = `我的声音 ${cloneCounterRef.current}`;
|
||||
cloneCounterRef.current += 1;
|
||||
return name;
|
||||
}, []);
|
||||
|
||||
/** 组件卸载时清理定时器和 MediaRecorder */
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
if (recordTimerRef.current) clearInterval(recordTimerRef.current);
|
||||
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== "inactive") {
|
||||
mediaRecorderRef.current.stop();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
@@ -65,12 +95,24 @@ const CloneModal: React.FC<CloneModalProps> = ({
|
||||
/** 重置弹窗状态 */
|
||||
const resetState = useCallback(() => {
|
||||
setPhase("input");
|
||||
setVoiceName("");
|
||||
setVoiceName(getNextDefaultName());
|
||||
setVoiceDescription("");
|
||||
setSelectedFile(null);
|
||||
setDragActive(false);
|
||||
setErrorMessage("");
|
||||
}, []);
|
||||
setIsRecording(false);
|
||||
setRecordTime(0);
|
||||
setRecordedBlob(null);
|
||||
audioChunksRef.current = [];
|
||||
if (recordTimerRef.current) {
|
||||
clearInterval(recordTimerRef.current);
|
||||
recordTimerRef.current = null;
|
||||
}
|
||||
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== "inactive") {
|
||||
mediaRecorderRef.current.stop();
|
||||
}
|
||||
mediaRecorderRef.current = null;
|
||||
}, [getNextDefaultName]);
|
||||
|
||||
/** 关闭弹窗 */
|
||||
const handleClose = useCallback(() => {
|
||||
@@ -114,6 +156,10 @@ const CloneModal: React.FC<CloneModalProps> = ({
|
||||
} else {
|
||||
setErrorMessage("");
|
||||
setSelectedFile(file);
|
||||
// 清除录音
|
||||
setRecordedBlob(null);
|
||||
setIsRecording(false);
|
||||
setRecordTime(0);
|
||||
}
|
||||
}
|
||||
e.target.value = "";
|
||||
@@ -144,12 +190,93 @@ const CloneModal: React.FC<CloneModalProps> = ({
|
||||
} else {
|
||||
setErrorMessage("");
|
||||
setSelectedFile(file);
|
||||
setRecordedBlob(null);
|
||||
setIsRecording(false);
|
||||
setRecordTime(0);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/* ── 录音(真实 MediaRecorder) ───────────────────── */
|
||||
|
||||
const handleRecord = async () => {
|
||||
if (isRecording) {
|
||||
// 停止录制
|
||||
setIsRecording(false);
|
||||
if (recordTimerRef.current) {
|
||||
clearInterval(recordTimerRef.current);
|
||||
recordTimerRef.current = null;
|
||||
}
|
||||
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== "inactive") {
|
||||
mediaRecorderRef.current.stop();
|
||||
}
|
||||
} else {
|
||||
// 开始录制
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
const mediaRecorder = new MediaRecorder(stream);
|
||||
mediaRecorderRef.current = mediaRecorder;
|
||||
audioChunksRef.current = [];
|
||||
|
||||
mediaRecorder.ondataavailable = (event) => {
|
||||
if (event.data.size > 0) {
|
||||
audioChunksRef.current.push(event.data);
|
||||
}
|
||||
};
|
||||
|
||||
mediaRecorder.onstop = () => {
|
||||
const blob = new Blob(audioChunksRef.current, { type: "audio/webm" });
|
||||
setRecordedBlob(blob);
|
||||
// 清除上传的文件
|
||||
setSelectedFile(null);
|
||||
// 停止音轨
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
};
|
||||
|
||||
mediaRecorder.start();
|
||||
setIsRecording(true);
|
||||
setRecordTime(0);
|
||||
setRecordedBlob(null);
|
||||
setErrorMessage("");
|
||||
|
||||
recordTimerRef.current = setInterval(() => {
|
||||
setRecordTime((prev) => {
|
||||
const next = prev + 1;
|
||||
if (next >= MAX_RECORD_SECONDS) {
|
||||
// 达到 5 分钟上限,自动停止录制
|
||||
setTimeout(() => {
|
||||
setIsRecording(false);
|
||||
if (recordTimerRef.current) {
|
||||
clearInterval(recordTimerRef.current);
|
||||
recordTimerRef.current = null;
|
||||
}
|
||||
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== "inactive") {
|
||||
mediaRecorderRef.current.stop();
|
||||
}
|
||||
setErrorMessage("已达最长录制时长(5分钟),已自动停止");
|
||||
}, 0);
|
||||
return MAX_RECORD_SECONDS;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, 1000);
|
||||
} catch {
|
||||
setErrorMessage("无法访问麦克风,请检查浏览器权限设置");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/** 格式化录制时间 mm:ss */
|
||||
const formatRecordTime = (seconds: number): string => {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = seconds % 60;
|
||||
return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
/* ── 表单验证 ──────────────────────────────────────── */
|
||||
|
||||
const hasAudio = selectedFile !== null || recordedBlob !== null;
|
||||
|
||||
const validateForm = (): string | null => {
|
||||
const name = voiceName.trim();
|
||||
if (!name) {
|
||||
@@ -158,8 +285,8 @@ const CloneModal: React.FC<CloneModalProps> = ({
|
||||
if (name.length < 2 || name.length > 20) {
|
||||
return "音色名称需在 2-20 个字符之间";
|
||||
}
|
||||
if (!selectedFile) {
|
||||
return "请上传音频文件";
|
||||
if (!hasAudio) {
|
||||
return "请上传音频文件或录制一段声音";
|
||||
}
|
||||
return null;
|
||||
};
|
||||
@@ -173,24 +300,38 @@ const CloneModal: React.FC<CloneModalProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
setPhase("uploading");
|
||||
setErrorMessage("");
|
||||
|
||||
try {
|
||||
// P1 修复:先上传音频文件获取真实 URL,再调用克隆 API
|
||||
// 阶段 1:上传音频
|
||||
setPhase("uploading");
|
||||
|
||||
let fileToUpload: File;
|
||||
if (selectedFile) {
|
||||
fileToUpload = selectedFile;
|
||||
} else {
|
||||
// 将录音 Blob 转为 File
|
||||
fileToUpload = new File([recordedBlob!], `recorded-${Date.now()}.webm`, {
|
||||
type: "audio/webm",
|
||||
});
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("file", selectedFile!);
|
||||
formData.append("file", fileToUpload);
|
||||
const uploadResult = await uploadAsset(formData);
|
||||
|
||||
// 阶段 2:克隆
|
||||
setPhase("cloning");
|
||||
const result = await createVoiceClone({
|
||||
name: voiceName.trim(),
|
||||
description: voiceDescription.trim() || undefined,
|
||||
audio_url: uploadResult.url,
|
||||
});
|
||||
|
||||
setPhase("success");
|
||||
// 阶段 3:完成
|
||||
setPhase("done");
|
||||
|
||||
// 2秒后自动关闭(P2-2 修复:使用 timerRef 以便 cleanup)
|
||||
// 2秒后自动关闭
|
||||
timerRef.current = setTimeout(() => {
|
||||
onSuccess?.(toVoiceClone(result));
|
||||
handleClose();
|
||||
@@ -206,9 +347,25 @@ const CloneModal: React.FC<CloneModalProps> = ({
|
||||
const canSubmit =
|
||||
voiceName.trim().length >= 2 &&
|
||||
voiceName.trim().length <= 20 &&
|
||||
selectedFile !== null;
|
||||
hasAudio;
|
||||
|
||||
const isProcessing = phase === "uploading";
|
||||
const isProcessing = phase === "uploading" || phase === "cloning";
|
||||
|
||||
/** 当前进度索引 */
|
||||
const getProgressIndex = (): number => {
|
||||
switch (phase) {
|
||||
case "uploading":
|
||||
return 0;
|
||||
case "cloning":
|
||||
return 1;
|
||||
case "done":
|
||||
return 2;
|
||||
default:
|
||||
return -1;
|
||||
}
|
||||
};
|
||||
|
||||
const progressIndex = getProgressIndex();
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@@ -228,7 +385,7 @@ const CloneModal: React.FC<CloneModalProps> = ({
|
||||
<div className="xx-clonemodal-steps">
|
||||
<div className="xx-clonemodal-step xx-clonemodal-step--active">
|
||||
<div className="xx-clonemodal-step-number">1</div>
|
||||
<span className="xx-clonemodal-step-label">上传音频</span>
|
||||
<span className="xx-clonemodal-step-label">上传/录制音频</span>
|
||||
</div>
|
||||
<div className="xx-clonemodal-step-connector" />
|
||||
<div className="xx-clonemodal-step">
|
||||
@@ -242,11 +399,27 @@ const CloneModal: React.FC<CloneModalProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 上传区域 */}
|
||||
{/* 音色名称 */}
|
||||
<div className="xx-clonemodal-field">
|
||||
<label className="xx-clonemodal-label">
|
||||
音频文件 <span className="xx-clonemodal-required">*</span>
|
||||
音色名称 <span className="xx-clonemodal-required">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="xx-clonemodal-input"
|
||||
value={voiceName}
|
||||
onChange={(e) => setVoiceName(e.target.value)}
|
||||
placeholder="输入音色名称(2-20字符)"
|
||||
maxLength={20}
|
||||
/>
|
||||
<div className="xx-clonemodal-char-count">
|
||||
{voiceName.length}/20
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 上传区域 */}
|
||||
<div className="xx-clonemodal-field">
|
||||
<label className="xx-clonemodal-label">上传音频</label>
|
||||
<div
|
||||
className={`xx-clonemodal-upload-zone${dragActive ? " xx-clonemodal-upload-zone--active" : ""}${selectedFile ? " xx-clonemodal-upload-zone--has-file" : ""}`}
|
||||
onClick={handleUploadClick}
|
||||
@@ -276,21 +449,43 @@ const CloneModal: React.FC<CloneModalProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 音色名称 */}
|
||||
{/* 或分隔 */}
|
||||
<div className="xx-clonemodal-divider">
|
||||
<div className="xx-clonemodal-divider-line" />
|
||||
<span className="xx-clonemodal-divider-text">或</span>
|
||||
<div className="xx-clonemodal-divider-line" />
|
||||
</div>
|
||||
|
||||
{/* 录制区域 */}
|
||||
<div className="xx-clonemodal-field">
|
||||
<label className="xx-clonemodal-label">
|
||||
音色名称 <span className="xx-clonemodal-required">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="xx-clonemodal-input"
|
||||
value={voiceName}
|
||||
onChange={(e) => setVoiceName(e.target.value)}
|
||||
placeholder="输入音色名称(2-20字符)"
|
||||
maxLength={20}
|
||||
/>
|
||||
<div className="xx-clonemodal-char-count">
|
||||
{voiceName.length}/20
|
||||
<label className="xx-clonemodal-label">直接录制</label>
|
||||
<div className="xx-clonemodal-record-area">
|
||||
<div className="xx-clonemodal-record-info">
|
||||
<p className="xx-clonemodal-record-hint">
|
||||
{isRecording
|
||||
? `录制中 ${formatRecordTime(recordTime)}`
|
||||
: recordedBlob
|
||||
? `已录制 ${formatRecordTime(recordTime)}`
|
||||
: "点击按钮开始录制(最长 5 分钟)"}
|
||||
</p>
|
||||
{isRecording && (
|
||||
<div className="xx-clonemodal-record-wave">
|
||||
<span className="xx-clonemodal-record-wave-bar" />
|
||||
<span className="xx-clonemodal-record-wave-bar" />
|
||||
<span className="xx-clonemodal-record-wave-bar" />
|
||||
<span className="xx-clonemodal-record-wave-bar" />
|
||||
<span className="xx-clonemodal-record-wave-bar" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={`xx-clonemodal-record-btn${isRecording ? " xx-clonemodal-record-btn--recording" : ""}`}
|
||||
onClick={handleRecord}
|
||||
title={isRecording ? "停止录制" : "开始录制"}
|
||||
>
|
||||
{isRecording ? "⏹" : "🎙️"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -342,25 +537,89 @@ const CloneModal: React.FC<CloneModalProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── 上传中阶段 ────────────────────────────────── */}
|
||||
{phase === "uploading" && (
|
||||
<div className="xx-clonemodal-uploading">
|
||||
<div className="xx-clonemodal-uploading-spinner" />
|
||||
<p className="xx-clonemodal-uploading-text">正在克隆你的音色…</p>
|
||||
<p className="xx-clonemodal-uploading-sub">
|
||||
AI 正在分析你的声音特征,请稍候
|
||||
</p>
|
||||
{/* ── 进度阶段(上传中 / 克隆中) ──────────────── */}
|
||||
{isProcessing && (
|
||||
<div className="xx-clonemodal-progress-body">
|
||||
{/* 步骤指示器 */}
|
||||
<div className="xx-clonemodal-steps-progress">
|
||||
{PROGRESS_STEPS.map((step, idx) => {
|
||||
const isActive = idx === progressIndex;
|
||||
const isDone = idx < progressIndex;
|
||||
const stepClass = [
|
||||
"xx-clonemodal-step-progress",
|
||||
isActive ? "xx-clonemodal-step-progress--active" : "",
|
||||
isDone ? "xx-clonemodal-step-progress--done" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
return (
|
||||
<React.Fragment key={step.key}>
|
||||
{idx > 0 && (
|
||||
<div
|
||||
className={`xx-clonemodal-step-connector${isDone ? " xx-clonemodal-step-connector--done" : ""}`}
|
||||
/>
|
||||
)}
|
||||
<div className={stepClass}>
|
||||
<div className="xx-clonemodal-step-icon">
|
||||
{isDone ? "✓" : step.icon}
|
||||
</div>
|
||||
<span className="xx-clonemodal-step-label">{step.label}</span>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 当前阶段描述 */}
|
||||
<div className="xx-clonemodal-progress-info">
|
||||
{phase === "uploading" && (
|
||||
<>
|
||||
<div className="xx-clonemodal-progress-spinner" />
|
||||
<p className="xx-clonemodal-progress-text">正在上传音频文件…</p>
|
||||
<p className="xx-clonemodal-progress-sub">
|
||||
请稍候,正在将音频上传至服务器
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
{phase === "cloning" && (
|
||||
<>
|
||||
<div className="xx-clonemodal-progress-spinner xx-clonemodal-progress-spinner--cloning" />
|
||||
<p className="xx-clonemodal-progress-text">AI 正在克隆你的声音…</p>
|
||||
<p className="xx-clonemodal-progress-sub">
|
||||
正在分析声音特征,生成专属音色模型
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── 成功阶段 ──────────────────────────────────── */}
|
||||
{phase === "success" && (
|
||||
<div className="xx-clonemodal-success">
|
||||
<div className="xx-clonemodal-success-icon">✅</div>
|
||||
<h3 className="xx-clonemodal-success-title">克隆已提交</h3>
|
||||
<p className="xx-clonemodal-success-desc">
|
||||
音色正在生成中,完成后将出现在「我的克隆」列表中
|
||||
</p>
|
||||
{/* ── 完成阶段 ──────────────────────────────────── */}
|
||||
{phase === "done" && (
|
||||
<div className="xx-clonemodal-progress-body">
|
||||
{/* 步骤指示器(全部完成) */}
|
||||
<div className="xx-clonemodal-steps-progress">
|
||||
{PROGRESS_STEPS.map((step, idx) => (
|
||||
<React.Fragment key={step.key}>
|
||||
{idx > 0 && (
|
||||
<div className="xx-clonemodal-step-connector xx-clonemodal-step-connector--done" />
|
||||
)}
|
||||
<div className="xx-clonemodal-step-progress xx-clonemodal-step-progress--done">
|
||||
<div className="xx-clonemodal-step-icon">✓</div>
|
||||
<span className="xx-clonemodal-step-label">{step.label}</span>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="xx-clonemodal-success">
|
||||
<div className="xx-clonemodal-success-icon">🎉</div>
|
||||
<h3 className="xx-clonemodal-success-title">克隆已提交</h3>
|
||||
<p className="xx-clonemodal-success-desc">
|
||||
音色正在生成中,完成后将出现在「我的克隆」列表中
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
@@ -284,41 +284,262 @@
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── 分隔线 ───────────────────────────────────────────── */
|
||||
|
||||
.xx-clonemodal-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.xx-clonemodal-divider-line {
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--xx-color-border, #e5e7eb);
|
||||
}
|
||||
|
||||
.xx-clonemodal-divider-text {
|
||||
font-size: 13px;
|
||||
color: var(--xx-color-text-secondary, #6b7280);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ── 录制区域 ─────────────────────────────────────────── */
|
||||
|
||||
.xx-clonemodal-record-area {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
border: 1px solid var(--xx-color-border, #e5e7eb);
|
||||
border-radius: 8px;
|
||||
background: var(--xx-color-bg-secondary, #f9fafb);
|
||||
}
|
||||
|
||||
.xx-clonemodal-record-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.xx-clonemodal-record-hint {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--xx-color-text-secondary, #6b7280);
|
||||
}
|
||||
|
||||
/* 录制波形动画 */
|
||||
.xx-clonemodal-record-wave {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 3px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.xx-clonemodal-record-wave-bar {
|
||||
width: 3px;
|
||||
background: var(--xx-color-primary, #6366f1);
|
||||
border-radius: 999px;
|
||||
animation: xx-clonemodal-wave 0.8s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
.xx-clonemodal-record-wave-bar:nth-child(1) { height: 40%; animation-delay: 0s; }
|
||||
.xx-clonemodal-record-wave-bar:nth-child(2) { height: 70%; animation-delay: 0.15s; }
|
||||
.xx-clonemodal-record-wave-bar:nth-child(3) { height: 100%; animation-delay: 0.3s; }
|
||||
.xx-clonemodal-record-wave-bar:nth-child(4) { height: 60%; animation-delay: 0.45s; }
|
||||
.xx-clonemodal-record-wave-bar:nth-child(5) { height: 30%; animation-delay: 0.6s; }
|
||||
|
||||
@keyframes xx-clonemodal-wave {
|
||||
from { transform: scaleY(0.4); }
|
||||
to { transform: scaleY(1); }
|
||||
}
|
||||
|
||||
/* 录制按钮 */
|
||||
.xx-clonemodal-record-btn {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: var(--xx-color-bg, #fff);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease, transform 0.15s ease, box-shadow 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.xx-clonemodal-record-btn:hover {
|
||||
background: rgba(99, 102, 241, 0.06);
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.xx-clonemodal-record-btn--recording {
|
||||
background: var(--xx-color-error, #ef4444);
|
||||
color: #fff;
|
||||
animation: xx-clonemodal-pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.xx-clonemodal-record-btn--recording:hover {
|
||||
background: #dc2626;
|
||||
}
|
||||
|
||||
@keyframes xx-clonemodal-pulse {
|
||||
0%, 100% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.4); }
|
||||
50% { box-shadow: 0 0 0 8px rgba(239, 68, 68, 0); }
|
||||
}
|
||||
|
||||
/* ── 进度阶段(上传中 / 克隆中 / 完成) ─────────────── */
|
||||
|
||||
.xx-clonemodal-progress-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
padding: 24px 0 16px;
|
||||
}
|
||||
|
||||
/* 进度步骤指示器 */
|
||||
.xx-clonemodal-steps-progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.xx-clonemodal-step-progress {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.xx-clonemodal-step-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: var(--xx-color-bg-tertiary, #f3f4f6);
|
||||
border: 2px solid var(--xx-color-border, #e5e7eb);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
color: var(--xx-color-text-secondary, #6b7280);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.xx-clonemodal-step-progress--active .xx-clonemodal-step-icon {
|
||||
background: rgba(99, 102, 241, 0.08);
|
||||
border-color: var(--xx-color-primary, #6366f1);
|
||||
color: var(--xx-color-primary, #6366f1);
|
||||
box-shadow: 0 0 0 4px rgba(99, 102, 241, 0.12);
|
||||
}
|
||||
|
||||
.xx-clonemodal-step-progress--done .xx-clonemodal-step-icon {
|
||||
background: rgba(34, 197, 94, 0.08);
|
||||
border-color: #22c55e;
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.xx-clonemodal-step-progress--active .xx-clonemodal-step-label {
|
||||
color: var(--xx-color-primary, #6366f1);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.xx-clonemodal-step-progress--done .xx-clonemodal-step-label {
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.xx-clonemodal-step-connector {
|
||||
flex: 0 0 40px;
|
||||
height: 2px;
|
||||
background: var(--xx-color-border, #e5e7eb);
|
||||
margin-bottom: 20px;
|
||||
transition: background 0.3s ease;
|
||||
}
|
||||
|
||||
.xx-clonemodal-step-connector--done {
|
||||
background: #86efac;
|
||||
}
|
||||
|
||||
/* 进度信息 */
|
||||
.xx-clonemodal-progress-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.xx-clonemodal-progress-spinner {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 3px solid rgba(99, 102, 241, 0.15);
|
||||
border-top-color: var(--xx-color-primary, #6366f1);
|
||||
border-radius: 50%;
|
||||
animation: xx-clonemodal-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
.xx-clonemodal-progress-spinner--cloning {
|
||||
border-color: rgba(34, 197, 94, 0.15);
|
||||
border-top-color: #22c55e;
|
||||
}
|
||||
|
||||
.xx-clonemodal-progress-text {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--xx-color-text, #111827);
|
||||
}
|
||||
|
||||
.xx-clonemodal-progress-sub {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--xx-color-text-secondary, #6b7280);
|
||||
}
|
||||
|
||||
/* ── 成功状态 ───────────────────────────────────────────── */
|
||||
|
||||
.xx-clonemodal-success {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px 20px;
|
||||
gap: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.xx-clonemodal-success-icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 50%;
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 28px;
|
||||
margin-bottom: 16px;
|
||||
font-size: 48px;
|
||||
line-height: 1;
|
||||
animation: xx-clonemodal-bounce 0.6s ease-out;
|
||||
}
|
||||
|
||||
@keyframes xx-clonemodal-bounce {
|
||||
0% { transform: scale(0); opacity: 0; }
|
||||
60% { transform: scale(1.2); opacity: 1; }
|
||||
100% { transform: scale(1); }
|
||||
}
|
||||
|
||||
.xx-clonemodal-success-title {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
font-weight: 700;
|
||||
color: var(--xx-color-text, #111827);
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.xx-clonemodal-success-desc {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--xx-color-text-secondary, #6b7280);
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
max-width: 280px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* ── 响应式 ─────────────────────────────────────────────── */
|
||||
@@ -342,4 +563,48 @@
|
||||
height: 24px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.xx-clonemodal-record-area {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.xx-clonemodal-record-btn {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.xx-clonemodal-steps-progress {
|
||||
max-width: 280px;
|
||||
}
|
||||
|
||||
.xx-clonemodal-footer {
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
|
||||
.xx-clonemodal-footer > * {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.xx-clonemodal-step-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.xx-clonemodal-step-connector {
|
||||
flex: 0 0 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.xx-clonemodal-progress-spinner {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.xx-clonemodal-success-icon {
|
||||
font-size: 36px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,72 +33,75 @@ export interface NavGroup {
|
||||
items: NavItem[];
|
||||
}
|
||||
|
||||
/** 全量导航项(Header 扁平列表使用) */
|
||||
/**
|
||||
* 全量导航项(Header 扁平列表使用)
|
||||
* 所有路径统一使用 /app 前缀,与路由配置保持一致
|
||||
*/
|
||||
export const NAV_ITEMS: NavItem[] = [
|
||||
{
|
||||
key: "dashboard",
|
||||
label: "概览",
|
||||
path: "/dashboard",
|
||||
path: "/app/dashboard",
|
||||
icon: React.createElement(DashboardOutlined),
|
||||
},
|
||||
{
|
||||
key: "assets",
|
||||
label: "素材库",
|
||||
path: "/assets",
|
||||
path: "/app/assets",
|
||||
icon: React.createElement(FileOutlined),
|
||||
},
|
||||
{
|
||||
key: "titles",
|
||||
label: "标题库",
|
||||
path: "/titles",
|
||||
path: "/app/titles",
|
||||
icon: React.createElement(FileTextOutlined),
|
||||
},
|
||||
{
|
||||
key: "voices",
|
||||
label: "配音库",
|
||||
path: "/voices",
|
||||
path: "/app/voices",
|
||||
icon: React.createElement(AudioOutlined),
|
||||
},
|
||||
{
|
||||
key: "templates",
|
||||
label: "模板库",
|
||||
path: "/templates",
|
||||
path: "/app/templates",
|
||||
icon: React.createElement(AppstoreOutlined),
|
||||
},
|
||||
{
|
||||
key: "editing-planner",
|
||||
label: "剪辑编辑器",
|
||||
path: "/editing-planner",
|
||||
path: "/app/editing-planner",
|
||||
icon: React.createElement(EditOutlined),
|
||||
},
|
||||
{
|
||||
key: "my-templates",
|
||||
label: "我的模板",
|
||||
path: "/my-templates",
|
||||
path: "/app/my-templates",
|
||||
icon: React.createElement(FolderOutlined),
|
||||
},
|
||||
{
|
||||
key: "generate",
|
||||
label: "一键生成",
|
||||
path: "/generate",
|
||||
path: "/app/generate",
|
||||
icon: React.createElement(VideoCameraOutlined),
|
||||
},
|
||||
{
|
||||
key: "history",
|
||||
label: "任务历史",
|
||||
path: "/history",
|
||||
path: "/app/history",
|
||||
icon: React.createElement(HistoryOutlined),
|
||||
},
|
||||
{
|
||||
key: "products",
|
||||
label: "成品库",
|
||||
path: "/products",
|
||||
path: "/app/products",
|
||||
icon: React.createElement(TrophyOutlined),
|
||||
},
|
||||
{
|
||||
key: "duplication",
|
||||
label: "查重",
|
||||
path: "/duplication",
|
||||
path: "/app/duplication",
|
||||
icon: React.createElement(ScanOutlined),
|
||||
},
|
||||
];
|
||||
@@ -111,19 +114,19 @@ export const NAV_GROUPS: NavGroup[] = [
|
||||
{
|
||||
key: "dashboard",
|
||||
label: "概览",
|
||||
path: "/dashboard",
|
||||
path: "/app/dashboard",
|
||||
icon: React.createElement(DashboardOutlined),
|
||||
},
|
||||
{
|
||||
key: "generate",
|
||||
label: "一键生成",
|
||||
path: "/generate",
|
||||
path: "/app/generate",
|
||||
icon: React.createElement(VideoCameraOutlined),
|
||||
},
|
||||
{
|
||||
key: "editing-planner",
|
||||
label: "剪辑编辑器",
|
||||
path: "/editing-planner",
|
||||
path: "/app/editing-planner",
|
||||
icon: React.createElement(EditOutlined),
|
||||
},
|
||||
],
|
||||
@@ -134,37 +137,37 @@ export const NAV_GROUPS: NavGroup[] = [
|
||||
{
|
||||
key: "assets",
|
||||
label: "素材库",
|
||||
path: "/assets",
|
||||
path: "/app/assets",
|
||||
icon: React.createElement(FileOutlined),
|
||||
},
|
||||
{
|
||||
key: "voices",
|
||||
label: "配音库",
|
||||
path: "/voices",
|
||||
path: "/app/voices",
|
||||
icon: React.createElement(AudioOutlined),
|
||||
},
|
||||
{
|
||||
key: "titles",
|
||||
label: "标题库",
|
||||
path: "/titles",
|
||||
path: "/app/titles",
|
||||
icon: React.createElement(FileTextOutlined),
|
||||
},
|
||||
{
|
||||
key: "products",
|
||||
label: "成品库",
|
||||
path: "/products",
|
||||
path: "/app/products",
|
||||
icon: React.createElement(TrophyOutlined),
|
||||
},
|
||||
{
|
||||
key: "templates",
|
||||
label: "模板库",
|
||||
path: "/templates",
|
||||
path: "/app/templates",
|
||||
icon: React.createElement(AppstoreOutlined),
|
||||
},
|
||||
{
|
||||
key: "my-templates",
|
||||
label: "我的模板",
|
||||
path: "/my-templates",
|
||||
path: "/app/my-templates",
|
||||
icon: React.createElement(FolderOutlined),
|
||||
},
|
||||
],
|
||||
@@ -175,25 +178,25 @@ export const NAV_GROUPS: NavGroup[] = [
|
||||
{
|
||||
key: "history",
|
||||
label: "任务历史",
|
||||
path: "/history",
|
||||
path: "/app/history",
|
||||
icon: React.createElement(HistoryOutlined),
|
||||
},
|
||||
{
|
||||
key: "duplication",
|
||||
label: "查重",
|
||||
path: "/duplication",
|
||||
path: "/app/duplication",
|
||||
icon: React.createElement(ScanOutlined),
|
||||
},
|
||||
{
|
||||
key: "admin",
|
||||
label: "控制台",
|
||||
path: "/admin",
|
||||
path: "/app/admin",
|
||||
icon: React.createElement(ControlOutlined),
|
||||
},
|
||||
{
|
||||
key: "subscription",
|
||||
label: "订阅管理",
|
||||
path: "/subscription",
|
||||
path: "/app/subscription",
|
||||
icon: React.createElement(CrownOutlined),
|
||||
},
|
||||
],
|
||||
|
||||
@@ -40,63 +40,63 @@ export const NAV_ITEMS: NavItem[] = [
|
||||
{
|
||||
key: "dashboard",
|
||||
label: "概览",
|
||||
path: "/dashboard",
|
||||
path: "/app/dashboard",
|
||||
icon: <DashboardOutlined />,
|
||||
},
|
||||
{ key: "assets", label: "素材库", path: "/assets", icon: <FileOutlined /> },
|
||||
{ key: "assets", label: "素材库", path: "/app/assets", icon: <FileOutlined /> },
|
||||
{
|
||||
key: "titles",
|
||||
label: "标题库",
|
||||
path: "/titles",
|
||||
path: "/app/titles",
|
||||
icon: <FileTextOutlined />,
|
||||
},
|
||||
{ key: "voices", label: "配音库", path: "/voices", icon: <AudioOutlined /> },
|
||||
{ key: "voices", label: "配音库", path: "/app/voices", icon: <AudioOutlined /> },
|
||||
{
|
||||
key: "voice-clone",
|
||||
label: "我的音色",
|
||||
path: "/voice-clone",
|
||||
path: "/app/voice-clone",
|
||||
icon: <AudioOutlined />,
|
||||
},
|
||||
{
|
||||
key: "templates",
|
||||
label: "模板库",
|
||||
path: "/templates",
|
||||
path: "/app/templates",
|
||||
icon: <AppstoreOutlined />,
|
||||
},
|
||||
{
|
||||
key: "editing-planner",
|
||||
label: "剪辑编辑器",
|
||||
path: "/editing-planner",
|
||||
path: "/app/editing-planner",
|
||||
icon: <EditOutlined />,
|
||||
},
|
||||
{
|
||||
key: "my-templates",
|
||||
label: "我的模板",
|
||||
path: "/my-templates",
|
||||
path: "/app/my-templates",
|
||||
icon: <FolderOutlined />,
|
||||
},
|
||||
{
|
||||
key: "generate",
|
||||
label: "一键生成",
|
||||
path: "/generate",
|
||||
path: "/app/generate",
|
||||
icon: <VideoCameraOutlined />,
|
||||
},
|
||||
{
|
||||
key: "history",
|
||||
label: "任务历史",
|
||||
path: "/history",
|
||||
path: "/app/history",
|
||||
icon: <HistoryOutlined />,
|
||||
},
|
||||
{
|
||||
key: "products",
|
||||
label: "成品库",
|
||||
path: "/products",
|
||||
path: "/app/products",
|
||||
icon: <TrophyOutlined />,
|
||||
},
|
||||
{
|
||||
key: "duplication",
|
||||
label: "查重",
|
||||
path: "/duplication",
|
||||
path: "/app/duplication",
|
||||
icon: <ScanOutlined />,
|
||||
},
|
||||
];
|
||||
@@ -111,13 +111,13 @@ export const NAV_GROUPS: NavGroup[] = [
|
||||
{
|
||||
key: "dashboard",
|
||||
label: "首页",
|
||||
path: "/dashboard",
|
||||
path: "/app/dashboard",
|
||||
icon: <DashboardOutlined />,
|
||||
},
|
||||
{
|
||||
key: "generate",
|
||||
label: "一键生成",
|
||||
path: "/generate",
|
||||
path: "/app/generate",
|
||||
icon: <VideoCameraOutlined />,
|
||||
},
|
||||
],
|
||||
@@ -128,37 +128,37 @@ export const NAV_GROUPS: NavGroup[] = [
|
||||
{
|
||||
key: "assets",
|
||||
label: "素材库",
|
||||
path: "/assets",
|
||||
path: "/app/assets",
|
||||
icon: <FileOutlined />,
|
||||
},
|
||||
{
|
||||
key: "voices",
|
||||
label: "配音库",
|
||||
path: "/voices",
|
||||
path: "/app/voices",
|
||||
icon: <AudioOutlined />,
|
||||
},
|
||||
{
|
||||
key: "voice-clone",
|
||||
label: "我的音色",
|
||||
path: "/voice-clone",
|
||||
path: "/app/voice-clone",
|
||||
icon: <AudioOutlined />,
|
||||
},
|
||||
{
|
||||
key: "titles",
|
||||
label: "标题库",
|
||||
path: "/titles",
|
||||
path: "/app/titles",
|
||||
icon: <FileTextOutlined />,
|
||||
},
|
||||
{
|
||||
key: "products",
|
||||
label: "成片库",
|
||||
path: "/products",
|
||||
path: "/app/products",
|
||||
icon: <TrophyOutlined />,
|
||||
},
|
||||
{
|
||||
key: "templates",
|
||||
label: "模板库",
|
||||
path: "/templates",
|
||||
path: "/app/templates",
|
||||
icon: <AppstoreOutlined />,
|
||||
},
|
||||
],
|
||||
@@ -169,19 +169,19 @@ export const NAV_GROUPS: NavGroup[] = [
|
||||
{
|
||||
key: "history",
|
||||
label: "任务历史",
|
||||
path: "/history",
|
||||
path: "/app/history",
|
||||
icon: <HistoryOutlined />,
|
||||
},
|
||||
{
|
||||
key: "admin",
|
||||
label: "控制台",
|
||||
path: "/admin",
|
||||
path: "/app/admin",
|
||||
icon: <ControlOutlined />,
|
||||
},
|
||||
{
|
||||
key: "subscription",
|
||||
label: "订阅管理",
|
||||
path: "/subscription",
|
||||
path: "/app/subscription",
|
||||
icon: <CrownOutlined />,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -17,7 +17,7 @@ const AdminComingSoon: React.FC = () => {
|
||||
extra={[
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => navigate("/")}
|
||||
onClick={() => navigate("/app/dashboard")}
|
||||
className="xx-primary-btn"
|
||||
>
|
||||
返回首页
|
||||
|
||||
@@ -25,7 +25,7 @@ const Login: React.FC = () => {
|
||||
password: values.password,
|
||||
});
|
||||
message.success("登录成功!");
|
||||
navigate("/");
|
||||
navigate("/app/dashboard");
|
||||
} catch (error: unknown) {
|
||||
if (!(error as { __msgShown?: boolean })?.__msgShown)
|
||||
message.error("登录失败,请检查邮箱和密码");
|
||||
|
||||
@@ -293,7 +293,7 @@ const Dashboard: React.FC = () => {
|
||||
<div className="xx-dashboard-section">
|
||||
<div className="xx-dashboard-section-header">
|
||||
<h3>最近任务</h3>
|
||||
<button onClick={() => navigate("/history")}>查看全部</button>
|
||||
<button onClick={() => navigate("/app/history")}>查看全部</button>
|
||||
</div>
|
||||
<div className="xx-task-list">
|
||||
{recentTasks.map((task) => (
|
||||
@@ -331,7 +331,7 @@ const Dashboard: React.FC = () => {
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
buttonSize="sm"
|
||||
onClick={() => navigate("/history")}
|
||||
onClick={() => navigate("/app/history")}
|
||||
>
|
||||
查看
|
||||
</Button>
|
||||
|
||||
@@ -170,7 +170,7 @@ const DuplicationDetail: React.FC = () => {
|
||||
<Button
|
||||
buttonType="primary"
|
||||
buttonSize="md"
|
||||
onClick={() => navigate("/duplication/results")}
|
||||
onClick={() => navigate("/app/duplication/results")}
|
||||
style={{ marginTop: 16 }}
|
||||
>
|
||||
返回列表
|
||||
|
||||
@@ -158,7 +158,7 @@ const DuplicationResults: React.FC = () => {
|
||||
<Button
|
||||
buttonType="primary"
|
||||
buttonSize="md"
|
||||
onClick={() => navigate("/duplication")}
|
||||
onClick={() => navigate("/app/duplication")}
|
||||
>
|
||||
📤 上传查重
|
||||
</Button>
|
||||
|
||||
Regular → Executable
+1
-1
@@ -196,7 +196,7 @@ const DuplicationUpload: React.FC = () => {
|
||||
<Button
|
||||
buttonType="primary"
|
||||
buttonSize="md"
|
||||
onClick={() => navigate("/duplication/results")}
|
||||
onClick={() => navigate("/app/duplication/results")}
|
||||
>
|
||||
查看结果
|
||||
</Button>
|
||||
|
||||
@@ -44,7 +44,7 @@ import { fetchPresetVoices } from "@/api/voices";
|
||||
import type { PresetVoiceItem } from "@/api/voices";
|
||||
import { formatDuration } from "@/api/voiceClone";
|
||||
import type { VoiceClone } from "@/api/voiceClone";
|
||||
import VoiceCloneModal from "@/components/modals/VoiceCloneModal";
|
||||
import CloneModal from "@/components/voice/CloneModal";
|
||||
import { synthesizeSpeech, getTTSJobStatus } from "@/api/tts";
|
||||
import { useCloneProgress } from "@/hooks/useCloneProgress";
|
||||
import "./generate.css";
|
||||
@@ -1291,7 +1291,7 @@ const GeneratePage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{/* ── 音色克隆弹窗 ── */}
|
||||
<VoiceCloneModal
|
||||
<CloneModal
|
||||
open={cloneModalOpen}
|
||||
onClose={() => setCloneModalOpen(false)}
|
||||
onSuccess={handleCloneSuccess}
|
||||
|
||||
@@ -141,7 +141,7 @@ const MyTemplates: React.FC = () => {
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => navigate("/editing-planner")}
|
||||
onClick={() => navigate("/app/editing-planner")}
|
||||
>
|
||||
新建模板
|
||||
</Button>
|
||||
@@ -178,7 +178,7 @@ const MyTemplates: React.FC = () => {
|
||||
description="还没有模板,点击右上角「新建模板」开始创建"
|
||||
style={{ padding: 80 }}
|
||||
>
|
||||
<Button type="primary" onClick={() => navigate("/editing-planner")}>
|
||||
<Button type="primary" onClick={() => navigate("/app/editing-planner")}>
|
||||
新建模板
|
||||
</Button>
|
||||
</Empty>
|
||||
|
||||
@@ -160,7 +160,7 @@ const UpgradeSubscription: React.FC = () => {
|
||||
try {
|
||||
const res = await cancelSubscription();
|
||||
message.success(res.message);
|
||||
navigate("/subscription");
|
||||
navigate("/app/subscription");
|
||||
} catch (err: unknown) {
|
||||
if (!(err as { __msgShown?: boolean })?.__msgShown)
|
||||
message.error("取消失败");
|
||||
|
||||
Regular → Executable
+2
-1
@@ -13,8 +13,9 @@ server {
|
||||
client_max_body_size 800m;
|
||||
|
||||
# SPA routing - all routes to index.html
|
||||
# 注意:不能加 $uri/,否则 /assets 等与构建产物目录同名的路由会被当成目录访问,返回 403
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
try_files $uri /index.html;
|
||||
}
|
||||
|
||||
# API proxy
|
||||
|
||||
Regular → Executable
+2
-1
@@ -13,8 +13,9 @@ server {
|
||||
client_max_body_size 800m;
|
||||
|
||||
# SPA routing - all routes to index.html
|
||||
# 注意:不能加 $uri/,否则 /assets 等与构建产物目录同名的路由会被当成目录访问,返回 403
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
try_files $uri /index.html;
|
||||
}
|
||||
|
||||
# API proxy
|
||||
|
||||
Regular → Executable
+2
-1
@@ -23,8 +23,9 @@ server {
|
||||
client_max_body_size 800m;
|
||||
|
||||
# SPA routing - all routes to index.html
|
||||
# 注意:不能加 $uri/,否则 /assets 等与构建产物目录同名的路由会被当成目录访问,返回 403
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
try_files $uri /index.html;
|
||||
}
|
||||
|
||||
# API proxy
|
||||
|
||||
Reference in New Issue
Block a user