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