feat(task-3.13): VoiceCloneModal 四阶段克隆弹窗 #151
@@ -0,0 +1,441 @@
|
||||
/**
|
||||
* 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 } from "@/api/voiceClone";
|
||||
import type { VoiceClone } from "@/api/voiceClone";
|
||||
import "./voice-clone-modal.css";
|
||||
|
||||
/* ── 类型定义 ───────────────────────────────────────────── */
|
||||
|
||||
/** 弹窗阶段:input=输入 | uploading=上传中 | cloning=克隆中 | done=完成 */
|
||||
type ModalPhase = "input" | "uploading" | "cloning" | "done";
|
||||
|
||||
export interface VoiceCloneModalProps {
|
||||
/** 弹窗是否可见 */
|
||||
open: boolean;
|
||||
/** 关闭弹窗回调 */
|
||||
onClose: () => void;
|
||||
/** 克隆成功回调(返回新创建的音色) */
|
||||
onSuccess?: (voice: VoiceClone) => void;
|
||||
}
|
||||
|
||||
/* ── 进度阶段配置 ─────────────────────────────────────────── */
|
||||
|
||||
const PROGRESS_STEPS: { key: ModalPhase; label: string; icon: string }[] = [
|
||||
{ key: "uploading", label: "上传中", icon: "📤" },
|
||||
{ key: "cloning", label: "克隆中", icon: "🧬" },
|
||||
{ key: "done", label: "完成", icon: "✅" },
|
||||
];
|
||||
|
||||
/* ── 默认音色名称计数器 ─────────────────────────────────── */
|
||||
|
||||
let cloneCounter = 1;
|
||||
|
||||
const getNextDefaultName = (): string => {
|
||||
const name = `我的声音 ${cloneCounter}`;
|
||||
cloneCounter += 1;
|
||||
return name;
|
||||
};
|
||||
|
||||
/* ── 支持的文件扩展名 ─────────────────────────────────── */
|
||||
|
||||
const ACCEPTED_EXTENSIONS = ["mp3", "wav", "m4a", "aac", "ogg"];
|
||||
const ACCEPTED_MIME = ".mp3,.wav,.m4a,.aac,.ogg,audio/mpeg,audio/wav,audio/mp4,audio/aac,audio/ogg";
|
||||
|
||||
/* ── 组件 ───────────────────────────────────────────────── */
|
||||
|
||||
const VoiceCloneModal: React.FC<VoiceCloneModalProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}) => {
|
||||
const [phase, setPhase] = useState<ModalPhase>("input");
|
||||
const [voiceName, setVoiceName] = useState("");
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [dragActive, setDragActive] = useState(false);
|
||||
const [recordTime, setRecordTime] = useState(0);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const recordTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
/** 重置弹窗状态 */
|
||||
const resetState = useCallback(() => {
|
||||
setPhase("input");
|
||||
setVoiceName("");
|
||||
setSelectedFile(null);
|
||||
setIsRecording(false);
|
||||
setDragActive(false);
|
||||
setRecordTime(0);
|
||||
if (recordTimerRef.current) {
|
||||
clearInterval(recordTimerRef.current);
|
||||
recordTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
/** 关闭弹窗 */
|
||||
const handleClose = useCallback(() => {
|
||||
resetState();
|
||||
onClose();
|
||||
}, [resetState, onClose]);
|
||||
|
||||
/** 弹窗打开时初始化默认名称 */
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setVoiceName(getNextDefaultName());
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
/** 清理录制计时器 */
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (recordTimerRef.current) {
|
||||
clearInterval(recordTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
/* ── 文件上传 ──────────────────────────────────────── */
|
||||
|
||||
const handleUploadClick = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
setSelectedFile(file);
|
||||
setIsRecording(false);
|
||||
setRecordTime(0);
|
||||
}
|
||||
e.target.value = "";
|
||||
};
|
||||
|
||||
/* ── 拖拽 ──────────────────────────────────────────── */
|
||||
|
||||
const handleDrag = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (e.type === "dragenter" || e.type === "dragover") {
|
||||
setDragActive(true);
|
||||
} else if (e.type === "dragleave") {
|
||||
setDragActive(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragActive(false);
|
||||
const file = e.dataTransfer.files?.[0];
|
||||
if (file) {
|
||||
const ext = file.name.split(".").pop()?.toLowerCase();
|
||||
if (ext && ACCEPTED_EXTENSIONS.includes(ext)) {
|
||||
setSelectedFile(file);
|
||||
setIsRecording(false);
|
||||
setRecordTime(0);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/* ── 录制(mock) ──────────────────────────────────── */
|
||||
|
||||
const handleRecord = () => {
|
||||
if (isRecording) {
|
||||
// 停止录制
|
||||
setIsRecording(false);
|
||||
if (recordTimerRef.current) {
|
||||
clearInterval(recordTimerRef.current);
|
||||
recordTimerRef.current = null;
|
||||
}
|
||||
} else {
|
||||
// 开始录制
|
||||
setIsRecording(true);
|
||||
setSelectedFile(null);
|
||||
setRecordTime(0);
|
||||
recordTimerRef.current = setInterval(() => {
|
||||
setRecordTime((prev) => prev + 1);
|
||||
}, 1000);
|
||||
}
|
||||
};
|
||||
|
||||
/** 格式化录制时间 mm:ss */
|
||||
const formatRecordTime = (seconds: number): string => {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = seconds % 60;
|
||||
return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
/* ── 提交克隆 ──────────────────────────────────────── */
|
||||
|
||||
const handleStartClone = async () => {
|
||||
const name = voiceName.trim() || getNextDefaultName();
|
||||
|
||||
// 阶段 1:上传中
|
||||
setPhase("uploading");
|
||||
await new Promise((r) => setTimeout(r, 1200));
|
||||
|
||||
// 阶段 2:克隆中
|
||||
setPhase("cloning");
|
||||
try {
|
||||
const result = await createVoiceClone({
|
||||
name,
|
||||
audio_url: selectedFile
|
||||
? `mock://${selectedFile.name}`
|
||||
: "mock://recorded-audio",
|
||||
});
|
||||
|
||||
// 阶段 3:完成
|
||||
setPhase("done");
|
||||
|
||||
// 2秒后自动关闭
|
||||
setTimeout(() => {
|
||||
onSuccess?.(result);
|
||||
handleClose();
|
||||
}, 2000);
|
||||
} catch {
|
||||
setPhase("input");
|
||||
}
|
||||
};
|
||||
|
||||
/** 当前进度索引(用于进度条展示) */
|
||||
const getProgressIndex = (): number => {
|
||||
switch (phase) {
|
||||
case "uploading":
|
||||
return 0;
|
||||
case "cloning":
|
||||
return 1;
|
||||
case "done":
|
||||
return 2;
|
||||
default:
|
||||
return -1;
|
||||
}
|
||||
};
|
||||
|
||||
const canStart = selectedFile || isRecording;
|
||||
const progressIndex = getProgressIndex();
|
||||
const isProcessing = phase === "uploading" || phase === "cloning";
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onCancel={handleClose}
|
||||
title="🎤 克隆新音色"
|
||||
width={540}
|
||||
footer={null}
|
||||
destroyOnClose
|
||||
maskClosable={!isProcessing}
|
||||
keyboard={!isProcessing}
|
||||
>
|
||||
{/* ── 输入阶段 ──────────────────────────────────── */}
|
||||
{phase === "input" && (
|
||||
<div className="xx-vcmodal-body">
|
||||
{/* 音色名称 */}
|
||||
<div className="xx-vcmodal-field">
|
||||
<label className="xx-vcmodal-label">音色名称</label>
|
||||
<input
|
||||
type="text"
|
||||
className="xx-vcmodal-input"
|
||||
value={voiceName}
|
||||
onChange={(e) => setVoiceName(e.target.value)}
|
||||
placeholder="输入音色名称"
|
||||
maxLength={30}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 上传区域 */}
|
||||
<div className="xx-vcmodal-field">
|
||||
<label className="xx-vcmodal-label">上传音频</label>
|
||||
<div
|
||||
className={`xx-vcmodal-upload-zone${dragActive ? " xx-vcmodal-upload-zone--active" : ""}${selectedFile ? " xx-vcmodal-upload-zone--has-file" : ""}`}
|
||||
onClick={handleUploadClick}
|
||||
onDragEnter={handleDrag}
|
||||
onDragOver={handleDrag}
|
||||
onDragLeave={handleDrag}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<div className="xx-vcmodal-upload-icon">
|
||||
{selectedFile ? "📄" : "🎵"}
|
||||
</div>
|
||||
<p className="xx-vcmodal-upload-title">
|
||||
{selectedFile
|
||||
? selectedFile.name
|
||||
: "拖拽音频文件到此处,或点击上传"}
|
||||
</p>
|
||||
<p className="xx-vcmodal-upload-hint">
|
||||
支持 MP3、WAV、M4A、AAC、OGG 格式
|
||||
</p>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept={ACCEPTED_MIME}
|
||||
style={{ display: "none" }}
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 或分隔 */}
|
||||
<div className="xx-vcmodal-divider">
|
||||
<div className="xx-vcmodal-divider-line" />
|
||||
<span className="xx-vcmodal-divider-text">或</span>
|
||||
<div className="xx-vcmodal-divider-line" />
|
||||
</div>
|
||||
|
||||
{/* 录制区域 */}
|
||||
<div className="xx-vcmodal-field">
|
||||
<label className="xx-vcmodal-label">直接录制</label>
|
||||
<div className="xx-vcmodal-record-area">
|
||||
<div className="xx-vcmodal-record-info">
|
||||
<p className="xx-vcmodal-record-hint">
|
||||
{isRecording
|
||||
? `录制中 ${formatRecordTime(recordTime)}`
|
||||
: "点击按钮开始录制你的声音"}
|
||||
</p>
|
||||
{isRecording && (
|
||||
<div className="xx-vcmodal-record-wave">
|
||||
<span className="xx-vcmodal-record-wave-bar" />
|
||||
<span className="xx-vcmodal-record-wave-bar" />
|
||||
<span className="xx-vcmodal-record-wave-bar" />
|
||||
<span className="xx-vcmodal-record-wave-bar" />
|
||||
<span className="xx-vcmodal-record-wave-bar" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={`xx-vcmodal-record-btn${isRecording ? " xx-vcmodal-record-btn--recording" : ""}`}
|
||||
onClick={handleRecord}
|
||||
title={isRecording ? "停止录制" : "开始录制"}
|
||||
>
|
||||
{isRecording ? "⏹" : "🎙️"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 提示 */}
|
||||
<div className="xx-vcmodal-tip">
|
||||
<span className="xx-vcmodal-tip-icon">💡</span>
|
||||
<span>
|
||||
建议上传 10 秒 ~ 3 分钟的清晰语音,环境安静、语速均匀效果最佳
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 底部按钮 */}
|
||||
<div className="xx-vcmodal-footer">
|
||||
<Button buttonType="ghost" onClick={handleClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
buttonType="primary"
|
||||
disabled={!canStart}
|
||||
onClick={handleStartClone}
|
||||
>
|
||||
🎤 开始克隆
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── 进度阶段(上传中 / 克隆中 / 完成) ─────── */}
|
||||
{isProcessing && (
|
||||
<div className="xx-vcmodal-progress-body">
|
||||
{/* 步骤指示器 */}
|
||||
<div className="xx-vcmodal-steps">
|
||||
{PROGRESS_STEPS.map((step, idx) => {
|
||||
const isActive = idx === progressIndex;
|
||||
const isDone = idx < progressIndex;
|
||||
const stepClass = [
|
||||
"xx-vcmodal-step",
|
||||
isActive ? "xx-vcmodal-step--active" : "",
|
||||
isDone ? "xx-vcmodal-step--done" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
return (
|
||||
<React.Fragment key={step.key}>
|
||||
{idx > 0 && (
|
||||
<div
|
||||
className={`xx-vcmodal-step-connector${isDone ? " xx-vcmodal-step-connector--done" : ""}`}
|
||||
/>
|
||||
)}
|
||||
<div className={stepClass}>
|
||||
<div className="xx-vcmodal-step-icon">
|
||||
{isDone ? "✓" : step.icon}
|
||||
</div>
|
||||
<span className="xx-vcmodal-step-label">
|
||||
{step.label}
|
||||
</span>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 当前阶段描述 */}
|
||||
<div className="xx-vcmodal-progress-info">
|
||||
{phase === "uploading" && (
|
||||
<>
|
||||
<div className="xx-vcmodal-progress-spinner" />
|
||||
<p className="xx-vcmodal-progress-text">正在上传音频文件…</p>
|
||||
<p className="xx-vcmodal-progress-sub">
|
||||
请稍候,正在将音频上传至服务器
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
{phase === "cloning" && (
|
||||
<>
|
||||
<div className="xx-vcmodal-progress-spinner xx-vcmodal-progress-spinner--cloning" />
|
||||
<p className="xx-vcmodal-progress-text">
|
||||
AI 正在克隆你的声音…
|
||||
</p>
|
||||
<p className="xx-vcmodal-progress-sub">
|
||||
正在分析声音特征,生成专属音色模型
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── 完成阶段 ──────────────────────────────────── */}
|
||||
{phase === "done" && (
|
||||
<div className="xx-vcmodal-progress-body">
|
||||
{/* 步骤指示器(全部完成) */}
|
||||
<div className="xx-vcmodal-steps">
|
||||
{PROGRESS_STEPS.map((step, idx) => (
|
||||
<React.Fragment key={step.key}>
|
||||
{idx > 0 && (
|
||||
<div className="xx-vcmodal-step-connector xx-vcmodal-step-connector--done" />
|
||||
)}
|
||||
<div className="xx-vcmodal-step xx-vcmodal-step--done">
|
||||
<div className="xx-vcmodal-step-icon">✓</div>
|
||||
<span className="xx-vcmodal-step-label">{step.label}</span>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="xx-vcmodal-success">
|
||||
<div className="xx-vcmodal-success-icon">🎉</div>
|
||||
<h3 className="xx-vcmodal-success-title">克隆已提交</h3>
|
||||
<p className="xx-vcmodal-success-desc">
|
||||
音色正在生成中,完成后将出现在「我的音色库」列表中
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default VoiceCloneModal;
|
||||
@@ -0,0 +1,471 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user