feat(voices): 任务3.12 克隆音色卡片组件 #170
@@ -13,9 +13,13 @@ export type VoiceCloneStatus = "ready" | "processing" | "failed";
|
||||
export interface VoiceClone {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
duration_seconds: number;
|
||||
status: VoiceCloneStatus;
|
||||
sample_url?: string;
|
||||
language: string;
|
||||
gender: string;
|
||||
error_message: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
@@ -84,9 +88,13 @@ export interface CreateVoiceCloneRequestFull {
|
||||
export const toVoiceClone = (profile: VoiceCloneProfile): VoiceClone => ({
|
||||
id: profile.id,
|
||||
name: profile.name,
|
||||
description: profile.description || "",
|
||||
duration_seconds: 0,
|
||||
status: profile.status === "pending" ? "processing" : profile.status,
|
||||
sample_url: profile.source_audio_url || undefined,
|
||||
language: profile.language || "",
|
||||
gender: profile.gender || "",
|
||||
error_message: profile.error_message || null,
|
||||
created_at: profile.created_at,
|
||||
updated_at: profile.updated_at,
|
||||
});
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
*/
|
||||
import React, { useMemo, useState, useRef, useEffect, useCallback } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
SoundOutlined,
|
||||
PlayCircleOutlined,
|
||||
@@ -21,8 +21,11 @@ import {
|
||||
AudioOutlined,
|
||||
HeartOutlined,
|
||||
UserOutlined,
|
||||
DeleteOutlined,
|
||||
ReloadOutlined,
|
||||
CloseCircleOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { Button, Input, Select } from "@/components/ui";
|
||||
import { Button, Input, Select, Tooltip } from "@/components/ui";
|
||||
import PageHead from "@/components/layout/PageHead";
|
||||
import {
|
||||
fetchPresetVoices,
|
||||
@@ -31,6 +34,8 @@ import {
|
||||
} from "@/api/voices";
|
||||
import {
|
||||
getVoiceClonesWithTotal,
|
||||
deleteVoiceClone,
|
||||
retryVoiceClone,
|
||||
toVoiceClone,
|
||||
type VoiceClone,
|
||||
} from "@/api/voiceClone";
|
||||
@@ -61,12 +66,17 @@ interface PresetVoiceDisplay {
|
||||
interface ClonedVoiceDisplay {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
sourceName: string;
|
||||
status: "ready" | "processing" | "failed";
|
||||
createdAt: string;
|
||||
duration: number;
|
||||
tags: string[];
|
||||
voiceId: string;
|
||||
language: string;
|
||||
gender: string;
|
||||
errorMessage: string | null;
|
||||
sampleUrl?: string;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
@@ -89,12 +99,17 @@ const mapPresetToDisplay = (item: PresetVoiceItem): PresetVoiceDisplay => ({
|
||||
const mapCloneToDisplay = (clone: VoiceClone): ClonedVoiceDisplay => ({
|
||||
id: clone.id,
|
||||
name: clone.name,
|
||||
description: clone.description || "",
|
||||
sourceName: clone.sample_url || "未知来源",
|
||||
status: clone.status,
|
||||
createdAt: new Date(clone.created_at).toLocaleDateString("zh-CN"),
|
||||
duration: clone.duration_seconds,
|
||||
tags: [],
|
||||
voiceId: clone.id,
|
||||
language: clone.language || "",
|
||||
gender: clone.gender || "",
|
||||
errorMessage: clone.error_message || null,
|
||||
sampleUrl: clone.sample_url || undefined,
|
||||
});
|
||||
|
||||
/* ============================================================
|
||||
@@ -227,6 +242,270 @@ const VoiceCard: React.FC<VoiceCardProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
/* ============================================================
|
||||
* 克隆音色卡片组件(任务 3.12)
|
||||
* ============================================================ */
|
||||
|
||||
/** 状态配置 */
|
||||
const CLONE_STATUS_CONFIG: Record<
|
||||
ClonedVoiceDisplay["status"],
|
||||
{ label: string; className: string }
|
||||
> = {
|
||||
ready: { label: "可用", className: "xx-clone-status--ready" },
|
||||
processing: { label: "处理中", className: "xx-clone-status--processing" },
|
||||
failed: { label: "失败", className: "xx-clone-status--failed" },
|
||||
};
|
||||
|
||||
/** Toast 类型 */
|
||||
interface Toast {
|
||||
id: number;
|
||||
message: string;
|
||||
type: "success" | "error";
|
||||
}
|
||||
|
||||
let toastIdSeq = 0;
|
||||
|
||||
/** 克隆音色卡片 */
|
||||
interface CloneVoiceCardProps {
|
||||
voice: ClonedVoiceDisplay;
|
||||
isPlaying: boolean;
|
||||
currentTime: number;
|
||||
onPlay: () => void;
|
||||
onPause: () => void;
|
||||
onUse: () => void;
|
||||
onDelete: () => void;
|
||||
onRetry: () => void;
|
||||
onShowDetail: () => void;
|
||||
}
|
||||
|
||||
const CloneVoiceCard: React.FC<CloneVoiceCardProps> = ({
|
||||
voice,
|
||||
isPlaying,
|
||||
currentTime,
|
||||
onPlay,
|
||||
onPause,
|
||||
onUse,
|
||||
onDelete,
|
||||
onRetry,
|
||||
onShowDetail,
|
||||
}) => {
|
||||
const statusCfg = CLONE_STATUS_CONFIG[voice.status];
|
||||
const isFailed = voice.status === "failed";
|
||||
const isProcessing = voice.status === "processing";
|
||||
const genderText = voice.gender === "male" ? "男声" : voice.gender === "female" ? "女声" : voice.gender;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`xx-clone-card${isPlaying ? " playing" : ""}${isFailed ? " failed" : ""}`}
|
||||
onClick={isFailed ? undefined : onShowDetail}
|
||||
>
|
||||
{/* 右上角操作按钮 */}
|
||||
<div className="xx-clone-card-actions">
|
||||
<Tooltip title="删除">
|
||||
<button
|
||||
type="button"
|
||||
className="xx-clone-action-btn xx-clone-action-btn--danger"
|
||||
onClick={(e) => { e.stopPropagation(); onDelete(); }}
|
||||
>
|
||||
<DeleteOutlined />
|
||||
</button>
|
||||
</Tooltip>
|
||||
{isFailed && (
|
||||
<Tooltip title="重试">
|
||||
<button
|
||||
type="button"
|
||||
className="xx-clone-action-btn"
|
||||
onClick={(e) => { e.stopPropagation(); onRetry(); }}
|
||||
>
|
||||
<ReloadOutlined />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 头部:头像 + 名称 + 状态 */}
|
||||
<div className="xx-clone-card-header">
|
||||
<div className={`xx-clone-avatar${isProcessing ? " xx-clone-avatar--processing" : ""}`}>
|
||||
<SoundOutlined />
|
||||
</div>
|
||||
<div className="xx-clone-header-info">
|
||||
<h4 className="xx-clone-name" title={voice.name}>{voice.name}</h4>
|
||||
<span className={`xx-clone-status ${statusCfg.className}`}>
|
||||
<span className="xx-clone-status-dot" />
|
||||
{statusCfg.label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 描述 */}
|
||||
{voice.description && (
|
||||
<p className="xx-clone-desc">{voice.description}</p>
|
||||
)}
|
||||
|
||||
{/* 元信息 */}
|
||||
<div className="xx-clone-meta">
|
||||
{(voice.gender || voice.language) && (
|
||||
<span className="xx-clone-meta-item">
|
||||
<UserOutlined />
|
||||
{genderText}{voice.language ? ` · ${voice.language}` : ""}
|
||||
</span>
|
||||
)}
|
||||
<span className="xx-clone-meta-item">
|
||||
{voice.createdAt}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 错误信息 */}
|
||||
{isFailed && voice.errorMessage && (
|
||||
<div className="xx-clone-error">
|
||||
<CloseCircleOutlined />
|
||||
<span>{voice.errorMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 底部操作区 */}
|
||||
<div className="xx-clone-footer">
|
||||
{voice.status === "ready" && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="xx-clone-play-btn"
|
||||
onClick={(e) => { e.stopPropagation(); isPlaying ? onPause() : onPlay(); }}
|
||||
title={isPlaying ? "暂停" : "试听"}
|
||||
>
|
||||
{isPlaying ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
|
||||
</button>
|
||||
<div className="xx-clone-progress">
|
||||
<div
|
||||
className="xx-clone-progress-bar"
|
||||
style={{ width: isPlaying ? `${Math.min((currentTime / Math.max(voice.duration, 1)) * 100, 100)}%` : "0%" }}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="xx-clone-use-btn"
|
||||
onClick={(e) => { e.stopPropagation(); onUse(); }}
|
||||
>
|
||||
使用
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{isProcessing && (
|
||||
<div className="xx-clone-processing-hint">
|
||||
<ReloadOutlined spin />
|
||||
克隆处理中,请稍候...
|
||||
</div>
|
||||
)}
|
||||
{isFailed && (
|
||||
<button
|
||||
type="button"
|
||||
className="xx-clone-retry-btn"
|
||||
onClick={(e) => { e.stopPropagation(); onRetry(); }}
|
||||
>
|
||||
<ReloadOutlined />
|
||||
重试克隆
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/** 克隆音色详情弹窗 */
|
||||
const CloneDetailModal: React.FC<{
|
||||
voice: ClonedVoiceDisplay;
|
||||
onClose: () => void;
|
||||
onUse: () => void;
|
||||
onDelete: () => void;
|
||||
onRetry: () => void;
|
||||
}> = ({ voice, onClose, onUse, onDelete, onRetry }) => {
|
||||
const statusCfg = CLONE_STATUS_CONFIG[voice.status];
|
||||
const genderText = voice.gender === "male" ? "男声" : voice.gender === "female" ? "女声" : voice.gender || "未知";
|
||||
const langText = voice.language || "未知";
|
||||
|
||||
return (
|
||||
<div className="xx-clone-detail-overlay" onClick={onClose}>
|
||||
<div className="xx-clone-detail" onClick={(e) => e.stopPropagation()}>
|
||||
<button type="button" className="xx-clone-detail-close" onClick={onClose}>
|
||||
<CloseCircleOutlined />
|
||||
</button>
|
||||
|
||||
<div className="xx-clone-detail-header">
|
||||
<div className="xx-clone-avatar">
|
||||
<SoundOutlined />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="xx-clone-detail-name">{voice.name}</h3>
|
||||
<span className={`xx-clone-status ${statusCfg.className}`}>
|
||||
<span className="xx-clone-status-dot" />
|
||||
{statusCfg.label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{voice.description && (
|
||||
<p className="xx-clone-detail-desc">{voice.description}</p>
|
||||
)}
|
||||
|
||||
<div className="xx-clone-detail-info">
|
||||
<div className="xx-clone-detail-row">
|
||||
<span className="xx-clone-detail-label">性别</span>
|
||||
<span>{genderText}</span>
|
||||
</div>
|
||||
<div className="xx-clone-detail-row">
|
||||
<span className="xx-clone-detail-label">语言</span>
|
||||
<span>{langText}</span>
|
||||
</div>
|
||||
<div className="xx-clone-detail-row">
|
||||
<span className="xx-clone-detail-label">来源</span>
|
||||
<span>{voice.sourceName}</span>
|
||||
</div>
|
||||
<div className="xx-clone-detail-row">
|
||||
<span className="xx-clone-detail-label">创建时间</span>
|
||||
<span>{voice.createdAt}</span>
|
||||
</div>
|
||||
{voice.errorMessage && (
|
||||
<div className="xx-clone-detail-row" style={{ color: "var(--error-color, #ef4444)" }}>
|
||||
<span className="xx-clone-detail-label">错误</span>
|
||||
<span>{voice.errorMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="xx-clone-detail-actions">
|
||||
{voice.status === "failed" && (
|
||||
<Button buttonType="ghost" buttonSize="sm" icon={<ReloadOutlined />} onClick={onRetry}>
|
||||
重试
|
||||
</Button>
|
||||
)}
|
||||
<Button buttonType="ghost" buttonSize="sm" icon={<DeleteOutlined />} onClick={onDelete}>
|
||||
删除
|
||||
</Button>
|
||||
{voice.status === "ready" && (
|
||||
<Button buttonType="primary" buttonSize="sm" onClick={onUse}>
|
||||
使用此音色
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/** 骨架屏 */
|
||||
const CloneCardSkeleton: React.FC = () => (
|
||||
<div className="xx-voice-card xx-skeleton-clone">
|
||||
<div className="xx-skeleton-clone-avatar" />
|
||||
<div className="xx-skeleton-clone-info">
|
||||
<div className="xx-skeleton-clone-line xx-skeleton-clone-line--name" />
|
||||
<div className="xx-skeleton-clone-line xx-skeleton-clone-line--status" />
|
||||
<div className="xx-skeleton-clone-line xx-skeleton-clone-line--desc" />
|
||||
<div className="xx-skeleton-clone-line xx-skeleton-clone-line--meta" />
|
||||
<div className="xx-skeleton-clone-line xx-skeleton-clone-line--footer" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
/* ============================================================
|
||||
* 主组件
|
||||
* ============================================================ */
|
||||
@@ -242,6 +521,42 @@ const VoiceLibrary: React.FC = () => {
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const intervalRef = useRef<number | null>(null);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const [detailVoice, setDetailVoice] = useState<ClonedVoiceDisplay | null>(null);
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
|
||||
const showToast = useCallback((message: string, type: Toast["type"]) => {
|
||||
const id = ++toastIdSeq;
|
||||
setToasts((prev) => [...prev, { id, message, type }]);
|
||||
setTimeout(() => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||
}, 3000);
|
||||
}, []);
|
||||
|
||||
/** 删除克隆音色 */
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: deleteVoiceClone,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["voice-clones"] });
|
||||
showToast("音色已删除", "success");
|
||||
},
|
||||
onError: () => {
|
||||
showToast("删除失败", "error");
|
||||
},
|
||||
});
|
||||
|
||||
/** 重试克隆 */
|
||||
const retryMutation = useMutation({
|
||||
mutationFn: retryVoiceClone,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["voice-clones"] });
|
||||
showToast("已重新提交克隆", "success");
|
||||
},
|
||||
onError: () => {
|
||||
showToast("重试失败", "error");
|
||||
},
|
||||
});
|
||||
|
||||
/* ── 数据查询(任务 3.11:替换 Mock) ──────────────── */
|
||||
|
||||
/** 预置音色列表 */
|
||||
@@ -342,6 +657,34 @@ const VoiceLibrary: React.FC = () => {
|
||||
// TODO: 后端暂未提供收藏接口
|
||||
};
|
||||
|
||||
/** 克隆音色操作 handlers */
|
||||
const handleCloneDelete = (voice: ClonedVoiceDisplay) => {
|
||||
if (window.confirm(`确定删除音色「${voice.name}」吗?`)) {
|
||||
deleteMutation.mutate(voice.id);
|
||||
if (detailVoice?.id === voice.id) setDetailVoice(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloneRetry = (voice: ClonedVoiceDisplay) => {
|
||||
retryMutation.mutate(voice.id);
|
||||
};
|
||||
|
||||
const handleCloneUse = (_voice: ClonedVoiceDisplay) => {
|
||||
showToast("已选择音色", "success");
|
||||
};
|
||||
|
||||
const handleShowDetail = (voice: ClonedVoiceDisplay) => {
|
||||
setDetailVoice(voice);
|
||||
};
|
||||
|
||||
const handleClonePlayPause = (voiceId: string, duration: number) => {
|
||||
if (playingId === voiceId) {
|
||||
handlePause();
|
||||
} else {
|
||||
handlePlay(voiceId, duration);
|
||||
}
|
||||
};
|
||||
|
||||
const pageActions = (
|
||||
<div className="xx-voices-actions">
|
||||
<Button buttonType="ghost" buttonSize="sm" icon={<UploadOutlined />}>
|
||||
@@ -462,36 +805,36 @@ const VoiceLibrary: React.FC = () => {
|
||||
|
||||
{activeTab === "cloned" && (
|
||||
<div className="xx-voices-tab-content">
|
||||
{/* 骨架屏加载 */}
|
||||
{cloneLoading && (
|
||||
<div className="xx-voices-empty">
|
||||
<div className="xx-voices-empty-icon"><UserOutlined /></div>
|
||||
<p>加载克隆音色中...</p>
|
||||
<div className="xx-voice-grid">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<CloneCardSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 卡片列表 */}
|
||||
{!cloneLoading && clonedVoices.length > 0 && (
|
||||
<div className="xx-voice-grid">
|
||||
{clonedVoices.map((voice) => (
|
||||
<VoiceCard
|
||||
<CloneVoiceCard
|
||||
key={voice.id}
|
||||
id={voice.id}
|
||||
name={voice.name}
|
||||
subtitle={`来源: ${voice.sourceName} · ${voice.createdAt}`}
|
||||
tags={voice.tags}
|
||||
duration={voice.duration}
|
||||
gender="female"
|
||||
voice={voice}
|
||||
isPlaying={playingId === voice.id}
|
||||
isSelected={false}
|
||||
currentTime={playingId === voice.id ? currentTime : 0}
|
||||
status={voice.status}
|
||||
onPlay={() => handlePlay(voice.id, voice.duration)}
|
||||
onPlay={() => handleClonePlayPause(voice.id, voice.duration)}
|
||||
onPause={handlePause}
|
||||
onSeek={(time) => handleSeek(voice.id, time, voice.duration)}
|
||||
onUse={() => handleCloneUse(voice)}
|
||||
onDelete={() => handleCloneDelete(voice)}
|
||||
onRetry={() => handleCloneRetry(voice)}
|
||||
onShowDetail={() => handleShowDetail(voice)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 空状态 */}
|
||||
{!cloneLoading && clonedVoices.length === 0 && (
|
||||
<div className="xx-voices-empty">
|
||||
<div className="xx-voices-empty-icon"><UserOutlined /></div>
|
||||
@@ -503,14 +846,37 @@ const VoiceLibrary: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{clonedVoices.some((v) => v.status === "processing") && (
|
||||
<div className="xx-voices-notice">
|
||||
{/* 处理中提示 */}
|
||||
{!cloneLoading && clonedVoices.some((v) => v.status === "processing") && (
|
||||
<div className="xx-clone-processing-hint">
|
||||
<RobotOutlined />
|
||||
<span>部分音色正在克隆处理中,完成后将自动出现在列表中。</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 详情弹窗 */}
|
||||
{detailVoice && (
|
||||
<CloneDetailModal
|
||||
voice={detailVoice}
|
||||
onClose={() => setDetailVoice(null)}
|
||||
onDelete={() => handleCloneDelete(detailVoice)}
|
||||
onRetry={() => handleCloneRetry(detailVoice)}
|
||||
onUse={() => handleCloneUse(detailVoice)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Toast 提示 */}
|
||||
{toasts.length > 0 && (
|
||||
<div className="vc-toast-container">
|
||||
{toasts.map((t) => (
|
||||
<div key={t.id} className={`vc-toast vc-toast--${t.type}`}>
|
||||
{t.type === "success" ? "✅" : "❌"} {t.message}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -370,6 +370,457 @@
|
||||
border: 1px solid color-mix(in srgb, var(--primary-color) 20%, transparent);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 克隆音色卡片(任务 3.12)
|
||||
* ============================================================ */
|
||||
|
||||
.xx-clone-card {
|
||||
position: relative;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-md);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
transition: var(--transition-all);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.xx-clone-card:hover {
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.xx-clone-card:hover .xx-clone-card-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.xx-clone-card.playing {
|
||||
border-color: var(--secondary-color);
|
||||
}
|
||||
|
||||
.xx-clone-card.failed {
|
||||
border-color: var(--error-color, #ef4444);
|
||||
border-style: dashed;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* 右上角操作按钮 */
|
||||
.xx-clone-card-actions {
|
||||
position: absolute;
|
||||
top: var(--space-sm);
|
||||
right: var(--space-sm);
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.xx-clone-action-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: var(--transition-all);
|
||||
}
|
||||
|
||||
.xx-clone-action-btn:hover {
|
||||
background: var(--bg-hover, #f1f5f9);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.xx-clone-action-btn--danger:hover {
|
||||
background: var(--error-soft, #fef2f2);
|
||||
color: var(--error-color, #ef4444);
|
||||
}
|
||||
|
||||
/* 卡片头部 */
|
||||
.xx-clone-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.xx-clone-avatar {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: var(--radius-full);
|
||||
background: linear-gradient(135deg, var(--color-primary-700) 0%, var(--primary-color) 50%, var(--color-primary-400) 100%);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--text-inverse);
|
||||
font-size: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.xx-clone-avatar--processing {
|
||||
background: linear-gradient(135deg, var(--text-tertiary), var(--text-secondary));
|
||||
animation: xx-clone-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes xx-clone-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
.xx-clone-header-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.xx-clone-name {
|
||||
margin: 0 0 4px;
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* 状态标签 */
|
||||
.xx-clone-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 2px 10px;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.xx-clone-status--ready {
|
||||
background: var(--success-bg, #f0fdf4);
|
||||
color: var(--success-color, #22c55e);
|
||||
}
|
||||
|
||||
.xx-clone-status--processing {
|
||||
background: var(--warning-soft, #fffbeb);
|
||||
color: var(--warning-color, #f59e0b);
|
||||
}
|
||||
|
||||
.xx-clone-status--failed {
|
||||
background: var(--error-soft, #fef2f2);
|
||||
color: var(--error-color, #ef4444);
|
||||
}
|
||||
|
||||
.xx-clone-status-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: var(--radius-full);
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
.xx-clone-status--processing .xx-clone-status-dot {
|
||||
animation: xx-clone-blink 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes xx-clone-blink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.3; }
|
||||
}
|
||||
|
||||
/* 描述 */
|
||||
.xx-clone-desc {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-secondary);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* 元信息 */
|
||||
.xx-clone-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.xx-clone-meta-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
/* 错误信息 */
|
||||
.xx-clone-error {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
background: var(--error-soft, #fef2f2);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--error-color, #ef4444);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.xx-clone-error span {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* 底部操作区 */
|
||||
.xx-clone-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
padding-top: var(--space-xs);
|
||||
border-top: 1px solid var(--border-color);
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.xx-clone-play-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--gradient-primary);
|
||||
color: var(--text-inverse);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: var(--font-size-md);
|
||||
cursor: pointer;
|
||||
transition: var(--transition-all);
|
||||
border: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.xx-clone-play-btn:hover {
|
||||
transform: scale(1.1);
|
||||
box-shadow: var(--shadow-primary);
|
||||
}
|
||||
|
||||
.xx-clone-play-btn:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.xx-clone-progress {
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: var(--radius-full);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.xx-clone-progress-bar {
|
||||
height: 100%;
|
||||
background: var(--gradient-primary);
|
||||
border-radius: var(--radius-full);
|
||||
transition: width 0.1s linear;
|
||||
}
|
||||
|
||||
.xx-clone-use-btn {
|
||||
margin-left: auto;
|
||||
padding: 4px 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--primary-color);
|
||||
color: var(--text-inverse);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-medium);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: var(--transition-all);
|
||||
}
|
||||
|
||||
.xx-clone-use-btn:hover {
|
||||
opacity: 0.9;
|
||||
box-shadow: var(--shadow-primary);
|
||||
}
|
||||
|
||||
.xx-clone-processing-hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--warning-color, #f59e0b);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.xx-clone-retry-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 6px 14px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--primary-soft);
|
||||
color: var(--primary-color);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-medium);
|
||||
border: 1px solid color-mix(in srgb, var(--primary-color) 30%, transparent);
|
||||
cursor: pointer;
|
||||
transition: var(--transition-all);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.xx-clone-retry-btn:hover {
|
||||
background: var(--primary-color);
|
||||
color: var(--text-inverse);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 克隆音色详情弹窗(任务 3.12)
|
||||
* ============================================================ */
|
||||
|
||||
.xx-clone-detail-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--overlay-bg, rgba(0, 0, 0, 0.4));
|
||||
animation: xx-clone-fade-in 0.2s;
|
||||
}
|
||||
|
||||
@keyframes xx-clone-fade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
.xx-clone-detail {
|
||||
position: relative;
|
||||
background: var(--bg-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-xl);
|
||||
width: 440px;
|
||||
max-width: calc(100vw - 40px);
|
||||
box-shadow: 0 20px 60px var(--shadow-lg, rgba(0, 0, 0, 0.15));
|
||||
animation: xx-clone-scale-in 0.2s ease-out;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
@keyframes xx-clone-scale-in {
|
||||
from { opacity: 0; transform: scale(0.95); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
.xx-clone-detail-close {
|
||||
position: absolute;
|
||||
top: var(--space-sm);
|
||||
right: var(--space-sm);
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
transition: var(--transition-all);
|
||||
}
|
||||
|
||||
.xx-clone-detail-close:hover {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.xx-clone-detail-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.xx-clone-detail-name {
|
||||
margin: 0 0 6px;
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.xx-clone-detail-desc {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.xx-clone-detail-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
padding: var(--space-md);
|
||||
background: var(--bg-secondary, #f8fafc);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.xx-clone-detail-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.xx-clone-detail-label {
|
||||
color: var(--text-tertiary);
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
.xx-clone-detail-actions {
|
||||
display: flex;
|
||||
gap: var(--space-sm);
|
||||
justify-content: flex-end;
|
||||
padding-top: var(--space-sm);
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 骨架屏(任务 3.12)
|
||||
* ============================================================ */
|
||||
|
||||
.xx-skeleton-clone {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.xx-skeleton-clone-avatar {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--bg-tertiary);
|
||||
animation: xx-skeleton-shimmer 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.xx-skeleton-clone-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.xx-skeleton-clone-line {
|
||||
height: 14px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-tertiary);
|
||||
animation: xx-skeleton-shimmer 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.xx-skeleton-clone-line--name { width: 60%; }
|
||||
.xx-skeleton-clone-line--status { width: 30%; height: 20px; }
|
||||
.xx-skeleton-clone-line--desc { width: 90%; }
|
||||
.xx-skeleton-clone-line--meta { width: 45%; }
|
||||
.xx-skeleton-clone-line--footer { width: 100%; height: 32px; margin-top: auto; }
|
||||
|
||||
@keyframes xx-skeleton-shimmer {
|
||||
0% { opacity: 0.5; }
|
||||
50% { opacity: 1; }
|
||||
100% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
/* 响应式 */
|
||||
@media (max-width: 1200px) {
|
||||
.xx-voice-grid { grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); }
|
||||
|
||||
Reference in New Issue
Block a user