diff --git a/apps/web/e2e/core-generation.spec.ts b/apps/web/e2e/core-generation.spec.ts index 7a8de7147..4ff0750ba 100755 --- a/apps/web/e2e/core-generation.spec.ts +++ b/apps/web/e2e/core-generation.spec.ts @@ -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 { diff --git a/apps/web/e2e/core-upload.spec.ts b/apps/web/e2e/core-upload.spec.ts index 82fbbd9bf..5717aa69b 100755 --- a/apps/web/e2e/core-upload.spec.ts +++ b/apps/web/e2e/core-upload.spec.ts @@ -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({ diff --git a/apps/web/e2e/test_asset.spec.ts b/apps/web/e2e/test_asset.spec.ts index 0dda89d1c..48b42eaca 100755 --- a/apps/web/e2e/test_asset.spec.ts +++ b/apps/web/e2e/test_asset.spec.ts @@ -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"); diff --git a/apps/web/e2e/test_auth.spec.ts b/apps/web/e2e/test_auth.spec.ts index e658f88ca..cb34b90fa 100755 --- a/apps/web/e2e/test_auth.spec.ts +++ b/apps/web/e2e/test_auth.spec.ts @@ -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); }); diff --git a/apps/web/e2e/test_project.spec.ts b/apps/web/e2e/test_project.spec.ts index 75e203d54..851d95f30 100755 --- a/apps/web/e2e/test_project.spec.ts +++ b/apps/web/e2e/test_project.spec.ts @@ -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"); diff --git a/apps/web/src/components/layout/Header.tsx b/apps/web/src/components/layout/Header.tsx index cb1223282..3bb2e9aff 100644 --- a/apps/web/src/components/layout/Header.tsx +++ b/apps/web/src/components/layout/Header.tsx @@ -30,13 +30,13 @@ const Header: React.FC = () => { key: "profile", icon: , label: "个人设置", - onClick: () => navigate("/profile"), + onClick: () => navigate("/app/profile"), }, { key: "subscription", icon: , 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 = () => { - - - - {/* 提示 */} -
- 💡 - - 建议上传 10 秒 ~ 3 分钟的清晰语音,环境安静、语速均匀效果最佳 - -
- - {/* 底部按钮 */} -
- - -
- - )} - - {/* ── 进度阶段(上传中 / 克隆中 / 完成) ─────── */} - {isProcessing && ( -
- {/* 步骤指示器 */} -
- {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 ( - - {idx > 0 && ( -
- )} -
-
- {isDone ? "✓" : step.icon} -
- {step.label} -
- - ); - })} -
- - {/* 当前阶段描述 */} -
- {phase === "uploading" && ( - <> -
-

正在上传音频文件…

-

- 请稍候,正在将音频上传至服务器 -

- - )} - {phase === "cloning" && ( - <> -
-

AI 正在克隆你的声音…

-

- 正在分析声音特征,生成专属音色模型 -

- - )} -
-
- )} - - {/* ── 完成阶段 ──────────────────────────────────── */} - {phase === "done" && ( -
- {/* 步骤指示器(全部完成) */} -
- {PROGRESS_STEPS.map((step, idx) => ( - - {idx > 0 && ( -
- )} -
-
- {step.label} -
- - ))} -
- -
-
🎉
-

克隆已提交

-

- 音色正在生成中,完成后将出现在「我的音色库」列表中 -

-
-
- )} - - ); -}; - -export default VoiceCloneModal; diff --git a/apps/web/src/components/modals/voice-clone-modal.css b/apps/web/src/components/modals/voice-clone-modal.css deleted file mode 100644 index 2b79260f3..000000000 --- a/apps/web/src/components/modals/voice-clone-modal.css +++ /dev/null @@ -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; - } -} diff --git a/apps/web/src/components/voice/CloneModal.tsx b/apps/web/src/components/voice/CloneModal.tsx index 95fc6a1d0..29d4d0b81 100644 --- a/apps/web/src/components/voice/CloneModal.tsx +++ b/apps/web/src/components/voice/CloneModal.tsx @@ -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 = ({ @@ -50,14 +60,34 @@ const CloneModal: React.FC = ({ const [selectedFile, setSelectedFile] = useState(null); const [dragActive, setDragActive] = useState(false); const [errorMessage, setErrorMessage] = useState(""); + + // 录音状态 + const [isRecording, setIsRecording] = useState(false); + const [recordTime, setRecordTime] = useState(0); + const [recordedBlob, setRecordedBlob] = useState(null); + const fileInputRef = useRef(null); const timerRef = useRef | null>(null); + const recordTimerRef = useRef | null>(null); + const mediaRecorderRef = useRef(null); + const audioChunksRef = useRef([]); + /** 默认音色名称计数器(组件级 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 = ({ /** 重置弹窗状态 */ 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 = ({ } else { setErrorMessage(""); setSelectedFile(file); + // 清除录音 + setRecordedBlob(null); + setIsRecording(false); + setRecordTime(0); } } e.target.value = ""; @@ -144,12 +190,93 @@ const CloneModal: React.FC = ({ } 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 = ({ 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 = ({ 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 = ({ 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 ( = ({
1
- 上传音频 + 上传/录制音频
@@ -242,11 +399,27 @@ const CloneModal: React.FC = ({
- {/* 上传区域 */} + {/* 音色名称 */}
+ setVoiceName(e.target.value)} + placeholder="输入音色名称(2-20字符)" + maxLength={20} + /> +
+ {voiceName.length}/20 +
+
+ + {/* 上传区域 */} +
+
= ({
- {/* 音色名称 */} + {/* 或分隔 */} +
+
+ +
+
+ + {/* 录制区域 */}
- - setVoiceName(e.target.value)} - placeholder="输入音色名称(2-20字符)" - maxLength={20} - /> -
- {voiceName.length}/20 + +
+
+

+ {isRecording + ? `录制中 ${formatRecordTime(recordTime)}` + : recordedBlob + ? `已录制 ${formatRecordTime(recordTime)}` + : "点击按钮开始录制(最长 5 分钟)"} +

+ {isRecording && ( +
+ + + + + +
+ )} +
+
@@ -342,25 +537,89 @@ const CloneModal: React.FC = ({
)} - {/* ── 上传中阶段 ────────────────────────────────── */} - {phase === "uploading" && ( -
-
-

正在克隆你的音色…

-

- AI 正在分析你的声音特征,请稍候 -

+ {/* ── 进度阶段(上传中 / 克隆中) ──────────────── */} + {isProcessing && ( +
+ {/* 步骤指示器 */} +
+ {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 ( + + {idx > 0 && ( +
+ )} +
+
+ {isDone ? "✓" : step.icon} +
+ {step.label} +
+ + ); + })} +
+ + {/* 当前阶段描述 */} +
+ {phase === "uploading" && ( + <> +
+

正在上传音频文件…

+

+ 请稍候,正在将音频上传至服务器 +

+ + )} + {phase === "cloning" && ( + <> +
+

AI 正在克隆你的声音…

+

+ 正在分析声音特征,生成专属音色模型 +

+ + )} +
)} - {/* ── 成功阶段 ──────────────────────────────────── */} - {phase === "success" && ( -
-
-

克隆已提交

-

- 音色正在生成中,完成后将出现在「我的克隆」列表中 -

+ {/* ── 完成阶段 ──────────────────────────────────── */} + {phase === "done" && ( +
+ {/* 步骤指示器(全部完成) */} +
+ {PROGRESS_STEPS.map((step, idx) => ( + + {idx > 0 && ( +
+ )} +
+
+ {step.label} +
+ + ))} +
+ +
+
🎉
+

克隆已提交

+

+ 音色正在生成中,完成后将出现在「我的克隆」列表中 +

+
)} diff --git a/apps/web/src/components/voice/clone-modal.css b/apps/web/src/components/voice/clone-modal.css index b88eed8d6..e12456c3e 100644 --- a/apps/web/src/components/voice/clone-modal.css +++ b/apps/web/src/components/voice/clone-modal.css @@ -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; + } } diff --git a/apps/web/src/config/navigation.ts b/apps/web/src/config/navigation.ts index 49e2939b5..a32cf0f96 100644 --- a/apps/web/src/config/navigation.ts +++ b/apps/web/src/config/navigation.ts @@ -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), }, ], diff --git a/apps/web/src/config/navigation.tsx b/apps/web/src/config/navigation.tsx index 3fdab37c4..a66ef3fd9 100644 --- a/apps/web/src/config/navigation.tsx +++ b/apps/web/src/config/navigation.tsx @@ -40,63 +40,63 @@ export const NAV_ITEMS: NavItem[] = [ { key: "dashboard", label: "概览", - path: "/dashboard", + path: "/app/dashboard", icon: , }, - { key: "assets", label: "素材库", path: "/assets", icon: }, + { key: "assets", label: "素材库", path: "/app/assets", icon: }, { key: "titles", label: "标题库", - path: "/titles", + path: "/app/titles", icon: , }, - { key: "voices", label: "配音库", path: "/voices", icon: }, + { key: "voices", label: "配音库", path: "/app/voices", icon: }, { key: "voice-clone", label: "我的音色", - path: "/voice-clone", + path: "/app/voice-clone", icon: , }, { key: "templates", label: "模板库", - path: "/templates", + path: "/app/templates", icon: , }, { key: "editing-planner", label: "剪辑编辑器", - path: "/editing-planner", + path: "/app/editing-planner", icon: , }, { key: "my-templates", label: "我的模板", - path: "/my-templates", + path: "/app/my-templates", icon: , }, { key: "generate", label: "一键生成", - path: "/generate", + path: "/app/generate", icon: , }, { key: "history", label: "任务历史", - path: "/history", + path: "/app/history", icon: , }, { key: "products", label: "成品库", - path: "/products", + path: "/app/products", icon: , }, { key: "duplication", label: "查重", - path: "/duplication", + path: "/app/duplication", icon: , }, ]; @@ -111,13 +111,13 @@ export const NAV_GROUPS: NavGroup[] = [ { key: "dashboard", label: "首页", - path: "/dashboard", + path: "/app/dashboard", icon: , }, { key: "generate", label: "一键生成", - path: "/generate", + path: "/app/generate", icon: , }, ], @@ -128,37 +128,37 @@ export const NAV_GROUPS: NavGroup[] = [ { key: "assets", label: "素材库", - path: "/assets", + path: "/app/assets", icon: , }, { key: "voices", label: "配音库", - path: "/voices", + path: "/app/voices", icon: , }, { key: "voice-clone", label: "我的音色", - path: "/voice-clone", + path: "/app/voice-clone", icon: , }, { key: "titles", label: "标题库", - path: "/titles", + path: "/app/titles", icon: , }, { key: "products", label: "成片库", - path: "/products", + path: "/app/products", icon: , }, { key: "templates", label: "模板库", - path: "/templates", + path: "/app/templates", icon: , }, ], @@ -169,19 +169,19 @@ export const NAV_GROUPS: NavGroup[] = [ { key: "history", label: "任务历史", - path: "/history", + path: "/app/history", icon: , }, { key: "admin", label: "控制台", - path: "/admin", + path: "/app/admin", icon: , }, { key: "subscription", label: "订阅管理", - path: "/subscription", + path: "/app/subscription", icon: , }, ], diff --git a/apps/web/src/pages/admin/AdminComingSoon.tsx b/apps/web/src/pages/admin/AdminComingSoon.tsx index 77d3fd17b..7be809566 100644 --- a/apps/web/src/pages/admin/AdminComingSoon.tsx +++ b/apps/web/src/pages/admin/AdminComingSoon.tsx @@ -17,7 +17,7 @@ const AdminComingSoon: React.FC = () => { extra={[ +
{recentTasks.map((task) => ( @@ -331,7 +331,7 @@ const Dashboard: React.FC = () => { diff --git a/apps/web/src/pages/duplication/DuplicationDetail.tsx b/apps/web/src/pages/duplication/DuplicationDetail.tsx index d603f6db0..39ad49949 100644 --- a/apps/web/src/pages/duplication/DuplicationDetail.tsx +++ b/apps/web/src/pages/duplication/DuplicationDetail.tsx @@ -170,7 +170,7 @@ const DuplicationDetail: React.FC = () => { diff --git a/apps/web/src/pages/duplication/DuplicationUpload.tsx b/apps/web/src/pages/duplication/DuplicationUpload.tsx old mode 100644 new mode 100755 index 0353d67ce..377a950e9 --- a/apps/web/src/pages/duplication/DuplicationUpload.tsx +++ b/apps/web/src/pages/duplication/DuplicationUpload.tsx @@ -196,7 +196,7 @@ const DuplicationUpload: React.FC = () => { diff --git a/apps/web/src/pages/generate/GeneratePage.tsx b/apps/web/src/pages/generate/GeneratePage.tsx index f8ec1a5a5..65822c93b 100644 --- a/apps/web/src/pages/generate/GeneratePage.tsx +++ b/apps/web/src/pages/generate/GeneratePage.tsx @@ -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 = () => {
{/* ── 音色克隆弹窗 ── */} - setCloneModalOpen(false)} onSuccess={handleCloneSuccess} diff --git a/apps/web/src/pages/my-templates/MyTemplates.tsx b/apps/web/src/pages/my-templates/MyTemplates.tsx index f6199c4ea..b2d961e44 100644 --- a/apps/web/src/pages/my-templates/MyTemplates.tsx +++ b/apps/web/src/pages/my-templates/MyTemplates.tsx @@ -141,7 +141,7 @@ const MyTemplates: React.FC = () => { @@ -178,7 +178,7 @@ const MyTemplates: React.FC = () => { description="还没有模板,点击右上角「新建模板」开始创建" style={{ padding: 80 }} > - diff --git a/apps/web/src/pages/subscription/UpgradeSubscription.tsx b/apps/web/src/pages/subscription/UpgradeSubscription.tsx index 87390d4aa..b24f06d40 100644 --- a/apps/web/src/pages/subscription/UpgradeSubscription.tsx +++ b/apps/web/src/pages/subscription/UpgradeSubscription.tsx @@ -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("取消失败"); diff --git a/infra/docker/nginx-production.conf b/infra/docker/nginx-production.conf old mode 100644 new mode 100755 index f03b38263..f9fdef9ae --- a/infra/docker/nginx-production.conf +++ b/infra/docker/nginx-production.conf @@ -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 diff --git a/infra/docker/nginx-staging.conf b/infra/docker/nginx-staging.conf old mode 100644 new mode 100755 index 56d7502aa..f3735ba18 --- a/infra/docker/nginx-staging.conf +++ b/infra/docker/nginx-staging.conf @@ -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 diff --git a/infra/docker/nginx.conf b/infra/docker/nginx.conf old mode 100644 new mode 100755 index c1a87006b..601ebaa75 --- a/infra/docker/nginx.conf +++ b/infra/docker/nginx.conf @@ -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