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
10 changed files with 542 additions and 488 deletions
+174
View File
@@ -0,0 +1,174 @@
/**
* 成品 / 视频相关 API
* 后端实际接口:/videos
*/
import apiClient from "./client"
/** 复核状态 */
export type ReviewStatus = "pending_review" | "approved" | "rejected"
/** 成品条目 */
export interface ProductItem {
id: string
title: string
video_url?: string
thumbnail_url?: string
duration_seconds?: number
file_size?: number
resolution?: string
status: "processing" | "completed" | "failed"
/** 复核状态 */
review_status?: ReviewStatus
/** 所属项目 ID */
project_id?: string
/** 所属项目名称 */
project_name?: string
/** 查重率(百分比) */
duplicate_rate?: number
created_at?: string
updated_at?: string
}
/** 列表查询参数 */
export interface ProductListParams {
page?: number
page_size?: number
project_id?: string
review_status?: ReviewStatus | "all"
}
/** 分页响应 */
export interface ProductListResponse {
items: ProductItem[]
total: number
page: number
page_size: number
}
/** 批量下载任务状态 */
export interface BatchDownloadStatus {
job_id: string
status: "processing" | "completed" | "failed"
/** 完成后返回的下载 URL */
download_url?: string
/** 进度百分比 */
progress?: number
}
/** 后端 /videos 接口返回的原始视频条目 */
interface VideoItem {
id: string
project_id: string
generation_task_id: string
name: string
file_url: string
file_size: number
duration: number
thumbnail_url: string | null
width: number
height: number
fps: number
status: string
review_status: ReviewStatus
generation_params: Record<string, unknown>
download_url: string
generated_at: string
}
/**
* 将后端 VideoItem 映射为 ProductItem 格式
*/
function mapVideoToProductItem(video: VideoItem): ProductItem {
return {
id: video.id,
title: video.name || "未命名视频",
// 优先用 download_url(带签名)播放,file_url 无签名无法访问
video_url: video.download_url || video.file_url,
thumbnail_url: video.thumbnail_url || undefined,
duration_seconds: video.duration,
file_size: video.file_size,
resolution: video.width && video.height ? `${video.width}x${video.height}` : undefined,
status:
video.status === "completed"
? "completed"
: video.status === "failed"
? "failed"
: "processing",
review_status: video.review_status,
project_id: video.project_id,
// 后端 /videos 接口暂无 project_name 字段
project_name: undefined,
// 后端字段名为 generated_at,映射为 created_at 供前端统一使用
created_at: video.generated_at,
updated_at: video.generated_at,
// 后端 /videos 接口暂无 duplicate_rate 字段
duplicate_rate: undefined,
}
}
/** 获取成品列表(支持分页和筛选) */
export const getProducts = async (params?: ProductListParams): Promise<ProductItem[]> => {
const response = await apiClient.get("/videos", { params })
const data = response.data
const videos: VideoItem[] = Array.isArray(data?.items)
? data.items
: Array.isArray(data)
? data
: []
return videos.map(mapVideoToProductItem)
}
/** 获取单个成品详情 */
export const getProduct = async (productId: string): Promise<ProductItem> => {
const response = await apiClient.get(`/videos/${productId}`)
return mapVideoToProductItem(response.data as VideoItem)
}
/**
* 删除成品
* 注意:后端暂未实现 /videos DELETE 接口,调用会返回 405
* 待后端实现后自动生效
*/
export const deleteProduct = async (productId: string): Promise<void> => {
await apiClient.delete(`/videos/${productId}`)
}
/**
* 获取成品下载链接
* 直接使用列表返回的 download_url(带OSS签名)
*/
export const getProductDownloadUrl = async (
productId: string,
): Promise<{ url: string; expires_at: string }> => {
// 优先从列表缓存取;如果没有则调详情接口
const product = await getProduct(productId)
if (!product.video_url) throw new Error("下载链接不可用")
return { url: product.video_url, expires_at: "" }
}
/** 更新复核状态 — TODO: 后端暂无对应端点,暂存本地状态 */
export const updateReviewStatus = async (
productId: string,
status: ReviewStatus,
): Promise<ProductItem> => {
// 后端暂无 /videos/{id}/review 端点
// 暂时返回当前状态,后续可扩展
const product = await getProduct(productId)
return { ...product, review_status: status }
}
/** 发起批量下载 — TODO: 后端暂无对应端点 */
export const batchDownload = async (videoIds: string[]): Promise<{ job_id: string }> => {
// 后端暂无 /videos/batch-download 端点
// 暂时返回模拟 job_id,后续可扩展
console.warn("[batchDownload] 后端暂无批量下载端点", videoIds)
return { job_id: `mock-${Date.now()}` }
}
/** 查询批量下载状态 — TODO: 后端暂无对应端点 */
export const getBatchDownloadStatus = async (jobId: string): Promise<BatchDownloadStatus> => {
// 后端暂无 /videos/batch-download/{jobId} 端点
// 暂时返回模拟状态,后续可扩展
console.warn("[getBatchDownloadStatus] 后端暂无批量下载状态端点", jobId)
return { job_id: jobId, status: "processing", progress: 0 }
}
-28
View File
@@ -1,28 +0,0 @@
/**
* 成品 / 视频相关 API — 目录化入口
* 保持与原 products.ts 相同导出,向后兼容
*/
// 类型
export type {
ReviewStatus,
ProductItem,
ProductListParams,
ProductListResponse,
BatchDownloadStatus,
VideoItem,
} from "./types"
// 工具函数
export { mapVideoToProductItem } from "./utils"
// API 函数
export {
getProducts,
getProduct,
deleteProduct,
getProductDownloadUrl,
updateReviewStatus,
batchDownload,
getBatchDownloadStatus,
} from "./products"
-80
View File
@@ -1,80 +0,0 @@
/**
* 成品 / 视频相关 API 函数
* 后端实际接口:/videos
*/
import apiClient from "../client"
import type {
BatchDownloadStatus,
ProductItem,
ProductListParams,
VideoItem,
ReviewStatus,
} from "./types"
import { mapVideoToProductItem } from "./utils"
/** 获取成品列表(支持分页和筛选) */
export const getProducts = async (params?: ProductListParams): Promise<ProductItem[]> => {
const response = await apiClient.get("/videos", { params })
const data = response.data
const videos: VideoItem[] = Array.isArray(data?.items)
? data.items
: Array.isArray(data)
? data
: []
return videos.map(mapVideoToProductItem)
}
/** 获取单个成品详情 */
export const getProduct = async (productId: string): Promise<ProductItem> => {
const response = await apiClient.get(`/videos/${productId}`)
return mapVideoToProductItem(response.data as VideoItem)
}
/**
* 删除成品
* 注意:后端暂未实现 /videos DELETE 接口,调用会返回 405
* 待后端实现后自动生效
*/
export const deleteProduct = async (productId: string): Promise<void> => {
await apiClient.delete(`/videos/${productId}`)
}
/**
* 获取成品下载链接
* 直接使用列表返回的 download_url(带OSS签名)
*/
export const getProductDownloadUrl = async (
productId: string,
): Promise<{ url: string; expires_at: string }> => {
// 优先从列表缓存取;如果没有则调详情接口
const product = await getProduct(productId)
if (!product.video_url) throw new Error("下载链接不可用")
return { url: product.video_url, expires_at: "" }
}
/** 更新复核状态 — TODO: 后端暂无对应端点,暂存本地状态 */
export const updateReviewStatus = async (
productId: string,
status: ReviewStatus,
): Promise<ProductItem> => {
// 后端暂无 /videos/{id}/review 端点
// 暂时返回当前状态,后续可扩展
const product = await getProduct(productId)
return { ...product, review_status: status }
}
/** 发起批量下载 — TODO: 后端暂无对应端点 */
export const batchDownload = async (videoIds: string[]): Promise<{ job_id: string }> => {
// 后端暂无 /videos/batch-download 端点
// 暂时返回模拟 job_id,后续可扩展
console.warn("[batchDownload] 后端暂无批量下载端点", videoIds)
return { job_id: `mock-${Date.now()}` }
}
/** 查询批量下载状态 — TODO: 后端暂无对应端点 */
export const getBatchDownloadStatus = async (jobId: string): Promise<BatchDownloadStatus> => {
// 后端暂无 /videos/batch-download/{jobId} 端点
// 暂时返回模拟状态,后续可扩展
console.warn("[getBatchDownloadStatus] 后端暂无批量下载状态端点", jobId)
return { job_id: jobId, status: "processing", progress: 0 }
}
-74
View File
@@ -1,74 +0,0 @@
/**
* 成品 / 视频相关类型定义
*/
/** 复核状态 */
export type ReviewStatus = "pending_review" | "approved" | "rejected"
/** 成品条目 */
export interface ProductItem {
id: string
title: string
video_url?: string
thumbnail_url?: string
duration_seconds?: number
file_size?: number
resolution?: string
status: "processing" | "completed" | "failed"
/** 复核状态 */
review_status?: ReviewStatus
/** 所属项目 ID */
project_id?: string
/** 所属项目名称 */
project_name?: string
/** 查重率(百分比) */
duplicate_rate?: number
created_at?: string
updated_at?: string
}
/** 列表查询参数 */
export interface ProductListParams {
page?: number
page_size?: number
project_id?: string
review_status?: ReviewStatus | "all"
}
/** 分页响应 */
export interface ProductListResponse {
items: ProductItem[]
total: number
page: number
page_size: number
}
/** 批量下载任务状态 */
export interface BatchDownloadStatus {
job_id: string
status: "processing" | "completed" | "failed"
/** 完成后返回的下载 URL */
download_url?: string
/** 进度百分比 */
progress?: number
}
/** 后端 /videos 接口返回的原始视频条目 */
export interface VideoItem {
id: string
project_id: string
generation_task_id: string
name: string
file_url: string
file_size: number
duration: number
thumbnail_url: string | null
width: number
height: number
fps: number
status: string
review_status: ReviewStatus
generation_params: Record<string, unknown>
download_url: string
generated_at: string
}
-35
View File
@@ -1,35 +0,0 @@
/**
* 成品数据转换工具函数
*/
import type { ProductItem, VideoItem } from "./types"
/**
* 将后端 VideoItem 映射为 ProductItem 格式
*/
export function mapVideoToProductItem(video: VideoItem): ProductItem {
return {
id: video.id,
title: video.name || "未命名视频",
// 优先用 download_url(带签名)播放,file_url 无签名无法访问
video_url: video.download_url || video.file_url,
thumbnail_url: video.thumbnail_url || undefined,
duration_seconds: video.duration,
file_size: video.file_size,
resolution: video.width && video.height ? `${video.width}x${video.height}` : undefined,
status:
video.status === "completed"
? "completed"
: video.status === "failed"
? "failed"
: "processing",
review_status: video.review_status,
project_id: video.project_id,
// 后端 /videos 接口暂无 project_name 字段
project_name: undefined,
// 后端字段名为 generated_at,映射为 created_at 供前端统一使用
created_at: video.generated_at,
updated_at: video.generated_at,
// 后端 /videos 接口暂无 duplicate_rate 字段
duplicate_rate: undefined,
}
}
@@ -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,
}
}