diff --git a/apps/web/src/components/layout/PageHead.tsx b/apps/web/src/components/layout/PageHead.tsx index 1036cf3e7..e0c1ff521 100644 --- a/apps/web/src/components/layout/PageHead.tsx +++ b/apps/web/src/components/layout/PageHead.tsx @@ -62,6 +62,7 @@ const ROUTE_TITLE_MAP: Record = { "/app/editing-planner": "剪辑规划", "/app/my-templates": "我的模板", "/app/voice-clone": "我的音色", + "/app/voice-materials": "配音素材", "/app/accounts": "账号管理", "/app/duplication": "查重", "/app/duplication/results": "查重结果", diff --git a/apps/web/src/config/navigation.tsx b/apps/web/src/config/navigation.tsx index e5e09a150..cebd7c255 100644 --- a/apps/web/src/config/navigation.tsx +++ b/apps/web/src/config/navigation.tsx @@ -67,6 +67,12 @@ export const NAV_ITEMS: NavItem[] = [ path: "/app/voice-clone", icon: , }, + { + key: "voice-materials", + label: "配音素材", + path: "/app/voice-materials", + icon: , + }, { key: "templates", label: "模板库", @@ -153,6 +159,12 @@ export const NAV_GROUPS: NavGroup[] = [ path: "/app/voice-clone", icon: , }, + { + key: "voice-materials", + label: "配音素材", + path: "/app/voice-materials", + icon: , + }, { key: "titles", label: "标题库", diff --git a/apps/web/src/pages/voice-materials/VoiceMaterialLibrary.tsx b/apps/web/src/pages/voice-materials/VoiceMaterialLibrary.tsx new file mode 100644 index 000000000..655700195 --- /dev/null +++ b/apps/web/src/pages/voice-materials/VoiceMaterialLibrary.tsx @@ -0,0 +1,988 @@ +/** + * 配音素材管理页面 — V21 设计系统 + * + * 功能:配音素材的增删改查 + * - 卡片 / 列表双布局 + * - 上传配音(音频文件 + 元信息) + * - 试听播放 + * - 编辑元信息(名称、描述、性别、风格标签) + * - 删除素材 + * + * 当前阶段:静态页面 + Mock 数据,等后端 API 就绪后对接 + */ +import React, { useState, useRef, useCallback, useEffect } from "react"; +import { + AudioOutlined, + PlayCircleOutlined, + PauseCircleOutlined, + SearchOutlined, + PlusOutlined, + EditOutlined, + DeleteOutlined, + UploadOutlined, + UnorderedListOutlined, + AppstoreOutlined, + CloseOutlined, + SoundOutlined, + UserOutlined, + ManOutlined, + WomanOutlined, +} from "@ant-design/icons"; +import { Button, Input, Select, Modal, Tag } from "@/components/ui"; +import PageHead from "@/components/layout/PageHead"; +import "./voice-materials.css"; + +/* ============================================================ + * 类型定义 + * ============================================================ */ + +type VoiceGender = "male" | "female" | "child" | "neutral"; +type ViewMode = "card" | "list"; + +/** 风格标签预设 */ +const STYLE_TAGS = [ + "活力", + "沉稳", + "温柔", + "专业", + "甜美", + "磁性", + "清新", + "激昂", + "幽默", + "庄重", +]; + +/** 性别选项 */ +const GENDER_OPTIONS: { value: VoiceGender; label: string; icon: React.ReactNode }[] = [ + { value: "male", label: "男声", icon: }, + { value: "female", label: "女声", icon: }, + { value: "child", label: "童声", icon: }, + { value: "neutral", label: "中性", icon: }, +]; + +/** 配音素材数据模型 */ +interface VoiceMaterial { + id: string; + name: string; + description: string; + gender: VoiceGender; + tags: string[]; + fileName: string; + fileSize: number; // bytes + duration: number; // seconds + mimeType: string; + createdAt: string; +} + +/* ============================================================ + * Mock 数据 + * ============================================================ */ + +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", + }, +]; + +/* ============================================================ + * 工具函数 + * ============================================================ */ + +const genderLabel = (g: VoiceGender) => + GENDER_OPTIONS.find((o) => o.value === g)?.label ?? g; + +const genderIcon = (g: VoiceGender) => + GENDER_OPTIONS.find((o) => o.value === g)?.icon ?? null; + +const genderClass = (g: VoiceGender) => `vmat-gender--${g}`; + +const formatDuration = (seconds: number): string => { + const m = Math.floor(seconds / 60); + const s = Math.floor(seconds % 60); + return `${m}:${s.toString().padStart(2, "0")}`; +}; + +const formatFileSize = (bytes: number): string => { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +}; + +const formatDate = (iso: string): string => + new Date(iso).toLocaleDateString("zh-CN", { + year: "numeric", + month: "2-digit", + day: "2-digit", + }); + +/* ============================================================ + * 上传 / 编辑 表单 + * ============================================================ */ + +interface MaterialFormProps { + initial?: VoiceMaterial; + onSubmit: (data: Omit & { file?: File }) => void; + onCancel: () => void; + loading?: boolean; +} + +const MaterialForm: React.FC = ({ + initial, + onSubmit, + onCancel, + loading, +}) => { + const [name, setName] = useState(initial?.name ?? ""); + const [description, setDescription] = useState(initial?.description ?? ""); + const [gender, setGender] = useState(initial?.gender ?? "female"); + const [selectedTags, setSelectedTags] = useState(initial?.tags ?? []); + const [file, setFile] = useState(undefined); + const fileInputRef = useRef(null); + + const handleTagToggle = (tag: string) => { + setSelectedTags((prev) => + prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag], + ); + }; + + const handleSubmit = () => { + if (!name.trim()) return; + if (!initial && !file) return; + onSubmit({ + name: name.trim(), + description: description.trim(), + gender, + tags: selectedTags, + fileName: file?.name ?? initial?.fileName ?? "", + fileSize: file?.size ?? initial?.fileSize ?? 0, + duration: initial?.duration ?? 0, + mimeType: file?.type ?? initial?.mimeType ?? "audio/mpeg", + file, + }); + }; + + return ( +
+ {/* 音频文件上传(编辑模式不显示) */} + {!initial && ( +
+ +
fileInputRef.current?.click()} + onDragOver={(e) => e.preventDefault()} + onDrop={(e) => { + e.preventDefault(); + const f = e.dataTransfer.files[0]; + if (f?.type.startsWith("audio/")) setFile(f); + }} + > + { + const f = e.target.files?.[0]; + if (f) setFile(f); + }} + /> + {file ? ( +
+ + {file.name} + + {formatFileSize(file.size)} + + +
+ ) : ( +
+ +

点击或拖拽音频文件到此处

+ 支持 MP3、WAV、AAC、FLAC 等格式 +
+ )} +
+
+ )} + + {/* 名称 */} +
+ + setName(e.target.value)} + maxLength={50} + /> +
+ + {/* 音色描述 */} +
+ + setDescription(e.target.value)} + rows={3} + maxLength={200} + /> +
+ + {/* 性别 */} +
+ +
+ {GENDER_OPTIONS.map((opt) => ( + + ))} +
+
+ + {/* 风格标签 */} +
+ +
+ {STYLE_TAGS.map((tag) => ( + + ))} +
+
+ + {/* 操作按钮 */} +
+ + +
+
+ ); +}; + +/* ============================================================ + * 卡片组件 + * ============================================================ */ + +interface VoiceCardProps { + material: VoiceMaterial; + isPlaying: boolean; + currentTime: number; + onPlay: () => void; + onPause: () => void; + onSeek: (time: number) => void; + onEdit: () => void; + onDelete: () => void; +} + +const VoiceMaterialCard: React.FC = ({ + material, + isPlaying, + currentTime, + onPlay, + onPause, + onSeek, + onEdit, + onDelete, +}) => { + const progressRef = useRef(null); + + const handleProgressClick = (e: React.MouseEvent) => { + if (!progressRef.current) return; + const rect = progressRef.current.getBoundingClientRect(); + const percent = (e.clientX - rect.left) / rect.width; + onSeek(Math.max(0, Math.min(1, percent)) * material.duration); + }; + + const progress = + material.duration > 0 ? (currentTime / material.duration) * 100 : 0; + + return ( +
+ {/* 操作按钮 */} +
+ + +
+ + {/* 头部:图标 + 名称 + 性别 */} +
+
+ +
+
+

+ {material.name} +

+ + {genderIcon(material.gender)} + {genderLabel(material.gender)} + +
+
+ + {/* 描述 */} + {material.description && ( +

{material.description}

+ )} + + {/* 标签 */} +
+ {material.tags.map((tag) => ( + + {tag} + + ))} +
+ + {/* 元信息 */} +
+ {formatDuration(material.duration)} + {formatFileSize(material.fileSize)} + {formatDate(material.createdAt)} +
+ + {/* 播放控制 */} +
+ +
+
+
+ + {isPlaying ? formatDuration(currentTime) : formatDuration(material.duration)} + +
+
+ ); +}; + +/* ============================================================ + * 列表行组件 + * ============================================================ */ + +interface VoiceRowProps { + material: VoiceMaterial; + isPlaying: boolean; + currentTime: number; + onPlay: () => void; + onPause: () => void; + onSeek: (time: number) => void; + onEdit: () => void; + onDelete: () => void; +} + +const VoiceMaterialRow: React.FC = ({ + material, + isPlaying, + currentTime, + onPlay, + onPause, + onSeek, + onEdit, + onDelete, +}) => { + const progressRef = useRef(null); + + const handleProgressClick = (e: React.MouseEvent) => { + if (!progressRef.current) return; + const rect = progressRef.current.getBoundingClientRect(); + const percent = (e.clientX - rect.left) / rect.width; + onSeek(Math.max(0, Math.min(1, percent)) * material.duration); + }; + + const progress = + material.duration > 0 ? (currentTime / material.duration) * 100 : 0; + + return ( +
+ {/* 播放按钮 */} + + + {/* 名称 + 描述 */} +
+

{material.name}

+ {material.description && ( +

{material.description}

+ )} +
+ + {/* 性别 */} + + {genderIcon(material.gender)} + {genderLabel(material.gender)} + + + {/* 标签 */} +
+ {material.tags.map((tag) => ( + + {tag} + + ))} +
+ + {/* 进度条 */} +
+
+
+ + {/* 时长 */} + + {isPlaying ? formatDuration(currentTime) : formatDuration(material.duration)} + + + {/* 文件大小 */} + {formatFileSize(material.fileSize)} + + {/* 操作 */} +
+ + +
+
+ ); +}; + +/* ============================================================ + * 主组件 + * ============================================================ */ + +const VoiceMaterialLibrary: React.FC = () => { + // 数据(Mock) + const [materials, setMaterials] = useState(MOCK_MATERIALS); + + // 视图 + const [viewMode, setViewMode] = useState("card"); + const [searchText, setSearchText] = useState(""); + const [filterGender, setFilterGender] = useState("all"); + const [filterTag, setFilterTag] = useState("all"); + + // 播放 + const [playingId, setPlayingId] = useState(null); + const [currentTime, setCurrentTime] = useState(0); + const intervalRef = useRef(null); + + // 弹窗 + const [uploadOpen, setUploadOpen] = useState(false); + const [editingMaterial, setEditingMaterial] = useState(null); + + /* ── 播放控制 ─────────────────────────────────────────── */ + + const stopPlayback = useCallback(() => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + setPlayingId(null); + setCurrentTime(0); + }, []); + + const startPlayback = useCallback( + (id: string, duration: number) => { + stopPlayback(); + setPlayingId(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); + }, + [playingId, startPlayback], + ); + + const handlePause = useCallback(() => { + stopPlayback(); + }, [stopPlayback]); + + const handleSeek = useCallback( + (id: string, time: number, duration: number) => { + setCurrentTime(time); + if (playingId !== id) { + startPlayback(id, duration); + } + }, + [playingId, startPlayback], + ); + + useEffect(() => { + return () => { + if (intervalRef.current) clearInterval(intervalRef.current); + }; + }, []); + + /* ── 数据操作(Mock) ──────────────────────────────────── */ + + const handleUpload = (data: Omit & { 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 handleEdit = (data: Omit & { 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, + ), + ); + setEditingMaterial(null); + }; + + const handleDelete = (id: string) => { + const material = materials.find((m) => m.id === id); + if (!material) return; + Modal.confirm({ + title: "确认删除", + content: `确定删除配音素材「${material.name}」吗?删除后不可恢复。`, + okText: "删除", + okButtonProps: { danger: true }, + cancelText: "取消", + onOk: () => { + if (playingId === id) stopPlayback(); + setMaterials((prev) => prev.filter((m) => m.id !== id)); + }, + }); + }; + + /* ── 筛选 ─────────────────────────────────────────────── */ + + const filtered = React.useMemo(() => { + let list = materials; + if (filterGender !== "all") { + list = list.filter((m) => m.gender === filterGender); + } + if (filterTag !== "all") { + list = list.filter((m) => m.tags.includes(filterTag)); + } + if (searchText.trim()) { + const q = searchText.trim().toLowerCase(); + list = list.filter( + (m) => + m.name.toLowerCase().includes(q) || + m.description.toLowerCase().includes(q) || + m.tags.some((t) => t.toLowerCase().includes(q)), + ); + } + return list; + }, [materials, filterGender, filterTag, searchText]); + + /* ── 所有用到的标签(用于筛选下拉) ────────────────────── */ + + const allTags = React.useMemo(() => { + const set = new Set(); + materials.forEach((m) => m.tags.forEach((t) => set.add(t))); + return Array.from(set).sort(); + }, [materials]); + + /* ── 渲染 ─────────────────────────────────────────────── */ + + const pageActions = ( +
+ +
+ ); + + return ( +
+ + + {/* 工具栏:搜索 + 筛选 + 视图切换 */} +
+
+ } + value={searchText} + onChange={(e) => setSearchText(e.target.value)} + allowClear + style={{ width: 240 }} + /> + ({ value: t, label: t })), + ]} + /> +
+
+ + 共 {filtered.length} 个素材 + +
+ + +
+
+
+ + {/* 内容区 */} + {filtered.length > 0 && viewMode === "card" && ( +
+ {filtered.map((m) => ( + handlePlay(m.id, m.duration)} + onPause={handlePause} + onSeek={(t) => handleSeek(m.id, t, m.duration)} + onEdit={() => setEditingMaterial(m)} + onDelete={() => handleDelete(m.id)} + /> + ))} +
+ )} + + {filtered.length > 0 && viewMode === "list" && ( +
+ {/* 列表头 */} +
+ + 名称 + 性别 + 风格 + 试听 + 时长 + 大小 + 操作 +
+ {filtered.map((m) => ( + handlePlay(m.id, m.duration)} + onPause={handlePause} + onSeek={(t) => handleSeek(m.id, t, m.duration)} + onEdit={() => setEditingMaterial(m)} + onDelete={() => handleDelete(m.id)} + /> + ))} +
+ )} + + {filtered.length === 0 && ( +
+
+ +
+

暂无配音素材

+

+ {searchText || filterGender !== "all" || filterTag !== "all" + ? "未找到匹配的素材,试试调整筛选条件" + : "上传音频文件,开始管理配音素材"} +

+ {!searchText && filterGender === "all" && filterTag === "all" && ( + + )} +
+ )} + + {/* 上传弹窗 */} + setUploadOpen(false)} + footer={null} + width={520} + destroyOnClose + > + setUploadOpen(false)} + /> + + + {/* 编辑弹窗 */} + setEditingMaterial(null)} + footer={null} + width={520} + destroyOnClose + > + {editingMaterial && ( + setEditingMaterial(null)} + /> + )} + +
+ ); +}; + +export default VoiceMaterialLibrary; diff --git a/apps/web/src/pages/voice-materials/voice-materials.css b/apps/web/src/pages/voice-materials/voice-materials.css new file mode 100644 index 000000000..bcdb88ba5 --- /dev/null +++ b/apps/web/src/pages/voice-materials/voice-materials.css @@ -0,0 +1,814 @@ +/* ============================================================ + * 配音素材管理页面 — V21 设计系统 + * ============================================================ */ + +.vmat-page { + padding: 24px 32px; + min-height: 100%; +} + +/* ── 页面操作按钮 ─────────────────────────────────────────── */ + +.vmat-page-actions { + display: flex; + gap: 8px; +} + +/* ── 工具栏 ───────────────────────────────────────────────── */ + +.vmat-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 20px; + flex-wrap: wrap; +} + +.vmat-toolbar-left { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +.vmat-toolbar-right { + display: flex; + align-items: center; + gap: 12px; +} + +.vmat-result-count { + font-size: 13px; + color: var(--text-secondary); + white-space: nowrap; +} + +/* ── 视图切换 ─────────────────────────────────────────────── */ + +.vmat-view-toggle { + display: flex; + border: 1px solid var(--border-primary); + border-radius: var(--radius-md); + overflow: hidden; +} + +.vmat-view-btn { + display: flex; + align-items: center; + justify-content: center; + width: 34px; + height: 34px; + border: none; + background: var(--bg-surface); + color: var(--text-tertiary); + cursor: pointer; + transition: all var(--transition-fast); + font-size: 15px; +} + +.vmat-view-btn:first-child { + border-right: 1px solid var(--border-primary); +} + +.vmat-view-btn:hover { + color: var(--text-primary); + background: var(--bg-hover); +} + +.vmat-view-btn.active { + color: var(--primary-600); + background: var(--primary-50); +} + +/* ── 卡片网格 ─────────────────────────────────────────────── */ + +.vmat-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 16px; +} + +/* ── 卡片 ─────────────────────────────────────────────────── */ + +.vmat-card { + position: relative; + background: var(--bg-surface); + border: 1px solid var(--border-primary); + border-radius: var(--radius-lg); + padding: 18px; + transition: all var(--transition-normal); + display: flex; + flex-direction: column; + gap: 12px; +} + +.vmat-card:hover { + border-color: var(--primary-300); + box-shadow: var(--shadow-md); + transform: translateY(-1px); +} + +.vmat-card.playing { + border-color: var(--primary-500); + box-shadow: 0 0 0 1px var(--primary-500), var(--shadow-md); +} + +/* 性别色带 */ +.vmat-card::before { + content: ""; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 3px; + border-radius: var(--radius-lg) var(--radius-lg) 0 0; + background: var(--neutral-300); +} + +.vmat-card.vmat-gender--male::before { + background: var(--primary-500); +} + +.vmat-card.vmat-gender--female::before { + background: var(--secondary-500); +} + +.vmat-card.vmat-gender--child::before { + background: var(--accent-500); +} + +.vmat-card.vmat-gender--neutral::before { + background: linear-gradient(90deg, var(--primary-500), var(--secondary-500)); +} + +/* 卡片操作按钮 */ +.vmat-card-actions { + position: absolute; + top: 12px; + right: 12px; + display: flex; + gap: 4px; + opacity: 0; + transition: opacity var(--transition-fast); +} + +.vmat-card:hover .vmat-card-actions { + opacity: 1; +} + +.vmat-card-action-btn { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border: none; + border-radius: var(--radius-sm); + background: var(--bg-hover); + color: var(--text-secondary); + cursor: pointer; + font-size: 13px; + transition: all var(--transition-fast); +} + +.vmat-card-action-btn:hover { + background: var(--primary-50); + color: var(--primary-600); +} + +.vmat-card-action-btn--danger:hover { + background: var(--error-50); + color: var(--error-600); +} + +/* 卡片头部 */ +.vmat-card-header { + display: flex; + align-items: center; + gap: 12px; +} + +.vmat-card-avatar { + width: 40px; + height: 40px; + border-radius: var(--radius-md); + background: var(--primary-50); + color: var(--primary-600); + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; + flex-shrink: 0; +} + +.vmat-gender--female .vmat-card-avatar { + background: var(--secondary-50); + color: var(--secondary-600); +} + +.vmat-gender--child .vmat-card-avatar { + background: var(--accent-50); + color: var(--accent-600); +} + +.vmat-gender--neutral .vmat-card-avatar { + background: var(--indigo-50); + color: var(--indigo-600); +} + +.vmat-card-title-area { + flex: 1; + min-width: 0; +} + +.vmat-card-name { + font-size: 14px; + font-weight: 600; + color: var(--text-primary); + margin: 0 0 4px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.vmat-card-gender { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 12px; + color: var(--text-tertiary); +} + +.vmat-card-gender.vmat-gender--male { + color: var(--primary-600); +} + +.vmat-card-gender.vmat-gender--female { + color: var(--secondary-600); +} + +.vmat-card-gender.vmat-gender--child { + color: var(--accent-600); +} + +/* 描述 */ +.vmat-card-desc { + font-size: 13px; + color: var(--text-secondary); + margin: 0; + line-height: 1.5; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +/* 标签 */ +.vmat-card-tags { + display: flex; + gap: 6px; + flex-wrap: wrap; +} + +/* 元信息 */ +.vmat-card-meta { + display: flex; + gap: 12px; + font-size: 12px; + color: var(--text-tertiary); +} + +/* 播放器 */ +.vmat-card-player { + display: flex; + align-items: center; + gap: 10px; + padding-top: 12px; + border-top: 1px solid var(--border-light); +} + +.vmat-play-btn { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border: none; + border-radius: 50%; + background: var(--primary-500); + color: #fff; + cursor: pointer; + font-size: 18px; + transition: all var(--transition-fast); + flex-shrink: 0; +} + +.vmat-play-btn:hover { + background: var(--primary-600); + transform: scale(1.05); +} + +.vmat-play-btn:active { + transform: scale(0.95); +} + +.vmat-progress { + flex: 1; + height: 4px; + background: var(--neutral-200); + border-radius: 2px; + cursor: pointer; + position: relative; + overflow: hidden; +} + +.vmat-progress:hover { + height: 6px; + margin-top: -1px; + margin-bottom: -1px; +} + +.vmat-progress-bar { + height: 100%; + background: var(--primary-500); + border-radius: 2px; + transition: width 0.1s linear; +} + +.vmat-time { + font-size: 12px; + color: var(--text-tertiary); + font-variant-numeric: tabular-nums; + min-width: 36px; + text-align: right; +} + +/* ── 列表视图 ─────────────────────────────────────────────── */ + +.vmat-list { + display: flex; + flex-direction: column; + border: 1px solid var(--border-primary); + border-radius: var(--radius-lg); + overflow: hidden; +} + +.vmat-list-header { + display: grid; + grid-template-columns: 48px 1fr 80px 160px 120px 60px 70px 80px; + align-items: center; + padding: 10px 16px; + background: var(--bg-secondary); + border-bottom: 1px solid var(--border-primary); + font-size: 12px; + font-weight: 600; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.vmat-row { + display: grid; + grid-template-columns: 48px 1fr 80px 160px 120px 60px 70px 80px; + align-items: center; + padding: 12px 16px; + border-bottom: 1px solid var(--border-light); + transition: background var(--transition-fast); + position: relative; +} + +.vmat-row:last-child { + border-bottom: none; +} + +.vmat-row:hover { + background: var(--bg-hover); +} + +.vmat-row.playing { + background: var(--primary-50); +} + +/* 列表性别色条 */ +.vmat-row::before { + content: ""; + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 3px; + background: transparent; +} + +.vmat-row.vmat-gender--male::before { + background: var(--primary-500); +} + +.vmat-row.vmat-gender--female::before { + background: var(--secondary-500); +} + +.vmat-row.vmat-gender--child::before { + background: var(--accent-500); +} + +.vmat-row.vmat-gender--neutral::before { + background: linear-gradient(180deg, var(--primary-500), var(--secondary-500)); +} + +/* 列表播放按钮 */ +.vmat-row-play { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border: none; + border-radius: 50%; + background: transparent; + color: var(--primary-600); + cursor: pointer; + font-size: 20px; + transition: all var(--transition-fast); +} + +.vmat-row-play:hover { + background: var(--primary-50); +} + +/* 列表信息 */ +.vmat-row-info { + min-width: 0; + padding-right: 12px; +} + +.vmat-row-name { + font-size: 14px; + font-weight: 500; + color: var(--text-primary); + margin: 0 0 2px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.vmat-row-desc { + font-size: 12px; + color: var(--text-tertiary); + margin: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* 列表性别 */ +.vmat-row-gender { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 12px; + color: var(--text-tertiary); +} + +.vmat-row-gender.vmat-gender--male { + color: var(--primary-600); +} + +.vmat-row-gender.vmat-gender--female { + color: var(--secondary-600); +} + +.vmat-row-gender.vmat-gender--child { + color: var(--accent-600); +} + +/* 列表标签 */ +.vmat-row-tags { + display: flex; + gap: 4px; + flex-wrap: wrap; +} + +/* 列表进度条 */ +.vmat-row-progress { + height: 4px; + background: var(--neutral-200); + border-radius: 2px; + cursor: pointer; + overflow: hidden; +} + +.vmat-row-progress:hover { + height: 6px; +} + +.vmat-row-progress-bar { + height: 100%; + background: var(--primary-500); + border-radius: 2px; + transition: width 0.1s linear; +} + +/* 列表其他列 */ +.vmat-row-time, +.vmat-row-size { + font-size: 12px; + color: var(--text-tertiary); + font-variant-numeric: tabular-nums; +} + +/* 列表操作 */ +.vmat-row-actions { + display: flex; + gap: 4px; + justify-content: flex-end; +} + +.vmat-row-action-btn { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border: none; + border-radius: var(--radius-sm); + background: transparent; + color: var(--text-tertiary); + cursor: pointer; + font-size: 13px; + transition: all var(--transition-fast); +} + +.vmat-row-action-btn:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + +.vmat-row-action-btn--danger:hover { + background: var(--error-50); + color: var(--error-600); +} + +/* ── 空状态 ───────────────────────────────────────────────── */ + +.vmat-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 64px 24px; + text-align: center; +} + +.vmat-empty-icon { + width: 64px; + height: 64px; + border-radius: 50%; + background: var(--neutral-100); + color: var(--neutral-400); + display: flex; + align-items: center; + justify-content: center; + font-size: 28px; + margin-bottom: 16px; +} + +.vmat-empty h3 { + font-size: 16px; + font-weight: 600; + color: var(--text-primary); + margin: 0 0 8px; +} + +.vmat-empty p { + font-size: 14px; + color: var(--text-secondary); + margin: 0 0 20px; +} + +/* ── 表单 ─────────────────────────────────────────────────── */ + +.vmat-form { + display: flex; + flex-direction: column; + gap: 18px; +} + +.vmat-form-field { + display: flex; + flex-direction: column; + gap: 6px; +} + +.vmat-form-label { + font-size: 13px; + font-weight: 500; + color: var(--text-secondary); +} + +/* 上传区域 */ +.vmat-upload-zone { + border: 2px dashed var(--border-primary); + border-radius: var(--radius-lg); + padding: 24px; + cursor: pointer; + transition: all var(--transition-fast); + text-align: center; +} + +.vmat-upload-zone:hover { + border-color: var(--primary-400); + background: var(--primary-50); +} + +.vmat-upload-placeholder { + color: var(--text-tertiary); +} + +.vmat-upload-placeholder .vmat-upload-icon { + font-size: 32px; + color: var(--primary-400); + margin-bottom: 8px; +} + +.vmat-upload-placeholder p { + font-size: 14px; + color: var(--text-secondary); + margin: 8px 0 4px; +} + +.vmat-upload-placeholder span { + font-size: 12px; + color: var(--text-tertiary); +} + +.vmat-upload-selected { + display: flex; + align-items: center; + gap: 10px; +} + +.vmat-upload-selected .vmat-upload-icon { + font-size: 20px; + color: var(--primary-500); +} + +.vmat-upload-filename { + font-size: 14px; + font-weight: 500; + color: var(--text-primary); + flex: 1; + text-align: left; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.vmat-upload-filesize { + font-size: 12px; + color: var(--text-tertiary); +} + +.vmat-upload-clear { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border: none; + border-radius: 50%; + background: var(--neutral-100); + color: var(--text-tertiary); + cursor: pointer; + font-size: 12px; + transition: all var(--transition-fast); +} + +.vmat-upload-clear:hover { + background: var(--error-50); + color: var(--error-600); +} + +/* 性别选择 */ +.vmat-gender-group { + display: flex; + gap: 8px; +} + +.vmat-gender-btn { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 14px; + border: 1px solid var(--border-primary); + border-radius: var(--radius-md); + background: var(--bg-surface); + color: var(--text-secondary); + font-size: 13px; + cursor: pointer; + transition: all var(--transition-fast); +} + +.vmat-gender-btn:hover { + border-color: var(--primary-300); + color: var(--text-primary); +} + +.vmat-gender-btn.active.vmat-gender--male { + border-color: var(--primary-500); + background: var(--primary-50); + color: var(--primary-700); +} + +.vmat-gender-btn.active.vmat-gender--female { + border-color: var(--secondary-500); + background: var(--secondary-50); + color: var(--secondary-700); +} + +.vmat-gender-btn.active.vmat-gender--child { + border-color: var(--accent-500); + background: var(--accent-50); + color: var(--accent-700); +} + +.vmat-gender-btn.active.vmat-gender--neutral { + border-color: var(--indigo-500); + background: var(--indigo-50); + color: var(--indigo-700); +} + +/* 标签选择 */ +.vmat-tag-group { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.vmat-tag-btn { + padding: 5px 12px; + border: 1px solid var(--border-primary); + border-radius: var(--radius-full); + background: var(--bg-surface); + color: var(--text-secondary); + font-size: 13px; + cursor: pointer; + transition: all var(--transition-fast); +} + +.vmat-tag-btn:hover { + border-color: var(--primary-300); + color: var(--text-primary); +} + +.vmat-tag-btn.active { + border-color: var(--primary-500); + background: var(--primary-50); + color: var(--primary-700); +} + +/* 表单操作 */ +.vmat-form-actions { + display: flex; + justify-content: flex-end; + gap: 10px; + padding-top: 8px; +} + +/* ── 响应式 ───────────────────────────────────────────────── */ + +@media (max-width: 768px) { + .vmat-page { + padding: 16px; + } + + .vmat-grid { + grid-template-columns: 1fr; + } + + .vmat-list-header, + .vmat-row { + grid-template-columns: 40px 1fr 60px 80px; + } + + .vmat-lh-progress, + .vmat-lh-time, + .vmat-lh-size, + .vmat-lh-actions, + .vmat-row-progress, + .vmat-row-time, + .vmat-row-size, + .vmat-row-actions { + display: none; + } + + .vmat-toolbar { + flex-direction: column; + align-items: stretch; + } + + .vmat-toolbar-right { + justify-content: space-between; + } +} diff --git a/apps/web/src/router/index.tsx b/apps/web/src/router/index.tsx index 1e18323f2..4485d2d4a 100644 --- a/apps/web/src/router/index.tsx +++ b/apps/web/src/router/index.tsx @@ -156,6 +156,13 @@ export const router = createBrowserRouter([ Component: m.default, })), }, + { + path: "voice-materials", + lazy: () => + import("@/pages/voice-materials/VoiceMaterialLibrary").then((m) => ({ + Component: m.default, + })), + }, { path: "my-voices", lazy: () =>