Compare commits

..

2 Commits

Author SHA1 Message Date
xiaoxia 3562b33ad0 Merge branch 'develop' into refactor/voice-clone-api
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 7s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m12s
CI/CD Pipeline / Validate - Code Quality (pull_request) Failing after 1m45s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m21s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 43s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 24s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 1m4s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m42s
AI Code Review / AI Code Review (pull_request) Successful in 1m39s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 42s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 3m39s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 15m19s
CI/CD Pipeline / CI Gate (pull_request) 失败: CI/CD Pipeline / Validate - Code Quality (pull_request) [frontend-only]
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 22s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 10s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
2026-07-27 12:35:00 +08:00
xiaoxia 01ca228c07 refactor(voice-clone): API层按模块拆分(208行→入口28行, -87%)
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 13s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 27s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m4s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 59s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 32s
CI/CD Pipeline / Validate - Code Quality (pull_request) Failing after 2m33s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 1m7s
AI Code Review / AI Code Review (pull_request) Successful in 1m38s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 2m12s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 25s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 42s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 3m14s
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 26s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
将 voice-clone.ts 单文件拆分为目录结构:
- types.ts — 类型定义
- utils.ts — toVoiceClone 映射 + formatDuration
- clones.ts — 克隆 CRUD + 状态 + 重试
- index.ts — 统一入口 re-export

导入路径 @/api/voice-clone 保持向后兼容。
2026-07-26 16:56:23 +08:00
10 changed files with 512 additions and 576 deletions
-208
View File
@@ -1,208 +0,0 @@
/**
* 音色克隆 API
* 任务 3.11:替换 Mock 数据,对接后端真实 API(3.05)
* 任务 3.15:新增 progress 字段用于进度展示
*/
import apiClient from "./client"
/* ── 前端兼容类型 ─────────────────────────────────────── */
/** 克隆音色状态(前端展示用) */
export type VoiceCloneStatus = "ready" | "processing" | "failed"
/** 克隆音色条目(前端展示用) */
export interface VoiceClone {
id: string
name: string
description: string
duration_seconds: number
status: VoiceCloneStatus
/** 克隆进度 0-100,仅 processing 状态时有值 */
progress: number
sample_url?: string
language: string
gender: string
error_message: string | null
created_at: string
updated_at: string
}
/** 创建克隆请求(前端简化版) */
export interface CreateVoiceCloneRequest {
name: string
audio_url: string
description?: string
}
/* ── 后端 API 类型 ────────────────────────────────────── */
/** 音色克隆元数据(克隆时附带的扩展信息) */
export interface VoiceCloneMetadata {
/** 语音时长(秒) */
duration?: number
/** 采样率(Hz */
sample_rate?: number
/** 音色 ID(克隆完成后分配) */
voice_id?: string
/** 其他扩展字段 */
[key: string]: unknown
}
/** 后端克隆档案响应 */
export interface VoiceCloneProfile {
id: string
user_id: string
name: string
description: string
source_audio_url: string
voice_id: string | null
voice_model: string
language: string
gender: string
status: "pending" | "processing" | "ready" | "failed"
error_message: string | null
retry_count: number
max_retries: number
metadata_: VoiceCloneMetadata | null
created_at: string
updated_at: string
}
/** 后端克隆列表响应 */
export interface ListVoiceCloneResponse {
items: VoiceCloneProfile[]
total: number
}
/** 后端克隆状态响应 */
export interface VoiceCloneStatusResponse {
id: string
status: "pending" | "processing" | "ready" | "failed"
error_message: string | null
voice_id: string | null
retry_count: number
}
/** 后端创建克隆请求(完整版) */
export interface CreateVoiceCloneRequestFull {
name: string
description?: string
source_audio_url: string
voice_model?: string
language?: string
gender?: string
max_retries?: number
metadata_?: VoiceCloneMetadata
}
/* ── 辅助函数 ─────────────────────────────────────────── */
/**
* 将后端 VoiceCloneProfile 转换为前端 VoiceClone
* 后端 status "pending" 映射为前端 "processing"
*/
export const toVoiceClone = (profile: VoiceCloneProfile): VoiceClone => ({
id: profile.id,
name: profile.name,
description: profile.description || "",
duration_seconds: 0,
status: profile.status === "pending" ? "processing" : profile.status,
progress: 0,
sample_url: profile.source_audio_url || undefined,
language: profile.language || "",
gender: profile.gender || "",
error_message: profile.error_message || null,
created_at: profile.created_at,
updated_at: profile.updated_at,
})
/** 格式化时长 */
export const formatDuration = (seconds: number): string => {
const m = Math.floor(seconds / 60)
const s = seconds % 60
return `${m}:${String(s).padStart(2, "0")}`
}
/* ── 查询参数 ─────────────────────────────────────────── */
export interface VoiceCloneListParams {
status?: string
skip?: number
limit?: number
}
/* ── API 函数 ─────────────────────────────────────────── */
/** 获取克隆音色列表(返回前端兼容数组) */
export const getVoiceClones = async (params?: VoiceCloneListParams): Promise<VoiceClone[]> => {
const searchParams = new URLSearchParams()
if (params?.status) searchParams.set("status", params.status)
if (params?.skip !== undefined) searchParams.set("skip", String(params.skip))
if (params?.limit !== undefined) searchParams.set("limit", String(params.limit))
const qs = searchParams.toString()
const response = await apiClient.get<ListVoiceCloneResponse>(`/voice-clones${qs ? `?${qs}` : ""}`)
return response.data.items.map(toVoiceClone)
}
/** 获取克隆音色列表(返回完整响应含 total) */
export const getVoiceClonesWithTotal = async (
params?: VoiceCloneListParams,
): Promise<ListVoiceCloneResponse> => {
const searchParams = new URLSearchParams()
if (params?.status) searchParams.set("status", params.status)
if (params?.skip !== undefined) searchParams.set("skip", String(params.skip))
if (params?.limit !== undefined) searchParams.set("limit", String(params.limit))
const qs = searchParams.toString()
const response = await apiClient.get<ListVoiceCloneResponse>(`/voice-clones${qs ? `?${qs}` : ""}`)
return response.data
}
/** 获取单个克隆音色详情 */
export const getVoiceCloneDetail = async (id: string): Promise<VoiceCloneProfile> => {
const response = await apiClient.get<VoiceCloneProfile>(`/voice-clones/${id}`)
return response.data
}
/** 创建克隆音色 */
export const createVoiceClone = async (
data: CreateVoiceCloneRequest,
): Promise<VoiceCloneProfile> => {
const payload: CreateVoiceCloneRequestFull = {
name: data.name,
description: data.description,
source_audio_url: data.audio_url,
}
const response = await apiClient.post<VoiceCloneProfile>("/voice-clones", payload)
return response.data
}
/** 删除克隆音色 */
export const deleteVoiceClone = async (id: string): Promise<void> => {
await apiClient.delete(`/voice-clones/${id}`)
}
/** 更新克隆音色名称(stub — 后端暂无 PATCH 端点) */
export const updateVoiceClone = async (
id: string,
data: Partial<Pick<VoiceClone, "name">>,
): Promise<VoiceClone> => {
// 后端暂未提供更新端点,暂用详情接口模拟
const response = await apiClient.get<VoiceCloneProfile>(`/voice-clones/${id}`)
return toVoiceClone({
...response.data,
...data,
updated_at: new Date().toISOString(),
})
}
/** 获取克隆状态 */
export const getVoiceCloneStatus = async (id: string): Promise<VoiceCloneStatusResponse> => {
const response = await apiClient.get<VoiceCloneStatusResponse>(`/voice-clones/${id}/status`)
return response.data
}
/** 重试克隆 */
export const retryVoiceClone = async (id: string): Promise<VoiceCloneProfile> => {
const response = await apiClient.post<VoiceCloneProfile>(`/voice-clones/${id}/retry`)
return response.data
}
+87
View File
@@ -0,0 +1,87 @@
/**
* 音色克隆 API 函数
*/
import apiClient from "../client"
import { toVoiceClone } from "./utils"
import type {
VoiceClone,
VoiceCloneProfile,
CreateVoiceCloneRequest,
CreateVoiceCloneRequestFull,
VoiceCloneListParams,
ListVoiceCloneResponse,
VoiceCloneStatusResponse,
} from "./types"
/** 获取克隆音色列表(返回前端兼容数组) */
export const getVoiceClones = async (params?: VoiceCloneListParams): Promise<VoiceClone[]> => {
const searchParams = new URLSearchParams()
if (params?.status) searchParams.set("status", params.status)
if (params?.skip !== undefined) searchParams.set("skip", String(params.skip))
if (params?.limit !== undefined) searchParams.set("limit", String(params.limit))
const qs = searchParams.toString()
const response = await apiClient.get<ListVoiceCloneResponse>(`/voice-clones${qs ? `?${qs}` : ""}`)
return response.data.items.map(toVoiceClone)
}
/** 获取克隆音色列表(返回完整响应含 total) */
export const getVoiceClonesWithTotal = async (
params?: VoiceCloneListParams,
): Promise<ListVoiceCloneResponse> => {
const searchParams = new URLSearchParams()
if (params?.status) searchParams.set("status", params.status)
if (params?.skip !== undefined) searchParams.set("skip", String(params.skip))
if (params?.limit !== undefined) searchParams.set("limit", String(params.limit))
const qs = searchParams.toString()
const response = await apiClient.get<ListVoiceCloneResponse>(`/voice-clones${qs ? `?${qs}` : ""}`)
return response.data
}
/** 获取单个克隆音色详情 */
export const getVoiceCloneDetail = async (id: string): Promise<VoiceCloneProfile> => {
const response = await apiClient.get<VoiceCloneProfile>(`/voice-clones/${id}`)
return response.data
}
/** 创建克隆音色 */
export const createVoiceClone = async (
data: CreateVoiceCloneRequest,
): Promise<VoiceCloneProfile> => {
const payload: CreateVoiceCloneRequestFull = {
name: data.name,
description: data.description,
source_audio_url: data.audio_url,
}
const response = await apiClient.post<VoiceCloneProfile>("/voice-clones", payload)
return response.data
}
/** 删除克隆音色 */
export const deleteVoiceClone = async (id: string): Promise<void> => {
await apiClient.delete(`/voice-clones/${id}`)
}
/** 更新克隆音色名称(stub — 后端暂无 PATCH 端点) */
export const updateVoiceClone = async (
id: string,
data: Partial<Pick<VoiceClone, "name">>,
): Promise<VoiceClone> => {
const response = await apiClient.get<VoiceCloneProfile>(`/voice-clones/${id}`)
return toVoiceClone({
...response.data,
...data,
updated_at: new Date().toISOString(),
})
}
/** 获取克隆状态 */
export const getVoiceCloneStatus = async (id: string): Promise<VoiceCloneStatusResponse> => {
const response = await apiClient.get<VoiceCloneStatusResponse>(`/voice-clones/${id}/status`)
return response.data
}
/** 重试克隆 */
export const retryVoiceClone = async (id: string): Promise<VoiceCloneProfile> => {
const response = await apiClient.post<VoiceCloneProfile>(`/voice-clones/${id}/retry`)
return response.data
}
+32
View File
@@ -0,0 +1,32 @@
/**
* 音色克隆 API — 目录化入口
* 保持与原 voice-clone.ts 相同导出,向后兼容
*/
// 类型
export type {
VoiceCloneStatus,
VoiceClone,
CreateVoiceCloneRequest,
VoiceCloneMetadata,
VoiceCloneProfile,
ListVoiceCloneResponse,
VoiceCloneStatusResponse,
CreateVoiceCloneRequestFull,
VoiceCloneListParams,
} from "./types"
// 工具函数
export { toVoiceClone, formatDuration } from "./utils"
// API 函数
export {
getVoiceClones,
getVoiceClonesWithTotal,
getVoiceCloneDetail,
createVoiceClone,
deleteVoiceClone,
updateVoiceClone,
getVoiceCloneStatus,
retryVoiceClone,
} from "./clones"
+92
View File
@@ -0,0 +1,92 @@
/**
* 音色克隆类型定义
*/
/** 克隆音色状态(前端展示用) */
export type VoiceCloneStatus = "ready" | "processing" | "failed"
/** 克隆音色条目(前端展示用) */
export interface VoiceClone {
id: string
name: string
description: string
duration_seconds: number
status: VoiceCloneStatus
/** 克隆进度 0-100,仅 processing 状态时有值 */
progress: number
sample_url?: string
language: string
gender: string
error_message: string | null
created_at: string
updated_at: string
}
/** 创建克隆请求(前端简化版) */
export interface CreateVoiceCloneRequest {
name: string
audio_url: string
description?: string
}
/** 音色克隆元数据 */
export interface VoiceCloneMetadata {
duration?: number
sample_rate?: number
voice_id?: string
[key: string]: unknown
}
/** 后端克隆档案响应 */
export interface VoiceCloneProfile {
id: string
user_id: string
name: string
description: string
source_audio_url: string
voice_id: string | null
voice_model: string
language: string
gender: string
status: "pending" | "processing" | "ready" | "failed"
error_message: string | null
retry_count: number
max_retries: number
metadata_: VoiceCloneMetadata | null
created_at: string
updated_at: string
}
/** 后端克隆列表响应 */
export interface ListVoiceCloneResponse {
items: VoiceCloneProfile[]
total: number
}
/** 后端克隆状态响应 */
export interface VoiceCloneStatusResponse {
id: string
status: "pending" | "processing" | "ready" | "failed"
error_message: string | null
voice_id: string | null
retry_count: number
}
/** 后端创建克隆请求(完整版) */
export interface CreateVoiceCloneRequestFull {
name: string
description?: string
source_audio_url: string
voice_model?: string
language?: string
gender?: string
max_retries?: number
metadata_?: VoiceCloneMetadata
}
/** 查询参数 */
export interface VoiceCloneListParams {
status?: string
skip?: number
limit?: number
}
+30
View File
@@ -0,0 +1,30 @@
/**
* 音色克隆工具函数
*/
import type { VoiceCloneProfile, VoiceClone } from "./types"
/**
* 将后端 VoiceCloneProfile 转换为前端 VoiceClone
* 后端 status "pending" 映射为前端 "processing"
*/
export const toVoiceClone = (profile: VoiceCloneProfile): VoiceClone => ({
id: profile.id,
name: profile.name,
description: profile.description || "",
duration_seconds: 0,
status: profile.status === "pending" ? "processing" : profile.status,
progress: 0,
sample_url: profile.source_audio_url || undefined,
language: profile.language || "",
gender: profile.gender || "",
error_message: profile.error_message || null,
created_at: profile.created_at,
updated_at: profile.updated_at,
})
/** 格式化时长 */
export const formatDuration = (seconds: number): string => {
const m = Math.floor(seconds / 60)
const s = seconds % 60
return `${m}:${String(s).padStart(2, "0")}`
}
@@ -1,5 +1,273 @@
/**
* BGM 选择器入口(向后兼容)
* 实际实现位于 ./bgm-selector/ 目录
* BGM 选择器 — Drawer 形式
* 预设 BGM 列表(按风格分类)、搜索、试听、音量/淡入淡出/人声闪避配置
*/
export { default } from "./bgm-selector"
import React, { useState, useRef, useCallback, useEffect } from "react"
import { Drawer, Slider, Input, Tag, message } from "antd"
import {
getBgmPresets,
type BgmPreset,
type BgmCategory,
type BgmMixConfig,
DEFAULT_BGM_MIX_CONFIG,
} from "@/api/bgm"
const { Search } = Input
/* ──────────── 分类标签 ──────────── */
const CATEGORY_LIST: {
key: BgmCategory | "all"
label: string
icon: string
}[] = [
{ key: "all", label: "全部", icon: "🎶" },
{ key: "轻快", label: "轻快", icon: "🎉" },
{ key: "治愈", label: "治愈", icon: "🌿" },
{ key: "科技", label: "科技", icon: "🔬" },
{ key: "电商", label: "电商", icon: "🛒" },
]
/* ──────────── Props ──────────── */
interface BgmSelectorProps {
open: boolean
onClose: () => void
config: BgmMixConfig
onChange: (config: BgmMixConfig) => void
}
const BgmSelector: React.FC<BgmSelectorProps> = ({ open, onClose, config, onChange }) => {
const [presets, setPresets] = useState<BgmPreset[]>([])
const [loading, setLoading] = useState(false)
const [activeCategory, setActiveCategory] = useState<BgmCategory | "all">("all")
const [keyword, setKeyword] = useState("")
const [previewingId, setPreviewingId] = useState<string | null>(null)
const audioRef = useRef<HTMLAudioElement | null>(null)
/* ── 加载 BGM 列表 ── */
const loadPresets = useCallback(async () => {
setLoading(true)
try {
const params: { category?: string; keyword?: string } = {}
if (activeCategory !== "all") params.category = activeCategory
if (keyword.trim()) params.keyword = keyword.trim()
const data = await getBgmPresets(params)
setPresets(data)
} catch {
message.error("加载 BGM 列表失败")
} finally {
setLoading(false)
}
}, [activeCategory, keyword])
useEffect(() => {
if (open) loadPresets()
}, [open, loadPresets])
/* ── 试听 ── */
const handlePreview = useCallback(
(bgm: BgmPreset) => {
if (previewingId === bgm.id) {
audioRef.current?.pause()
setPreviewingId(null)
return
}
audioRef.current?.pause()
const audio = new Audio(bgm.url)
audioRef.current = audio
audio.play().catch(() => {})
audio.onended = () => setPreviewingId(null)
setPreviewingId(bgm.id)
},
[previewingId],
)
/* ── 选中 BGM ── */
const handleSelect = useCallback(
(bgm: BgmPreset) => {
onChange({
...config,
enabled: true,
music_id: bgm.id,
})
},
[config, onChange],
)
/* ── 关闭时停止播放 ── */
const handleClose = useCallback(() => {
audioRef.current?.pause()
setPreviewingId(null)
onClose()
}, [onClose])
/* ── 移除 BGM ── */
const handleClear = useCallback(() => {
audioRef.current?.pause()
setPreviewingId(null)
onChange({ ...DEFAULT_BGM_MIX_CONFIG })
}, [onChange])
/* ── 当前选中的 BGM ── */
const selectedBgm = presets.find((p) => p.id === config.music_id)
return (
<Drawer
title="🎵 BGM 音乐选择"
placement="right"
width={420}
open={open}
onClose={handleClose}
className="bgm-selector-drawer"
>
{/* ── 搜索框 ── */}
<div className="bgm-search-row">
<Search
placeholder="搜索 BGM 名称..."
allowClear
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
onSearch={() => loadPresets()}
/>
</div>
{/* ── 分类标签 ── */}
<div className="bgm-category-bar">
{CATEGORY_LIST.map((cat) => (
<Tag
key={cat.key}
className={`bgm-category-tag${activeCategory === cat.key ? " active" : ""}`}
onClick={() => setActiveCategory(cat.key)}
>
{cat.icon} {cat.label}
</Tag>
))}
</div>
{/* ── BGM 列表 ── */}
<div className="bgm-list">
{loading && <div className="bgm-loading">...</div>}
{!loading && presets.length === 0 && <div className="bgm-empty"> BGM </div>}
{presets.map((bgm) => {
const isSelected = config.music_id === bgm.id
const isPlaying = previewingId === bgm.id
return (
<div
key={bgm.id}
className={`bgm-item${isSelected ? " selected" : ""}`}
onClick={() => handleSelect(bgm)}
>
<div className="bgm-item-cover">
{bgm.cover_url ? (
<img src={bgm.cover_url} alt={bgm.name} />
) : (
<span className="bgm-item-cover-icon">🎵</span>
)}
</div>
<div className="bgm-item-info">
<div className="bgm-item-name">{bgm.name}</div>
<div className="bgm-item-meta">
<span className="bgm-item-category">{bgm.category}</span>
<span className="bgm-item-duration">
{Math.floor(bgm.duration / 60)}:
{String(Math.floor(bgm.duration % 60)).padStart(2, "0")}
</span>
</div>
{bgm.tags.length > 0 && (
<div className="bgm-item-tags">
{bgm.tags.slice(0, 3).map((t) => (
<span key={t} className="bgm-item-tag">
{t}
</span>
))}
</div>
)}
</div>
<button
className={`bgm-item-preview-btn${isPlaying ? " playing" : ""}`}
onClick={(e) => {
e.stopPropagation()
handlePreview(bgm)
}}
title={isPlaying ? "暂停" : "试听"}
>
{isPlaying ? "⏸" : "▶️"}
</button>
{isSelected && <span className="bgm-item-check"></span>}
</div>
)
})}
</div>
{/* ── 混音配置 ── */}
{config.enabled && config.music_id && (
<div className="bgm-mix-config">
<div className="bgm-mix-header">
<span></span>
<button className="bgm-mix-clear" onClick={handleClear}>
BGM
</button>
</div>
<div className="bgm-mix-selected">
{selectedBgm ? `当前:${selectedBgm.name}` : `当前:${config.music_id}`}
</div>
{/* 音量 */}
<div className="bgm-mix-field">
<label className="bgm-mix-label">
<span className="bgm-mix-value">{config.volume}%</span>
</label>
<Slider
min={0}
max={100}
value={config.volume}
onChange={(v) => onChange({ ...config, volume: v })}
/>
</div>
{/* 淡入 */}
<div className="bgm-mix-field">
<label className="bgm-mix-label">
<span className="bgm-mix-value">{config.fade_in.toFixed(1)}s</span>
</label>
<Slider
min={0}
max={3}
step={0.1}
value={config.fade_in}
onChange={(v) => onChange({ ...config, fade_in: v })}
/>
</div>
{/* 淡出 */}
<div className="bgm-mix-field">
<label className="bgm-mix-label">
<span className="bgm-mix-value">{config.fade_out.toFixed(1)}s</span>
</label>
<Slider
min={0}
max={3}
step={0.1}
value={config.fade_out}
onChange={(v) => onChange({ ...config, fade_out: v })}
/>
</div>
{/* 人声闪避 */}
<div className="bgm-mix-field bgm-mix-toggle-row">
<label className="bgm-mix-label">sidechain</label>
<div
className={`ep-toggle${config.voice_dodge ? " active" : ""}`}
onClick={() => onChange({ ...config, voice_dodge: !config.voice_dodge })}
>
<div className="ep-toggle-knob" />
</div>
</div>
</div>
)}
</Drawer>
)
}
export default BgmSelector
@@ -1,66 +0,0 @@
import React from "react"
import type { BgmPreset } from "@/api/bgm"
interface BgmItemProps {
bgm: BgmPreset
isSelected: boolean
isPlaying: boolean
onSelect: () => void
onPreview: () => void
}
/**
* 单个 BGM 列表项组件
*/
export const BgmItem: React.FC<BgmItemProps> = ({
bgm,
isSelected,
isPlaying,
onSelect,
onPreview,
}) => {
const formatDuration = (seconds: number) => {
const mins = Math.floor(seconds / 60)
const secs = String(Math.floor(seconds % 60)).padStart(2, "0")
return `${mins}:${secs}`
}
return (
<div className={`bgm-item${isSelected ? " selected" : ""}`} onClick={onSelect}>
<div className="bgm-item-cover">
{bgm.cover_url ? (
<img src={bgm.cover_url} alt={bgm.name} />
) : (
<span className="bgm-item-cover-icon">🎵</span>
)}
</div>
<div className="bgm-item-info">
<div className="bgm-item-name">{bgm.name}</div>
<div className="bgm-item-meta">
<span className="bgm-item-category">{bgm.category}</span>
<span className="bgm-item-duration">{formatDuration(bgm.duration)}</span>
</div>
{bgm.tags.length > 0 && (
<div className="bgm-item-tags">
{bgm.tags.slice(0, 3).map((t) => (
<span key={t} className="bgm-item-tag">
{t}
</span>
))}
</div>
)}
</div>
<button
className={`bgm-item-preview-btn${isPlaying ? " playing" : ""}`}
onClick={(e) => {
e.stopPropagation()
onPreview()
}}
title={isPlaying ? "暂停" : "试听"}
>
{isPlaying ? "⏸" : "▶️"}
</button>
{isSelected && <span className="bgm-item-check"></span>}
</div>
)
}
@@ -1,88 +0,0 @@
import React from "react"
import { Slider } from "antd"
import type { BgmMixConfig as BgmMixConfigType, BgmPreset } from "@/api/bgm"
interface BgmMixConfigProps {
config: BgmMixConfigType
selectedBgm: BgmPreset | undefined
onChange: (config: BgmMixConfigType) => void
onClear: () => void
}
/**
* BGM 混音配置面板
* 音量、淡入淡出、人声闪避等设置
*/
export const BgmMixConfig: React.FC<BgmMixConfigProps> = ({
config,
selectedBgm,
onChange,
onClear,
}) => {
return (
<div className="bgm-mix-config">
<div className="bgm-mix-header">
<span></span>
<button className="bgm-mix-clear" onClick={onClear}>
BGM
</button>
</div>
<div className="bgm-mix-selected">
{selectedBgm ? `当前:${selectedBgm.name}` : `当前:${config.music_id}`}
</div>
{/* 音量 */}
<div className="bgm-mix-field">
<label className="bgm-mix-label">
<span className="bgm-mix-value">{config.volume}%</span>
</label>
<Slider
min={0}
max={100}
value={config.volume}
onChange={(v) => onChange({ ...config, volume: v })}
/>
</div>
{/* 淡入 */}
<div className="bgm-mix-field">
<label className="bgm-mix-label">
<span className="bgm-mix-value">{config.fade_in.toFixed(1)}s</span>
</label>
<Slider
min={0}
max={3}
step={0.1}
value={config.fade_in}
onChange={(v) => onChange({ ...config, fade_in: v })}
/>
</div>
{/* 淡出 */}
<div className="bgm-mix-field">
<label className="bgm-mix-label">
<span className="bgm-mix-value">{config.fade_out.toFixed(1)}s</span>
</label>
<Slider
min={0}
max={3}
step={0.1}
value={config.fade_out}
onChange={(v) => onChange({ ...config, fade_out: v })}
/>
</div>
{/* 人声闪避 */}
<div className="bgm-mix-field bgm-mix-toggle-row">
<label className="bgm-mix-label">sidechain</label>
<div
className={`ep-toggle${config.voice_dodge ? " active" : ""}`}
onClick={() => onChange({ ...config, voice_dodge: !config.voice_dodge })}
>
<div className="ep-toggle-knob" />
</div>
</div>
</div>
)
}
@@ -1,124 +0,0 @@
/**
* BGM 选择器 — Drawer 形式
* 预设 BGM 列表(按风格分类)、搜索、试听、音量/淡入淡出/人声闪避配置
*/
import React, { useCallback } from "react"
import { Drawer, Input, Tag } from "antd"
import { type BgmMixConfig, DEFAULT_BGM_MIX_CONFIG } from "@/api/bgm"
import { useBgmSelector, CATEGORY_LIST } from "./useBgmSelector"
import { BgmItem } from "./BgmItem"
import { BgmMixConfig as BgmMixConfigPanel } from "./BgmMixConfig"
const { Search } = Input
interface BgmSelectorProps {
open: boolean
onClose: () => void
config: BgmMixConfig
onChange: (config: BgmMixConfig) => void
}
const BgmSelector: React.FC<BgmSelectorProps> = ({ open, onClose, config, onChange }) => {
const {
presets,
loading,
activeCategory,
setActiveCategory,
keyword,
setKeyword,
previewingId,
loadPresets,
handlePreview,
stopPreview,
} = useBgmSelector(open)
/* ── 选中 BGM ── */
const handleSelect = useCallback(
(bgmId: string) => {
onChange({
...config,
enabled: true,
music_id: bgmId,
})
},
[config, onChange],
)
/* ── 关闭时停止播放 ── */
const handleClose = useCallback(() => {
stopPreview()
onClose()
}, [stopPreview, onClose])
/* ── 移除 BGM ── */
const handleClear = useCallback(() => {
stopPreview()
onChange({ ...DEFAULT_BGM_MIX_CONFIG })
}, [stopPreview, onChange])
/* ── 当前选中的 BGM ── */
const selectedBgm = presets.find((p) => p.id === config.music_id)
return (
<Drawer
title="🎵 BGM 音乐选择"
placement="right"
width={420}
open={open}
onClose={handleClose}
className="bgm-selector-drawer"
>
{/* 搜索框 */}
<div className="bgm-search-row">
<Search
placeholder="搜索 BGM 名称..."
allowClear
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
onSearch={() => loadPresets()}
/>
</div>
{/* 分类标签 */}
<div className="bgm-category-bar">
{CATEGORY_LIST.map((cat) => (
<Tag
key={cat.key}
className={`bgm-category-tag${activeCategory === cat.key ? " active" : ""}`}
onClick={() => setActiveCategory(cat.key)}
>
{cat.icon} {cat.label}
</Tag>
))}
</div>
{/* BGM 列表 */}
<div className="bgm-list">
{loading && <div className="bgm-loading">...</div>}
{!loading && presets.length === 0 && <div className="bgm-empty"> BGM </div>}
{presets.map((bgm) => (
<BgmItem
key={bgm.id}
bgm={bgm}
isSelected={config.music_id === bgm.id}
isPlaying={previewingId === bgm.id}
onSelect={() => handleSelect(bgm.id)}
onPreview={() => handlePreview(bgm)}
/>
))}
</div>
{/* 混音配置 */}
{config.enabled && config.music_id && (
<BgmMixConfigPanel
config={config}
selectedBgm={selectedBgm}
onChange={onChange}
onClear={handleClear}
/>
)}
</Drawer>
)
}
export default BgmSelector
@@ -1,87 +0,0 @@
import { useState, useRef, useCallback, useEffect } from "react"
import { message } from "antd"
import { getBgmPresets, type BgmPreset, type BgmCategory } from "@/api/bgm"
/* ──────────── 分类标签 ──────────── */
export const CATEGORY_LIST: {
key: BgmCategory | "all"
label: string
icon: string
}[] = [
{ key: "all", label: "全部", icon: "🎶" },
{ key: "轻快", label: "轻快", icon: "🎉" },
{ key: "治愈", label: "治愈", icon: "🌿" },
{ key: "科技", label: "科技", icon: "🔬" },
{ key: "电商", label: "电商", icon: "🛒" },
]
/**
* BGM 选择器数据与交互 Hook
* 封装列表加载、搜索、分类筛选、试听播放逻辑
*/
export function useBgmSelector(open: boolean) {
const [presets, setPresets] = useState<BgmPreset[]>([])
const [loading, setLoading] = useState(false)
const [activeCategory, setActiveCategory] = useState<BgmCategory | "all">("all")
const [keyword, setKeyword] = useState("")
const [previewingId, setPreviewingId] = useState<string | null>(null)
const audioRef = useRef<HTMLAudioElement | null>(null)
/* ── 加载 BGM 列表 ── */
const loadPresets = useCallback(async () => {
setLoading(true)
try {
const params: { category?: string; keyword?: string } = {}
if (activeCategory !== "all") params.category = activeCategory
if (keyword.trim()) params.keyword = keyword.trim()
const data = await getBgmPresets(params)
setPresets(data)
} catch {
message.error("加载 BGM 列表失败")
} finally {
setLoading(false)
}
}, [activeCategory, keyword])
useEffect(() => {
if (open) loadPresets()
}, [open, loadPresets])
/* ── 试听 ── */
const handlePreview = useCallback(
(bgm: BgmPreset) => {
if (previewingId === bgm.id) {
audioRef.current?.pause()
setPreviewingId(null)
return
}
audioRef.current?.pause()
const audio = new Audio(bgm.url)
audioRef.current = audio
audio.play().catch(() => {})
audio.onended = () => setPreviewingId(null)
setPreviewingId(bgm.id)
},
[previewingId],
)
/* ── 停止播放(关闭/移除时调用) ── */
const stopPreview = useCallback(() => {
audioRef.current?.pause()
setPreviewingId(null)
}, [])
return {
presets,
loading,
activeCategory,
setActiveCategory,
keyword,
setKeyword,
previewingId,
loadPresets,
handlePreview,
stopPreview,
}
}