Compare commits

..

4 Commits

Author SHA1 Message Date
xiaoxia 994e14585c fix: 重命名BgmMixConfig类型避免与组件名冲突
CI/CD Pipeline / CI Gate (pull_request) CI运行中 [frontend-only]
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 13s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 59s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m25s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m30s
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 23s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 1m2s
CI/CD Pipeline / Validate - Code Quality (pull_request) Failing after 3m8s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 2m4s
AI Code Review / AI Code Review (pull_request) Successful in 2m5s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m11s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 6m46s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 7m47s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Has been cancelled
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 / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 15s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 13s
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
2026-07-27 15:25:17 +08:00
xiaoxia 7f3ca7c029 fix(bgm-selector): 恢复主入口文件,re-export 子目录保持路径兼容
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 8s
CI/CD Pipeline / Validate - Code Quality (pull_request) Failing after 1m36s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 59s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m7s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 45s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 24s
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 API Image (pull_request) Successful in 2m6s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 36s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 38s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 24s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 3m11s
AI Code Review / AI Code Review (pull_request) Successful in 3m26s
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
2026-07-27 12:40:31 +08:00
xiaoxia e4fa8492a5 Merge branch 'develop' into refactor/bgm-selector-split
CI/CD Pipeline / Check if frontend-only change (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Lint (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / PR Build API Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Web Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
AI Code Review / AI Code Review (pull_request) Has been cancelled
Preview Deploy / Deploy Preview Environment (pull_request) Has been cancelled
PR Automation / Auto Approve on CI Green (pull_request) Successful in 41s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 48s
2026-07-27 12:34:25 +08:00
xiaoxia 192947b884 refactor(editing-planner): 拆分 BgmSelector 组件,抽离 Hook + 子组件
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 26s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m8s
CI/CD Pipeline / Validate - Code Quality (pull_request) Failing after 1m53s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 38s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 16s
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 / Validate - Migration (alembic) (pull_request) Successful in 1m3s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 1m3s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m15s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 42s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 2m25s
AI Code Review / AI Code Review (pull_request) Successful in 2m10s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 6m53s
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 37s
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 / 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
主文件 273→120 行(-56%),拆分为:
- useBgmSelector: 列表加载、搜索、分类、试听逻辑
- BgmItem: 单个 BGM 列表项
- BgmMixConfig: 混音配置面板
- index: 组合入口 + Drawer 布局

导入路径保持向后兼容
2026-07-27 07:31:45 +08:00
9 changed files with 556 additions and 486 deletions
+188
View File
@@ -0,0 +1,188 @@
/**
* TTS 语音合成 API
* 对接后端 /api/v1/tts/* 端点
*
* 任务 3.14 新增
*/
import apiClient from "./client"
/* ── 类型定义 ──────────────────────────────────── */
/** TTS 元数据(合成时附带的扩展信息) */
export interface TTSMetadata {
/** 语音时长(秒) */
duration?: number
/** 采样率(Hz */
sample_rate?: number
/** 语言 */
language?: string
/** 其他扩展字段 */
[key: string]: unknown
}
/** TTS 合成请求参数 */
export interface TTSSynthesizeRequest {
text: string
voice_id?: string
output_name?: string
language?: string
speed?: number
voice_model?: string
voice_clone_profile_id?: string
format?: string
metadata?: TTSMetadata
}
/** TTS 合成创建响应 */
export interface TTSSynthesizeResponse {
job_id: string
status: string
message: string
}
/** TTS 任务详情 */
export interface TTSJob {
id: string
user_id: string
project_id: string | null
text: string
voice_id: string | null
voice_model: string | null
voice_clone_profile_id: string | null
language: string
speed: number
output_name: string | null
output_audio_url: string | null
output_format: string
duration_seconds: number | null
file_size_bytes: number | null
sample_rate: number | null
status: string
error_message: string | null
retry_count: number
max_retries: number
metadata_: TTSMetadata | null
created_at: string
updated_at: string
}
/** TTS 任务状态(轻量轮询用) */
export interface TTSJobStatus {
id: string
status: string
output_audio_url: string | null
error_message: string | null
duration_seconds: number | null
retry_count: number
}
/** TTS 任务列表响应 */
export interface TTSJobListResponse {
items: TTSJob[]
total: number
skip: number
limit: number
}
/** TTS 任务列表查询参数 */
export interface TTSJobListParams {
status?: string
skip?: number
limit?: number
}
/* ── API 函数 ──────────────────────────────────── */
/** 创建 TTS 合成任务 */
export const synthesizeSpeech = async (
data: TTSSynthesizeRequest,
): Promise<TTSSynthesizeResponse> => {
const response = await apiClient.post<TTSSynthesizeResponse>("/tts/synthesize", data)
return response.data
}
/** 获取 TTS 任务详情 */
export const getTTSJob = async (jobId: string): Promise<TTSJob> => {
const response = await apiClient.get<TTSJob>(`/tts/jobs/${jobId}`)
return response.data
}
/** 获取 TTS 任务状态(轻量轮询) */
export const getTTSJobStatus = async (jobId: string): Promise<TTSJobStatus> => {
const response = await apiClient.get<TTSJobStatus>(`/tts/jobs/${jobId}/status`)
return response.data
}
/** 获取 TTS 任务列表 */
export const getTTSJobs = async (params?: TTSJobListParams): Promise<TTSJobListResponse> => {
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<TTSJobListResponse>(`/tts/jobs${qs ? `?${qs}` : ""}`)
return response.data
}
/** 存为素材请求参数 */
export interface SaveTtsToLibraryRequest {
name?: string
tag_ids?: string[]
}
/** 将 TTS 合成结果保存到配音库 */
export const saveTtsToLibrary = async (
jobId: string,
data?: SaveTtsToLibraryRequest,
): Promise<void> => {
await apiClient.post(`/tts/jobs/${jobId}/save-to-library`, data ?? {})
}
/** 删除 TTS 任务 */
export const deleteTTSJob = async (jobId: string): Promise<void> => {
await apiClient.delete(`/tts/jobs/${jobId}`)
}
/* ── 音色列表 ──────────────────────────────────── */
/** TTS 音色 */
export interface TTSVoice {
id: string
name: string
/** 音色分类标签:male/female/young/service/news/emotion */
category?: string
/** 语言 */
language?: string
/** 试听 URL */
preview_url?: string
/** 描述 */
description?: string
}
/** 获取 TTS 音色列表 */
export const getTtsVoices = async (): Promise<TTSVoice[]> => {
const response = await apiClient.get<TTSVoice[]>("/tts/voices")
return response.data
}
/* ── TTS 试听 ──────────────────────────────────── */
/** TTS 试听请求参数 */
export interface TTSPreviewRequest {
text: string
voice_id: string
speed?: number
pitch?: number
}
/** TTS 试听响应 */
export interface TTSPreviewResponse {
audio_url: string
duration?: number
}
/** TTS 试听 */
export const previewTts = async (data: TTSPreviewRequest): Promise<TTSPreviewResponse> => {
const response = await apiClient.post<TTSPreviewResponse>("/tts/preview", data)
return response.data
}
-31
View File
@@ -1,31 +0,0 @@
/**
* TTS 语音合成 API — 目录化入口
* 保持与原 tts.ts 相同导出,向后兼容
*/
// 类型
export type {
TTSMetadata,
TTSSynthesizeRequest,
TTSSynthesizeResponse,
TTSJob,
TTSJobStatus,
TTSJobListResponse,
TTSJobListParams,
SaveTtsToLibraryRequest,
TTSVoice,
TTSPreviewRequest,
TTSPreviewResponse,
} from "./types"
// API 函数
export {
synthesizeSpeech,
getTTSJob,
getTTSJobStatus,
getTTSJobs,
saveTtsToLibrary,
deleteTTSJob,
getTtsVoices,
previewTts,
} from "./jobs"
-72
View File
@@ -1,72 +0,0 @@
/**
* TTS 语音合成 API 函数
*/
import apiClient from "../client"
import type {
TTSSynthesizeRequest,
TTSSynthesizeResponse,
TTSJob,
TTSJobStatus,
TTSJobListResponse,
TTSJobListParams,
SaveTtsToLibraryRequest,
TTSVoice,
TTSPreviewRequest,
TTSPreviewResponse,
} from "./types"
/** 创建 TTS 合成任务 */
export const synthesizeSpeech = async (
data: TTSSynthesizeRequest,
): Promise<TTSSynthesizeResponse> => {
const response = await apiClient.post<TTSSynthesizeResponse>("/tts/synthesize", data)
return response.data
}
/** 获取 TTS 任务详情 */
export const getTTSJob = async (jobId: string): Promise<TTSJob> => {
const response = await apiClient.get<TTSJob>(`/tts/jobs/${jobId}`)
return response.data
}
/** 获取 TTS 任务状态(轻量轮询) */
export const getTTSJobStatus = async (jobId: string): Promise<TTSJobStatus> => {
const response = await apiClient.get<TTSJobStatus>(`/tts/jobs/${jobId}/status`)
return response.data
}
/** 获取 TTS 任务列表 */
export const getTTSJobs = async (params?: TTSJobListParams): Promise<TTSJobListResponse> => {
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<TTSJobListResponse>(`/tts/jobs${qs ? `?${qs}` : ""}`)
return response.data
}
/** 将 TTS 合成结果保存到配音库 */
export const saveTtsToLibrary = async (
jobId: string,
data?: SaveTtsToLibraryRequest,
): Promise<void> => {
await apiClient.post(`/tts/jobs/${jobId}/save-to-library`, data ?? {})
}
/** 删除 TTS 任务 */
export const deleteTTSJob = async (jobId: string): Promise<void> => {
await apiClient.delete(`/tts/jobs/${jobId}`)
}
/** 获取 TTS 音色列表 */
export const getTtsVoices = async (): Promise<TTSVoice[]> => {
const response = await apiClient.get<TTSVoice[]>("/tts/voices")
return response.data
}
/** TTS 试听 */
export const previewTts = async (data: TTSPreviewRequest): Promise<TTSPreviewResponse> => {
const response = await apiClient.post<TTSPreviewResponse>("/tts/preview", data)
return response.data
}
-112
View File
@@ -1,112 +0,0 @@
/**
* TTS 语音合成类型定义
*/
/** TTS 元数据 */
export interface TTSMetadata {
duration?: number
sample_rate?: number
language?: string
[key: string]: unknown
}
/** TTS 合成请求参数 */
export interface TTSSynthesizeRequest {
text: string
voice_id?: string
output_name?: string
language?: string
speed?: number
voice_model?: string
voice_clone_profile_id?: string
format?: string
metadata?: TTSMetadata
}
/** TTS 合成创建响应 */
export interface TTSSynthesizeResponse {
job_id: string
status: string
message: string
}
/** TTS 任务详情 */
export interface TTSJob {
id: string
user_id: string
project_id: string | null
text: string
voice_id: string | null
voice_model: string | null
voice_clone_profile_id: string | null
language: string
speed: number
output_name: string | null
output_audio_url: string | null
output_format: string
duration_seconds: number | null
file_size_bytes: number | null
sample_rate: number | null
status: string
error_message: string | null
retry_count: number
max_retries: number
metadata_: TTSMetadata | null
created_at: string
updated_at: string
}
/** TTS 任务状态(轻量轮询用) */
export interface TTSJobStatus {
id: string
status: string
output_audio_url: string | null
error_message: string | null
duration_seconds: number | null
retry_count: number
}
/** TTS 任务列表响应 */
export interface TTSJobListResponse {
items: TTSJob[]
total: number
skip: number
limit: number
}
/** TTS 任务列表查询参数 */
export interface TTSJobListParams {
status?: string
skip?: number
limit?: number
}
/** 存为素材请求参数 */
export interface SaveTtsToLibraryRequest {
name?: string
tag_ids?: string[]
}
/** TTS 音色 */
export interface TTSVoice {
id: string
name: string
category?: string
language?: string
preview_url?: string
description?: string
}
/** TTS 试听请求参数 */
export interface TTSPreviewRequest {
text: string
voice_id: string
speed?: number
pitch?: number
}
/** TTS 试听响应 */
export interface TTSPreviewResponse {
audio_url: string
duration?: number
}
@@ -1,273 +1,5 @@
/**
* BGM 选择器 — Drawer 形式
* 预设 BGM 列表(按风格分类)、搜索、试听、音量/淡入淡出/人声闪避配置
* BGM 选择器入口(向后兼容)
* 实际实现位于 ./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
export { default } from "./bgm-selector"
@@ -0,0 +1,66 @@
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>
)
}
@@ -0,0 +1,88 @@
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>
)
}
@@ -0,0 +1,124 @@
/**
* 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
@@ -0,0 +1,87 @@
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,
}
}