8 changed files with 602 additions and 29 deletions
Binary file not shown.
Binary file not shown.
@ -0,0 +1,358 @@ |
|||||
|
/* AI科技多巴胺风格配色 */ |
||||
|
@primary-color: #00ff88; |
||||
|
@secondary-color: #00d4ff; |
||||
|
@accent-color: #ff6b6b; |
||||
|
@bg-color: #0a0e27; |
||||
|
@text-color: #ffffff; |
||||
|
@text-light: #b0b8d4; |
||||
|
@border-color: #1e2746; |
||||
|
@card-color: #121a33; |
||||
|
@shadow-color: rgba(0, 255, 136, 0.3); |
||||
|
|
||||
|
.container { |
||||
|
min-height: 100vh; |
||||
|
background: @bg-color; |
||||
|
color: @text-color; |
||||
|
overflow-x: hidden; |
||||
|
} |
||||
|
|
||||
|
.header { |
||||
|
display: flex; |
||||
|
justify-content: space-between; |
||||
|
align-items: center; |
||||
|
padding: 16px 20px; |
||||
|
background: linear-gradient(135deg, #0a0e27 0%, #1a1f3a 100%); |
||||
|
border-bottom: 1px solid @border-color; |
||||
|
box-shadow: 0 2px 8px @shadow-color; |
||||
|
|
||||
|
.title { |
||||
|
font-size: 20px; |
||||
|
font-weight: bold; |
||||
|
color: @primary-color; |
||||
|
} |
||||
|
|
||||
|
.registerBtn { |
||||
|
border-color: @primary-color; |
||||
|
color: @primary-color; |
||||
|
&:hover { |
||||
|
background: @primary-color; |
||||
|
color: @bg-color; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.banner { |
||||
|
padding: 24px; |
||||
|
background: linear-gradient(135deg, @secondary-color 0%, @primary-color 100%); |
||||
|
margin: 16px; |
||||
|
border-radius: 12px; |
||||
|
box-shadow: 0 4px 16px rgba(0, 212, 255, 0.4); |
||||
|
|
||||
|
.bannerPlaceholder { |
||||
|
display: flex; |
||||
|
flex-direction: column; |
||||
|
align-items: center; |
||||
|
justify-content: center; |
||||
|
padding: 40px 20px; |
||||
|
background: rgba(255, 255, 255, 0.1); |
||||
|
border-radius: 8px; |
||||
|
backdrop-filter: blur(10px); |
||||
|
|
||||
|
.bannerIcon { |
||||
|
font-size: 48px; |
||||
|
color: #ffffff; |
||||
|
margin-bottom: 12px; |
||||
|
} |
||||
|
|
||||
|
span { |
||||
|
font-size: 16px; |
||||
|
color: #ffffff; |
||||
|
font-weight: 500; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.contentList { |
||||
|
padding: 0 16px 100px; |
||||
|
max-height: calc(100vh - 200px); |
||||
|
overflow-y: auto; |
||||
|
|
||||
|
// 自定义滚动条 |
||||
|
&::-webkit-scrollbar { |
||||
|
width: 4px; |
||||
|
} |
||||
|
|
||||
|
&::-webkit-scrollbar-track { |
||||
|
background: @border-color; |
||||
|
} |
||||
|
|
||||
|
&::-webkit-scrollbar-thumb { |
||||
|
background: @primary-color; |
||||
|
border-radius: 2px; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.audioItem { |
||||
|
display: flex; |
||||
|
align-items: flex-start; |
||||
|
padding: 16px; |
||||
|
margin-bottom: 12px; |
||||
|
background: @card-color; |
||||
|
border-radius: 12px; |
||||
|
border: 1px solid @border-color; |
||||
|
transition: all 0.3s ease; |
||||
|
|
||||
|
&:hover { |
||||
|
border-color: @primary-color; |
||||
|
box-shadow: 0 4px 16px @shadow-color; |
||||
|
transform: translateY(-2px); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.playBtnContainer { |
||||
|
position: relative; |
||||
|
margin-right: 16px; |
||||
|
flex-shrink: 0; |
||||
|
} |
||||
|
|
||||
|
.playBtn { |
||||
|
width: 56px; |
||||
|
height: 56px; |
||||
|
border-radius: 50%; |
||||
|
background: @border-color; |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
justify-content: center; |
||||
|
cursor: pointer; |
||||
|
transition: all 0.3s ease; |
||||
|
position: relative; |
||||
|
overflow: hidden; |
||||
|
|
||||
|
&::before { |
||||
|
content: ''; |
||||
|
position: absolute; |
||||
|
top: 50%; |
||||
|
left: 50%; |
||||
|
width: 48px; |
||||
|
height: 48px; |
||||
|
border-radius: 50%; |
||||
|
background: @card-color; |
||||
|
transform: translate(-50%, -50%); |
||||
|
} |
||||
|
|
||||
|
&:hover { |
||||
|
transform: scale(1.1); |
||||
|
box-shadow: 0 4px 12px rgba(0, 255, 136, 0.4); |
||||
|
} |
||||
|
|
||||
|
&.playing { |
||||
|
animation: pulse 1.5s ease-in-out infinite; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@keyframes pulse { |
||||
|
0%, 100% { |
||||
|
box-shadow: 0 0 0 0 rgba(0, 255, 136, 0.4); |
||||
|
} |
||||
|
70% { |
||||
|
box-shadow: 0 0 0 10px rgba(0, 255, 136, 0); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.playIcon { |
||||
|
font-size: 24px; |
||||
|
color: @primary-color; |
||||
|
position: relative; |
||||
|
z-index: 1; |
||||
|
} |
||||
|
|
||||
|
.textContent { |
||||
|
flex: 1; |
||||
|
font-size: 14px; |
||||
|
line-height: 1.6; |
||||
|
color: @text-light; |
||||
|
word-break: break-all; |
||||
|
margin-right: 16px; |
||||
|
} |
||||
|
|
||||
|
.menuBtn { |
||||
|
font-size: 20px; |
||||
|
color: @text-light; |
||||
|
cursor: pointer; |
||||
|
transition: color 0.3s ease; |
||||
|
flex-shrink: 0; |
||||
|
|
||||
|
&:hover { |
||||
|
color: @primary-color; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.footer { |
||||
|
position: fixed; |
||||
|
bottom: 0; |
||||
|
left: 0; |
||||
|
right: 0; |
||||
|
padding: 16px; |
||||
|
background: linear-gradient(180deg, rgba(10, 14, 39, 0) 0%, @bg-color 50%); |
||||
|
|
||||
|
.inputContainer { |
||||
|
margin-bottom: 12px; |
||||
|
|
||||
|
:global(.ant-input) { |
||||
|
background: @card-color; |
||||
|
border: 1px solid @border-color; |
||||
|
color: @text-color; |
||||
|
border-radius: 8px; |
||||
|
transition: all 0.3s ease; |
||||
|
|
||||
|
&:focus { |
||||
|
border-color: @primary-color; |
||||
|
box-shadow: 0 0 0 2px @shadow-color; |
||||
|
} |
||||
|
|
||||
|
&::placeholder { |
||||
|
color: @text-light; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
:global(.ant-input-auto-size) { |
||||
|
min-height: 44px; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.footerButtons { |
||||
|
display: flex; |
||||
|
justify-content: flex-end; |
||||
|
gap: 12px; |
||||
|
|
||||
|
.generateBtn { |
||||
|
background: linear-gradient(135deg, @primary-color 0%, @secondary-color 100%); |
||||
|
border: none; |
||||
|
height: 44px; |
||||
|
padding: 0 24px; |
||||
|
border-radius: 8px; |
||||
|
font-weight: 500; |
||||
|
box-shadow: 0 4px 12px rgba(0, 255, 136, 0.4); |
||||
|
transition: all 0.3s ease; |
||||
|
|
||||
|
&:hover { |
||||
|
transform: translateY(-2px); |
||||
|
box-shadow: 0 6px 16px rgba(0, 255, 136, 0.5); |
||||
|
} |
||||
|
|
||||
|
&:active { |
||||
|
transform: translateY(0); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.settingBtn { |
||||
|
width: 44px; |
||||
|
height: 44px; |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
justify-content: center; |
||||
|
border-color: @border-color; |
||||
|
color: @text-light; |
||||
|
transition: all 0.3s ease; |
||||
|
|
||||
|
&:hover { |
||||
|
border-color: @primary-color; |
||||
|
color: @primary-color; |
||||
|
box-shadow: 0 4px 12px rgba(0, 255, 136, 0.3); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 适配移动端 |
||||
|
@media (max-width: 480px) { |
||||
|
.container { |
||||
|
padding-bottom: 120px; |
||||
|
} |
||||
|
|
||||
|
.header { |
||||
|
padding: 12px 16px; |
||||
|
|
||||
|
.title { |
||||
|
font-size: 18px; |
||||
|
} |
||||
|
|
||||
|
.registerBtn { |
||||
|
font-size: 12px; |
||||
|
padding: 4px 12px; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.banner { |
||||
|
padding: 16px; |
||||
|
margin: 12px; |
||||
|
|
||||
|
.bannerPlaceholder { |
||||
|
padding: 32px 16px; |
||||
|
|
||||
|
.bannerIcon { |
||||
|
font-size: 40px; |
||||
|
} |
||||
|
|
||||
|
span { |
||||
|
font-size: 14px; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.contentList { |
||||
|
padding: 0 12px 100px; |
||||
|
} |
||||
|
|
||||
|
.audioItem { |
||||
|
padding: 12px; |
||||
|
margin-bottom: 10px; |
||||
|
} |
||||
|
|
||||
|
.playBtn { |
||||
|
width: 52px; |
||||
|
height: 52px; |
||||
|
|
||||
|
&::before { |
||||
|
width: 44px; |
||||
|
height: 44px; |
||||
|
} |
||||
|
|
||||
|
.playIcon { |
||||
|
font-size: 22px; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.textContent { |
||||
|
font-size: 13px; |
||||
|
} |
||||
|
|
||||
|
.footer { |
||||
|
padding: 12px; |
||||
|
|
||||
|
.footerButtons { |
||||
|
.generateBtn { |
||||
|
padding: 0 20px; |
||||
|
font-size: 14px; |
||||
|
} |
||||
|
|
||||
|
.settingBtn { |
||||
|
width: 42px; |
||||
|
height: 42px; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 加载动画 |
||||
|
:global(.ant-pull-to-refresh-mask) { |
||||
|
color: @primary-color; |
||||
|
} |
||||
|
|
||||
|
:global(.ant-pull-to-refresh-indicator) { |
||||
|
color: @primary-color; |
||||
|
} |
||||
|
|
||||
|
:global(.ant-spin-dot-item) { |
||||
|
background-color: @primary-color; |
||||
|
} |
||||
@ -0,0 +1,229 @@ |
|||||
|
import React, { useState, useEffect, useRef } from 'react'; |
||||
|
import { Button, Input, List, Space, Spin, message, InfiniteScroll } from 'antd'; |
||||
|
import { PlayCircleOutlined, MenuOutlined, SettingOutlined, SoundOutlined } from '@ant-design/icons'; |
||||
|
import { useIntl } from 'umi'; |
||||
|
import styles from './tts_mobile_pro.less'; |
||||
|
|
||||
|
// 定义音频播放状态类型
|
||||
|
interface AudioItem { |
||||
|
id: string; |
||||
|
text: string; |
||||
|
audioUrl: string; |
||||
|
isPlaying: boolean; |
||||
|
progress: number; |
||||
|
} |
||||
|
|
||||
|
const TTSMobilePro: React.FC = () => { |
||||
|
const intl = useIntl(); |
||||
|
const [inputText, setInputText] = useState<string>(''); |
||||
|
const [audioList, setAudioList] = useState<AudioItem[]>([]); |
||||
|
const [loading, setLoading] = useState<boolean>(false); |
||||
|
const [loadMoreLoading, setLoadMoreLoading] = useState<boolean>(false); |
||||
|
const audioRefs = useRef<Map<string, HTMLAudioElement>>(new Map()); |
||||
|
const animationRefs = useRef<Map<string, number>>(new Map()); |
||||
|
|
||||
|
// 初始化测试数据
|
||||
|
useEffect(() => { |
||||
|
const testAudio: AudioItem = { |
||||
|
id: `test-${Date.now()}`, |
||||
|
text: '这是一条测试音频,用于演示文字转语音功能。', |
||||
|
audioUrl: '/test.wav', |
||||
|
isPlaying: false, |
||||
|
progress: 0, |
||||
|
}; |
||||
|
setAudioList([testAudio]); |
||||
|
}, []); |
||||
|
|
||||
|
// 播放音频
|
||||
|
const playAudio = (item: AudioItem) => { |
||||
|
const audio = audioRefs.current.get(item.id); |
||||
|
if (!audio) return; |
||||
|
|
||||
|
if (item.isPlaying) { |
||||
|
// 暂停
|
||||
|
audio.pause(); |
||||
|
cancelAnimationFrame(animationRefs.current.get(item.id) || 0); |
||||
|
} else { |
||||
|
// 播放
|
||||
|
audio.play().catch(err => { |
||||
|
message.error('音频播放失败:' + err.message); |
||||
|
}); |
||||
|
// 启动进度动画
|
||||
|
const updateProgress = () => { |
||||
|
if (audio.duration) { |
||||
|
const progress = (audio.currentTime / audio.duration) * 100; |
||||
|
setAudioList(prev => prev.map(a => |
||||
|
a.id === item.id ? { ...a, progress } : a |
||||
|
)); |
||||
|
} |
||||
|
animationRefs.current.set(item.id, requestAnimationFrame(updateProgress)); |
||||
|
}; |
||||
|
updateProgress(); |
||||
|
} |
||||
|
|
||||
|
// 更新播放状态
|
||||
|
setAudioList(prev => prev.map(a => |
||||
|
a.id === item.id ? { ...a, isPlaying: !a.isPlaying } : a |
||||
|
)); |
||||
|
}; |
||||
|
|
||||
|
// 音频结束事件
|
||||
|
const handleAudioEnded = (id: string) => { |
||||
|
setAudioList(prev => prev.map(a => |
||||
|
a.id === id ? { ...a, isPlaying: false, progress: 0 } : a |
||||
|
)); |
||||
|
cancelAnimationFrame(animationRefs.current.get(id) || 0); |
||||
|
}; |
||||
|
|
||||
|
// 生成语音
|
||||
|
const generateAudio = async () => { |
||||
|
if (!inputText.trim()) { |
||||
|
message.warning('请输入文本内容'); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
setLoading(true); |
||||
|
try { |
||||
|
// 调用HTTP接口获取音频链接(这里模拟接口调用)
|
||||
|
// 实际项目中替换为真实接口请求
|
||||
|
const mockAudioUrl = '/test.wav'; // 模拟返回的音频链接
|
||||
|
|
||||
|
// 创建新的音频条目
|
||||
|
const newItem: AudioItem = { |
||||
|
id: `audio-${Date.now()}`, |
||||
|
text: inputText.trim().substring(0, 20), // 只显示前20个字
|
||||
|
audioUrl: mockAudioUrl, |
||||
|
isPlaying: false, |
||||
|
progress: 0, |
||||
|
}; |
||||
|
|
||||
|
// 插入列表数据结构尾部(注意:最新的数据在最上面,所以需要unshift)
|
||||
|
setAudioList(prev => [newItem, ...prev].slice(0, 10)); // 最多展示10条
|
||||
|
setInputText(''); // 清空输入框
|
||||
|
message.success('语音生成成功'); |
||||
|
} catch (error) { |
||||
|
message.error('语音生成失败:' + (error as Error).message); |
||||
|
} finally { |
||||
|
setLoading(false); |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
// 上拉加载更多
|
||||
|
const onLoadMore = () => { |
||||
|
setLoadMoreLoading(true); |
||||
|
// 模拟加载历史数据
|
||||
|
setTimeout(() => { |
||||
|
// 这里可以添加加载历史数据的逻辑
|
||||
|
setLoadMoreLoading(false); |
||||
|
message.info('已加载全部数据'); |
||||
|
}, 1500); |
||||
|
}; |
||||
|
|
||||
|
// 加载更多的组件
|
||||
|
const loadMore = () => { |
||||
|
return ( |
||||
|
<div style={{ textAlign: 'center', padding: '16px 0' }}> |
||||
|
{loadMoreLoading ? <Spin /> : '上拉加载更多'} |
||||
|
</div> |
||||
|
); |
||||
|
}; |
||||
|
|
||||
|
// 渲染音频列表项
|
||||
|
const renderAudioItem = (item: AudioItem) => ( |
||||
|
<div className={styles.audioItem} key={item.id}> |
||||
|
{/* 播放按钮 */} |
||||
|
<div className={styles.playBtnContainer}> |
||||
|
<div |
||||
|
className={`${styles.playBtn} ${item.isPlaying ? styles.playing : ''}`} |
||||
|
style={{ background: `conic-gradient(#00ff88 ${item.progress}%, transparent ${item.progress}%)` }} |
||||
|
onClick={() => playAudio(item)} |
||||
|
> |
||||
|
<div className={styles.playIcon}> |
||||
|
<PlayCircleOutlined /> |
||||
|
</div> |
||||
|
</div> |
||||
|
{/* 隐藏的音频元素 */} |
||||
|
<audio |
||||
|
ref={el => { |
||||
|
if (el) audioRefs.current.set(item.id, el); |
||||
|
}} |
||||
|
src={item.audioUrl} |
||||
|
onEnded={() => handleAudioEnded(item.id)} |
||||
|
/> |
||||
|
</div> |
||||
|
|
||||
|
{/* 文本内容 */} |
||||
|
<div className={styles.textContent}>{item.text}</div> |
||||
|
|
||||
|
{/* 菜单按钮 */} |
||||
|
<div className={styles.menuBtn}> |
||||
|
<MenuOutlined /> |
||||
|
</div> |
||||
|
</div> |
||||
|
); |
||||
|
|
||||
|
return ( |
||||
|
<div className={styles.container}> |
||||
|
{/* 顶部标题栏 */} |
||||
|
<div className={styles.header}> |
||||
|
<div className={styles.title}>{intl.formatMessage({ id: 'tts.title', defaultMessage: '文字转语音' })}</div> |
||||
|
<Button type="primary" ghost className={styles.registerBtn}> |
||||
|
{intl.formatMessage({ id: 'tts.register', defaultMessage: '注册' })} |
||||
|
</Button> |
||||
|
</div> |
||||
|
|
||||
|
{/* Banner区 */} |
||||
|
<div className={styles.banner}> |
||||
|
<div className={styles.bannerPlaceholder}> |
||||
|
<SoundOutlined className={styles.bannerIcon} /> |
||||
|
<span>{intl.formatMessage({ id: 'tts.banner', defaultMessage: 'banner 图片' })}</span> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
{/* 内容列表 */} |
||||
|
<div className={styles.contentList}> |
||||
|
<InfiniteScroll |
||||
|
initialLoad={false} |
||||
|
pageStart={0} |
||||
|
loadMore={onLoadMore} |
||||
|
hasMore={!loadMoreLoading} |
||||
|
useWindow={false} |
||||
|
> |
||||
|
<List |
||||
|
dataSource={audioList} |
||||
|
renderItem={renderAudioItem} |
||||
|
locale={{ emptyText: '暂无音频数据' }} |
||||
|
loadMore={loadMore()} |
||||
|
/> |
||||
|
</InfiniteScroll> |
||||
|
</div> |
||||
|
|
||||
|
{/* 底部输入区 */} |
||||
|
<div className={styles.footer}> |
||||
|
<div className={styles.inputContainer}> |
||||
|
<Input.TextArea |
||||
|
placeholder={intl.formatMessage({ id: 'tts.inputPlaceholder', defaultMessage: '输入中英文' })} |
||||
|
value={inputText} |
||||
|
onChange={e => setInputText(e.target.value)} |
||||
|
onPressEnter={generateAudio} |
||||
|
autoSize={{ minRows: 1, maxRows: 3 }} |
||||
|
/> |
||||
|
</div> |
||||
|
|
||||
|
<Space className={styles.footerButtons}> |
||||
|
<Button |
||||
|
type="primary" |
||||
|
onClick={generateAudio} |
||||
|
loading={loading} |
||||
|
className={styles.generateBtn} |
||||
|
> |
||||
|
{intl.formatMessage({ id: 'tts.generate', defaultMessage: '生成' })} |
||||
|
</Button> |
||||
|
<Button icon={<SettingOutlined />} className={styles.settingBtn} /> |
||||
|
</Space> |
||||
|
</div> |
||||
|
</div> |
||||
|
); |
||||
|
}; |
||||
|
|
||||
|
export default TTSMobilePro; |
||||
Loading…
Reference in new issue