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
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com> Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
253 lines
7.4 KiB
TypeScript
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
|