From 680a9c10c2234415cea255153d65eeaa533263a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=81=B5=E5=BA=94?= Date: Sat, 4 Jul 2026 16:06:06 +0800 Subject: [PATCH 1/6] =?UTF-8?q?fix:=20=E5=90=88=E5=B9=B6=E9=9F=B3=E8=89=B2?= =?UTF-8?q?=E5=85=8B=E9=9A=86=E5=BC=B9=E7=AA=97=EF=BC=8C=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E5=81=87=E5=BD=95=E9=9F=B3=E5=81=87=E4=B8=8A=E4=BC=A0=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 升级 CloneModal 为共享组件:真实 MediaRecorder 录音 + uploadAsset 真实上传 - GeneratePage 改用 CloneModal 替代旧的 VoiceCloneModal(mock:// 假 URL) - 删除旧文件:VoiceCloneModal.tsx / voice-clone-modal.css - 新增:录制波形动画、进度步骤指示器(上传中→克隆中→完成)、错误处理 - 文件验证:mp3/wav/m4a,≤10MB;录音 Blob→File 转换后上传 Fixes: 音色克隆弹窗录音和上传全是假数据的问题 --- .../src/components/modals/VoiceCloneModal.tsx | 438 --------------- .../components/modals/voice-clone-modal.css | 512 ------------------ apps/web/src/components/voice/CloneModal.tsx | 347 ++++++++++-- apps/web/src/components/voice/clone-modal.css | 295 +++++++++- apps/web/src/pages/generate/GeneratePage.tsx | 4 +- 5 files changed, 575 insertions(+), 1021 deletions(-) delete mode 100644 apps/web/src/components/modals/VoiceCloneModal.tsx delete mode 100644 apps/web/src/components/modals/voice-clone-modal.css diff --git a/apps/web/src/components/modals/VoiceCloneModal.tsx b/apps/web/src/components/modals/VoiceCloneModal.tsx deleted file mode 100644 index 271bc1db0..000000000 --- a/apps/web/src/components/modals/VoiceCloneModal.tsx +++ /dev/null @@ -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 = ({ - open, - onClose, - onSuccess, -}) => { - const [phase, setPhase] = useState("input"); - const [voiceName, setVoiceName] = useState(""); - const [isRecording, setIsRecording] = useState(false); - const [selectedFile, setSelectedFile] = useState(null); - const [dragActive, setDragActive] = useState(false); - const [recordTime, setRecordTime] = useState(0); - const fileInputRef = useRef(null); - const recordTimerRef = useRef | 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) => { - 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 ( - - {/* ── 输入阶段 ──────────────────────────────────── */} - {phase === "input" && ( -
- {/* 音色名称 */} -
- - setVoiceName(e.target.value)} - placeholder="输入音色名称" - maxLength={30} - /> -
- - {/* 上传区域 */} -
- -
-
- {selectedFile ? "📄" : "🎵"} -
-

- {selectedFile - ? selectedFile.name - : "拖拽音频文件到此处,或点击上传"} -

-

- 支持 MP3、WAV、M4A、AAC、OGG 格式 -

- -
-
- - {/* 或分隔 */} -
-
- -
-
- - {/* 录制区域 */} -
- -
-
-

- {isRecording - ? `录制中 ${formatRecordTime(recordTime)}` - : "点击按钮开始录制你的声音"} -

- {isRecording && ( -
- - - - - -
- )} -
- -
-
- - {/* 提示 */} -
- 💡 - - 建议上传 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..1107862e8 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,30 @@ 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 +/* ── 默认音色名称计数器 ─────────────────────────────────── */ + +let cloneCounter = 1; + +const getNextDefaultName = (): string => { + const name = `我的声音 ${cloneCounter}`; + cloneCounter += 1; + return name; +}; + /* ── 组件 ───────────────────────────────────────────────── */ const CloneModal: React.FC = ({ @@ -50,14 +67,25 @@ 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([]); - /** 组件卸载时清理定时器(P2-2 修复) */ + /** 组件卸载时清理定时器和 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,11 +93,23 @@ 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; }, []); /** 关闭弹窗 */ @@ -114,6 +154,10 @@ const CloneModal: React.FC = ({ } else { setErrorMessage(""); setSelectedFile(file); + // 清除录音 + setRecordedBlob(null); + setIsRecording(false); + setRecordTime(0); } } e.target.value = ""; @@ -144,12 +188,75 @@ 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) => prev + 1); + }, 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 +265,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 +280,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 +327,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 +379,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)}` + : "点击按钮开始录制你的声音"} +

+ {isRecording && ( +
+ + + + + +
+ )} +
+
@@ -342,25 +517,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/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} From fa640ca5375ca56c7b42e7e293b73dab95975b93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=81=B5=E5=BA=94?= Date: Sat, 4 Jul 2026 16:23:26 +0800 Subject: [PATCH 2/6] =?UTF-8?q?fix(voice):=20CloneModal=20=E5=BD=95?= =?UTF-8?q?=E9=9F=B3=205=20=E5=88=86=E9=92=9F=E4=B8=8A=E9=99=90=20+=20clon?= =?UTF-8?q?eCounter=20=E6=94=B9=20useRef?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P3 优化(代码审计建议): 1. 录音达到 5 分钟自动停止并提示用户,避免传超大文件 2. cloneCounter 从模块级变量改为组件内 useRef,避免多实例串号 Co-Authored-By: Claude Fable 5 --- apps/web/src/components/voice/CloneModal.tsx | 44 ++++++++++++++------ 1 file changed, 32 insertions(+), 12 deletions(-) diff --git a/apps/web/src/components/voice/CloneModal.tsx b/apps/web/src/components/voice/CloneModal.tsx index 1107862e8..29d4d0b81 100644 --- a/apps/web/src/components/voice/CloneModal.tsx +++ b/apps/web/src/components/voice/CloneModal.tsx @@ -44,15 +44,8 @@ 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 -/* ── 默认音色名称计数器 ─────────────────────────────────── */ - -let cloneCounter = 1; - -const getNextDefaultName = (): string => { - const name = `我的声音 ${cloneCounter}`; - cloneCounter += 1; - return name; -}; +/** 最长录制时长:5 分钟(秒) */ +const MAX_RECORD_SECONDS = 5 * 60; /* ── 组件 ───────────────────────────────────────────────── */ @@ -78,6 +71,15 @@ const CloneModal: React.FC = ({ const recordTimerRef = useRef | null>(null); const mediaRecorderRef = useRef(null); const audioChunksRef = useRef([]); + /** 默认音色名称计数器(组件级 ref,避免多实例串号) */ + const cloneCounterRef = useRef(1); + + /** 生成下一个默认音色名称 */ + const getNextDefaultName = useCallback((): string => { + const name = `我的声音 ${cloneCounterRef.current}`; + cloneCounterRef.current += 1; + return name; + }, []); /** 组件卸载时清理定时器和 MediaRecorder */ useEffect(() => { @@ -110,7 +112,7 @@ const CloneModal: React.FC = ({ mediaRecorderRef.current.stop(); } mediaRecorderRef.current = null; - }, []); + }, [getNextDefaultName]); /** 关闭弹窗 */ const handleClose = useCallback(() => { @@ -238,7 +240,25 @@ const CloneModal: React.FC = ({ setErrorMessage(""); recordTimerRef.current = setInterval(() => { - setRecordTime((prev) => prev + 1); + 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("无法访问麦克风,请检查浏览器权限设置"); @@ -446,7 +466,7 @@ const CloneModal: React.FC = ({ ? `录制中 ${formatRecordTime(recordTime)}` : recordedBlob ? `已录制 ${formatRecordTime(recordTime)}` - : "点击按钮开始录制你的声音"} + : "点击按钮开始录制(最长 5 分钟)"}

{isRecording && (
From 254ffd5391e1ebfe4c20732826668a23db27f611 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Sat, 4 Jul 2026 16:42:55 +0800 Subject: [PATCH 3/6] =?UTF-8?q?test(e2e):=20=E4=BF=AE=E5=A4=8D=E7=94=9F?= =?UTF-8?q?=E4=BA=A7=E7=8E=AF=E5=A2=83=E7=A8=B3=E5=AE=9A=E6=80=A7=E9=97=AE?= =?UTF-8?q?=E9=A2=98=20-=20force=20click=20+=20=E9=99=90=E6=B5=81=E9=87=8D?= =?UTF-8?q?=E8=AF=95=20+=20=E8=B6=85=E6=97=B6=E8=B0=83=E6=95=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - core-generation: 素材卡片和生成按钮点击加 force:true,跳过 actionability 检查 - core-upload: 素材库项点击加 force:true - test_auth: 反向登录测试增加限流重试逻辑 - test_asset/test_auth/test_project: describe 超时从 120s 增至 180s - 生产环境建议 workers=1 运行,避免登录限流累积 --- apps/web/e2e/core-generation.spec.ts | 6 +++--- apps/web/e2e/core-upload.spec.ts | 2 +- apps/web/e2e/test_asset.spec.ts | 2 +- apps/web/e2e/test_auth.spec.ts | 15 +++++++++++---- apps/web/e2e/test_project.spec.ts | 2 +- 5 files changed, 17 insertions(+), 10 deletions(-) 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"); From efb9d6c57f37dcd7c4015d73058c97acefe9949e Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Sat, 4 Jul 2026 17:30:12 +0800 Subject: [PATCH 4/6] =?UTF-8?q?fix:=20nginx=20SPA=E8=B7=AF=E7=94=B1?= =?UTF-8?q?=E7=A7=BB=E9=99=A4$uri/=EF=BC=8C=E9=81=BF=E5=85=8D/assets?= =?UTF-8?q?=E7=AD=89=E8=B7=AF=E5=BE=84=E8=A2=AB=E5=BD=93=E6=88=90=E9=9D=99?= =?UTF-8?q?=E6=80=81=E7=9B=AE=E5=BD=95=E8=BF=94=E5=9B=9E403?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题根因: - try_files $uri $uri/ /index.html 中的 $uri/ 会匹配与构建产物目录同名的路由 - Vite 默认将静态资源放在 assets/ 目录下 - 访问 /assets 时,$uri/ 匹配到 assets/ 目录,Nginx 尝试目录访问返回 403 - 类似的路径冲突还有 /dashboard /history 等如果有同名目录也会出问题 修复方案: - 移除 $uri/,改为 try_files $uri /index.html - 真实存在的静态文件(如 /assets/index-abc.js)仍然能通过 $uri 正确返回 - 前端路由路径统一 fallback 到 index.html 由 SPA 路由处理 影响范围:nginx.conf / nginx-production.conf / nginx-staging.conf 三个文件 --- infra/docker/nginx-production.conf | 3 ++- infra/docker/nginx-staging.conf | 3 ++- infra/docker/nginx.conf | 3 ++- 3 files changed, 6 insertions(+), 3 deletions(-) mode change 100644 => 100755 infra/docker/nginx-production.conf mode change 100644 => 100755 infra/docker/nginx-staging.conf mode change 100644 => 100755 infra/docker/nginx.conf 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 From 319dd2c8399d508264fae612ce421df6dba28851 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=81=B5=E5=BA=94?= Date: Sat, 4 Jul 2026 17:35:30 +0800 Subject: [PATCH 5/6] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E4=BE=A7=E8=BE=B9?= =?UTF-8?q?=E6=A0=8F=E5=AF=BC=E8=88=AA=20P0=20=E2=80=94=20=E6=89=80?= =?UTF-8?q?=E6=9C=89=E5=AF=BC=E8=88=AA=E8=B7=AF=E5=BE=84=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E6=B7=BB=E5=8A=A0=20/app=20=E5=89=8D=E7=BC=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 根因:导航配置中所有 path 缺少 /app 前缀,与路由配置不匹配。 侧边栏 navigate('/dashboard') 但路由实际为 /app/dashboard, 导致所有侧边栏按钮点击后 URL 停在 /app/dashboard。 修复内容: - navigation.ts/tsx: NAV_ITEMS 和 NAV_GROUPS 所有 path 加 /app 前缀 - Sidebar.tsx: isMenuItemActive 中 dashboard 特殊判断更新为 /app/dashboard - Header.tsx: isActive 函数 + logo 点击 + 用户菜单导航路径修复 - 页面内硬编码路径修复: - Dashboard.tsx: /history → /app/history - MyTemplates.tsx: /editing-planner → /app/editing-planner - DuplicationResults.tsx: /duplication → /app/duplication - DuplicationDetail.tsx: /duplication/results → /app/duplication/results - UpgradeSubscription.tsx: /subscription → /app/subscription - AdminComingSoon.tsx: / → /app/dashboard - Login.tsx: 登录成功后 / → /app/dashboard TypeScript 零错误。 --- apps/web/src/components/layout/Header.tsx | 11 ++-- apps/web/src/components/layout/Sidebar.tsx | 5 +- apps/web/src/config/navigation.ts | 53 ++++++++++--------- apps/web/src/config/navigation.tsx | 46 ++++++++-------- apps/web/src/pages/admin/AdminComingSoon.tsx | 2 +- apps/web/src/pages/auth/Login.tsx | 2 +- apps/web/src/pages/dashboard/Dashboard.tsx | 4 +- .../pages/duplication/DuplicationDetail.tsx | 2 +- .../pages/duplication/DuplicationResults.tsx | 2 +- .../src/pages/my-templates/MyTemplates.tsx | 4 +- .../subscription/UpgradeSubscription.tsx | 2 +- 11 files changed, 69 insertions(+), 64 deletions(-) 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 = () => { +
{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/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("取消失败"); From fb47512f9c240b323c42fe914a689893f5506c2c Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Sat, 4 Jul 2026 17:52:45 +0800 Subject: [PATCH 6/6] =?UTF-8?q?fix:=20=E8=A1=A5=E5=85=85=20DuplicationUplo?= =?UTF-8?q?ad=20=E5=AF=BC=E8=88=AA=E8=B7=AF=E5=BE=84=20/app=20=E5=89=8D?= =?UTF-8?q?=E7=BC=80=E9=81=97=E6=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/src/pages/duplication/DuplicationUpload.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) mode change 100644 => 100755 apps/web/src/pages/duplication/DuplicationUpload.tsx 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 = () => {