// 资产 · 背景音乐素材库 // 两块组件: // 1) 人群表(6 条房产人群预设,含年龄段 / 第一帧画面提示 / 口播第一句关键词 / 推荐风格 / 当前命中曲数) // 2) BGM 上传管理(顶栏批量上传 + 风格自定义弹窗(绑人群) + 曲库列表 + 人群推荐预览) // // 风格 schema:{name, audience_slugs:[]} —— 风格与人群多对多 // 复刻 / 合成时按 audience_slug 查 recommend 接口拿匹配曲库 const BGMLibraryPage = () => { const [tab, setTab] = React.useState("audiences"); // audiences | tracks const [audiences, setAudiences] = React.useState([]); const [styles, setStyles] = React.useState([]); // [{name, audience_slugs:[]}] const [tracks, setTracks] = React.useState([]); const [recommendByAudience, setRecommendByAudience] = React.useState({}); // slug → {total, bound_styles} const [filterAudience, setFilterAudience] = React.useState(""); const [filterStyle, setFilterStyle] = React.useState(""); const [recommendAudience, setRecommendAudience] = React.useState(""); // 曲库 tab 顶部「按人群预览」 const [recommendList, setRecommendList] = React.useState(null); const [loading, setLoading] = React.useState(false); const [err, setErr] = React.useState(""); const [msg, setMsg] = React.useState(""); // 上传 const fileInputRef = React.useRef(null); const topUploadRef = React.useRef(null); const [uploadAudiences, setUploadAudiences] = React.useState([]); const [uploadStyles, setUploadStyles] = React.useState([]); const [uploadNote, setUploadNote] = React.useState(""); const [uploading, setUploading] = React.useState(false); // 风格自定义弹窗 const [styleModalOpen, setStyleModalOpen] = React.useState(false); const [styleModalName, setStyleModalName] = React.useState(""); const [styleModalAudiences, setStyleModalAudiences] = React.useState([]); const [styleModalEditing, setStyleModalEditing] = React.useState(null); // null = 新建;string = 编辑该名 const [styleBusy, setStyleBusy] = React.useState(false); const loadCore = React.useCallback(async () => { setLoading(true); setErr(""); try { const [a, s, t] = await Promise.all([ API.bgmLibrary.audiences(), API.bgmLibrary.listStyles(), API.bgmLibrary.listTracks(filterAudience, filterStyle), ]); setAudiences(a.audiences || []); setStyles(s.styles || []); setTracks(t.tracks || []); } catch (e) { setErr(String(e.message || e)); } finally { setLoading(false); } }, [filterAudience, filterStyle]); // 拉每个 audience 的命中数(人群表第 6 列展示) const loadRecommendCounts = React.useCallback(async (audList) => { const out = {}; for (const a of audList) { try { const r = await API.bgmLibrary.recommendForAudience(a.slug, 200); out[a.slug] = { total: r.total || 0, bound_styles: r.bound_styles || [] }; } catch (_) { out[a.slug] = { total: 0, bound_styles: [] }; } } setRecommendByAudience(out); }, []); React.useEffect(() => { loadCore(); }, [loadCore]); React.useEffect(() => { if (audiences.length > 0) loadRecommendCounts(audiences); }, [audiences, tracks, styles, loadRecommendCounts]); const toggle = (list, val, setter) => { setter(list.includes(val) ? list.filter(x => x !== val) : [...list, val]); }; // ---- 风格弹窗 ---- const openNewStyleModal = () => { setStyleModalEditing(null); setStyleModalName(""); setStyleModalAudiences([]); setStyleModalOpen(true); }; const openEditStyleModal = (s) => { setStyleModalEditing(s.name); setStyleModalName(s.name); setStyleModalAudiences([...(s.audience_slugs || [])]); setStyleModalOpen(true); }; const submitStyleModal = async () => { const n = (styleModalName || "").trim(); if (!n) return; setStyleBusy(true); setErr(""); setMsg(""); try { if (styleModalEditing) { const r = await API.bgmLibrary.updateStyleAudiences(styleModalEditing, styleModalAudiences); setStyles(r.styles || []); setMsg(`已更新「${styleModalEditing}」的人群绑定`); } else { const r = await API.bgmLibrary.addStyle(n, styleModalAudiences); setStyles(r.styles || []); setMsg(r.added ? `已添加风格「${n}」` : `「${n}」已存在,已合并人群绑定`); } setStyleModalOpen(false); } catch (e) { setErr(String(e.message || e)); } finally { setStyleBusy(false); } }; const handleDeleteStyle = async (name) => { if (!confirm(`删除风格「${name}」?已绑定此风格的曲目分类会被清掉。`)) return; setStyleBusy(true); setErr(""); try { const r = await API.bgmLibrary.deleteStyle(name); setStyles(r.styles || []); setUploadStyles(uploadStyles.filter(s => s !== name)); if (filterStyle === name) setFilterStyle(""); await loadCore(); } catch (e) { setErr(String(e.message || e)); } finally { setStyleBusy(false); } }; // ---- 上传 ---- const handleUpload = async (files, opts = {}) => { if (!files || !files.length) return; setUploading(true); setErr(""); setMsg(""); const fd = new FormData(); for (const f of files) fd.append("files", f); const audSlugs = opts.audiences || uploadAudiences; const styleNames = opts.styles || uploadStyles; const note = opts.note ?? uploadNote; fd.append("audience_slugs", audSlugs.join(",")); fd.append("style_names", styleNames.join(",")); fd.append("note", note); try { const r = await API.bgmLibrary.uploadTracks(fd); const ok = (r.uploaded || []).length; const skip = (r.skipped || []).length; const skipMsg = skip ? `,跳过 ${skip} 条(${(r.skipped || []).map(s=>s.reason).slice(0,3).join("; ")})` : ""; setMsg(`批量上传完成:成功 ${ok} 条${skipMsg}`); await loadCore(); } catch (e) { setErr(String(e.message || e)); } finally { setUploading(false); if (fileInputRef.current) fileInputRef.current.value = ""; if (topUploadRef.current) topUploadRef.current.value = ""; } }; const handleDeleteTrack = async (track) => { if (!confirm(`删除「${track.original_filename || track.filename}」?`)) return; try { await API.bgmLibrary.deleteTrack(track.track_id); await loadCore(); } catch (e) { setErr(String(e.message || e)); } }; // ---- 人群推荐预览 ---- const loadRecommend = async (slug) => { setRecommendAudience(slug); if (!slug) { setRecommendList(null); return; } try { const r = await API.bgmLibrary.recommendForAudience(slug, 50); setRecommendList(r); } catch (e) { setErr(String(e.message || e)); setRecommendList(null); } }; const audienceName = (slug) => { const a = audiences.find(x => x.slug === slug); return a ? a.name : slug; }; return ( <> {audiences.length} 人群 · {styles.length} 风格 · {tracks.length} 曲目 {/* 顶栏批量上传:随便在哪个 tab 都能用 */} handleUpload(e.target.files, { audiences: [], styles: [], note: "" })} style={{display:"none"}} /> }/> {/* Tab 切换 */}
{[ {k:"audiences",l:"模块一 · 人群 × 风格绑定"}, {k:"tracks",l:"模块二 · BGM 曲库 / 推荐预览"}, ].map(t=>(
setTab(t.k)} style={{ padding:"10px 18px", fontSize:12, cursor:"pointer", color: tab===t.k?"var(--fg-0)":"var(--fg-3)", fontWeight: tab===t.k?600:400, borderBottom: tab===t.k?"2px solid var(--cyan)":"2px solid transparent", }}>{t.l}
))}
{(err || msg) && (
{err &&
{err}
} {msg && !err &&
{msg}
}
)}
{tab === "audiences" ? (
{/* 人群预设表 */}
房产 6 大人群 · 标签 / 画面 / 口播 / 推荐 BGM 风格 / 命中曲数
{audiences.length} 条预设
人群标签
年龄段
第一帧画面提示
口播第一句关键词
已绑风格 / 命中曲
{audiences.map((a,i)=> { const recAt = recommendByAudience[a.slug] || {total:0, bound_styles:[]}; return (
{a.name} {a.slug}
{a.age_range}
{a.first_frame_hint}
{a.opening_keyword}
{(recAt.bound_styles || []).length === 0 ? 未绑风格 : recAt.bound_styles.map(s=>({s}))}
0 ? "var(--cyan-soft)" : "var(--bg-2)", color: recAt.total > 0 ? "var(--cyan)" : "var(--fg-3)", }}>{recAt.total} 条 BGM 命中
); })}
{/* 风格清单:每条带 audience_slugs,可编辑可删 */}
风格清单 · 与人群多对多绑定
{styles.length} 条
{styles.length === 0 ? (
暂无风格,点右上「新建风格」开始。
) : (
风格名
绑定人群
操作
{styles.map((s,i)=>(
{s.name}
{(s.audience_slugs || []).length === 0 ? 未绑 : (s.audience_slugs || []).map(slug=>( {audienceName(slug)} ))}
))}
)}
联动说明
· 风格 ↔ 人群多对多:一条风格可同时绑给多个人群;一个人群可关联多条风格。
· 曲目挂哪条:曲目本身可选直接打人群标签 + 多个风格。复刻时按 audience 拉曲时,会合并「直接打 audience 的曲」与「该 audience 绑定的风格命中曲」。
· 复刻自动选曲:质检合成 (POST /api/jobs/{id}/compose) 现已支持 audience_slug 字段,传入后会优先用该人群推荐的曲池;未命中再回退 music.zip 随机池。
) : ( <> {/* 左侧:上传 + 自定义风格快捷入口 */}
批量上传(可一次选多个文件)
handleUpload(e.target.files)} style={{display:"none"}} />
人群(多选) · 下拉
{uploadAudiences.map(slug=>( {audienceName(slug)} setUploadAudiences(uploadAudiences.filter(x=>x!==slug))} style={{marginLeft:4, cursor:"pointer", fontWeight:700}}>× ))}
风格(多选)
{uploadStyles.map(s=>( {s} setUploadStyles(uploadStyles.filter(x=>x!==s))} style={{marginLeft:4, cursor:"pointer", fontWeight:700}}>× ))}
备注(可选)
setUploadNote(e.target.value)} placeholder="例:80s 朱明瑛风" style={{padding:"6px 8px", fontSize:11.5, border:"1px solid var(--line-1)", borderRadius:4, background:"var(--bg-1)"}}/> 支持 mp3 / m4a / wav / aac / flac / ogg,单文件 ≤ 50MB,可一次多选
{/* 右侧:人群推荐预览 + 全量曲库列表 */}
按人群推荐预览(复刻时实际用到的曲池)
{!recommendAudience ? (
选一个人群即可预览该人群的可用 BGM 池。
) : !recommendList ? (
加载中…
) : (
人群 {audienceName(recommendList.audience)} · 已绑风格 [ {(recommendList.bound_styles || []).join(" · ") || "无"} ] · 命中曲目 {recommendList.total}
{recommendList.tracks.length === 0 ? (
当前该人群没有任何命中曲目。建议先给「{audienceName(recommendList.audience)}」绑定 1-2 条风格,并上传对应风格的 BGM。
) : recommendList.tracks.map((t,i)=>(
{t.original_filename || t.filename} {(t._match_reason || []).join(" + ")} {(t._matched_styles || []).length > 0 && ` · 经风格 ${(t._matched_styles).join("/")}`}
))}
)}
人群过滤
风格过滤
{(filterAudience || filterStyle) && ( )}
{tracks.length} 条
全量曲库
{tracks.length === 0 ? (
{loading ? "加载中…" : "暂无曲目。顶栏「批量上传 BGM」开始。"}
) : (
名称
人群
风格
试听
操作
{tracks.map((t,i)=>(
{t.original_filename || t.filename} {t.track_id} · {(t.size_bytes/1024).toFixed(0)} KB {t.note && {t.note}}
{(t.audience_slugs || []).length === 0 ? 未分类 : (t.audience_slugs || []).map(s=>( {audienceName(s)} ))}
{(t.style_names || []).length === 0 ? : (t.style_names || []).map(s=>( {s} ))}
))}
)}
)}
{/* 风格自定义弹窗 */} {styleModalOpen && (
!styleBusy && setStyleModalOpen(false)}>
e.stopPropagation()} style={{ background:"var(--bg-1)", border:"1px solid var(--line-1)", borderRadius:8, width:420, padding:20, }}>
{styleModalEditing ? `编辑风格:${styleModalEditing}` : "新建风格"}
风格名 setStyleModalName(e.target.value)} disabled={!!styleModalEditing} placeholder="例:电子 / 民谣 / 国风 / 卡通" style={{padding:"6px 8px", fontSize:12, border:"1px solid var(--line-1)", borderRadius:4, background: styleModalEditing ? "var(--bg-2)" : "var(--bg-1)"}}/> {styleModalEditing && ( 编辑模式下风格名只读;要改名请先删了重建。 )}
绑定人群(多选) · 决定复刻时哪种人群会用到该风格
{audiences.map(a=>( toggle(styleModalAudiences, a.slug, setStyleModalAudiences)} className={`tag ${styleModalAudiences.includes(a.slug)?"cyan":""}`} style={{cursor:"pointer", fontSize:10.5}}> {a.name} · {a.age_range} ))}
)} ); }; Object.assign(window, { BGMLibraryPage });