f9daa08b2e
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / Check push changed paths (push) Successful in 2s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 31s
CI/CD Pipeline / Build Staging API Image (push) Successful in 40s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 1m46s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 2m29s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m1s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 36s
CI/CD Pipeline / Integration Tests (push) Successful in 4m53s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m36s
CI/CD Pipeline / Validate - Style (push) Failing after 5m37s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 6m10s
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
CI/CD Pipeline / Validate - Security (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
242 lines
7.7 KiB
TypeScript
242 lines
7.7 KiB
TypeScript
/**
|
|
* 随机混剪 — 配音选择弹窗(#1970)
|
|
* 内容复用 Step5VoiceSelect 的配音库音频卡片(图标+文件名+时长/大小+▶试听),
|
|
* 无 TTS / 克隆音色入口;确认后进入 Step2。
|
|
*/
|
|
import React from "react"
|
|
import { Modal } from "antd"
|
|
import { AudioOutlined } from "@ant-design/icons"
|
|
import { useNavigate } from "react-router-dom"
|
|
import { useQuery } from "@tanstack/react-query"
|
|
import { useState, useRef, useCallback } from "react"
|
|
import { getAssetsByKind } from "@/api/assets"
|
|
import type { AssetItem } from "@/api/assets"
|
|
|
|
interface VoiceSelectModalProps {
|
|
open: boolean
|
|
selectedVoice: string
|
|
onCancel: () => void
|
|
onConfirm: (voiceAssetId: string) => void
|
|
}
|
|
|
|
const getDuration = (item: AssetItem): number =>
|
|
item.duration ?? (item.metadata?.duration as number) ?? 0
|
|
const getFileSize = (item: AssetItem): number =>
|
|
item.file_size ?? (item.metadata?.file_size as number) ?? 0
|
|
const isAiVoice = (item: AssetItem): boolean => {
|
|
const d = getDuration(item)
|
|
const s = getFileSize(item)
|
|
return (!d || d <= 0) && (!s || s <= 0)
|
|
}
|
|
const fmtDur = (s?: number): string => {
|
|
if (!s || s <= 0) return "时长未知"
|
|
return `${s.toFixed(1)}秒`
|
|
}
|
|
const fmtSize = (b?: number): string => {
|
|
if (!b || b <= 0) return "未知"
|
|
if (b < 1024) return `${b} B`
|
|
if (b < 1024 * 1024) return `${(b / 1024).toFixed(1)} KB`
|
|
if (b < 1024 * 1024 * 1024) return `${(b / (1024 * 1024)).toFixed(1)} MB`
|
|
return `${(b / (1024 * 1024 * 1024)).toFixed(1)} GB`
|
|
}
|
|
|
|
const VoiceSelectModal: React.FC<VoiceSelectModalProps> = ({
|
|
open,
|
|
selectedVoice,
|
|
onCancel,
|
|
onConfirm,
|
|
}) => {
|
|
const navigate = useNavigate()
|
|
const [innerSelected, setInnerSelected] = React.useState(selectedVoice)
|
|
const [playingId, setPlayingId] = useState<string | null>(null)
|
|
const audioRef = useRef<HTMLAudioElement | null>(null)
|
|
|
|
React.useEffect(() => {
|
|
if (open) setInnerSelected(selectedVoice)
|
|
}, [open, selectedVoice])
|
|
|
|
const { data: materials = [], isLoading } = useQuery({
|
|
queryKey: ["assets", "voice", "modal"],
|
|
queryFn: () => getAssetsByKind("voice", { limit: 50 }),
|
|
enabled: open,
|
|
})
|
|
|
|
const togglePlay = useCallback(
|
|
(item: AssetItem) => {
|
|
if (playingId === item.id && audioRef.current) {
|
|
audioRef.current.pause()
|
|
setPlayingId(null)
|
|
return
|
|
}
|
|
if (audioRef.current) audioRef.current.pause()
|
|
if (!item.file_url) return
|
|
const audio = new Audio(item.file_url)
|
|
audioRef.current = audio
|
|
setPlayingId(item.id)
|
|
audio.onended = () => {
|
|
setPlayingId(null)
|
|
audioRef.current = null
|
|
}
|
|
audio.play().catch(() => {
|
|
setPlayingId(null)
|
|
audioRef.current = null
|
|
})
|
|
},
|
|
[playingId],
|
|
)
|
|
|
|
const handleGoUpload = () => navigate("/app/voices?tab=material&upload=1")
|
|
|
|
const handleConfirm = () => {
|
|
if (!innerSelected) return
|
|
onConfirm(innerSelected)
|
|
}
|
|
|
|
return (
|
|
<Modal
|
|
title="🎙️ 选择配音"
|
|
open={open}
|
|
onCancel={onCancel}
|
|
onOk={handleConfirm}
|
|
okText="确认选择"
|
|
cancelText="取消"
|
|
okButtonProps={{ disabled: !innerSelected, style: { background: "#7c3aed" } }}
|
|
width={720}
|
|
destroyOnClose
|
|
>
|
|
<p style={{ color: "#666", fontSize: 13, marginBottom: 12 }}>
|
|
从配音库中选择已上传的音频素材,点击 ▶ 可试听
|
|
</p>
|
|
{isLoading ? (
|
|
<div style={{ textAlign: "center", padding: "40px 0", color: "#999" }}>加载中...</div>
|
|
) : materials.length === 0 ? (
|
|
<div style={{ textAlign: "center", padding: "40px 0", color: "#999" }}>
|
|
<AudioOutlined style={{ fontSize: 48, color: "#d9d9d9", marginBottom: 12 }} />
|
|
<p style={{ marginBottom: 12 }}>暂无配音素材</p>
|
|
<button
|
|
type="button"
|
|
onClick={handleGoUpload}
|
|
style={{
|
|
padding: "8px 20px",
|
|
background: "#7c3aed",
|
|
color: "#fff",
|
|
border: "none",
|
|
borderRadius: 6,
|
|
cursor: "pointer",
|
|
}}
|
|
>
|
|
去配音库上传
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<div
|
|
style={{
|
|
display: "grid",
|
|
gridTemplateColumns: "repeat(auto-fill, minmax(200px, 1fr))",
|
|
gap: 12,
|
|
maxHeight: 460,
|
|
overflowY: "auto",
|
|
paddingRight: 4,
|
|
}}
|
|
>
|
|
{materials.map((item) => {
|
|
const isSel = innerSelected === item.id
|
|
const isPlaying = playingId === item.id
|
|
return (
|
|
<div
|
|
key={item.id}
|
|
onClick={() => setInnerSelected(item.id)}
|
|
style={{
|
|
padding: 14,
|
|
borderRadius: 8,
|
|
border: isSel ? "2px solid #7c3aed" : "1px solid #e8e8e8",
|
|
background: isSel ? "#ede9fe" : "#fff",
|
|
cursor: "pointer",
|
|
transition: "all 0.2s",
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "space-between",
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
width: 36,
|
|
height: 36,
|
|
borderRadius: 8,
|
|
background: isSel
|
|
? "linear-gradient(135deg,#7c3aed,#a78bfa)"
|
|
: "linear-gradient(135deg,#f0f0f0,#e8e8e8)",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
}}
|
|
>
|
|
<AudioOutlined style={{ color: isSel ? "#fff" : "#666" }} />
|
|
</div>
|
|
{item.file_url && (
|
|
<button
|
|
type="button"
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
togglePlay(item)
|
|
}}
|
|
style={{
|
|
width: 30,
|
|
height: 30,
|
|
borderRadius: "50%",
|
|
border: "none",
|
|
background: isPlaying ? "#ef4444" : "#7c3aed",
|
|
color: "#fff",
|
|
cursor: "pointer",
|
|
fontSize: 12,
|
|
}}
|
|
>
|
|
▶
|
|
</button>
|
|
)}
|
|
</div>
|
|
<div
|
|
style={{
|
|
fontSize: 13,
|
|
fontWeight: 500,
|
|
marginTop: 8,
|
|
overflow: "hidden",
|
|
textOverflow: "ellipsis",
|
|
whiteSpace: "nowrap",
|
|
color: isSel ? "#6d28d9" : "#333",
|
|
}}
|
|
title={item.name}
|
|
>
|
|
{item.name}
|
|
</div>
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
justifyContent: "space-between",
|
|
fontSize: 11,
|
|
color: "#999",
|
|
marginTop: 4,
|
|
}}
|
|
>
|
|
{isAiVoice(item) ? (
|
|
<span style={{ color: "#7c3aed", fontWeight: 500 }}>AI 音色</span>
|
|
) : (
|
|
<span>{fmtDur(getDuration(item))}</span>
|
|
)}
|
|
<span>{isAiVoice(item) ? "按文本合成" : fmtSize(getFileSize(item))}</span>
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
</Modal>
|
|
)
|
|
}
|
|
|
|
export default VoiceSelectModal
|