feat(P1): #579 配音库新增配音素材Tab #596
@@ -184,13 +184,19 @@ export const getAssetsByKind = async (
|
||||
gender?: string
|
||||
style?: string
|
||||
tag_ids?: string[]
|
||||
limit?: number
|
||||
page?: number
|
||||
page_size?: number
|
||||
},
|
||||
): Promise<AssetItem[]> => {
|
||||
const params: Record<string, string> = { kind }
|
||||
const params: Record<string, string | number> = { kind }
|
||||
if (filters?.keyword) params.keyword = filters.keyword
|
||||
if (filters?.gender) params.gender = filters.gender
|
||||
if (filters?.style) params.style = filters.style
|
||||
if (filters?.tag_ids?.length) params.tag_ids = filters.tag_ids.join(",")
|
||||
if (filters?.limit) params.limit = filters.limit
|
||||
if (filters?.page) params.page = filters.page
|
||||
if (filters?.page_size) params.page_size = filters.page_size
|
||||
const response = await apiClient.get("/assets", { params })
|
||||
return response.data.items || []
|
||||
}
|
||||
|
||||
Regular → Executable
+92
-2
@@ -36,7 +36,13 @@ import {
|
||||
type VoiceClone,
|
||||
} from "@/api/voiceClone"
|
||||
import { synthesizeSpeech, getTTSJobStatus, saveTtsToLibrary } from "@/api/tts"
|
||||
import { uploadAssetDirect, getAssetLibraries, createAsset } from "@/api/assets"
|
||||
import {
|
||||
getAssetsByKind,
|
||||
type AssetItem,
|
||||
uploadAssetDirect,
|
||||
getAssetLibraries,
|
||||
createAsset,
|
||||
} from "@/api/assets"
|
||||
import CloneModal from "@/components/voice/CloneModal"
|
||||
import "./voices.css"
|
||||
|
||||
@@ -45,7 +51,7 @@ import "./voices.css"
|
||||
* ============================================================ */
|
||||
type VoiceGender = "male" | "female" | "child" | "elderly"
|
||||
type VoiceLanguage = "zh" | "en" | "ja" | "ko"
|
||||
type TabKey = "preset" | "cloned"
|
||||
type TabKey = "preset" | "cloned" | "material"
|
||||
|
||||
/** 前端展示用的预置音色(从 PresetVoiceItem 映射) */
|
||||
interface PresetVoiceDisplay {
|
||||
@@ -798,6 +804,12 @@ const VoiceLibrary: React.FC = () => {
|
||||
queryFn: () => getVoiceClonesWithTotal({ limit: 50 }),
|
||||
})
|
||||
|
||||
/** 配音素材列表(用户上传音频) */
|
||||
const { data: materialData, isLoading: materialLoading } = useQuery({
|
||||
queryKey: ["voice-materials"],
|
||||
queryFn: () => getAssetsByKind("voice", { limit: 50 }),
|
||||
})
|
||||
|
||||
/** 统一统计(preset_count / clone_count) */
|
||||
const { data: unifiedStats } = useQuery({
|
||||
queryKey: ["voices-unified"],
|
||||
@@ -814,6 +826,7 @@ const VoiceLibrary: React.FC = () => {
|
||||
)
|
||||
const presetCount = unifiedStats?.preset_count ?? presetData?.total ?? 0
|
||||
const cloneCount = unifiedStats?.clone_count ?? cloneData?.total ?? 0
|
||||
const materialCount = materialData?.length ?? 0
|
||||
|
||||
const filteredPreset = useMemo(() => {
|
||||
let list = presetVoices
|
||||
@@ -984,6 +997,17 @@ const VoiceLibrary: React.FC = () => {
|
||||
我的克隆
|
||||
<span className="xx-voices-tab-count">{cloneCount}</span>
|
||||
</button>
|
||||
<button
|
||||
className={`xx-voices-tab${activeTab === "material" ? " active" : ""}`}
|
||||
onClick={() => {
|
||||
setActiveTab("material")
|
||||
handlePause()
|
||||
}}
|
||||
>
|
||||
<SoundOutlined />
|
||||
配音素材
|
||||
<span className="xx-voices-tab-count">{materialCount}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{activeTab === "preset" && (
|
||||
@@ -1133,6 +1157,72 @@ const VoiceLibrary: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "material" && (
|
||||
<div className="xx-voices-tab-content">
|
||||
{/* 骨架屏加载 */}
|
||||
{materialLoading && (
|
||||
<div className="xx-voice-grid">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="vmat-card vmat-card--skeleton">
|
||||
<div className="vmat-thumb" />
|
||||
<div className="vmat-info">
|
||||
<div className="vmat-skeleton-line vmat-skeleton-title" />
|
||||
<div className="vmat-skeleton-line" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 卡片列表 */}
|
||||
{!materialLoading && (materialData?.length || 0) > 0 && (
|
||||
<div className="xx-voice-grid">
|
||||
{(materialData || []).map((asset: AssetItem) => {
|
||||
const duration = (asset.metadata?.duration as number) || 0
|
||||
const minutes = Math.floor(duration / 60)
|
||||
const seconds = Math.floor(duration % 60)
|
||||
return (
|
||||
<div key={asset.id} className="vmat-card">
|
||||
<div className="vmat-thumb">
|
||||
<AudioOutlined className="vmat-thumb-icon" />
|
||||
<span className="vmat-duration">
|
||||
{minutes}:{seconds.toString().padStart(2, "0")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="vmat-info">
|
||||
<div className="vmat-name" title={asset.name}>
|
||||
{asset.name}
|
||||
</div>
|
||||
<div className="vmat-meta">
|
||||
<span>
|
||||
{asset.file_size
|
||||
? `${(asset.file_size / 1024 / 1024).toFixed(1)} MB`
|
||||
: "--"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 空状态 */}
|
||||
{!materialLoading && (materialData?.length || 0) === 0 && (
|
||||
<div className="xx-voices-empty">
|
||||
<div className="xx-voices-empty-icon">
|
||||
<SoundOutlined />
|
||||
</div>
|
||||
<h3>暂无配音素材</h3>
|
||||
<p>上传您的音频素材,用于视频配音</p>
|
||||
<Button buttonType="primary" buttonSize="md" onClick={() => setUploadOpen(true)}>
|
||||
<UploadOutlined /> 上传音频
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 克隆音色弹窗 */}
|
||||
<CloneModal
|
||||
open={cloneModalOpen}
|
||||
|
||||
Regular → Executable
+102
@@ -970,3 +970,105 @@
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
配音素材卡片(与配音库Tab集成)
|
||||
================================================================ */
|
||||
|
||||
.vmat-card {
|
||||
background: var(--bg-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border-color);
|
||||
transition: all 0.2s;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.vmat-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.08);
|
||||
border-color: var(--primary-300);
|
||||
}
|
||||
|
||||
.vmat-thumb {
|
||||
position: relative;
|
||||
aspect-ratio: 16 / 9;
|
||||
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.vmat-thumb-icon {
|
||||
font-size: 32px;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.vmat-duration {
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
right: 8px;
|
||||
padding: 2px 8px;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.vmat-info {
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.vmat-name {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.vmat-meta {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* 骨架屏 */
|
||||
.vmat-card--skeleton {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.vmat-card--skeleton .vmat-thumb {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.vmat-skeleton-line {
|
||||
height: 12px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 6px;
|
||||
margin-bottom: 8px;
|
||||
animation: vmat-shimmer 1.5s infinite linear;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--bg-tertiary) 25%,
|
||||
var(--border-color) 50%,
|
||||
var(--bg-tertiary) 75%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
}
|
||||
|
||||
.vmat-skeleton-title {
|
||||
width: 70%;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
@keyframes vmat-shimmer {
|
||||
0% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user