Files
xiaoxia-saas/apps/web/src/pages/generate/components/VoiceSelectModal.tsx
T
xiaoxia 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
feat(generate): #1970 智能剪辑流程重构 - 选择模式→素材→标题→确认→封面 (#1973)
2026-09-18 00:23:19 +08:00

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