feat(voices): 任务3.12 克隆音色卡片组件 #170

Merged
xiaoxia merged 1 commits from feature/task-312-clone-voice-card into develop 2026-07-02 14:22:52 +08:00
3 changed files with 843 additions and 18 deletions
+8
View File
@@ -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,
});
+384 -18
View File
@@ -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>
);
};
+451
View File
@@ -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)); }