ci: Prettier纳入两层防御体系 (#520)
CI Build & Deploy Pipeline / Staging E2E Tests (push) Failing after 2m50s
CI Build & Deploy Pipeline / Production Browser E2E (push) Failing after 1437h19m24s
CI Build & Deploy Pipeline / Build Production Web Image (push) Failing after 1437h19m26s
CI Build & Deploy Pipeline / Deploy Production (push) Failing after 1437h19m25s
CI Build & Deploy Pipeline / Build Production API Image (push) Failing after 1437h19m26s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1437h21m16s
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Worker Image (push) Failing after 1437h51m29s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped

Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
This commit was merged in pull request #520.
This commit is contained in:
2026-07-18 18:08:19 +08:00
committed by auto-approve-bot
parent 1ee779a566
commit 0fcb77b991
145 changed files with 10310 additions and 12869 deletions
+193 -240
View File
@@ -10,24 +10,24 @@
*
* 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 { uploadAsset } from "@/api/assets";
import "./clone-modal.css";
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 { uploadAsset } from "@/api/assets"
import "./clone-modal.css"
/* ── 类型定义 ───────────────────────────────────────────── */
type ModalPhase = "input" | "uploading" | "cloning" | "done";
type ModalPhase = "input" | "uploading" | "cloning" | "done"
export interface CloneModalProps {
/** 弹窗是否可见 */
open: boolean;
open: boolean
/** 关闭弹窗回调 */
onClose: () => void;
onClose: () => void
/** 克隆成功回调(返回新创建的音色) */
onSuccess?: (voice: VoiceClone) => void;
onSuccess?: (voice: VoiceClone) => void
}
/* ── 进度阶段配置 ─────────────────────────────────────────── */
@@ -36,352 +36,331 @@ 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
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 MAX_RECORD_SECONDS = 5 * 60
/* ── 组件 ───────────────────────────────────────────────── */
const CloneModal: React.FC<CloneModalProps> = ({
open,
onClose,
onSuccess,
}) => {
const [phase, setPhase] = useState<ModalPhase>("input");
const [voiceName, setVoiceName] = useState("");
const [voiceDescription, setVoiceDescription] = useState("");
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [dragActive, setDragActive] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
const CloneModal: React.FC<CloneModalProps> = ({ open, onClose, onSuccess }) => {
const [phase, setPhase] = useState<ModalPhase>("input")
const [voiceName, setVoiceName] = useState("")
const [voiceDescription, setVoiceDescription] = useState("")
const [selectedFile, setSelectedFile] = useState<File | null>(null)
const [dragActive, setDragActive] = useState(false)
const [errorMessage, setErrorMessage] = useState("")
// 录音状态
const [isRecording, setIsRecording] = useState(false);
const [recordTime, setRecordTime] = useState(0);
const [recordedBlob, setRecordedBlob] = useState<Blob | null>(null);
const [isRecording, setIsRecording] = useState(false)
const [recordTime, setRecordTime] = useState(0)
const [recordedBlob, setRecordedBlob] = useState<Blob | null>(null)
const fileInputRef = useRef<HTMLInputElement>(null);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const recordTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const audioChunksRef = useRef<Blob[]>([]);
const fileInputRef = useRef<HTMLInputElement>(null)
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const recordTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
const mediaRecorderRef = useRef<MediaRecorder | null>(null)
const audioChunksRef = useRef<Blob[]>([])
/** 默认音色名称计数器(组件级 ref,避免多实例串号) */
const cloneCounterRef = useRef(1);
const cloneCounterRef = useRef(1)
/** 生成下一个默认音色名称 */
const getNextDefaultName = useCallback((): string => {
const name = `我的声音 ${cloneCounterRef.current}`;
cloneCounterRef.current += 1;
return name;
}, []);
const name = `我的声音 ${cloneCounterRef.current}`
cloneCounterRef.current += 1
return name
}, [])
/** 组件卸载时清理定时器和 MediaRecorder */
useEffect(() => {
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
if (recordTimerRef.current) clearInterval(recordTimerRef.current);
if (
mediaRecorderRef.current &&
mediaRecorderRef.current.state !== "inactive"
) {
mediaRecorderRef.current.stop();
if (timerRef.current) clearTimeout(timerRef.current)
if (recordTimerRef.current) clearInterval(recordTimerRef.current)
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== "inactive") {
mediaRecorderRef.current.stop()
}
};
}, []);
}
}, [])
/** 重置弹窗状态 */
const resetState = useCallback(() => {
setPhase("input");
setVoiceName(getNextDefaultName());
setVoiceDescription("");
setSelectedFile(null);
setDragActive(false);
setErrorMessage("");
setIsRecording(false);
setRecordTime(0);
setRecordedBlob(null);
audioChunksRef.current = [];
setPhase("input")
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;
clearInterval(recordTimerRef.current)
recordTimerRef.current = null
}
if (
mediaRecorderRef.current &&
mediaRecorderRef.current.state !== "inactive"
) {
mediaRecorderRef.current.stop();
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== "inactive") {
mediaRecorderRef.current.stop()
}
mediaRecorderRef.current = null;
}, [getNextDefaultName]);
mediaRecorderRef.current = null
}, [getNextDefaultName])
/** 关闭弹窗 */
const handleClose = useCallback(() => {
resetState();
onClose();
}, [resetState, onClose]);
resetState()
onClose()
}, [resetState, onClose])
/** 弹窗打开时重置状态 */
useEffect(() => {
if (open) {
resetState();
resetState()
}
}, [open, resetState]);
}, [open, resetState])
/* ── 文件验证 ──────────────────────────────────────── */
const validateFile = (file: File): string | null => {
const ext = file.name.split(".").pop()?.toLowerCase();
const ext = file.name.split(".").pop()?.toLowerCase()
if (!ext || !ACCEPTED_EXTENSIONS.includes(ext)) {
return "不支持的音频格式,请上传 MP3、WAV 或 M4A 文件";
return "不支持的音频格式,请上传 MP3、WAV 或 M4A 文件"
}
if (file.size > MAX_FILE_SIZE) {
return "文件大小超过 10MB,请压缩后重试";
return "文件大小超过 10MB,请压缩后重试"
}
return null;
};
return null
}
/* ── 文件上传 ──────────────────────────────────────── */
const handleUploadClick = () => {
fileInputRef.current?.click();
};
fileInputRef.current?.click()
}
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
const file = e.target.files?.[0]
if (file) {
const error = validateFile(file);
const error = validateFile(file)
if (error) {
setErrorMessage(error);
setSelectedFile(null);
setErrorMessage(error)
setSelectedFile(null)
} else {
setErrorMessage("");
setSelectedFile(file);
setErrorMessage("")
setSelectedFile(file)
// 清除录音
setRecordedBlob(null);
setIsRecording(false);
setRecordTime(0);
setRecordedBlob(null)
setIsRecording(false)
setRecordTime(0)
}
}
e.target.value = "";
};
e.target.value = ""
}
/* ── 拖拽 ──────────────────────────────────────────── */
const handleDrag = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
e.preventDefault()
e.stopPropagation()
if (e.type === "dragenter" || e.type === "dragover") {
setDragActive(true);
setDragActive(true)
} else if (e.type === "dragleave") {
setDragActive(false);
setDragActive(false)
}
};
}
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setDragActive(false);
const file = e.dataTransfer.files?.[0];
e.preventDefault()
e.stopPropagation()
setDragActive(false)
const file = e.dataTransfer.files?.[0]
if (file) {
const error = validateFile(file);
const error = validateFile(file)
if (error) {
setErrorMessage(error);
setSelectedFile(null);
setErrorMessage(error)
setSelectedFile(null)
} else {
setErrorMessage("");
setSelectedFile(file);
setRecordedBlob(null);
setIsRecording(false);
setRecordTime(0);
setErrorMessage("")
setSelectedFile(file)
setRecordedBlob(null)
setIsRecording(false)
setRecordTime(0)
}
}
};
}
/* ── 录音(真实 MediaRecorder) ───────────────────── */
const handleRecord = async () => {
if (isRecording) {
// 停止录制
setIsRecording(false);
setIsRecording(false)
if (recordTimerRef.current) {
clearInterval(recordTimerRef.current);
recordTimerRef.current = null;
clearInterval(recordTimerRef.current)
recordTimerRef.current = null
}
if (
mediaRecorderRef.current &&
mediaRecorderRef.current.state !== "inactive"
) {
mediaRecorderRef.current.stop();
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 = [];
})
const mediaRecorder = new MediaRecorder(stream)
mediaRecorderRef.current = mediaRecorder
audioChunksRef.current = []
mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) {
audioChunksRef.current.push(event.data);
audioChunksRef.current.push(event.data)
}
};
}
mediaRecorder.onstop = () => {
const blob = new Blob(audioChunksRef.current, { type: "audio/webm" });
setRecordedBlob(blob);
const blob = new Blob(audioChunksRef.current, { type: "audio/webm" })
setRecordedBlob(blob)
// 清除上传的文件
setSelectedFile(null);
setSelectedFile(null)
// 停止音轨
stream.getTracks().forEach((track) => track.stop());
};
stream.getTracks().forEach((track) => track.stop())
}
mediaRecorder.start();
setIsRecording(true);
setRecordTime(0);
setRecordedBlob(null);
setErrorMessage("");
mediaRecorder.start()
setIsRecording(true)
setRecordTime(0)
setRecordedBlob(null)
setErrorMessage("")
recordTimerRef.current = setInterval(() => {
setRecordTime((prev) => {
const next = prev + 1;
const next = prev + 1
if (next >= MAX_RECORD_SECONDS) {
// 达到 5 分钟上限,自动停止录制
setTimeout(() => {
setIsRecording(false);
setIsRecording(false)
if (recordTimerRef.current) {
clearInterval(recordTimerRef.current);
recordTimerRef.current = null;
clearInterval(recordTimerRef.current)
recordTimerRef.current = null
}
if (
mediaRecorderRef.current &&
mediaRecorderRef.current.state !== "inactive"
) {
mediaRecorderRef.current.stop();
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== "inactive") {
mediaRecorderRef.current.stop()
}
setErrorMessage("已达最长录制时长(5分钟),已自动停止");
}, 0);
return MAX_RECORD_SECONDS;
setErrorMessage("已达最长录制时长(5分钟),已自动停止")
}, 0)
return MAX_RECORD_SECONDS
}
return next;
});
}, 1000);
return next
})
}, 1000)
} catch {
setErrorMessage("无法访问麦克风,请检查浏览器权限设置");
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 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 hasAudio = selectedFile !== null || recordedBlob !== null
const validateForm = (): string | null => {
const name = voiceName.trim();
const name = voiceName.trim()
if (!name) {
return "请输入音色名称";
return "请输入音色名称"
}
if (name.length < 2 || name.length > 20) {
return "音色名称需在 2-20 个字符之间";
return "音色名称需在 2-20 个字符之间"
}
if (!hasAudio) {
return "请上传音频文件或录制一段声音";
return "请上传音频文件或录制一段声音"
}
return null;
};
return null
}
/* ── 提交克隆 ──────────────────────────────────────── */
const handleSubmit = async () => {
const formError = validateForm();
const formError = validateForm()
if (formError) {
setErrorMessage(formError);
return;
setErrorMessage(formError)
return
}
setErrorMessage("");
setErrorMessage("")
try {
// 阶段 1:上传音频
setPhase("uploading");
setPhase("uploading")
let fileToUpload: File;
let fileToUpload: File
if (selectedFile) {
fileToUpload = selectedFile;
fileToUpload = selectedFile
} else {
// 将录音 Blob 转为 File
fileToUpload = new File(
[recordedBlob!],
`recorded-${Date.now()}.webm`,
{
type: "audio/webm",
},
);
fileToUpload = new File([recordedBlob!], `recorded-${Date.now()}.webm`, {
type: "audio/webm",
})
}
const formData = new FormData();
formData.append("file", fileToUpload);
const uploadResult = await uploadAsset(formData);
const formData = new FormData()
formData.append("file", fileToUpload)
const uploadResult = await uploadAsset(formData)
// 阶段 2:克隆
setPhase("cloning");
setPhase("cloning")
const result = await createVoiceClone({
name: voiceName.trim(),
description: voiceDescription.trim() || undefined,
audio_url: uploadResult.url,
});
})
// 阶段 3:完成
setPhase("done");
setPhase("done")
// 2秒后自动关闭
timerRef.current = setTimeout(() => {
onSuccess?.(toVoiceClone(result));
handleClose();
}, 2000);
onSuccess?.(toVoiceClone(result))
handleClose()
}, 2000)
} catch (err) {
setPhase("input");
setErrorMessage(err instanceof Error ? err.message : "克隆失败,请重试");
setPhase("input")
setErrorMessage(err instanceof Error ? err.message : "克隆失败,请重试")
}
};
}
/* ── 计算属性 ──────────────────────────────────────── */
const canSubmit =
voiceName.trim().length >= 2 && voiceName.trim().length <= 20 && hasAudio;
const canSubmit = voiceName.trim().length >= 2 && voiceName.trim().length <= 20 && hasAudio
const isProcessing = phase === "uploading" || phase === "cloning";
const isProcessing = phase === "uploading" || phase === "cloning"
/** 当前进度索引 */
const getProgressIndex = (): number => {
switch (phase) {
case "uploading":
return 0;
return 0
case "cloning":
return 1;
return 1
case "done":
return 2;
return 2
default:
return -1;
return -1
}
};
}
const progressIndex = getProgressIndex();
const progressIndex = getProgressIndex()
return (
<Modal
@@ -428,9 +407,7 @@ const CloneModal: React.FC<CloneModalProps> = ({
placeholder="输入音色名称(2-20字符)"
maxLength={20}
/>
<div className="xx-clonemodal-char-count">
{voiceName.length}/20
</div>
<div className="xx-clonemodal-char-count">{voiceName.length}/20</div>
</div>
{/* 上传区域 */}
@@ -444,17 +421,11 @@ const CloneModal: React.FC<CloneModalProps> = ({
onDragLeave={handleDrag}
onDrop={handleDrop}
>
<div className="xx-clonemodal-upload-icon">
{selectedFile ? "📄" : "🎵"}
</div>
<div className="xx-clonemodal-upload-icon">{selectedFile ? "📄" : "🎵"}</div>
<p className="xx-clonemodal-upload-title">
{selectedFile
? selectedFile.name
: "拖拽音频文件到此处,或点击上传"}
</p>
<p className="xx-clonemodal-upload-hint">
支持 MP3、WAV、M4A 格式,最大 10MB
{selectedFile ? selectedFile.name : "拖拽音频文件到此处,或点击上传"}
</p>
<p className="xx-clonemodal-upload-hint">支持 MP3、WAV、M4A 格式,最大 10MB</p>
<input
ref={fileInputRef}
type="file"
@@ -516,9 +487,7 @@ const CloneModal: React.FC<CloneModalProps> = ({
maxLength={100}
rows={3}
/>
<div className="xx-clonemodal-char-count">
{voiceDescription.length}/100
</div>
<div className="xx-clonemodal-char-count">{voiceDescription.length}/100</div>
</div>
{/* 错误提示 */}
@@ -532,9 +501,7 @@ const CloneModal: React.FC<CloneModalProps> = ({
{/* 提示 */}
<div className="xx-clonemodal-tip">
<span className="xx-clonemodal-tip-icon">💡</span>
<span>
建议上传 10 秒 ~ 3 分钟的清晰语音,环境安静、语速均匀效果最佳
</span>
<span>建议上传 10 秒 ~ 3 分钟的清晰语音,环境安静、语速均匀效果最佳</span>
</div>
{/* 底部按钮 */}
@@ -542,11 +509,7 @@ const CloneModal: React.FC<CloneModalProps> = ({
<Button buttonType="ghost" onClick={handleClose}>
取消
</Button>
<Button
buttonType="primary"
disabled={!canSubmit}
onClick={handleSubmit}
>
<Button buttonType="primary" disabled={!canSubmit} onClick={handleSubmit}>
🎤 开始克隆
</Button>
</div>
@@ -559,15 +522,15 @@ const CloneModal: React.FC<CloneModalProps> = ({
{/* 步骤指示器 */}
<div className="xx-clonemodal-steps-progress">
{PROGRESS_STEPS.map((step, idx) => {
const isActive = idx === progressIndex;
const isDone = idx < progressIndex;
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(" ");
.join(" ")
return (
<React.Fragment key={step.key}>
@@ -577,15 +540,11 @@ const CloneModal: React.FC<CloneModalProps> = ({
/>
)}
<div className={stepClass}>
<div className="xx-clonemodal-step-icon">
{isDone ? "✓" : step.icon}
</div>
<span className="xx-clonemodal-step-label">
{step.label}
</span>
<div className="xx-clonemodal-step-icon">{isDone ? "✓" : step.icon}</div>
<span className="xx-clonemodal-step-label">{step.label}</span>
</div>
</React.Fragment>
);
)
})}
</div>
@@ -595,20 +554,14 @@ const CloneModal: React.FC<CloneModalProps> = ({
<>
<div className="xx-clonemodal-progress-spinner" />
<p className="xx-clonemodal-progress-text">正在上传音频文件…</p>
<p className="xx-clonemodal-progress-sub">
请稍候,正在将音频上传至服务器
</p>
<p className="xx-clonemodal-progress-sub">请稍候,正在将音频上传至服务器</p>
</>
)}
{phase === "cloning" && (
<>
<div className="xx-clonemodal-progress-spinner xx-clonemodal-progress-spinner--cloning" />
<p className="xx-clonemodal-progress-text">
AI 正在克隆你的声音…
</p>
<p className="xx-clonemodal-progress-sub">
正在分析声音特征,生成专属音色模型
</p>
<p className="xx-clonemodal-progress-text">AI 正在克隆你的声音…</p>
<p className="xx-clonemodal-progress-sub">正在分析声音特征,生成专属音色模型</p>
</>
)}
</div>
@@ -643,7 +596,7 @@ const CloneModal: React.FC<CloneModalProps> = ({
</div>
)}
</Modal>
);
};
)
}
export default CloneModal;
export default CloneModal
@@ -49,8 +49,7 @@
flex-shrink: 0;
}
.xx-clonemodal-step:not(.xx-clonemodal-step--active)
.xx-clonemodal-step-number {
.xx-clonemodal-step:not(.xx-clonemodal-step--active) .xx-clonemodal-step-number {
background: var(--xx-color-border, #e5e7eb);
color: var(--xx-color-text-secondary, #6b7280);
}