Browse Source

添加科技多巴胺风格Text To sound 移动端页面

pull/11591/head
子阳 4 months ago
parent
commit
125bad57d9
  1. 4
      config/routes.ts
  2. 30
      mock/requestRecord.mock.js
  3. BIN
      public/test.wav
  4. 5
      src/locales/en-US/pages.ts
  5. 5
      src/locales/zh-CN/pages.ts
  6. BIN
      src/pages/localsrc/test.wav
  7. 358
      src/pages/tts_mobile_pro.less
  8. 229
      src/pages/tts_mobile_pro.tsx

4
config/routes.ts

@ -55,6 +55,10 @@ export default [
path: '/', path: '/',
redirect: '/welcome', redirect: '/welcome',
}, },
{
path: '/tts-mobile-pro',
component: './tts_mobile_pro',
},
{ {
component: '404', component: '404',
layout: false, layout: false,

30
mock/requestRecord.mock.js

@ -1,33 +1,5 @@
module.exports = { module.exports = {
'GET /api/currentUser': {
data: {
name: 'Serati Ma',
avatar:
'https://gw.alipayobjects.com/zos/rmsportal/BiazfanxmamNRoxxVxka.png',
userid: '00000001',
email: 'antdesign@alipay.com',
signature: '海纳百川,有容乃大',
title: '交互专家',
group: '蚂蚁金服-某某某事业群-某某平台部-某某技术部-UED',
tags: [
{ key: '0', label: '很有想法的' },
{ key: '1', label: '专注设计' },
{ key: '2', label: '辣~' },
{ key: '3', label: '大长腿' },
{ key: '4', label: '川妹子' },
{ key: '5', label: '海纳百川' },
],
notifyCount: 12,
unreadCount: 11,
country: 'China',
geographic: {
province: { label: '浙江省', key: '330000' },
city: { label: '杭州市', key: '330100' },
},
address: '西湖区工专路 77 号',
phone: '0752-268888888',
},
},
'GET /api/rule': { 'GET /api/rule': {
data: [ data: [
{ {

BIN
public/test.wav

Binary file not shown.

5
src/locales/en-US/pages.ts

@ -73,4 +73,9 @@ export default {
'pages.searchTable.tenThousand': '0000', 'pages.searchTable.tenThousand': '0000',
'pages.searchTable.batchDeletion': 'batch deletion', 'pages.searchTable.batchDeletion': 'batch deletion',
'pages.searchTable.batchApproval': 'batch approval', 'pages.searchTable.batchApproval': 'batch approval',
'tts.title': 'Text to Speech',
'tts.register': 'Register',
'tts.banner': 'Banner Image',
'tts.inputPlaceholder': 'Enter Chinese/English',
'tts.generate': 'Generate',
}; };

5
src/locales/zh-CN/pages.ts

@ -68,4 +68,9 @@ export default {
'pages.searchTable.tenThousand': '万', 'pages.searchTable.tenThousand': '万',
'pages.searchTable.batchDeletion': '批量删除', 'pages.searchTable.batchDeletion': '批量删除',
'pages.searchTable.batchApproval': '批量审批', 'pages.searchTable.batchApproval': '批量审批',
'tts.title': '文字转语音',
'tts.register': '注册',
'tts.banner': 'banner 图片',
'tts.inputPlaceholder': '输入中英文',
'tts.generate': '生成',
}; };

BIN
src/pages/localsrc/test.wav

Binary file not shown.

358
src/pages/tts_mobile_pro.less

@ -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;
}

229
src/pages/tts_mobile_pro.tsx

@ -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…
Cancel
Save