Compare commits

...

2 Commits

Author SHA1 Message Date
xiaoxia 201c4ae5a6 fix: 移除不必要的useCallback依赖 newTitleType 2026-07-27 21:34:00 +08:00
xiaoxia 1598da861a refactor(voice-materials): 深化拆分 UI 组件(603→338行, -44%)
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1m0s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 56s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m44s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m47s
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
PR Automation / Auto Approve on CI Green (pull_request) Successful in 1m22s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 53s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 1m34s
CI/CD Pipeline / Validate - Code Quality (pull_request) Failing after 3m28s
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 2m39s
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 2m34s
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
AI Code Review / AI Code Review (pull_request) Successful in 2m26s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 7m11s
CI/CD Pipeline / CI Gate (pull_request) Successful in 40s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 10m22s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 36s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 55s
Phase 4: 继续拆 5 个 UI 组件
- Toolbar — 搜索 + 性别筛选 + 视图切换
- TagFilterBar — 标签筛选药丸条
- BatchBar — 批量操作栏(全选/打标签/删除)
- EmptyState — 加载/空状态
- TtsModal — AI 配音 TTS 合成弹窗

更新 smoke test 新增模块 import
2026-07-27 21:15:53 +08:00
8 changed files with 503 additions and 330 deletions
@@ -59,7 +59,7 @@ export const useTitleEdit = ({ updateMutation, createMutation }: UseTitleEditPro
message.success("标题创建成功")
},
})
}, [newTitleContent, newTitleType, createMutation])
}, [newTitleContent, createMutation])
/* 关闭新建弹窗 */
const handleCloseCreateModal = useCallback(() => {
+59 -329
View File
@@ -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"