feat: 配音素材库对接真实 API + 导航标签更新
CI/CD Pipeline / Frontend Lint (push) Failing after 50h53m19s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 50h53m20s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1709h27m34s
CI/CD Pipeline / Deploy Staging (push) Failing after 1709h27m38s
CI/CD Pipeline / Deploy Production (push) Failing after 1709h27m36s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1709h27m36s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1709h59m5s

- VoiceMaterialLibrary.tsx: 移除 mock 数据,对接真实 Assets API
  - 列表: GET /assets?kind=voice
  - 上传: uploadAssetDirect → createAsset
  - 试听: 使用 file_url 播放
  - 编辑: PUT /assets/{id}
  - 删除: DELETE /assets/{id}
  - metadata 格式: voice_name, gender, speed, style, description
- navigation.tsx: 侧边栏标签改为「配音素材库」
- PageHead.tsx: 页面标题改为「配音素材库」
This commit is contained in:
灵应
2026-07-07 10:01:08 +08:00
parent 30c5cd4bbe
commit ae9a79f01d
3 changed files with 304 additions and 171 deletions
+1 -1
View File
@@ -62,7 +62,7 @@ const ROUTE_TITLE_MAP: Record<string, string> = {
"/app/editing-planner": "剪辑规划",
"/app/my-templates": "我的模板",
"/app/voice-clone": "我的音色",
"/app/voice-materials": "配音素材",
"/app/voice-materials": "配音素材库",
"/app/accounts": "账号管理",
"/app/duplication": "查重",
"/app/duplication/results": "查重结果",
+2 -2
View File
@@ -69,7 +69,7 @@ export const NAV_ITEMS: NavItem[] = [
},
{
key: "voice-materials",
label: "配音素材",
label: "配音素材库",
path: "/app/voice-materials",
icon: <AudioOutlined />,
},
@@ -161,7 +161,7 @@ export const NAV_GROUPS: NavGroup[] = [
},
{
key: "voice-materials",
label: "配音素材",
label: "配音素材库",
path: "/app/voice-materials",
icon: <AudioOutlined />,
},
@@ -1,16 +1,15 @@
/**
* 配音素材管理页面 — V21 设计系统
*
* 功能:配音素材的增删改查
* 功能:配音素材的增删改查(对接真实 API)
* - 卡片 / 列表双布局
* - 上传配音(音频文件 + 元信息)
* - 试听播放
* - 试听播放(使用 file_url 真实播放)
* - 编辑元信息(名称、描述、性别、风格标签)
* - 删除素材
*
* 当前阶段:静态页面 + Mock 数据,等后端 API 就绪后对接
*/
import React, { useState, useRef, useCallback, useEffect } from "react";
import React, { useState, useRef, useCallback, useEffect, useMemo } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import {
AudioOutlined,
PlayCircleOutlined,
@@ -30,6 +29,16 @@ import {
} from "@ant-design/icons";
import { Button, Input, Select, Modal, Tag } from "@/components/ui";
import PageHead from "@/components/layout/PageHead";
import {
getAssetsByKind,
createAsset,
updateAsset,
deleteAsset,
uploadAssetDirect,
getAssetLibraries,
createAssetLibrary,
type AssetItem,
} from "@/api/assets";
import "./voice-materials.css";
/* ============================================================
@@ -61,7 +70,7 @@ const GENDER_OPTIONS: { value: VoiceGender; label: string; icon: React.ReactNode
{ value: "neutral", label: "中性", icon: <SoundOutlined /> },
];
/** 配音素材数据模型 */
/** 前端配音素材数据模型(从 AssetItem 映射) */
interface VoiceMaterial {
id: string;
name: string;
@@ -69,114 +78,53 @@ interface VoiceMaterial {
gender: VoiceGender;
tags: string[];
fileName: string;
fileSize: number; // bytes
duration: number; // seconds
fileSize: number;
duration: number;
mimeType: string;
createdAt: string;
fileUrl?: string;
}
/* ============================================================
* Mock 数据
* 数据映射:AssetItem ↔ VoiceMaterial
* ============================================================ */
const MOCK_MATERIALS: VoiceMaterial[] = [
{
id: "vm-001",
name: "品牌宣传片配音",
description: "适合企业品牌宣传片的男声配音,沉稳大气",
gender: "male",
tags: ["沉稳", "专业"],
fileName: "brand_promo.mp3",
fileSize: 4_500_000,
duration: 45,
mimeType: "audio/mpeg",
createdAt: "2026-06-28T10:00:00Z",
},
{
id: "vm-002",
name: "产品解说配音-活力版",
description: "适合短视频产品介绍的活力女声",
gender: "female",
tags: ["活力", "甜美"],
fileName: "product_intro_v1.mp3",
fileSize: 2_800_000,
duration: 28,
mimeType: "audio/mpeg",
createdAt: "2026-06-29T14:30:00Z",
},
{
id: "vm-003",
name: "儿童故事旁白",
description: "温柔亲切的童声风格,适合儿童内容",
gender: "child",
tags: ["温柔", "清新"],
fileName: "kids_story.wav",
fileSize: 8_200_000,
duration: 82,
mimeType: "audio/wav",
createdAt: "2026-07-01T09:15:00Z",
},
{
id: "vm-004",
name: "新闻播报配音",
description: "专业新闻播报风格,中性偏男声",
gender: "neutral",
tags: ["专业", "庄重"],
fileName: "news_report.mp3",
fileSize: 3_600_000,
duration: 36,
mimeType: "audio/mpeg",
createdAt: "2026-07-02T16:45:00Z",
},
{
id: "vm-005",
name: "广告旁白-磁性男声",
description: "磁性男声,适合高端产品广告",
gender: "male",
tags: ["磁性", "专业"],
fileName: "ad_voice_male.mp3",
fileSize: 1_900_000,
duration: 19,
mimeType: "audio/mpeg",
createdAt: "2026-07-03T11:20:00Z",
},
{
id: "vm-006",
name: "教程解说配音",
description: "清晰亲切的女声解说,适合教学视频",
gender: "female",
tags: ["清新", "专业"],
fileName: "tutorial_voice.mp3",
fileSize: 5_100_000,
duration: 51,
mimeType: "audio/mpeg",
createdAt: "2026-07-04T08:00:00Z",
},
{
id: "vm-007",
name: "搞笑视频配音",
description: "幽默风格的男声配音,适合搞笑短视频",
gender: "male",
tags: ["幽默", "活力"],
fileName: "funny_voice.mp3",
fileSize: 1_200_000,
duration: 12,
mimeType: "audio/mpeg",
createdAt: "2026-07-05T13:30:00Z",
},
{
id: "vm-008",
name: "有声书旁白-温柔女声",
description: "温柔细腻的女声,适合有声读物和情感类内容",
gender: "female",
tags: ["温柔", "甜美"],
fileName: "audiobook_voice.mp3",
fileSize: 12_000_000,
duration: 120,
mimeType: "audio/mpeg",
createdAt: "2026-07-06T17:00:00Z",
},
];
/** 后端 AssetItem → 前端 VoiceMaterial */
const mapAssetToMaterial = (asset: AssetItem): VoiceMaterial => {
const meta = asset.metadata || {};
const tags = Array.isArray(meta.style)
? (meta.style as string[])
: typeof meta.style === "string" && meta.style
? (meta.style as string).split(",").map((s) => s.trim())
: [];
return {
id: asset.id,
name: asset.name,
description: (meta.description as string) || "",
gender: ((meta.gender as VoiceGender) || "neutral"),
tags,
fileName: asset.storage_key?.split("/").pop() || asset.name,
fileSize: asset.file_size || 0,
duration: (meta.duration as number) || 0,
mimeType: asset.mime_type || "audio/mpeg",
createdAt: asset.created_at || new Date().toISOString(),
fileUrl: asset.file_url,
};
};
/** 前端表单数据 → 后端 metadata */
const buildMetadata = (data: {
gender: VoiceGender;
description: string;
tags: string[];
duration?: number;
}): Record<string, unknown> => ({
gender: data.gender,
description: data.description,
style: data.tags,
duration: data.duration || 0,
});
/* ============================================================
* 工具函数
@@ -209,6 +157,23 @@ const formatDate = (iso: string): string =>
day: "2-digit",
});
/** 获取音频文件时长(秒) */
const getAudioDuration = (file: File): Promise<number> => {
return new Promise((resolve) => {
const audio = new Audio();
const url = URL.createObjectURL(file);
audio.addEventListener("loadedmetadata", () => {
resolve(audio.duration);
URL.revokeObjectURL(url);
});
audio.addEventListener("error", () => {
resolve(0);
URL.revokeObjectURL(url);
});
audio.src = url;
});
};
/* ============================================================
* 上传 / 编辑 表单
* ============================================================ */
@@ -498,6 +463,7 @@ const VoiceMaterialCard: React.FC<VoiceCardProps> = ({
e.stopPropagation();
isPlaying ? onPause() : onPlay();
}}
disabled={!material.fileUrl}
>
{isPlaying ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
</button>
@@ -566,6 +532,7 @@ const VoiceMaterialRow: React.FC<VoiceRowProps> = ({
e.stopPropagation();
isPlaying ? onPause() : onPlay();
}}
disabled={!material.fileUrl}
>
{isPlaying ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
</button>
@@ -647,59 +614,189 @@ const VoiceMaterialRow: React.FC<VoiceRowProps> = ({
* ============================================================ */
const VoiceMaterialLibrary: React.FC = () => {
// 数据(Mock)
const [materials, setMaterials] = useState<VoiceMaterial[]>(MOCK_MATERIALS);
const queryClient = useQueryClient();
// 视图
// ── 获取 voice 类型素材库(用于上传) ──────────────────────
const { data: libraries = [] } = useQuery({
queryKey: ["asset-libraries"],
queryFn: getAssetLibraries,
staleTime: 60_000,
});
const voiceLibrary = useMemo(
() => libraries.find((lib) => lib.kind === "voice"),
[libraries],
);
// 自动创建 voice 素材库(如果不存在)
const createLibMutation = useMutation({
mutationFn: () => createAssetLibrary({ name: "配音素材库", kind: "voice" }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] });
},
});
// 页面加载时,如果没有 voice 库则创建
useEffect(() => {
if (libraries.length > 0 && !voiceLibrary && !createLibMutation.isPending) {
createLibMutation.mutate();
}
}, [libraries, voiceLibrary, createLibMutation]);
// ── 获取配音素材列表 ──────────────────────────────────────
const {
data: assets = [],
isLoading,
} = useQuery({
queryKey: ["assets", "voice"],
queryFn: () => getAssetsByKind("voice"),
staleTime: 30_000,
});
const materials: VoiceMaterial[] = useMemo(
() => assets.map(mapAssetToMaterial),
[assets],
);
// ── 上传 mutation ─────────────────────────────────────────
const uploadMutation = useMutation({
mutationFn: async (data: {
file: File;
name: string;
gender: VoiceGender;
description: string;
tags: string[];
}) => {
// 1. 获取或等待 voice library
let lib = voiceLibrary;
if (!lib) {
if (createLibMutation.isPending) {
await createLibMutation.mutateAsync();
}
const libs = await queryClient.fetchQuery({
queryKey: ["asset-libraries"],
queryFn: getAssetLibraries,
});
lib = libs.find((l) => l.kind === "voice");
if (!lib) throw new Error("无法创建配音素材库");
}
// 2. 上传文件
const { storage_key } = await uploadAssetDirect({
file: data.file,
library_id: lib.id,
});
// 3. 获取音频时长
const duration = await getAudioDuration(data.file);
// 4. 创建素材记录
await createAsset({
library_id: lib.id,
name: data.name,
storage_key,
mime_type: data.file.type || "audio/mpeg",
metadata: buildMetadata({
gender: data.gender,
description: data.description,
tags: data.tags,
duration,
}),
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["assets", "voice"] });
},
});
// ── 编辑 mutation ─────────────────────────────────────────
const editMutation = useMutation({
mutationFn: (data: {
id: string;
name: string;
gender: VoiceGender;
description: string;
tags: string[];
}) =>
updateAsset(data.id, {
name: data.name,
metadata: buildMetadata({
gender: data.gender,
description: data.description,
tags: data.tags,
}),
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["assets", "voice"] });
},
});
// ── 删除 mutation ─────────────────────────────────────────
const deleteMutation = useMutation({
mutationFn: (assetId: string) => deleteAsset(assetId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["assets", "voice"] });
},
});
// ── 视图状态 ──────────────────────────────────────────────
const [viewMode, setViewMode] = useState<ViewMode>("card");
const [searchText, setSearchText] = useState("");
const [filterGender, setFilterGender] = useState<string>("all");
const [filterTag, setFilterTag] = useState<string>("all");
// 播放
// ── 播放状态 ──────────────────────────────────────────────
const [playingId, setPlayingId] = useState<string | null>(null);
const [currentTime, setCurrentTime] = useState(0);
const intervalRef = useRef<number | null>(null);
const audioRef = useRef<HTMLAudioElement | null>(null);
// 弹窗
// ── 弹窗状态 ──────────────────────────────────────────────
const [uploadOpen, setUploadOpen] = useState(false);
const [editingMaterial, setEditingMaterial] = useState<VoiceMaterial | null>(null);
/* ── 播放控制 ─────────────────────────────────────────── */
// ── 播放控制 ──────────────────────────────────────────────
const stopPlayback = useCallback(() => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
if (audioRef.current) {
audioRef.current.pause();
audioRef.current = null;
}
setPlayingId(null);
setCurrentTime(0);
}, []);
const startPlayback = useCallback(
(id: string, duration: number) => {
(material: VoiceMaterial) => {
if (!material.fileUrl) return;
stopPlayback();
setPlayingId(id);
const audio = new Audio(material.fileUrl);
audioRef.current = audio;
audio.addEventListener("timeupdate", () => {
setCurrentTime(audio.currentTime);
});
audio.addEventListener("ended", () => {
setPlayingId(null);
setCurrentTime(0);
audioRef.current = null;
});
audio.play().catch(() => {
audioRef.current = null;
setPlayingId(null);
});
setPlayingId(material.id);
setCurrentTime(0);
intervalRef.current = window.setInterval(() => {
setCurrentTime((prev) => {
if (prev >= duration) {
if (intervalRef.current) clearInterval(intervalRef.current);
intervalRef.current = null;
setPlayingId(null);
return 0;
}
return prev + 0.1;
});
}, 100);
},
[stopPlayback],
);
const handlePlay = useCallback(
(id: string, duration: number) => {
if (playingId === id) return;
startPlayback(id, duration);
(material: VoiceMaterial) => {
if (playingId === material.id) return;
startPlayback(material);
},
[playingId, startPlayback],
);
@@ -709,44 +806,65 @@ const VoiceMaterialLibrary: React.FC = () => {
}, [stopPlayback]);
const handleSeek = useCallback(
(id: string, time: number, duration: number) => {
setCurrentTime(time);
if (playingId !== id) {
startPlayback(id, duration);
(material: VoiceMaterial, time: number) => {
if (audioRef.current) {
audioRef.current.currentTime = time;
setCurrentTime(time);
} else {
startPlayback(material);
setTimeout(() => {
if (audioRef.current) {
audioRef.current.currentTime = time;
}
}, 100);
}
},
[playingId, startPlayback],
[startPlayback],
);
// 组件卸载时清理 audio
useEffect(() => {
return () => {
if (intervalRef.current) clearInterval(intervalRef.current);
if (audioRef.current) {
audioRef.current.pause();
audioRef.current = null;
}
};
}, []);
/* ── 数据操作(Mock) ──────────────────────────────────── */
/* ── 数据操作 ──────────────────────────────────────────── */
const handleUpload = (data: Omit<VoiceMaterial, "id" | "createdAt"> & { file?: File }) => {
const newMaterial: VoiceMaterial = {
...data,
id: `vm-${Date.now()}`,
createdAt: new Date().toISOString(),
// 模拟音频时长(实际应从 Audio 元素获取)
duration: data.duration || Math.floor(Math.random() * 60) + 10,
};
setMaterials((prev) => [newMaterial, ...prev]);
setUploadOpen(false);
const handleUpload = (
data: Omit<VoiceMaterial, "id" | "createdAt"> & { file?: File },
) => {
if (!data.file) return;
uploadMutation.mutate(
{
file: data.file,
name: data.name,
gender: data.gender,
description: data.description,
tags: data.tags,
},
{
onSuccess: () => {
setUploadOpen(false);
},
},
);
};
const handleEdit = (data: Omit<VoiceMaterial, "id" | "createdAt"> & { file?: File }) => {
const handleEdit = (
data: Omit<VoiceMaterial, "id" | "createdAt"> & { file?: File },
) => {
if (!editingMaterial) return;
setMaterials((prev) =>
prev.map((m) =>
m.id === editingMaterial.id
? { ...m, name: data.name, description: data.description, gender: data.gender, tags: data.tags }
: m,
),
);
editMutation.mutate({
id: editingMaterial.id,
name: data.name,
gender: data.gender,
description: data.description,
tags: data.tags,
});
setEditingMaterial(null);
};
@@ -761,7 +879,7 @@ const VoiceMaterialLibrary: React.FC = () => {
cancelText: "取消",
onOk: () => {
if (playingId === id) stopPlayback();
setMaterials((prev) => prev.filter((m) => m.id !== id));
deleteMutation.mutate(id);
},
});
};
@@ -798,6 +916,9 @@ const VoiceMaterialLibrary: React.FC = () => {
/* ── 渲染 ─────────────────────────────────────────────── */
const isUploading = uploadMutation.isPending;
const isEditing = editMutation.isPending;
const pageActions = (
<div className="vmat-page-actions">
<Button
@@ -814,7 +935,7 @@ const VoiceMaterialLibrary: React.FC = () => {
return (
<div className="vmat-page">
<PageHead
title="配音素材"
title="配音素材库"
description="管理配音音频素材,支持上传、试听、编辑元信息"
actions={pageActions}
/>
@@ -877,8 +998,18 @@ const VoiceMaterialLibrary: React.FC = () => {
</div>
</div>
{/* 加载中 */}
{isLoading && (
<div className="vmat-empty">
<div className="vmat-empty-icon">
<AudioOutlined />
</div>
<h3>加载中...</h3>
</div>
)}
{/* 内容区 */}
{filtered.length > 0 && viewMode === "card" && (
{!isLoading && filtered.length > 0 && viewMode === "card" && (
<div className="vmat-grid">
{filtered.map((m) => (
<VoiceMaterialCard
@@ -886,9 +1017,9 @@ const VoiceMaterialLibrary: React.FC = () => {
material={m}
isPlaying={playingId === m.id}
currentTime={playingId === m.id ? currentTime : 0}
onPlay={() => handlePlay(m.id, m.duration)}
onPlay={() => handlePlay(m)}
onPause={handlePause}
onSeek={(t) => handleSeek(m.id, t, m.duration)}
onSeek={(t) => handleSeek(m, t)}
onEdit={() => setEditingMaterial(m)}
onDelete={() => handleDelete(m.id)}
/>
@@ -896,7 +1027,7 @@ const VoiceMaterialLibrary: React.FC = () => {
</div>
)}
{filtered.length > 0 && viewMode === "list" && (
{!isLoading && filtered.length > 0 && viewMode === "list" && (
<div className="vmat-list">
{/* 列表头 */}
<div className="vmat-list-header">
@@ -915,9 +1046,9 @@ const VoiceMaterialLibrary: React.FC = () => {
material={m}
isPlaying={playingId === m.id}
currentTime={playingId === m.id ? currentTime : 0}
onPlay={() => handlePlay(m.id, m.duration)}
onPlay={() => handlePlay(m)}
onPause={handlePause}
onSeek={(t) => handleSeek(m.id, t, m.duration)}
onSeek={(t) => handleSeek(m, t)}
onEdit={() => setEditingMaterial(m)}
onDelete={() => handleDelete(m.id)}
/>
@@ -925,7 +1056,7 @@ const VoiceMaterialLibrary: React.FC = () => {
</div>
)}
{filtered.length === 0 && (
{!isLoading && filtered.length === 0 && (
<div className="vmat-empty">
<div className="vmat-empty-icon">
<AudioOutlined />
@@ -961,6 +1092,7 @@ const VoiceMaterialLibrary: React.FC = () => {
<MaterialForm
onSubmit={handleUpload}
onCancel={() => setUploadOpen(false)}
loading={isUploading}
/>
</Modal>
@@ -978,6 +1110,7 @@ const VoiceMaterialLibrary: React.FC = () => {
initial={editingMaterial}
onSubmit={handleEdit}
onCancel={() => setEditingMaterial(null)}
loading={isEditing}
/>
)}
</Modal>