Files
xiaoxia-saas/apps/web/src/pages/generate/components/Step5VoiceSelect.tsx
T
xiaoxia af25045123
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 4s
CI/CD Pipeline / Check push changed paths (push) Successful in 5s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 25s
CI/CD Pipeline / Build Staging API Image (push) Successful in 25s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 30s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 1m50s
CI/CD Pipeline / Integration Tests (push) Successful in 1m51s
CI/CD Pipeline / Validate - Style (push) Successful in 2m29s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 53s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m20s
CI/CD Pipeline / Validate - Security (push) Successful in 4m27s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m49s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m45s
AI Code Review / AI Code Review (pull_request) Successful in 6m21s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 6m51s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m49s
CI/CD Pipeline / Unit Tests (push) Successful in 9m32s
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 14m36s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 4s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 4s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 25s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 22s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 21s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 2s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 269h53m9s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 269h53m8s
CI/CD Pipeline / Canary Release to Production (push) Failing after 270h11m3s
CI/CD Pipeline / Deploy Production (push) Failing after 270h11m3s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 270h11m6s
CI/CD Pipeline / Build Production Web Image (push) Failing after 270h11m6s
CI/CD Pipeline / Build Production API Image (push) Failing after 270h11m6s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Failing after 270h16m17s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 269h59m15s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 269h59m22s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Failing after 270h19m3s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Failing after 270h19m4s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 269h53m10s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 269h59m16s
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Failing after 269h59m27s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 270h0m7s
CI/CD Pipeline / Retag skipped Staging API Image (push) Failing after 270h19m5s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 270h20m39s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 269h53m10s
CI/CD Pipeline / PR Build Web Image (push) Failing after 270h20m40s
CI/CD Pipeline / Frontend Lint (push) Failing after 270h20m40s
CI/CD Pipeline / Check push changed paths (pull_request) Failing after 270h0m8s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Failing after 269h59m27s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 270h20m48s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 270h0m0s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 270h0m1s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 270h0m1s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 270h0m2s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Failing after 270h0m2s
CI/CD Pipeline / Validate - Security (pull_request) Failing after 270h0m3s
CI/CD Pipeline / Validate - Style (pull_request) Failing after 270h0m3s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 270h27m37s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 270h33m45s
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Failing after 270h33m56s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 270h34m37s
CI/CD Pipeline / CI Gate (push) Failing after 270h45m36s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 270h34m37s
CI/CD Pipeline / PR Build API Image (push) Failing after 270h55m10s
feat(#1677): 多视频批量生成前端 — 数量弹窗/批量预览/独立标题配音封面/5步流程 (#1704)
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-05 09:03:39 +08:00

276 lines
8.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Step 5 配音选择组件
* 展示用户已上传的配音素材,支持选中、预览播放
*/
import React, { useState, useRef, useCallback } from "react"
import { useNavigate } from "react-router-dom"
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
/** 卡片标题(独立配音模式下显示"视频 N 的配音"),默认"选择配音" */
heading?: string
/** 描述文案 */
description?: string
/** 是否使用紧凑卡片样式(独立配音模式下 N 个并排) */
compact?: boolean
}
/** 获取素材实际时长(优先顶层 duration,fallback 到 metadata.duration) */
const getDuration = (item: AssetItem): number => {
return item.duration ?? (item.metadata?.duration as number) ?? 0
}
/** 获取素材实际文件大小 */
const getFileSize = (item: AssetItem): number => {
return item.file_size ?? (item.metadata?.file_size as number) ?? 0
}
/** 是否为 AI 音色(克隆/预置音色模型:无固定时长、无实体音频文件,按脚本实时合成) */
const isAiVoice = (item: AssetItem): boolean => {
const duration = getDuration(item)
const size = getFileSize(item)
return (!duration || duration <= 0) && (!size || size <= 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,
heading = "🎙️ 选择配音",
description = "从配音库中选择已上传的素材,点击卡片可预览播放",
compact = false,
}) => {
const navigate = useNavigate()
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(() => {
navigate("/app/voices?tab=material&upload=1")
}, [navigate])
// 加载中状态
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>{heading}</h3>
<p style={{ color: "#666", marginBottom: 16, fontSize: 14 }}>{description}</p>
<div
style={{
display: "grid",
gridTemplateColumns: compact
? "repeat(auto-fill, minmax(160px, 1fr))"
: "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",
alignItems: "center",
}}
>
{isAiVoice(item) ? (
<span style={{ color: "#1677ff", fontWeight: 500 }}>AI 音色</span>
) : null}
<span>{isAiVoice(item) ? "按文本合成" : formatFileSize(getFileSize(item))}</span>
</div>
</div>
)
})}
</div>
</div>
)
}
export default Step5VoiceSelect