Files
xiaoxia-saas/apps/web/src/pages/generate/components/Step5VoiceSelect.tsx
T
xiaoxia 9e57d35b2e
CI/CD Pipeline / Canary Release to Production (push) Failing after 962h56m13s
CI/CD Pipeline / Production Browser E2E (push) Failing after 963h0m46s
CI/CD Pipeline / Deploy Production (push) Failing after 963h0m47s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 963h0m48s
CI/CD Pipeline / Build Production Web Image (push) Failing after 963h0m48s
CI/CD Pipeline / Build Production API Image (push) Failing after 963h0m48s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 963h10m22s
CI/CD Pipeline / PR Build API Image (push) Failing after 963h10m22s
CI/CD Pipeline / Frontend Lint (push) Failing after 963h10m23s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 963h10m24s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m4s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 11m9s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 17s
CI/CD Pipeline / Build Staging API Image (push) Successful in 12m45s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 5m24s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 4m56s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 52s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 51s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 3m29s
CI/CD Pipeline / Unit Tests (push) Successful in 9m34s
CI/CD Pipeline / Integration Tests (push) Successful in 1m45s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m36s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 36s
CI/CD Pipeline / CI Gate (push) Failing after 963h34m6s
CI/CD Pipeline / PR Build Web Image (push) Failing after 963h43m40s
feat: Step5 配音选择改为展示用户上传的配音素材 (#1252)
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-08-07 12:06:00 +08:00

253 lines
7.4 KiB
TypeScript

/**
* Step 5 配音选择组件
* 展示用户已上传的配音素材,支持选中、预览播放
*/
import React, { useState, useRef, useCallback } from "react"
import { useQuery } from "@tanstack/react-query"
import { AudioOutlined, SoundOutlined } from "@ant-design/icons"
import { getAssetsByKind } from "@/api/assets"
import type { AssetItem } from "@/api/assets"
interface Step5VoiceSelectProps {
selectedVoice: string
onSelectedVoiceChange: (id: string) => void
}
/** 格式化时长 mm:ss */
const formatDuration = (seconds?: number): string => {
if (!seconds || seconds <= 0) return "00:00"
const m = Math.floor(seconds / 60)
const s = Math.floor(seconds % 60)
return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`
}
/** 格式化文件大小 */
const formatFileSize = (bytes?: number): string => {
if (!bytes || bytes <= 0) return "未知"
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`
}
const Step5VoiceSelect: React.FC<Step5VoiceSelectProps> = ({
selectedVoice,
onSelectedVoiceChange,
}) => {
const [playingId, setPlayingId] = useState<string | null>(null)
const audioRef = useRef<HTMLAudioElement | null>(null)
// 获取用户上传的配音素材
const { data: materials = [], isLoading } = useQuery({
queryKey: ["assets", "voice"],
queryFn: () => getAssetsByKind("voice", { limit: 50 }),
})
/** 切换播放/暂停 */
const togglePlay = useCallback(
(material: AssetItem) => {
// 如果当前正在播放同一个素材,则暂停
if (playingId === material.id && audioRef.current) {
audioRef.current.pause()
setPlayingId(null)
return
}
// 停止之前的播放
if (audioRef.current) {
audioRef.current.pause()
}
// 创建新的 audio 元素播放
const audio = new Audio(material.file_url)
audioRef.current = audio
setPlayingId(material.id)
audio.onended = () => {
setPlayingId(null)
audioRef.current = null
}
audio.play().catch(() => {
setPlayingId(null)
audioRef.current = null
})
},
[playingId],
)
/** 选中素材 */
const handleSelect = useCallback(
(id: string) => {
onSelectedVoiceChange(id)
},
[onSelectedVoiceChange],
)
/** 跳转到配音库上传 */
const handleGoToUpload = useCallback(() => {
window.location.href = "/voices"
}, [])
// 加载中状态
if (isLoading) {
return (
<div className="xx-form-section">
<h3>🎙️ 选择配音</h3>
<div style={{ textAlign: "center", padding: "40px 0", color: "#999" }}>
加载配音素材中...
</div>
</div>
)
}
// 空状态
if (materials.length === 0) {
return (
<div className="xx-form-section">
<h3>🎙️ 选择配音</h3>
<div
style={{
textAlign: "center",
padding: "60px 0",
color: "#999",
}}
>
<AudioOutlined style={{ fontSize: 48, color: "#d9d9d9", marginBottom: 16 }} />
<p style={{ fontSize: 16, marginBottom: 16 }}>暂无配音素材</p>
<button
type="button"
onClick={handleGoToUpload}
style={{
padding: "8px 24px",
background: "#1677ff",
color: "#fff",
border: "none",
borderRadius: 6,
cursor: "pointer",
fontSize: 14,
}}
>
去配音库上传
</button>
</div>
</div>
)
}
return (
<div className="xx-form-section">
<h3>🎙️ 选择配音</h3>
<p style={{ color: "#666", marginBottom: 16, fontSize: 14 }}>
从配音库中选择已上传的素材,点击卡片可预览播放
</p>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(220px, 1fr))",
gap: 12,
}}
>
{materials.map((item) => {
const isSelected = selectedVoice === item.id
const isPlaying = playingId === item.id
return (
<div
key={item.id}
onClick={() => handleSelect(item.id)}
style={{
padding: 16,
borderRadius: 8,
border: isSelected ? "2px solid #1677ff" : "1px solid #e8e8e8",
background: isSelected ? "#e6f4ff" : "#fff",
cursor: "pointer",
transition: "all 0.2s",
display: "flex",
flexDirection: "column",
gap: 8,
}}
>
{/* 顶部:图标 + 播放按钮 */}
<div
style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}
>
<div
style={{
width: 40,
height: 40,
borderRadius: 8,
background: isSelected
? "linear-gradient(135deg, #1677ff, #4096ff)"
: "linear-gradient(135deg, #f0f0f0, #e8e8e8)",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<AudioOutlined style={{ fontSize: 20, color: isSelected ? "#fff" : "#666" }} />
</div>
{item.file_url && (
<button
type="button"
onClick={(e) => {
e.stopPropagation()
togglePlay(item)
}}
style={{
width: 32,
height: 32,
borderRadius: "50%",
border: "none",
background: isPlaying ? "#ff4d4f" : "#1677ff",
color: "#fff",
cursor: "pointer",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 14,
}}
title={isPlaying ? "暂停" : "播放"}
>
<SoundOutlined />
</button>
)}
</div>
{/* 名称 */}
<div
style={{
fontSize: 14,
fontWeight: 500,
color: "#333",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
title={item.name}
>
{item.name}
</div>
{/* 时长 + 大小 */}
<div
style={{
display: "flex",
justifyContent: "space-between",
fontSize: 12,
color: "#999",
}}
>
<span>{formatDuration(item.duration)}</span>
<span>{formatFileSize(item.file_size)}</span>
</div>
</div>
)
})}
</div>
</div>
)
}
export default Step5VoiceSelect