Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 201c4ae5a6 | |||
| 1598da861a |
@@ -59,7 +59,7 @@ export const useTitleEdit = ({ updateMutation, createMutation }: UseTitleEditPro
|
||||
message.success("标题创建成功")
|
||||
},
|
||||
})
|
||||
}, [newTitleContent, newTitleType, createMutation])
|
||||
}, [newTitleContent, createMutation])
|
||||
|
||||
/* 关闭新建弹窗 */
|
||||
const handleCloseCreateModal = useCallback(() => {
|
||||
|
||||
Executable → Regular
+59
-329
@@ -9,21 +9,8 @@
|
||||
* - 删除素材
|
||||
*/
|
||||
import React from "react"
|
||||
import {
|
||||
AudioOutlined,
|
||||
SearchOutlined,
|
||||
PlusOutlined,
|
||||
DeleteOutlined,
|
||||
UploadOutlined,
|
||||
UnorderedListOutlined,
|
||||
AppstoreOutlined,
|
||||
CheckOutlined,
|
||||
TagsOutlined,
|
||||
RobotOutlined,
|
||||
LoadingOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import { Button, Input, Select, Modal } from "@/components/ui"
|
||||
import { Popover, Popconfirm } from "antd"
|
||||
import { AudioOutlined, PlusOutlined, RobotOutlined } from "@ant-design/icons"
|
||||
import { Button, Modal } from "@/components/ui"
|
||||
import PageHead from "@/components/layout/PageHead"
|
||||
import { useVoiceMaterials } from "./hooks/useVoiceMaterials"
|
||||
import { useAudioPlayer } from "./hooks/useAudioPlayer"
|
||||
@@ -32,6 +19,11 @@ import { useTtsSynthesize } from "./hooks/useTtsSynthesize"
|
||||
import MaterialForm from "./components/MaterialForm"
|
||||
import VoiceMaterialCard from "./components/VoiceMaterialCard"
|
||||
import VoiceMaterialRow from "./components/VoiceMaterialRow"
|
||||
import Toolbar from "./components/Toolbar"
|
||||
import TagFilterBar from "./components/TagFilterBar"
|
||||
import BatchBar from "./components/BatchBar"
|
||||
import EmptyState from "./components/EmptyState"
|
||||
import TtsModal from "./components/TtsModal"
|
||||
import "./voice-materials.css"
|
||||
|
||||
/* ============================================================
|
||||
@@ -137,7 +129,7 @@ const VoiceMaterialLibrary: React.FC = () => {
|
||||
})
|
||||
}
|
||||
|
||||
/* ── 渲染 ─────────────────────────────────────────────── */
|
||||
const hasFilter = !!searchText || filterGender !== "all" || filterTagId !== "all"
|
||||
|
||||
const pageActions = (
|
||||
<div className="vmat-page-actions">
|
||||
@@ -164,155 +156,47 @@ const VoiceMaterialLibrary: React.FC = () => {
|
||||
/>
|
||||
|
||||
{/* 工具栏:搜索 + 筛选 + 视图切换 */}
|
||||
<div className="vmat-toolbar">
|
||||
<div className="vmat-toolbar-left">
|
||||
<Input
|
||||
placeholder="搜索配音素材..."
|
||||
prefix={<SearchOutlined />}
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
allowClear
|
||||
style={{ width: 240 }}
|
||||
/>
|
||||
<Select
|
||||
value={filterGender}
|
||||
onChange={setFilterGender}
|
||||
style={{ width: 120 }}
|
||||
options={[
|
||||
{ value: "all", label: "全部性别" },
|
||||
{ value: "male", label: "男声" },
|
||||
{ value: "female", label: "女声" },
|
||||
{ value: "child", label: "童声" },
|
||||
{ value: "neutral", label: "中性" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className="vmat-toolbar-right">
|
||||
<span className="vmat-result-count">共 {filtered.length} 个素材</span>
|
||||
<div className="vmat-view-toggle">
|
||||
<button
|
||||
type="button"
|
||||
className={`vmat-view-btn${viewMode === "card" ? " active" : ""}`}
|
||||
onClick={() => setViewMode("card")}
|
||||
title="卡片视图"
|
||||
>
|
||||
<AppstoreOutlined />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`vmat-view-btn${viewMode === "list" ? " active" : ""}`}
|
||||
onClick={() => setViewMode("list")}
|
||||
title="列表视图"
|
||||
>
|
||||
<UnorderedListOutlined />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Toolbar
|
||||
searchText={searchText}
|
||||
filterGender={filterGender}
|
||||
viewMode={viewMode}
|
||||
resultCount={filtered.length}
|
||||
onSearchChange={setSearchText}
|
||||
onGenderChange={setFilterGender}
|
||||
onViewModeChange={setViewMode}
|
||||
/>
|
||||
|
||||
{/* ── 标签筛选药丸条 ─────────────────────────────────── */}
|
||||
{tags.length > 0 && (
|
||||
<div className="vmat-tag-filter-bar">
|
||||
<button
|
||||
type="button"
|
||||
className={`vmat-filter-pill${filterTagId === "all" ? " active" : ""}`}
|
||||
onClick={() => setFilterTagId("all")}
|
||||
tabIndex={0}
|
||||
>
|
||||
全部
|
||||
</button>
|
||||
{tags.map((tag) => (
|
||||
<button
|
||||
key={tag.id}
|
||||
type="button"
|
||||
className={`vmat-filter-pill${filterTagId === tag.id ? " active" : ""}`}
|
||||
onClick={() => setFilterTagId(tag.id)}
|
||||
tabIndex={0}
|
||||
>
|
||||
{tag.name}
|
||||
<span className="vmat-filter-pill-count">{tagCountMap[tag.id] || 0}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 加载中 */}
|
||||
{isLoading && (
|
||||
<div className="vmat-empty">
|
||||
<div className="vmat-empty-icon">
|
||||
<AudioOutlined />
|
||||
</div>
|
||||
<h3>加载中...</h3>
|
||||
</div>
|
||||
)}
|
||||
{/* 标签筛选药丸条 */}
|
||||
<TagFilterBar
|
||||
tags={tags}
|
||||
filterTagId={filterTagId}
|
||||
tagCountMap={tagCountMap}
|
||||
onTagSelect={setFilterTagId}
|
||||
/>
|
||||
|
||||
{/* 批量操作栏 */}
|
||||
{batchMode && (
|
||||
<div className="vmat-batch-bar">
|
||||
<div className="vmat-batch-bar-left">
|
||||
<div
|
||||
className={`vmat-checkbox${allSelected ? " checked" : ""}`}
|
||||
onClick={handleSelectAll}
|
||||
>
|
||||
{allSelected && <CheckOutlined />}
|
||||
</div>
|
||||
<span className="vmat-select-all" onClick={handleSelectAll}>
|
||||
{allSelected ? "取消全选" : "全选"}
|
||||
</span>
|
||||
<span className="vmat-batch-count">已选择 {selectedIds.size} 项</span>
|
||||
</div>
|
||||
<div className="vmat-batch-bar-right">
|
||||
<Popover
|
||||
content={
|
||||
<div className="vmat-tag-popover">
|
||||
<div className="vmat-tag-pop-input-row">
|
||||
<Input
|
||||
placeholder="输入自定义标签后回车"
|
||||
value={batchCustomTag}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setBatchCustomTag(e.target.value)
|
||||
}
|
||||
onKeyDown={(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter" && batchCustomTag.trim()) {
|
||||
handleBatchCustomTag(batchCustomTag.trim())
|
||||
setBatchCustomTag("")
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{tags.map((tag) => (
|
||||
<button
|
||||
key={tag.id}
|
||||
type="button"
|
||||
className="vmat-tag-pop-btn"
|
||||
onClick={() => handleBatchTag(tag.id)}
|
||||
>
|
||||
{tag.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
title="批量打标签"
|
||||
trigger="click"
|
||||
>
|
||||
<Button buttonType="ghost" buttonSize="sm" icon={<TagsOutlined />}>
|
||||
批量打标签
|
||||
</Button>
|
||||
</Popover>
|
||||
<Popconfirm
|
||||
title={`确定删除选中的 ${selectedIds.size} 个素材?`}
|
||||
onConfirm={handleBatchDelete}
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button buttonType="danger" buttonSize="sm" icon={<DeleteOutlined />}>
|
||||
批量删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
</div>
|
||||
<BatchBar
|
||||
selectedCount={selectedIds.size}
|
||||
allSelected={allSelected}
|
||||
batchCustomTag={batchCustomTag}
|
||||
tags={tags}
|
||||
onSelectAll={handleSelectAll}
|
||||
onBatchCustomTagChange={setBatchCustomTag}
|
||||
onBatchCustomTagSubmit={handleBatchCustomTag}
|
||||
onBatchTag={handleBatchTag}
|
||||
onBatchDelete={handleBatchDelete}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 加载/空状态 */}
|
||||
<EmptyState
|
||||
isLoading={isLoading}
|
||||
isEmpty={filtered.length === 0}
|
||||
hasFilter={hasFilter}
|
||||
onUploadClick={() => setUploadOpen(true)}
|
||||
/>
|
||||
|
||||
{/* 内容区 — 卡片视图 */}
|
||||
{!isLoading && filtered.length > 0 && viewMode === "card" && (
|
||||
<div className="vmat-grid">
|
||||
@@ -374,31 +258,6 @@ const VoiceMaterialLibrary: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 空状态 */}
|
||||
{!isLoading && filtered.length === 0 && (
|
||||
<div className="vmat-empty">
|
||||
<div className="vmat-empty-icon">
|
||||
<AudioOutlined />
|
||||
</div>
|
||||
<h3>暂无配音素材</h3>
|
||||
<p>
|
||||
{searchText || filterGender !== "all" || filterTagId !== "all"
|
||||
? "未找到匹配的素材,试试调整筛选条件"
|
||||
: "上传音频文件,开始管理配音素材"}
|
||||
</p>
|
||||
{!searchText && filterGender === "all" && filterTagId === "all" && (
|
||||
<Button
|
||||
buttonType="primary"
|
||||
buttonSize="md"
|
||||
icon={<UploadOutlined />}
|
||||
onClick={() => setUploadOpen(true)}
|
||||
>
|
||||
上传配音
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 上传弹窗 */}
|
||||
<Modal
|
||||
title="上传配音素材"
|
||||
@@ -450,151 +309,22 @@ const VoiceMaterialLibrary: React.FC = () => {
|
||||
width={560}
|
||||
destroyOnClose
|
||||
>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
||||
{/* 文本输入 */}
|
||||
<div>
|
||||
<label
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
marginBottom: 6,
|
||||
display: "block",
|
||||
}}
|
||||
>
|
||||
输入文本
|
||||
</label>
|
||||
<textarea
|
||||
rows={4}
|
||||
placeholder="请输入需要转换为语音的文本内容…"
|
||||
value={ttsText}
|
||||
onChange={(e) => setTtsText(e.target.value)}
|
||||
maxLength={2000}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "8px 12px",
|
||||
border: "1px solid var(--border-color, #d9d9d9)",
|
||||
borderRadius: 6,
|
||||
fontSize: 13,
|
||||
resize: "vertical",
|
||||
fontFamily: "inherit",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: "var(--text-tertiary, #999)",
|
||||
marginTop: 4,
|
||||
textAlign: "right",
|
||||
}}
|
||||
>
|
||||
{ttsText.length}/2000
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 音色选择 */}
|
||||
<div>
|
||||
<label
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
marginBottom: 6,
|
||||
display: "block",
|
||||
}}
|
||||
>
|
||||
选择音色
|
||||
</label>
|
||||
<select
|
||||
value={ttsVoiceId}
|
||||
onChange={(e) => setTtsVoiceId(e.target.value)}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: 36,
|
||||
padding: "0 10px",
|
||||
border: "1px solid var(--border-color, #d9d9d9)",
|
||||
borderRadius: 6,
|
||||
fontSize: 13,
|
||||
background: "var(--bg-primary, #fff)",
|
||||
}}
|
||||
>
|
||||
<option value="">默认音色</option>
|
||||
{presetVoices.map((v) => (
|
||||
<option key={v.voice_id} value={v.voice_id}>
|
||||
{v.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 语速调节 */}
|
||||
<div>
|
||||
<label
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
marginBottom: 6,
|
||||
display: "block",
|
||||
}}
|
||||
>
|
||||
语速:{ttsSpeed.toFixed(1)}x
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min={0.5}
|
||||
max={2.0}
|
||||
step={0.1}
|
||||
value={ttsSpeed}
|
||||
onChange={(e) => setTtsSpeed(parseFloat(e.target.value))}
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 合成按钮 */}
|
||||
<Button
|
||||
buttonType="primary"
|
||||
buttonSize="md"
|
||||
icon={ttsStatus === "synthesizing" ? <LoadingOutlined /> : <RobotOutlined />}
|
||||
onClick={handleTtsSynthesize}
|
||||
disabled={ttsStatus === "synthesizing" || !ttsText.trim()}
|
||||
>
|
||||
{ttsStatus === "synthesizing" ? "合成中…" : "开始合成"}
|
||||
</Button>
|
||||
|
||||
{/* 错误提示 */}
|
||||
{ttsStatus === "error" && ttsError && (
|
||||
<div
|
||||
style={{
|
||||
padding: "8px 12px",
|
||||
background: "#fff2f0",
|
||||
borderRadius: 6,
|
||||
color: "#ff4d4f",
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
{ttsError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 合成结果 */}
|
||||
{ttsStatus === "done" && ttsAudioUrl && (
|
||||
<div
|
||||
style={{
|
||||
padding: 12,
|
||||
background: "var(--bg-surface, #f5f5f5)",
|
||||
borderRadius: 8,
|
||||
}}
|
||||
>
|
||||
<audio controls src={ttsAudioUrl} style={{ width: "100%", marginBottom: 12 }} />
|
||||
<Button
|
||||
buttonType="primary"
|
||||
buttonSize="sm"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={handleTtsSave}
|
||||
>
|
||||
保存到配音库
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<TtsModal
|
||||
open={ttsOpen}
|
||||
text={ttsText}
|
||||
voiceId={ttsVoiceId}
|
||||
speed={ttsSpeed}
|
||||
status={ttsStatus}
|
||||
audioUrl={ttsAudioUrl}
|
||||
error={ttsError}
|
||||
presetVoices={presetVoices}
|
||||
onClose={handleTtsClose}
|
||||
onTextChange={setTtsText}
|
||||
onVoiceChange={setTtsVoiceId}
|
||||
onSpeedChange={setTtsSpeed}
|
||||
onSynthesize={handleTtsSynthesize}
|
||||
onSave={handleTtsSave}
|
||||
/>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import React from "react"
|
||||
import { CheckOutlined, TagsOutlined, DeleteOutlined } from "@ant-design/icons"
|
||||
import { Button, Input } from "@/components/ui"
|
||||
import { Popover, Popconfirm } from "antd"
|
||||
import type { TagItem } from "@/api/tags"
|
||||
|
||||
interface BatchBarProps {
|
||||
selectedCount: number
|
||||
allSelected: boolean
|
||||
batchCustomTag: string
|
||||
tags: TagItem[]
|
||||
onSelectAll: () => void
|
||||
onBatchCustomTagChange: (value: string) => void
|
||||
onBatchCustomTagSubmit: (tag: string) => void
|
||||
onBatchTag: (tagId: string) => void
|
||||
onBatchDelete: () => void
|
||||
}
|
||||
|
||||
const BatchBar: React.FC<BatchBarProps> = ({
|
||||
selectedCount,
|
||||
allSelected,
|
||||
batchCustomTag,
|
||||
tags,
|
||||
onSelectAll,
|
||||
onBatchCustomTagChange,
|
||||
onBatchCustomTagSubmit,
|
||||
onBatchTag,
|
||||
onBatchDelete,
|
||||
}) => {
|
||||
return (
|
||||
<div className="vmat-batch-bar">
|
||||
<div className="vmat-batch-bar-left">
|
||||
<div className={`vmat-checkbox${allSelected ? " checked" : ""}`} onClick={onSelectAll}>
|
||||
{allSelected && <CheckOutlined />}
|
||||
</div>
|
||||
<span className="vmat-select-all" onClick={onSelectAll}>
|
||||
{allSelected ? "取消全选" : "全选"}
|
||||
</span>
|
||||
<span className="vmat-batch-count">已选择 {selectedCount} 项</span>
|
||||
</div>
|
||||
<div className="vmat-batch-bar-right">
|
||||
<Popover
|
||||
content={
|
||||
<div className="vmat-tag-popover">
|
||||
<div className="vmat-tag-pop-input-row">
|
||||
<Input
|
||||
placeholder="输入自定义标签后回车"
|
||||
value={batchCustomTag}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
onBatchCustomTagChange(e.target.value)
|
||||
}
|
||||
onKeyDown={(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter" && batchCustomTag.trim()) {
|
||||
onBatchCustomTagSubmit(batchCustomTag.trim())
|
||||
onBatchCustomTagChange("")
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{tags.map((tag) => (
|
||||
<button
|
||||
key={tag.id}
|
||||
type="button"
|
||||
className="vmat-tag-pop-btn"
|
||||
onClick={() => onBatchTag(tag.id)}
|
||||
>
|
||||
{tag.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
title="批量打标签"
|
||||
trigger="click"
|
||||
>
|
||||
<Button buttonType="ghost" buttonSize="sm" icon={<TagsOutlined />}>
|
||||
批量打标签
|
||||
</Button>
|
||||
</Popover>
|
||||
<Popconfirm
|
||||
title={`确定删除选中的 ${selectedCount} 个素材?`}
|
||||
onConfirm={onBatchDelete}
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button buttonType="danger" buttonSize="sm" icon={<DeleteOutlined />}>
|
||||
批量删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default BatchBar
|
||||
@@ -0,0 +1,52 @@
|
||||
import React from "react"
|
||||
import { AudioOutlined, UploadOutlined } from "@ant-design/icons"
|
||||
import { Button } from "@/components/ui"
|
||||
|
||||
interface EmptyStateProps {
|
||||
isLoading: boolean
|
||||
isEmpty: boolean
|
||||
hasFilter: boolean
|
||||
onUploadClick: () => void
|
||||
}
|
||||
|
||||
const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
isLoading,
|
||||
isEmpty,
|
||||
hasFilter,
|
||||
onUploadClick,
|
||||
}) => {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="vmat-empty">
|
||||
<div className="vmat-empty-icon">
|
||||
<AudioOutlined />
|
||||
</div>
|
||||
<h3>加载中...</h3>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!isEmpty) return null
|
||||
|
||||
return (
|
||||
<div className="vmat-empty">
|
||||
<div className="vmat-empty-icon">
|
||||
<AudioOutlined />
|
||||
</div>
|
||||
<h3>暂无配音素材</h3>
|
||||
<p>{hasFilter ? "未找到匹配的素材,试试调整筛选条件" : "上传音频文件,开始管理配音素材"}</p>
|
||||
{!hasFilter && (
|
||||
<Button
|
||||
buttonType="primary"
|
||||
buttonSize="md"
|
||||
icon={<UploadOutlined />}
|
||||
onClick={onUploadClick}
|
||||
>
|
||||
上传配音
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default EmptyState
|
||||
@@ -0,0 +1,45 @@
|
||||
import React from "react"
|
||||
import type { TagItem } from "@/api/tags"
|
||||
|
||||
interface TagFilterBarProps {
|
||||
tags: TagItem[]
|
||||
filterTagId: string
|
||||
tagCountMap: Record<string, number>
|
||||
onTagSelect: (tagId: string) => void
|
||||
}
|
||||
|
||||
const TagFilterBar: React.FC<TagFilterBarProps> = ({
|
||||
tags,
|
||||
filterTagId,
|
||||
tagCountMap,
|
||||
onTagSelect,
|
||||
}) => {
|
||||
if (tags.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="vmat-tag-filter-bar">
|
||||
<button
|
||||
type="button"
|
||||
className={`vmat-filter-pill${filterTagId === "all" ? " active" : ""}`}
|
||||
onClick={() => onTagSelect("all")}
|
||||
tabIndex={0}
|
||||
>
|
||||
全部
|
||||
</button>
|
||||
{tags.map((tag) => (
|
||||
<button
|
||||
key={tag.id}
|
||||
type="button"
|
||||
className={`vmat-filter-pill${filterTagId === tag.id ? " active" : ""}`}
|
||||
onClick={() => onTagSelect(tag.id)}
|
||||
tabIndex={0}
|
||||
>
|
||||
{tag.name}
|
||||
<span className="vmat-filter-pill-count">{tagCountMap[tag.id] || 0}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TagFilterBar
|
||||
@@ -0,0 +1,76 @@
|
||||
import React from "react"
|
||||
import { SearchOutlined, AppstoreOutlined, UnorderedListOutlined } from "@ant-design/icons"
|
||||
import { Input, Select } from "@/components/ui"
|
||||
import type { ViewMode } from "../types"
|
||||
|
||||
interface ToolbarProps {
|
||||
searchText: string
|
||||
filterGender: string
|
||||
viewMode: ViewMode
|
||||
resultCount: number
|
||||
onSearchChange: (value: string) => void
|
||||
onGenderChange: (value: string) => void
|
||||
onViewModeChange: (mode: ViewMode) => void
|
||||
}
|
||||
|
||||
const GENDER_OPTIONS = [
|
||||
{ value: "all", label: "全部性别" },
|
||||
{ value: "male", label: "男声" },
|
||||
{ value: "female", label: "女声" },
|
||||
{ value: "child", label: "童声" },
|
||||
{ value: "neutral", label: "中性" },
|
||||
]
|
||||
|
||||
const Toolbar: React.FC<ToolbarProps> = ({
|
||||
searchText,
|
||||
filterGender,
|
||||
viewMode,
|
||||
resultCount,
|
||||
onSearchChange,
|
||||
onGenderChange,
|
||||
onViewModeChange,
|
||||
}) => {
|
||||
return (
|
||||
<div className="vmat-toolbar">
|
||||
<div className="vmat-toolbar-left">
|
||||
<Input
|
||||
placeholder="搜索配音素材..."
|
||||
prefix={<SearchOutlined />}
|
||||
value={searchText}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
allowClear
|
||||
style={{ width: 240 }}
|
||||
/>
|
||||
<Select
|
||||
value={filterGender}
|
||||
onChange={onGenderChange}
|
||||
style={{ width: 120 }}
|
||||
options={GENDER_OPTIONS}
|
||||
/>
|
||||
</div>
|
||||
<div className="vmat-toolbar-right">
|
||||
<span className="vmat-result-count">共 {resultCount} 个素材</span>
|
||||
<div className="vmat-view-toggle">
|
||||
<button
|
||||
type="button"
|
||||
className={`vmat-view-btn${viewMode === "card" ? " active" : ""}`}
|
||||
onClick={() => onViewModeChange("card")}
|
||||
title="卡片视图"
|
||||
>
|
||||
<AppstoreOutlined />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`vmat-view-btn${viewMode === "list" ? " active" : ""}`}
|
||||
onClick={() => onViewModeChange("list")}
|
||||
title="列表视图"
|
||||
>
|
||||
<UnorderedListOutlined />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Toolbar
|
||||
@@ -0,0 +1,171 @@
|
||||
import React from "react"
|
||||
import { RobotOutlined, LoadingOutlined, PlusOutlined } from "@ant-design/icons"
|
||||
import { Button } from "@/components/ui"
|
||||
|
||||
export type TtsStatus = "idle" | "synthesizing" | "done" | "error"
|
||||
|
||||
export interface TtsPresetVoice {
|
||||
voice_id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
interface TtsModalProps {
|
||||
open: boolean
|
||||
text: string
|
||||
voiceId: string
|
||||
speed: number
|
||||
status: TtsStatus
|
||||
audioUrl: string
|
||||
error: string
|
||||
presetVoices: TtsPresetVoice[]
|
||||
onClose: () => void
|
||||
onTextChange: (value: string) => void
|
||||
onVoiceChange: (voiceId: string) => void
|
||||
onSpeedChange: (speed: number) => void
|
||||
onSynthesize: () => void
|
||||
onSave: () => void
|
||||
}
|
||||
|
||||
const TtsModal: React.FC<TtsModalProps> = ({
|
||||
open,
|
||||
text,
|
||||
voiceId,
|
||||
speed,
|
||||
status,
|
||||
audioUrl,
|
||||
error,
|
||||
presetVoices,
|
||||
onClose,
|
||||
onTextChange,
|
||||
onVoiceChange,
|
||||
onSpeedChange,
|
||||
onSynthesize,
|
||||
onSave,
|
||||
}) => {
|
||||
if (!open) return null
|
||||
|
||||
const labelStyle: React.CSSProperties = {
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
marginBottom: 6,
|
||||
display: "block",
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
||||
{/* 文本输入 */}
|
||||
<div>
|
||||
<label style={labelStyle}>输入文本</label>
|
||||
<textarea
|
||||
rows={4}
|
||||
placeholder="请输入需要转换为语音的文本内容…"
|
||||
value={text}
|
||||
onChange={(e) => onTextChange(e.target.value)}
|
||||
maxLength={2000}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "8px 12px",
|
||||
border: "1px solid var(--border-color, #d9d9d9)",
|
||||
borderRadius: 6,
|
||||
fontSize: 13,
|
||||
resize: "vertical",
|
||||
fontFamily: "inherit",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: "var(--text-tertiary, #999)",
|
||||
marginTop: 4,
|
||||
textAlign: "right",
|
||||
}}
|
||||
>
|
||||
{text.length}/2000
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 音色选择 */}
|
||||
<div>
|
||||
<label style={labelStyle}>选择音色</label>
|
||||
<select
|
||||
value={voiceId}
|
||||
onChange={(e) => onVoiceChange(e.target.value)}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: 36,
|
||||
padding: "0 10px",
|
||||
border: "1px solid var(--border-color, #d9d9d9)",
|
||||
borderRadius: 6,
|
||||
fontSize: 13,
|
||||
background: "var(--bg-primary, #fff)",
|
||||
}}
|
||||
>
|
||||
<option value="">默认音色</option>
|
||||
{presetVoices.map((v) => (
|
||||
<option key={v.voice_id} value={v.voice_id}>
|
||||
{v.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 语速调节 */}
|
||||
<div>
|
||||
<label style={labelStyle}>语速:{speed.toFixed(1)}x</label>
|
||||
<input
|
||||
type="range"
|
||||
min={0.5}
|
||||
max={2.0}
|
||||
step={0.1}
|
||||
value={speed}
|
||||
onChange={(e) => onSpeedChange(parseFloat(e.target.value))}
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 合成按钮 */}
|
||||
<Button
|
||||
buttonType="primary"
|
||||
buttonSize="md"
|
||||
icon={status === "synthesizing" ? <LoadingOutlined /> : <RobotOutlined />}
|
||||
onClick={onSynthesize}
|
||||
disabled={status === "synthesizing" || !text.trim()}
|
||||
>
|
||||
{status === "synthesizing" ? "合成中…" : "开始合成"}
|
||||
</Button>
|
||||
|
||||
{/* 错误提示 */}
|
||||
{status === "error" && error && (
|
||||
<div
|
||||
style={{
|
||||
padding: "8px 12px",
|
||||
background: "#fff2f0",
|
||||
borderRadius: 6,
|
||||
color: "#ff4d4f",
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 合成结果 */}
|
||||
{status === "done" && audioUrl && (
|
||||
<div
|
||||
style={{
|
||||
padding: 12,
|
||||
background: "var(--bg-surface, #f5f5f5)",
|
||||
borderRadius: 8,
|
||||
}}
|
||||
>
|
||||
<audio controls src={audioUrl} style={{ width: "100%", marginBottom: 12 }} />
|
||||
<Button buttonType="primary" buttonSize="sm" icon={<PlusOutlined />} onClick={onSave}>
|
||||
保存到配音库
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TtsModal
|
||||
@@ -13,6 +13,11 @@ import "@/pages/voice-materials/components/TagSelector"
|
||||
import "@/pages/voice-materials/components/MaterialForm"
|
||||
import "@/pages/voice-materials/components/VoiceMaterialCard"
|
||||
import "@/pages/voice-materials/components/VoiceMaterialRow"
|
||||
import "@/pages/voice-materials/components/Toolbar"
|
||||
import "@/pages/voice-materials/components/TagFilterBar"
|
||||
import "@/pages/voice-materials/components/BatchBar"
|
||||
import "@/pages/voice-materials/components/EmptyState"
|
||||
import "@/pages/voice-materials/components/TtsModal"
|
||||
|
||||
// 工具函数
|
||||
import "@/pages/voice-materials/utils/format"
|
||||
|
||||
Reference in New Issue
Block a user