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
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:
@@ -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": "查重结果",
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user