├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── index.html ├── package-lock.json ├── package.json ├── package.json.bak ├── public ├── electron-vite-vue.gif ├── favicon.ico └── node.png ├── src ├── App.vue ├── api │ ├── local-tts.ts │ └── tts.ts ├── assets-shim.d.ts ├── assets │ ├── electron.png │ ├── i18n │ │ └── i18n.ts │ ├── main.css │ ├── styles │ │ └── modern-theme.css │ ├── vite.svg │ └── vue.png ├── components │ ├── aside │ │ ├── Aside.vue │ │ └── Version.vue │ ├── configpage │ │ ├── BiliBtn.vue │ │ └── ConfigPage.vue │ ├── header │ │ ├── FixedHeader.vue │ │ └── Logo.vue │ ├── history │ │ └── HistoryRecord.vue │ └── main │ │ ├── FreeTTSErrorDisplay.vue │ │ ├── Loading.vue │ │ ├── Main.vue │ │ ├── MainOptions.vue │ │ ├── MainScopedStyles.css │ │ ├── MainStyles.css │ │ ├── VoiceSelector.vue │ │ ├── emoji-config.ts │ │ └── options-config.ts ├── composables │ ├── main-option.ts │ └── main.ts ├── env.d.ts ├── global-shim.d.ts ├── global │ ├── index.ts │ ├── initLocalStore.ts │ ├── registerElement.ts │ ├── voice-config.ts │ └── voices.ts ├── main.ts ├── module-shims.d.ts ├── router │ └── router.ts ├── shims-vue.d.ts ├── store │ ├── play.ts │ ├── store.ts │ ├── store.ts.bak │ ├── types.ts │ └── web-store.ts ├── types │ ├── local-tts.d.ts │ ├── pinia.d.ts │ ├── prompGPT.ts │ └── vue.d.ts ├── voice-utils.ts └── vue-shim.d.ts ├── tsconfig.json ├── tsconfig.json.bak ├── tsconfig.node.json ├── vite.config.ts ├── vite.config.ts.bak ├── voice.json └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | # 依赖相关 2 | node_modules 3 | .pnpm-store/ 4 | 5 | # 编译输出 6 | dist 7 | dist-ssr 8 | *.local 9 | 10 | # 文档 11 | src/docs/ 12 | 13 | # 日志 14 | logs 15 | *.log 16 | npm-debug.log* 17 | yarn-debug.log* 18 | yarn-error.log* 19 | pnpm-debug.log* 20 | lerna-debug.log* 21 | 22 | # 编辑器目录和文件 23 | .vscode/* 24 | !.vscode/extensions.json 25 | !.vscode/settings.json 26 | .idea 27 | .DS_Store 28 | *.suo 29 | *.ntvs* 30 | *.njsproj 31 | *.sln 32 | *.sw? 33 | 34 | # 环境变量 35 | .env 36 | .env.* 37 | !.env.example 38 | 39 | # 测试覆盖率 40 | coverage 41 | .nyc_output 42 | 43 | # 缓存 44 | .eslintcache 45 | .stylelintcache 46 | .temp 47 | .cache 48 | 49 | # 构建和发布 50 | release/*.zip 51 | release/*.exe 52 | release/*.dmg 53 | release/*.AppImage 54 | !release/*/ 55 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 2025-04-20 2 | 3 | [v2.0.0](https://github.com/electron-vite/electron-vite-vue/pull/156) 4 | 5 | 这是一个基于原项目二次开发的新版本,从2.0.0版本开始重新编号。 6 | 7 | 主要功能更新: 8 | 9 | 1. **支持Web在线部署访问** 10 | * 项目支持直接部署到Web服务器,可通过浏览器在线访问使用 11 | * 不再局限于桌面应用,使用更加便捷 12 | 2. **修复Edge Speech API问题** 13 | * 修复了Edge本地朗读接口无法使用的问题 14 | * 优化了接口的稳定性和响应速度 15 | * 需要下载桌面端免费调用 16 | 3. **第三方中转TTS88 API支持** 17 | * 新增对TTS88 API的支持 18 | * 提供更多语音转换选择,提高转换质量和效率 19 | 20 | ## 2025-05-15 21 | 22 | [v2.1.0]() 23 | 24 | 此版本主要对用户界面和交互体验进行了全面优化,提高了应用的易用性和视觉吸引力。 25 | 26 | ### 用户界面与交互改进: 27 | 28 | 1. **设置区域可折叠设计** 29 | * 添加了可折叠的设置面板,点击"语音设置"标题可展开/折叠 30 | * 折叠时节省界面空间,让用户专注于文本输入 31 | * 展开时可完整显示所有设置选项 32 | 33 | 2. **"开始转换"按钮位置优化** 34 | * 将按钮从页面底部移至顶部与标题同行 35 | * 解决用户需要滚动到页面底部才能点击按钮的问题 36 | * 无论用户在页面哪个位置,都能轻松开始转换过程 37 | 38 | 3. **状态反馈增强** 39 | * 添加了"准备好了吗?"和"正在转换中..."等状态提示文字 40 | * 按钮加载状态视觉反馈更明确 41 | * 完善的错误提示和成功通知机制 42 | 43 | 4. **视觉设计提升** 44 | * 按钮添加了微妙的光效动画,增强用户体验 45 | * 优化颜色和阴影效果,使界面更现代化 46 | * 统一了各组件的视觉风格 47 | 48 | 5. **响应式布局改进** 49 | * 优化了在不同屏幕尺寸下的显示效果 50 | * 移动设备上的交互体验更加友好 51 | * 设置区网格布局自动适应屏幕宽度 52 | 53 | 这次更新极大地提升了应用的用户体验,使TTS-Web-Vue更加易用、高效和美观。特别是"开始转换"按钮位置的优化,彻底解决了以往用户体验中的痛点问题。 54 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 草鞋没号 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TTS-Vue Web版本 2 | 3 | 🎤 微软语音合成工具,Web版本,使用 `Vue` + `ElementPlus` + `Vite` 构建。 4 | 5 | ## 网站示例 6 | 7 | https://web.tts88.top 8 | ### 新版本界面 9 | ![image](https://github.com/user-attachments/assets/177c8c0d-33d1-48e7-81e3-778f36d8fedd) 10 | 11 | ### 旧版本界面 12 | ![image](https://github.com/user-attachments/assets/67cafd2c-7b0f-4b0d-b14b-bf402aaff0cd) 13 | 14 | ## 功能特点 15 | 16 | - 🌐 完全Web化,无需安装桌面应用 17 | - 🔊 支持微软多种语音合成 18 | - 🚀 支持TTS88 API集成 19 | - 🆓 支持免费TTS调用,每日免费额度 20 | - 🧠 支持OpenAI的文本生成 21 | - 🌍 支持多语言:中文、英文、西班牙语 22 | - 🔐 浏览器指纹识别,更安全的用户体验 23 | 24 | ## 最新更新 25 | 26 | ### 界面重构与功能增强 (v2.2.0) 27 | 28 | - 📱 **UI重构**:固定顶部导航,文本框采用吸顶模式,优化移动端适配 29 | - 🔤 **SSML增强**:新增SSML格式化功能,自动根据设置变化更新SSML 30 | - 🎵 **播放器优化**:集成播放器到主界面,改进音频播放和下载体验 31 | - 🎞️ **字幕功能**:新增在线生成字幕功能,提升内容创作效率 32 | - 🌓 **主题优化**:改善暗黑模式下的界面表现,增加全局主题切换 33 | - 🛠️ **高级设置**:优化免费TTS服务界面,新增语速和音调配置 34 | - 💡 **交互体验**:添加工具提示功能,增强用户操作指引 35 | - 🔄 **依赖升级**:Vue更新至3.5.11,ElementPlus更新至2.9.9 36 | - 🌐 **链接更新**:更新GitHub仓库地址 37 | 38 | ### 免费TTS服务集成 (v2.1.0) 39 | 40 | - 🆕 **免费TTS功能**:无需API密钥,每日都有免费额度 41 | - 👤 **浏览器指纹识别**:保证每个用户公平使用免费额度 42 | - 🔄 **额度状态显示**:实时显示剩余免费字符数和重置时间 43 | - 🌐 **服务器状态检测**:自动检测免费TTS服务器连接状态 44 | - 🛡️ **增强错误处理**:更友好的错误提示和状态反馈 45 | - 🎛️ **免费TTS设置面板**:方便用户配置和查看额度信息 46 | 47 | ### 界面与用户体验优化 (v2.0.0) 48 | 49 | - ✨ **设置区域优化**:设置面板支持折叠,点击"语音设置"标题即可展开/折叠 50 | - 🔘 **智能按钮位置**:"开始转换"按钮移至顶部,无需滚动即可点击 51 | - 💬 **状态提示增强**:添加直观的转换状态提示文字 52 | - 🎨 **视觉效果改进**:按钮添加光效动画,增强用户体验 53 | - 📱 **响应式布局优化**:改进在不同屏幕尺寸下的显示效果 54 | 55 | 这些改进大大提升了使用效率,特别是解决了传统设计中用户需要滚动到页面底部才能点击"开始转换"按钮的问题。现在,无论用户在页面的哪个位置,都可以轻松启动转换过程。 56 | 57 | ## 开发计划 58 | 59 | > **📢 重要通知:桌面版本正在开发中!** 60 | > 我们正在开发跨平台桌面应用版本,将支持更多功能和更好的用户体验。 61 | > 敬请期待后续更新,请关注项目动态获取最新信息。 62 | 63 | ## 快速开始 64 | 65 | ### 开发环境 66 | 67 | ```bash 68 | # 克隆仓库 69 | git clone https://github.com/henryhu55/tts-web-vue.git 70 | 71 | # 安装依赖 72 | yarn install 73 | 74 | # 启动开发服务器 75 | yarn dev 76 | ``` 77 | 78 | ### 生产构建 79 | 80 | ```bash 81 | # 构建生产版本 82 | yarn build 83 | 84 | # 预览生产版本 85 | yarn preview 86 | ``` 87 | 88 | ## 部署 89 | 90 | 构建完成后,将 `dist`目录的内容部署到任何静态Web服务器上即可。 91 | 92 | ## API配置 93 | 94 | 本Web版本目前支持免费TTS服务、TTS88 API和OpenAI API: 95 | 96 | ### 免费TTS服务配置 97 | 98 | 1. 在设置页面中找到"免费TTS服务"选项 99 | 2. 系统默认配置了免费TTS服务器地址 100 | 3. 您可以查看当日剩余免费额度和重置时间 101 | 4. 每个浏览器客户端拥有独立的免费额度 102 | 103 | ### TTS88 API配置 104 | 105 | 1. 在设置页面中找到"第三方API URL"设置选项 106 | 2. 输入您的TTS88 API地址 107 | 3. 如果有API密钥,请输入到"TTS88 API密钥"字段 108 | 109 | ### OpenAI API配置 110 | 111 | 1. 在设置页面中找到OpenAI设置选项 112 | 2. 输入您的OpenAI API密钥 113 | 3. 选择要使用的模型(默认为gpt-3.5-turbo) 114 | 4. 如果使用自托管或代理,可以设置自定义的API Base URL 115 | 116 | ## 注意事项 117 | 118 | - 数据仅存储在浏览器本地存储中,刷新或清除缓存不会影响到其他用户 119 | - 转换后的音频文件可以直接在浏览器中播放或下载到本地 120 | - 免费TTS服务每日有使用额度限制,超出需等待次日重置 121 | 122 | ## 技术栈 123 | 124 | - Vue 3.2 125 | - Pinia 126 | - ElementPlus 127 | - Vite 128 | 129 | ## 许可证 130 | 131 | MIT License 132 | 133 | ## 开始使用 134 | 135 | - [项目简介](https://docs.tts88.top//guide/intro.html) 136 | - [安装运行](https://docs.tts88.top//guide/install.html) 137 | - [功能介绍](https://docs.tts88.top/guide/features.html) 138 | - [常见问题](https://docs.tts88.top//guide/qa.html) 139 | - [更新日志](https://docs.tts88.top//guide/update.html) 140 | 141 | ## 注意 142 | 143 | 该软件以及代码仅为个人学习测试使用,请在下载后24小时内删除,不得用于商业用途,否则后果自负。任何违规使用造成的法律后果与本人无关。该软件也永远不会收费,如果您使用该软件前支付了额外费用,或付费获得源码或成品软件,那么你一定被骗了! 144 | 145 | **搬运请注明出处。禁止诱导他人以加群、私信等方式获取软件的仓库、下载地址和安装包。** 146 | 147 | ### 意见问题反馈,版本发布企鹅群: 148 | 149 | `【tts-web-vue问题反馈群1】279895662` 150 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | TTS Vue - Web Version 11 | 12 | 13 |
14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tts-vue-web", 3 | "version": "v2.3.0", 4 | "description": "🎤 微软语音合成工具,Web版本,使用 Vue + ElementPlus + Vite 构建。", 5 | "author": "Anger <3302029635@qq.com>", 6 | "license": "MIT", 7 | "private": true, 8 | "type": "module", 9 | "scripts": { 10 | "dev": "vite", 11 | "build": "vue-tsc --noEmit && vite build", 12 | "preview": "vite preview" 13 | }, 14 | "engines": { 15 | "node": ">=14.17.0" 16 | }, 17 | "devDependencies": { 18 | "@types/vue": "^1.0.31", 19 | "@vitejs/plugin-vue": "4.5.2", 20 | "@vue/runtime-core": "3.5.11", 21 | "typescript": "5.0.4", 22 | "vite": "4.5.2", 23 | "vue": "3.5.11", 24 | "vue-tsc": "1.8.27" 25 | }, 26 | "env": { 27 | "VITE_DEV_SERVER_HOST": "127.0.0.1", 28 | "VITE_DEV_SERVER_PORT": "3344" 29 | }, 30 | "keywords": [ 31 | "vite", 32 | "vue3", 33 | "vue" 34 | ], 35 | "dependencies": { 36 | "axios": "0.27.2", 37 | "element-plus": "^2.9.9", 38 | "openai": "^4.0.0", 39 | "pinia": "3.0.2", 40 | "uuid": "8.3.2", 41 | "vue-demi": "^0.14.10", 42 | "vue-i18n": "9.6.5", 43 | "vue-router": "^4.5.1" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /package.json.bak: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tts-vue-web", 3 | "version": "2.0.0", 4 | "description": "🎤 微软语音合成工具,Web版本,使用 Vue + ElementPlus + Vite 构建。", 5 | "author": "Anger <3302029635@qq.com>", 6 | "license": "MIT", 7 | "private": true, 8 | "scripts": { 9 | "dev": "vite", 10 | "build": "vue-tsc --noEmit && vite build", 11 | "preview": "vite preview" 12 | }, 13 | "engines": { 14 | "node": ">=14.17.0" 15 | }, 16 | "devDependencies": { 17 | "@types/vue": "^1.0.31", 18 | "@vitejs/plugin-vue": "2.3.3", 19 | "@vue/runtime-core": "^3.5.13", 20 | "typescript": "4.7.4", 21 | "vite": "2.9.13", 22 | "vue": "3.2.37", 23 | "vue-tsc": "0.38.3" 24 | }, 25 | "env": { 26 | "VITE_DEV_SERVER_HOST": "127.0.0.1", 27 | "VITE_DEV_SERVER_PORT": "3344" 28 | }, 29 | "keywords": [ 30 | "vite", 31 | "vue3", 32 | "vue" 33 | ], 34 | "dependencies": { 35 | "axios": "0.27.2", 36 | "element-plus": "2.2.9", 37 | "openai": "^4.0.0", 38 | "pinia": "2.0.14", 39 | "uuid": "8.3.2", 40 | "vue-i18n": "9.6.5" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /public/electron-vite-vue.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/henryhu55/tts-web-vue/70475509e10aedbed9122dd7f7573321e275cc48/public/electron-vite-vue.gif -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/henryhu55/tts-web-vue/70475509e10aedbed9122dd7f7573321e275cc48/public/favicon.ico -------------------------------------------------------------------------------- /public/node.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/henryhu55/tts-web-vue/70475509e10aedbed9122dd7f7573321e275cc48/public/node.png -------------------------------------------------------------------------------- /src/api/local-tts.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | 3 | // 本地TTS服务器的配置接口 4 | export interface LocalTTSConfig { 5 | baseUrl: string; // 服务器地址,例如:http://localhost:8080 6 | defaultVoice: string; // 默认语音 7 | defaultLanguage: string; // 默认语言 8 | retryCount: number; 9 | retryInterval: number; 10 | defaultAudioFormat: string; 11 | autoPlay: boolean; 12 | enabled: boolean; 13 | } 14 | 15 | // TTS请求参数接口 16 | export interface TTSRequestParams { 17 | text?: string; // 纯文本 18 | ssml?: string; // SSML格式文本 19 | voice: string; // 语音 20 | language: string; // 语言 21 | format?: string; // 格式,默认mp3 22 | speed?: number; // 语速 23 | pitch?: number; // 音调 24 | } 25 | 26 | // 免费额度信息接口 27 | export interface FreeLimitInfo { 28 | free_limit: number; // 总免费额度 29 | used: number; // 已使用 30 | remaining: number; // 剩余 31 | reset_date: string; // 重置日期 32 | days_streak?: number; // 连续使用天数 33 | debug?: any; // 调试信息 34 | } 35 | 36 | // 错误响应接口 37 | export interface ErrorResponse { 38 | message: string; 39 | code: number; 40 | status?: number; 41 | } 42 | 43 | /** 44 | * 检查服务器连接性 45 | * @param config 服务器配置 46 | * @returns 服务器是否可连接 47 | */ 48 | export async function checkServerConnection(config: LocalTTSConfig): Promise { 49 | try { 50 | const response = await axios.get(`${config.baseUrl}/api/v1/health`, { 51 | timeout: 5000 // 5秒超时 52 | }); 53 | return response.status === 200 && response.data?.status === 'ok'; 54 | } catch (error) { 55 | console.error('无法连接到TTS服务器:', error); 56 | throw error; // 向上抛出错误,让调用者处理 57 | } 58 | } 59 | 60 | /** 61 | * 生成浏览器指纹 62 | * 虽然最终在服务器端会重新生成更可靠的指纹,但前端仍提供一个初始值以便服务器端比对 63 | */ 64 | export function generateBrowserFingerprint(): string { 65 | try { 66 | const screenInfo = `${window.screen.width}x${window.screen.height}x${window.screen.colorDepth}`; 67 | const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; 68 | const languages = navigator.languages ? navigator.languages.join(',') : navigator.language; 69 | const canvas = document.createElement('canvas'); 70 | const gl = canvas.getContext('webgl'); 71 | const glInfo = gl ? gl.getParameter(gl.RENDERER) : 'no-webgl'; 72 | 73 | // 组合所有信息 74 | const components = [ 75 | navigator.userAgent, 76 | screenInfo, 77 | timeZone, 78 | languages, 79 | glInfo, 80 | navigator.platform, 81 | new Date().getTimezoneOffset() 82 | ]; 83 | 84 | // 创建一个简单的哈希 85 | let hash = 0; 86 | const str = components.join('|'); 87 | for (let i = 0; i < str.length; i++) { 88 | const char = str.charCodeAt(i); 89 | hash = ((hash << 5) - hash) + char; 90 | hash = hash & hash; // 转换为32位整数 91 | } 92 | 93 | return hash.toString(16); 94 | } catch (e) { 95 | // 如果发生任何错误,返回一个随机值 96 | console.error('生成浏览器指纹时出错:', e); 97 | return Math.random().toString(36).substring(2, 15); 98 | } 99 | } 100 | 101 | /** 102 | * 处理API错误 103 | * @param error 错误对象 104 | * @returns 抛出增强的错误对象 105 | */ 106 | export async function handleApiError(error: any): Promise { 107 | console.error('API请求错误:', error); 108 | 109 | // 默认错误响应 110 | const errorResponse: ErrorResponse = { 111 | message: '未知错误', 112 | code: 500, 113 | status: 500 114 | }; 115 | 116 | if (!error) { 117 | throw new Error(errorResponse.message); 118 | } 119 | 120 | // 处理不同类型的错误 121 | if (error.response) { 122 | // 服务器返回了错误状态码 123 | errorResponse.status = error.response.status; 124 | 125 | // 尝试解析响应内容 126 | if (error.response.data) { 127 | try { 128 | // 如果响应是Blob,需要先转换 129 | if (error.response.data instanceof Blob) { 130 | const text = await error.response.data.text(); 131 | try { 132 | const jsonData = JSON.parse(text); 133 | errorResponse.message = jsonData.message || '服务器错误'; 134 | errorResponse.code = jsonData.code || error.response.status; 135 | } catch (e) { 136 | // 不是JSON格式,直接使用文本 137 | errorResponse.message = text || '服务器返回了无效的响应'; 138 | } 139 | } else { 140 | // 直接使用响应数据 141 | errorResponse.message = error.response.data.message || '服务器错误'; 142 | errorResponse.code = error.response.data.code || error.response.status; 143 | } 144 | } catch (e) { 145 | // 无法解析响应内容 146 | errorResponse.message = '无法解析错误响应'; 147 | } 148 | } 149 | 150 | // 使用状态码设置特定的错误消息 151 | switch (error.response.status) { 152 | case 402: 153 | errorResponse.message = errorResponse.message || '免费额度不足,请明天再试'; 154 | break; 155 | case 403: 156 | errorResponse.message = errorResponse.message || '您的账户已被暂时限制使用'; 157 | break; 158 | case 429: 159 | errorResponse.message = errorResponse.message || '请求过于频繁,请稍后再试'; 160 | break; 161 | case 500: 162 | case 502: 163 | case 503: 164 | case 504: 165 | errorResponse.message = errorResponse.message || '服务器内部错误'; 166 | break; 167 | } 168 | } else if (error.request) { 169 | // 请求已发出但没有收到响应 170 | errorResponse.message = '服务器未响应,请检查网络连接'; 171 | errorResponse.status = -1; 172 | errorResponse.code = -1; 173 | } else { 174 | // 设置请求时发生错误 175 | errorResponse.message = error.message || '请求设置错误'; 176 | } 177 | 178 | // 抛出完整的错误对象 179 | const enhancedError = new Error(errorResponse.message); 180 | (enhancedError as any).response = { 181 | status: errorResponse.status, 182 | data: { message: errorResponse.message, code: errorResponse.code } 183 | }; 184 | 185 | throw enhancedError; 186 | } 187 | 188 | /** 189 | * 获取免费额度信息 190 | * @param config 服务器配置 191 | * @returns 免费额度信息 192 | */ 193 | export async function getFreeLimitInfo(config: LocalTTSConfig): Promise { 194 | try { 195 | // 请求配置,添加浏览器指纹头 196 | const requestConfig = { 197 | headers: { 198 | 'X-Browser-Fingerprint': generateBrowserFingerprint() 199 | }, 200 | timeout: 5000 // 5秒超时 201 | }; 202 | 203 | const response = await axios.get( 204 | `${config.baseUrl}/api/v1/free-limit`, 205 | requestConfig 206 | ); 207 | 208 | if (response.status === 200 && response.data?.data) { 209 | return response.data.data as FreeLimitInfo; 210 | } 211 | throw new Error('获取免费额度信息失败'); 212 | } catch (error: any) { 213 | return handleApiError(error); 214 | } 215 | } 216 | 217 | // 默认配置 218 | export const DEFAULT_LOCAL_TTS_CONFIG: LocalTTSConfig = { 219 | baseUrl: 'https://free.tts88.top', // 修改为您的实际服务器地址 220 | defaultVoice: 'zh-CN-XiaoxiaoNeural', 221 | defaultLanguage: 'zh-CN', 222 | retryCount: 3, 223 | retryInterval: 2000, 224 | defaultAudioFormat: 'mp3', 225 | autoPlay: true, 226 | enabled: true 227 | }; -------------------------------------------------------------------------------- /src/api/tts.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | 3 | interface VoiceData { 4 | activeIndex: string; 5 | ssmlContent: string; 6 | inputContent: string; 7 | retryCount?: number; 8 | retryInterval?: number; 9 | } 10 | 11 | interface TTSParams { 12 | api: number; 13 | voiceData: VoiceData; 14 | speechKey: string; 15 | region: string; 16 | thirdPartyApi: string; 17 | tts88Key: string; 18 | } 19 | 20 | export interface TTSResponse { 21 | audioContent?: string; 22 | audibleUrl?: string; 23 | buffer?: ArrayBuffer; 24 | error?: string; 25 | errorCode?: string; 26 | } 27 | 28 | export async function callTTSApi(params: TTSParams): Promise { 29 | try { 30 | const { api, voiceData, speechKey, region, thirdPartyApi, tts88Key } = params; 31 | 32 | // 根据不同的 API 类型构建不同的请求 URL 和认证头 33 | let apiUrl = ''; 34 | let headers: Record = { 35 | 'Content-Type': 'application/ssml+xml', 36 | 'X-Microsoft-OutputFormat': 'audio-16khz-128kbitrate-mono-mp3', 37 | }; 38 | 39 | if (api === 4) { 40 | apiUrl = thirdPartyApi; 41 | // TTS88 API 使用 tts88Key 42 | if (tts88Key) { 43 | headers['Authorization'] = `Bearer ${tts88Key}`; 44 | } 45 | } else if (api === 5) { 46 | // 导入本地TTS服务相关功能 47 | try { 48 | const { useFreeTTSstore } = await import('@/store/play'); 49 | const localTTSStore = useFreeTTSstore(); 50 | 51 | // 获取配置 52 | const config = localTTSStore.fullConfig; 53 | 54 | // 准备API请求的URL和参数 55 | apiUrl = `${config.baseUrl}/api/v1/free-tts-stream`; 56 | 57 | // 获取本地TTS所需的参数 58 | const isSSML = voiceData.activeIndex === "1"; // 判断是否为SSML内容 59 | 60 | // 对于免费服务,优先使用纯文本,因为SSML可能导致错误 61 | // 即使activeIndex为1,也尝试提取纯文本内容 62 | let content = ""; 63 | if (isSSML) { 64 | // 从SSML中尝试提取纯文本内容 65 | try { 66 | // 尝试简单提取SSML中的文本 67 | content = voiceData.ssmlContent 68 | .replace(/<[^>]*>/g, '') // 移除所有XML标签 69 | .replace(/\s+/g, ' ') // 将多个空格合并为单个空格 70 | .trim(); // 移除前后空格 71 | 72 | console.log('从SSML中提取的纯文本:', content); 73 | 74 | // 如果提取失败或内容为空,则使用原始SSML 75 | if (!content) { 76 | content = voiceData.ssmlContent; 77 | console.log('提取纯文本失败,使用原始SSML'); 78 | } 79 | } catch (e) { 80 | console.error('提取纯文本时出错:', e); 81 | content = voiceData.ssmlContent; 82 | } 83 | } else { 84 | content = voiceData.inputContent; 85 | } 86 | 87 | const { useTtsStore } = await import('@/store/store'); 88 | const ttsStore = useTtsStore(); 89 | const selectedVoice = ttsStore.formConfig.voiceSelect; 90 | const speed = ttsStore.formConfig.speed; 91 | const pitch = ttsStore.formConfig.pitch; 92 | 93 | // 获取浏览器指纹 94 | const fingerprint = await generateBrowserFingerprint(); 95 | 96 | // 设置请求头 97 | headers = { 98 | 'Content-Type': 'application/json', 99 | 'X-Browser-Fingerprint': fingerprint 100 | }; 101 | 102 | console.log('发送请求到免费TTS服务,使用的声音:', selectedVoice); 103 | 104 | // 使用正确的参数格式 105 | // 对于免费服务,尝试使用纯文本模式,避免SSML解析错误 106 | const requestBody = { 107 | text: content, 108 | is_ssml: false, // 强制设置为false,使用纯文本模式 109 | voice: selectedVoice || 'zh-CN-XiaoxiaoNeural', 110 | language: ttsStore.formConfig.languageSelect || 'zh-CN', 111 | format: 'mp3', 112 | speed: speed || 1.0, 113 | pitch: pitch || 1.0 114 | }; 115 | 116 | console.log('发送到免费TTS服务的请求参数:', requestBody); 117 | 118 | // 发送请求 119 | const response = await axios.post( 120 | apiUrl, 121 | requestBody, 122 | { 123 | headers, 124 | responseType: 'arraybuffer', 125 | timeout: 300000 126 | } 127 | ); 128 | 129 | // 返回二进制音频数据 130 | return { 131 | buffer: response.data 132 | }; 133 | } catch (localError: any) { 134 | return { 135 | error: `FreeTTS服务错误: ${localError.message}`, 136 | errorCode: "LOCAL_TTS_ERROR" 137 | }; 138 | } 139 | } else { 140 | // Azure API 141 | apiUrl = `https://${region}.tts.speech.microsoft.com/cognitiveservices/v1`; 142 | // Azure API 直接使用 speechKey 143 | headers['Ocp-Apim-Subscription-Key'] = speechKey; 144 | } 145 | 146 | // 所有API类型都需要发送HTTP请求 147 | if (api !== 5) { // 上面已经处理了API类型5的情况 148 | try { 149 | // 发送请求 150 | const response = await axios.post( 151 | apiUrl, 152 | voiceData.ssmlContent, 153 | { 154 | headers, 155 | responseType: 'arraybuffer', 156 | timeout: 300000 // 设置300秒超时 157 | } 158 | ); 159 | 160 | // 将 ArrayBuffer 转换为 base64 字符串 161 | const audioData = new Uint8Array(response.data); 162 | const base64Audio = btoa( 163 | audioData.reduce((data, byte) => data + String.fromCharCode(byte), '') 164 | ); 165 | 166 | // 返回音频数据 167 | return { 168 | buffer: response.data, 169 | audioContent: base64Audio 170 | }; 171 | } catch (httpError: any) { 172 | // 简化错误处理,只返回基本错误信息和状态码 173 | return { 174 | error: httpError.message || '获取语音数据失败', 175 | errorCode: httpError.response?.status ? `HTTP_${httpError.response.status}` : "HTTP_ERROR" 176 | }; 177 | } 178 | } 179 | 180 | // 如果执行到这里且未返回,返回一个默认错误 181 | return { 182 | error: "未知错误,无法处理API请求", 183 | errorCode: "UNKNOWN_ERROR" 184 | }; 185 | } catch (error: any) { 186 | // 全局错误处理 187 | return { 188 | audioContent: '', 189 | error: error.message || '获取语音数据失败', 190 | errorCode: "GLOBAL_ERROR" 191 | }; 192 | } 193 | } 194 | 195 | // 生成浏览器指纹 196 | async function generateBrowserFingerprint(): Promise { 197 | try { 198 | const screenInfo = `${window.screen.width}x${window.screen.height}x${window.screen.colorDepth}`; 199 | const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; 200 | const languages = navigator.languages ? navigator.languages.join(',') : navigator.language; 201 | const canvas = document.createElement('canvas'); 202 | const gl = canvas.getContext('webgl'); 203 | const glInfo = gl ? gl.getParameter(gl.RENDERER) : 'no-webgl'; 204 | 205 | // 组合所有信息 206 | const components = [ 207 | navigator.userAgent, 208 | screenInfo, 209 | timeZone, 210 | languages, 211 | glInfo, 212 | navigator.platform, 213 | new Date().getTimezoneOffset() 214 | ]; 215 | 216 | // 创建一个简单的哈希 217 | let hash = 0; 218 | const str = components.join('|'); 219 | for (let i = 0; i < str.length; i++) { 220 | const char = str.charCodeAt(i); 221 | hash = ((hash << 5) - hash) + char; 222 | hash = hash & hash; // 转换为32位整数 223 | } 224 | 225 | return hash.toString(16); 226 | } catch (e) { 227 | // 如果发生任何错误,返回一个随机值 228 | console.error('生成浏览器指纹时出错:', e); 229 | return Math.random().toString(36).substring(2, 15); 230 | } 231 | } 232 | 233 | // 批量转换任务接口 234 | interface BatchTask { 235 | inputKind: string; 236 | inputs: Array<{ 237 | content: string; 238 | }>; 239 | properties: { 240 | wordBoundaryEnabled: boolean; 241 | }; 242 | } 243 | 244 | // 批量转换任务状态接口 245 | interface BatchTaskStatus { 246 | id: string; 247 | status: string; 248 | createdDateTime: string; 249 | lastActionDateTime: string; 250 | properties: { 251 | timeToLiveInHours: number; 252 | succeededAudioCount: number; 253 | failedAudioCount: number; 254 | durationInMilliseconds: number; 255 | }; 256 | outputs?: { 257 | result: string; 258 | }; 259 | } 260 | 261 | // 生成唯一的任务ID 262 | function generateTaskId(): string { 263 | return 'task_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9); 264 | } 265 | 266 | // 转换API URL从单文件到批量处理的URL 267 | function convertToBatchApiUrl(apiUrl: string): string { 268 | // 将 /cognitiveservices/v1 替换为 /texttospeech 269 | return apiUrl.replace(/\/cognitiveservices\/v1/, '/texttospeech'); 270 | } 271 | 272 | // 创建批量转换任务 273 | export async function createBatchTask( 274 | thirdPartyApi: string, 275 | tts88Key: string, 276 | task: BatchTask 277 | ): Promise { 278 | try { 279 | // 调试日志 280 | console.log('createBatchTask 参数:', { 281 | thirdPartyApi, 282 | hasKey: !!tts88Key, 283 | task 284 | }); 285 | 286 | // 转换为批量处理的API URL 287 | const batchApiUrl = convertToBatchApiUrl(thirdPartyApi); 288 | console.log('转换后的批量API URL:', batchApiUrl); 289 | 290 | // 移除URL末尾的斜杠 291 | const baseUrl = batchApiUrl.replace(/\/$/, ''); 292 | 293 | // 生成任务ID 294 | const taskId = generateTaskId(); 295 | console.log('生成的任务ID:', taskId); 296 | 297 | // 构建完整的请求URL 298 | const requestUrl = `${baseUrl}/batchsyntheses/${taskId}?api-version=2024-04-01`; 299 | console.log('请求URL:', requestUrl); 300 | 301 | // 发送创建任务请求 302 | const response = await axios.put( 303 | requestUrl, 304 | task, 305 | { 306 | headers: { 307 | 'Authorization': `Bearer ${tts88Key}`, 308 | 'Content-Type': 'application/json' 309 | } 310 | } 311 | ); 312 | 313 | console.log('创建任务响应:', response.data); 314 | 315 | // 返回生成的任务ID 316 | return taskId; 317 | } catch (error: any) { 318 | // 增强错误日志 319 | console.error('创建批量任务失败:', { 320 | error, 321 | message: error.message, 322 | response: error.response?.data, 323 | status: error.response?.status 324 | }); 325 | throw new Error(`创建批量任务失败: ${error.message}`); 326 | } 327 | } 328 | 329 | // 获取批量转换任务状态 330 | export async function getBatchTaskStatus( 331 | thirdPartyApi: string, 332 | tts88Key: string, 333 | taskId: string 334 | ): Promise { 335 | try { 336 | // 检查任务ID是否有效 337 | if (!taskId || taskId === 'undefined') { 338 | throw new Error('无效的任务ID'); 339 | } 340 | 341 | // 调试日志 342 | console.log('getBatchTaskStatus 参数:', { 343 | thirdPartyApi, 344 | taskId, 345 | hasKey: !!tts88Key 346 | }); 347 | 348 | // 转换为批量处理的API URL 349 | const batchApiUrl = convertToBatchApiUrl(thirdPartyApi); 350 | console.log('转换后的批量API URL:', batchApiUrl); 351 | 352 | // 移除URL末尾的斜杠 353 | const baseUrl = batchApiUrl.replace(/\/$/, ''); 354 | 355 | // 构建完整的请求URL 356 | const requestUrl = `${baseUrl}/batchsyntheses/${taskId}?api-version=2024-04-01`; 357 | console.log('请求URL:', requestUrl); 358 | 359 | const response = await axios.get( 360 | requestUrl, 361 | { 362 | headers: { 363 | 'Authorization': `Bearer ${tts88Key}`, 364 | 'Content-Type': 'application/json' 365 | } 366 | } 367 | ); 368 | 369 | // 调试日志 370 | console.log('获取到的响应:', response.data); 371 | 372 | // 检查响应数据的完整性 373 | if (!response.data?.status) { 374 | console.error('响应数据缺少status字段:', response.data); 375 | throw new Error('服务器返回的状态数据无效'); 376 | } 377 | 378 | return response.data; 379 | } catch (error: any) { 380 | // 增强错误日志 381 | console.error('获取任务状态失败:', { 382 | error, 383 | message: error.message, 384 | response: error.response?.data, 385 | status: error.response?.status 386 | }); 387 | throw new Error(`获取任务状态失败: ${error.message}`); 388 | } 389 | } 390 | 391 | // 删除批量转换任务 392 | export async function deleteBatchTask( 393 | thirdPartyApi: string, 394 | tts88Key: string, 395 | taskId: string 396 | ): Promise { 397 | try { 398 | // 检查任务ID是否有效 399 | if (!taskId || taskId === 'undefined') { 400 | throw new Error('无效的任务ID'); 401 | } 402 | 403 | // 移除URL末尾的斜杠 404 | const baseUrl = thirdPartyApi.replace(/\/$/, ''); 405 | 406 | // 构建完整的请求URL,避免路径重复 407 | const requestUrl = `${baseUrl}/batchsyntheses/${taskId}?api-version=2024-04-01`; 408 | 409 | await axios.delete( 410 | requestUrl, 411 | { 412 | headers: { 413 | 'Authorization': `Bearer ${tts88Key}` 414 | } 415 | } 416 | ); 417 | } catch (error: any) { 418 | console.error('删除任务失败:', error); 419 | throw new Error(`删除任务失败: ${error.message}`); 420 | } 421 | } -------------------------------------------------------------------------------- /src/assets-shim.d.ts: -------------------------------------------------------------------------------- 1 | // 为assets下的i18n模块创建声明 2 | declare module '@/assets/i18n/i18n' { 3 | const i18n: { 4 | global: { 5 | t: (key: string, ...args: any[]) => string; 6 | locale: string; 7 | } 8 | }; 9 | export default i18n; 10 | } 11 | 12 | // 为全局voices模块创建声明 13 | declare module './../../global/voices' { 14 | export const voices: any[]; 15 | } -------------------------------------------------------------------------------- /src/assets/electron.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/henryhu55/tts-web-vue/70475509e10aedbed9122dd7f7573321e275cc48/src/assets/electron.png -------------------------------------------------------------------------------- /src/assets/main.css: -------------------------------------------------------------------------------- 1 | /* 全局变量 */ 2 | :root { 3 | --primary-color: #409eff; 4 | --primary-color-rgb: 64, 158, 255; 5 | --accent-color: #67c23a; 6 | --warning-color: #e6a23c; 7 | --error-color: #f56c6c; 8 | --text-primary: #303133; 9 | --text-secondary: #606266; 10 | --text-tertiary: #909399; 11 | --border-color: #ebeef5; 12 | --hover-color: #f5f7fa; 13 | --card-background: #ffffff; 14 | --card-background-rgb: 255, 255, 255; 15 | --card-background-light: #f5f7fa; 16 | --background-color: #f2f5fa; 17 | --border-radius-small: 4px; 18 | --border-radius-medium: 8px; 19 | --border-radius-large: 12px; 20 | --shadow-light: 0 2px 12px 0 rgba(0, 0, 0, 0.05); 21 | --shadow-medium: 0 4px 16px 0 rgba(0, 0, 0, 0.1); 22 | --shadow-large: 0 8px 24px 0 rgba(0, 0, 0, 0.12); 23 | --transition-normal: 0.3s ease; 24 | --sidebar-width: 250px; 25 | --sidebar-collapsed-width: 80px; 26 | --header-height: 60px; 27 | } 28 | 29 | /* 深色模式变量 */ 30 | :root[theme-mode="dark"] { 31 | --primary-color: #409eff; 32 | --accent-color: #67c23a; 33 | --warning-color: #e6a23c; 34 | --error-color: #f56c6c; 35 | --text-primary: #e5eaf3; 36 | --text-secondary: #a3abd2; 37 | --text-tertiary: #6e7191; 38 | --border-color: #424656; 39 | --hover-color: #333645; 40 | --card-background: #282c3a; 41 | --card-background-rgb: 40, 44, 58; 42 | --card-background-light: #323644; 43 | --background-color: #1e2030; 44 | --shadow-light: 0 2px 12px 0 rgba(0, 0, 0, 0.2); 45 | --shadow-medium: 0 4px 16px 0 rgba(0, 0, 0, 0.25); 46 | --shadow-large: 0 8px 24px 0 rgba(0, 0, 0, 0.3); 47 | } 48 | 49 | /* 基础样式 */ 50 | html, body { 51 | margin: 0; 52 | padding: 0; 53 | font-family: 'Inter', 'Helvetica Neue', Helvetica, 'PingFang SC', 'Microsoft YaHei', sans-serif; 54 | -webkit-font-smoothing: antialiased; 55 | -moz-osx-font-smoothing: grayscale; 56 | background-color: var(--background-color); 57 | color: var(--text-primary); 58 | height: 100%; 59 | width: 100%; 60 | overflow: hidden; 61 | } 62 | 63 | * { 64 | box-sizing: border-box; 65 | } 66 | 67 | /* 应用容器 */ 68 | .app-container { 69 | display: flex; 70 | flex-direction: column; 71 | height: 100vh; 72 | background-color: var(--background-color); 73 | transition: background-color var(--transition-normal); 74 | overflow: hidden; 75 | } 76 | 77 | /* 布局容器 */ 78 | .layout-container { 79 | display: flex; 80 | flex-direction: column; 81 | height: 100%; 82 | overflow: hidden; 83 | } 84 | 85 | /* 主内容区域 */ 86 | .main-container { 87 | flex: 1; 88 | display: flex; 89 | overflow: hidden; 90 | } 91 | 92 | /* 内容布局调整 */ 93 | .content-wrapper { 94 | display: flex; 95 | width: 100%; 96 | height: 100%; 97 | overflow: hidden; 98 | position: relative; 99 | } 100 | 101 | /* 侧边栏样式调整 */ 102 | .modern-aside { 103 | width: var(--sidebar-width); 104 | height: 100%; 105 | transition: width var(--transition-normal), transform var(--transition-normal); 106 | background-color: var(--card-background); 107 | border-right: 1px solid var(--border-color); 108 | overflow-y: auto; 109 | position: relative; 110 | z-index: 100; 111 | flex-shrink: 0; /* 防止侧边栏收缩 */ 112 | } 113 | 114 | .sidebar-collapsed { 115 | width: var(--sidebar-collapsed-width); 116 | } 117 | 118 | /* 主内容区样式调整 */ 119 | .main-content-area { 120 | flex: 1; 121 | overflow-y: auto; 122 | height: 100%; 123 | background-color: var(--background-color); 124 | position: relative; 125 | width: calc(100% - var(--sidebar-width)); /* 减去侧边栏宽度 */ 126 | margin-left: 0; /* 移除左侧外边距 */ 127 | box-sizing: border-box; 128 | display: flex; 129 | flex-direction: column; 130 | padding-top: 60px; /* 为固定标题栏留出空间 */ 131 | } 132 | 133 | /* 当侧边栏折叠时的主内容区宽度 */ 134 | .sidebar-collapsed + .main-content-area { 135 | width: calc(100% - var(--sidebar-collapsed-width)); 136 | } 137 | 138 | /* 移动端样式调整 */ 139 | @media (max-width: 768px) { 140 | .main-content-area { 141 | width: 100% !important; 142 | margin-left: 0 !important; 143 | } 144 | 145 | .modern-aside { 146 | position: fixed; 147 | left: 0; 148 | top: 0; 149 | bottom: 0; 150 | z-index: 1000; 151 | transform: translateX(-100%); 152 | } 153 | 154 | .modern-aside.sidebar-collapsed { 155 | transform: translateX(0); 156 | width: var(--sidebar-width) !important; 157 | } 158 | } 159 | 160 | /* 移动端菜单按钮 */ 161 | .mobile-menu-button { 162 | position: fixed; 163 | top: 10px; 164 | left: 10px; 165 | z-index: 1000; 166 | background-color: var(--card-background); 167 | border: none; 168 | border-radius: 50%; 169 | width: 40px; 170 | height: 40px; 171 | display: flex; 172 | align-items: center; 173 | justify-content: center; 174 | cursor: pointer; 175 | box-shadow: var(--shadow-medium); 176 | transition: all var(--transition-normal); 177 | } 178 | 179 | .menu-icon { 180 | position: relative; 181 | width: 20px; 182 | height: 20px; 183 | } 184 | 185 | .menu-icon span { 186 | display: block; 187 | position: absolute; 188 | height: 2px; 189 | width: 100%; 190 | background: var(--primary-color); 191 | border-radius: 2px; 192 | opacity: 1; 193 | left: 0; 194 | transform: rotate(0deg); 195 | transition: var(--transition-normal); 196 | } 197 | 198 | .menu-icon span:nth-child(1) { 199 | top: 0px; 200 | } 201 | 202 | .menu-icon span:nth-child(2) { 203 | top: 8px; 204 | } 205 | 206 | .menu-icon span:nth-child(3) { 207 | top: 16px; 208 | } 209 | 210 | .menu-icon.is-active span:nth-child(1) { 211 | top: 8px; 212 | transform: rotate(135deg); 213 | } 214 | 215 | .menu-icon.is-active span:nth-child(2) { 216 | opacity: 0; 217 | left: -60px; 218 | } 219 | 220 | .menu-icon.is-active span:nth-child(3) { 221 | top: 8px; 222 | transform: rotate(-135deg); 223 | } 224 | 225 | /* 用户引导样式 */ 226 | .guide-overlay { 227 | position: fixed; 228 | top: 0; 229 | left: 0; 230 | right: 0; 231 | bottom: 0; 232 | z-index: 9999; 233 | pointer-events: all; 234 | } 235 | 236 | .guide-backdrop { 237 | position: absolute; 238 | top: 0; 239 | left: 0; 240 | right: 0; 241 | bottom: 0; 242 | background-color: rgba(0, 0, 0, 0.5); 243 | backdrop-filter: blur(2px); 244 | } 245 | 246 | .guide-highlight { 247 | position: absolute; 248 | box-shadow: 0 0 0 2000px rgba(0, 0, 0, 0.7); 249 | border-radius: 4px; 250 | transition: all 0.3s ease; 251 | z-index: 1; 252 | } 253 | 254 | .guide-card { 255 | position: absolute; 256 | width: 320px; 257 | background-color: white; 258 | border-radius: 8px; 259 | box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2); 260 | transition: all 0.3s ease; 261 | overflow: hidden; 262 | z-index: 2; 263 | } 264 | 265 | .guide-card-content { 266 | padding: 16px; 267 | } 268 | 269 | .guide-title { 270 | margin: 0 0 8px 0; 271 | font-size: 18px; 272 | color: var(--primary-color); 273 | } 274 | 275 | .guide-description { 276 | margin: 0 0 12px 0; 277 | font-size: 14px; 278 | line-height: 1.5; 279 | color: var(--text-secondary); 280 | } 281 | 282 | .guide-hint { 283 | margin: 0; 284 | padding: 8px; 285 | background-color: rgba(var(--primary-color-rgb), 0.1); 286 | border-radius: 4px; 287 | font-size: 13px; 288 | color: var(--text-secondary); 289 | display: flex; 290 | align-items: flex-start; 291 | gap: 6px; 292 | } 293 | 294 | .hint-icon { 295 | font-size: 16px; 296 | } 297 | 298 | .guide-card-footer { 299 | display: flex; 300 | justify-content: space-between; 301 | align-items: center; 302 | padding: 12px 16px; 303 | background-color: #f5f7fa; 304 | border-top: 1px solid #ebeef5; 305 | } 306 | 307 | .guide-button { 308 | padding: 6px 12px; 309 | border-radius: 4px; 310 | font-size: 14px; 311 | cursor: pointer; 312 | transition: all 0.2s ease; 313 | border: none; 314 | } 315 | 316 | .guide-prev { 317 | background-color: #f0f0f0; 318 | color: #606266; 319 | } 320 | 321 | .guide-next, .guide-finish { 322 | background-color: var(--primary-color); 323 | color: white; 324 | } 325 | 326 | .guide-button:hover { 327 | opacity: 0.9; 328 | } -------------------------------------------------------------------------------- /src/assets/styles/modern-theme.css: -------------------------------------------------------------------------------- 1 | :root { 2 | /* 亮色主题变量 - 增强版 */ 3 | --primary-color: #4a6cf7; 4 | --primary-gradient: linear-gradient(135deg, #4a6cf7, #6a8ff7); 5 | --secondary-color: #6c5ce7; 6 | --accent-color: #00cec9; 7 | --success-color: #10b981; 8 | --warning-color: #f59e0b; 9 | --error-color: #ef4444; 10 | --background-color: #f8f9fa; 11 | --card-background: #ffffff; 12 | --text-primary: #2d3436; 13 | --text-secondary: #636e72; 14 | --border-color: rgba(0, 0, 0, 0.05); 15 | --shadow-light: 0 4px 6px rgba(0, 0, 0, 0.05); 16 | --shadow-medium: 0 8px 16px rgba(0, 0, 0, 0.08); 17 | --shadow-large: 0 12px 24px rgba(0, 0, 0, 0.12); 18 | --border-radius-small: 8px; 19 | --border-radius-medium: 12px; 20 | --border-radius-large: 16px; 21 | --transition-fast: 0.2s ease; 22 | --transition-normal: 0.3s ease; 23 | --card-gradient: linear-gradient(to right bottom, rgba(255, 255, 255, 0.8), rgba(255, 255, 255, 0.4)); 24 | --backdrop-blur: blur(10px); 25 | } 26 | 27 | .dark-theme { 28 | /* 暗色主题变量 - 增强版 */ 29 | --primary-color: #5468ff; 30 | --primary-gradient: linear-gradient(135deg, #5468ff, #7b96ff); 31 | --secondary-color: #a29bfe; 32 | --accent-color: #00b894; 33 | --success-color: #10b981; 34 | --warning-color: #f59e0b; 35 | --error-color: #ef4444; 36 | --background-color: #1a1b1e; 37 | --card-background: #2d2e32; 38 | --text-primary: #f1f2f3; 39 | --text-secondary: #b2bec3; 40 | --border-color: rgba(255, 255, 255, 0.08); 41 | --shadow-light: 0 4px 6px rgba(0, 0, 0, 0.2); 42 | --shadow-medium: 0 8px 16px rgba(0, 0, 0, 0.3); 43 | --shadow-large: 0 12px 24px rgba(0, 0, 0, 0.4); 44 | --card-gradient: linear-gradient(to right bottom, rgba(45, 46, 50, 0.8), rgba(45, 46, 50, 0.4)); 45 | } 46 | 47 | /* 元素样式重置 */ 48 | .el-button { 49 | border-radius: var(--border-radius-small); 50 | transition: all var(--transition-fast); 51 | font-weight: 500; 52 | letter-spacing: 0.3px; 53 | } 54 | 55 | .el-button--primary { 56 | background: var(--primary-gradient) !important; 57 | border: none; 58 | box-shadow: 0 4px 10px rgba(74, 108, 247, 0.3); 59 | } 60 | 61 | .el-button--primary:hover { 62 | transform: translateY(-2px); 63 | box-shadow: 0 6px 15px rgba(74, 108, 247, 0.4); 64 | } 65 | 66 | .el-input, .el-select, .el-textarea { 67 | --el-input-border-radius: var(--border-radius-small); 68 | } 69 | 70 | .el-input__inner, .el-textarea__inner { 71 | transition: all var(--transition-fast); 72 | font-size: 15px; 73 | } 74 | 75 | .el-input:focus, .el-select:focus, .el-textarea:focus { 76 | border-color: var(--primary-color); 77 | box-shadow: 0 0 0 3px rgba(74, 108, 247, 0.15); 78 | } 79 | 80 | .el-card { 81 | border-radius: var(--border-radius-medium); 82 | overflow: hidden; 83 | box-shadow: var(--shadow-medium) !important; 84 | border: none !important; 85 | transition: transform var(--transition-normal), box-shadow var(--transition-normal); 86 | background: var(--card-gradient); 87 | backdrop-filter: var(--backdrop-blur); 88 | } 89 | 90 | .el-card:hover { 91 | transform: translateY(-4px); 92 | box-shadow: var(--shadow-large) !important; 93 | } 94 | 95 | .el-menu { 96 | border-right: none; 97 | background-color: transparent; 98 | } 99 | 100 | .el-menu-item.is-active { 101 | background: var(--primary-gradient) !important; 102 | color: white !important; 103 | border-radius: var(--border-radius-small); 104 | margin: 5px 10px; 105 | box-shadow: 0 4px 10px rgba(74, 108, 247, 0.2); 106 | } 107 | 108 | .el-menu-item { 109 | border-radius: var(--border-radius-small); 110 | margin: 5px 10px; 111 | height: 46px; 112 | line-height: 46px; 113 | transition: all var(--transition-fast); 114 | font-weight: 500; 115 | } 116 | 117 | .el-menu-item:hover { 118 | background-color: rgba(74, 108, 247, 0.1) !important; 119 | } 120 | 121 | /* 自定义滚动条 */ 122 | ::-webkit-scrollbar { 123 | width: 8px; 124 | } 125 | 126 | ::-webkit-scrollbar-track { 127 | background: transparent; 128 | } 129 | 130 | ::-webkit-scrollbar-thumb { 131 | background: #d4d4d8; 132 | border-radius: 4px; 133 | } 134 | 135 | ::-webkit-scrollbar-thumb:hover { 136 | background: #a1a1aa; 137 | } 138 | 139 | .dark-theme ::-webkit-scrollbar-thumb { 140 | background: #52525b; 141 | } 142 | 143 | .dark-theme ::-webkit-scrollbar-thumb:hover { 144 | background: #71717a; 145 | } 146 | 147 | /* 动画过渡 */ 148 | .fade-enter-active, 149 | .fade-leave-active { 150 | transition: opacity 0.3s ease; 151 | } 152 | 153 | .fade-enter-from, 154 | .fade-leave-to { 155 | opacity: 0; 156 | } 157 | 158 | .slide-up-enter-active, 159 | .slide-up-leave-active { 160 | transition: transform 0.3s ease, opacity 0.3s ease; 161 | } 162 | 163 | .slide-up-enter-from, 164 | .slide-up-leave-to { 165 | transform: translateY(20px); 166 | opacity: 0; 167 | } 168 | 169 | /* 暗色模式特定样式 */ 170 | .dark-theme .el-input__inner, 171 | .dark-theme .el-textarea__inner { 172 | background-color: var(--card-background); 173 | color: var(--text-primary); 174 | border-color: var(--border-color); 175 | } 176 | 177 | .dark-theme .el-button:not(.el-button--primary) { 178 | background-color: var(--card-background); 179 | color: var(--text-primary); 180 | border-color: var(--border-color); 181 | } 182 | 183 | /* 新增: 工具提示样式 */ 184 | .el-tooltip__popper { 185 | border-radius: var(--border-radius-small); 186 | box-shadow: var(--shadow-medium); 187 | font-weight: 500; 188 | padding: 8px 12px; 189 | font-size: 14px; 190 | backdrop-filter: var(--backdrop-blur); 191 | } 192 | 193 | /* 新增: 卡片内容样式增强 */ 194 | .input-area-card, 195 | .batch-area-card { 196 | border-radius: var(--border-radius-large); 197 | background-color: var(--card-background); 198 | box-shadow: var(--shadow-medium); 199 | transition: var(--transition-normal); 200 | margin-bottom: 20px; 201 | border: 1px solid var(--border-color); 202 | overflow: hidden; 203 | } 204 | 205 | .input-area-card:hover, 206 | .batch-area-card:hover { 207 | box-shadow: var(--shadow-large); 208 | } 209 | 210 | .card-header { 211 | padding: 16px; 212 | border-bottom: 1px solid var(--border-color); 213 | background-color: rgba(0, 0, 0, 0.02); 214 | } 215 | 216 | .dark-theme .card-header { 217 | background-color: rgba(255, 255, 255, 0.02); 218 | } 219 | 220 | .card-body { 221 | padding: 20px; 222 | } 223 | 224 | /* 新增: 按钮悬浮强调效果 */ 225 | .el-button { 226 | position: relative; 227 | overflow: hidden; 228 | } 229 | 230 | .el-button::after { 231 | content: ''; 232 | position: absolute; 233 | top: 50%; 234 | left: 50%; 235 | width: 5px; 236 | height: 5px; 237 | background: rgba(255, 255, 255, 0.5); 238 | opacity: 0; 239 | border-radius: 100%; 240 | transform: scale(1, 1) translate(-50%); 241 | transform-origin: 50% 50%; 242 | } 243 | 244 | .el-button:hover::after { 245 | animation: ripple 1s ease-out; 246 | } 247 | 248 | @keyframes ripple { 249 | 0% { 250 | transform: scale(0, 0); 251 | opacity: 0.5; 252 | } 253 | 100% { 254 | transform: scale(30, 30); 255 | opacity: 0; 256 | } 257 | } -------------------------------------------------------------------------------- /src/assets/vite.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /src/assets/vue.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/henryhu55/tts-web-vue/70475509e10aedbed9122dd7f7573321e275cc48/src/assets/vue.png -------------------------------------------------------------------------------- /src/components/aside/Aside.vue: -------------------------------------------------------------------------------- 1 | 72 | 73 | 190 | 191 | 463 | -------------------------------------------------------------------------------- /src/components/aside/Version.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 205 | 206 | 270 | 700 | -------------------------------------------------------------------------------- /src/components/configpage/BiliBtn.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 22 | 23 | 78 | -------------------------------------------------------------------------------- /src/components/configpage/ConfigPage.vue: -------------------------------------------------------------------------------- 1 | 224 | 225 | 553 | 554 | 644 | -------------------------------------------------------------------------------- /src/components/header/FixedHeader.vue: -------------------------------------------------------------------------------- 1 | 122 | 123 | 275 | 276 | -------------------------------------------------------------------------------- /src/components/header/Logo.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 12 | 13 | 48 | -------------------------------------------------------------------------------- /src/components/history/HistoryRecord.vue: -------------------------------------------------------------------------------- 1 | 80 | 81 | 481 | 482 | -------------------------------------------------------------------------------- /src/components/main/FreeTTSErrorDisplay.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 164 | 165 | -------------------------------------------------------------------------------- /src/components/main/Loading.vue: -------------------------------------------------------------------------------- 1 | 35 | 36 | 111 | 112 | 311 | -------------------------------------------------------------------------------- /src/components/main/MainStyles.css: -------------------------------------------------------------------------------- 1 | /* 全局样式,确保抽屉在所有场景下都能正确显示 */ 2 | 3 | /* 购买按钮样式 */ 4 | .purchase-button { 5 | height: 32px !important; 6 | display: inline-flex !important; 7 | align-items: center !important; 8 | justify-content: center !important; 9 | gap: 6px !important; 10 | background: linear-gradient(135deg, #67c23a, #85ce61) !important; 11 | border: none !important; 12 | box-shadow: 0 2px 6px rgba(103, 194, 58, 0.3) !important; 13 | transition: all 0.3s ease !important; 14 | } 15 | 16 | .purchase-button:hover { 17 | transform: translateY(-2px) !important; 18 | box-shadow: 0 4px 8px rgba(103, 194, 58, 0.4) !important; 19 | } 20 | 21 | .purchase-button .el-icon { 22 | font-size: 14px !important; 23 | } 24 | 25 | /* 简化样式,减少占用空间 */ 26 | .text-footer-controls { 27 | display: flex; 28 | justify-content: space-between; 29 | align-items: center; 30 | margin: 10px 0 5px 0; 31 | gap: 10px; 32 | } 33 | 34 | /* 合并后的字数限制信息样式 */ 35 | .simple-limit-info { 36 | display: flex; 37 | align-items: center; 38 | gap: 6px; 39 | font-size: 13px; 40 | color: var(--text-secondary); 41 | background: linear-gradient(135deg, rgba(64, 158, 255, 0.1), rgba(100, 180, 255, 0.15)); 42 | padding: 8px 12px; 43 | border-radius: 8px; 44 | border: 1px solid rgba(64, 158, 255, 0.2); 45 | min-height: 32px; 46 | } 47 | 48 | .simple-limit-info .el-icon { 49 | color: var(--primary-color); 50 | font-size: 16px; 51 | } 52 | 53 | .simple-limit-info b { 54 | color: var(--primary-color); 55 | font-weight: 600; 56 | } 57 | 58 | /* 当配额接近用完时的警告样式 */ 59 | .quota-warning { 60 | color: var(--error-color); 61 | font-weight: 500; 62 | } 63 | 64 | /* 添加分组样式,让AI生成按钮和转换按钮放在一起 */ 65 | .action-buttons-group { 66 | display: flex; 67 | gap: 10px; 68 | margin-left: 5px; 69 | } 70 | 71 | /* 调整AI生成按钮样式,与其他按钮保持一致 */ 72 | .ai-button { 73 | height: 32px !important; 74 | line-height: 32px !important; 75 | font-size: 13px !important; 76 | padding: 0 12px !important; 77 | display: inline-flex !important; 78 | align-items: center !important; 79 | justify-content: center !important; 80 | gap: 4px !important; 81 | } 82 | 83 | .ai-button .el-icon { 84 | font-size: 14px !important; 85 | } 86 | 87 | .option-section { 88 | background-color: var(--card-background-light, #f5f7fa); 89 | border-radius: 8px; 90 | padding: 20px; 91 | } 92 | 93 | .section-title { 94 | font-size: 16px; 95 | font-weight: 600; 96 | color: var(--text-primary); 97 | margin: 0 0 16px 0; 98 | padding: 0 0 8px 0; /* 移除左侧padding */ 99 | border-bottom: 1px solid var(--border-color); 100 | } 101 | 102 | .section-header { 103 | padding: 0; /* 移除左侧padding */ 104 | margin-bottom: 16px; 105 | } 106 | 107 | .title-row { 108 | display: flex; 109 | align-items: center; 110 | gap: 8px; 111 | margin-bottom: 4px; 112 | } 113 | 114 | .title-row .el-icon { 115 | font-size: 20px; 116 | color: var(--primary-color); 117 | } 118 | 119 | .title-row span { 120 | font-size: 16px; 121 | font-weight: 600; 122 | color: var(--text-primary); 123 | } 124 | 125 | .section-desc { 126 | color: var(--text-secondary); 127 | font-size: 14px; 128 | margin: 0; 129 | } 130 | 131 | /* 响应式调整 */ 132 | @media screen and (max-width: 768px) { 133 | .section-title, 134 | .section-header { 135 | padding-left: 0; /* 移除左侧padding */ 136 | } 137 | } 138 | 139 | .el-drawer { 140 | --el-drawer-padding-primary: 0 !important; 141 | z-index: 2001 !important; 142 | } 143 | 144 | .el-drawer__header { 145 | margin-bottom: 0 !important; 146 | padding: 2px 20px !important; 147 | border-bottom: 1px solid var(--el-border-color-lighter) !important; 148 | font-size: 18px !important; 149 | font-weight: 600 !important; 150 | } 151 | 152 | .el-drawer__body { 153 | padding: 0 !important; 154 | overflow-y: auto !important; 155 | height: calc(100% - 60px) !important; 156 | } 157 | 158 | .el-drawer__content { 159 | overflow: hidden !important; 160 | } 161 | 162 | .el-overlay { 163 | z-index: 2000 !important; 164 | } 165 | 166 | /* 文本区域样式增强 */ 167 | .text-area-container { 168 | display: flex; 169 | flex-direction: column; 170 | gap: 12px; 171 | } 172 | 173 | .text-area-header { 174 | display: flex; 175 | justify-content: space-between; 176 | align-items: flex-start; 177 | margin-bottom: 16px; 178 | } 179 | 180 | .text-area-header-left { 181 | flex: 1; 182 | } 183 | 184 | .text-area-header-right { 185 | display: flex; 186 | align-items: center; 187 | } 188 | 189 | .input-mode-toggle { 190 | display: flex; 191 | align-items: center; 192 | gap: 8px; 193 | background: rgba(var(--card-background-rgb), 0.8); 194 | padding: 6px 12px; 195 | border-radius: 8px; 196 | border: 1px solid var(--border-color); 197 | } 198 | 199 | .mode-label { 200 | font-size: 14px; 201 | color: var(--text-secondary); 202 | } 203 | 204 | .mode-switch { 205 | margin: 0 4px; 206 | } 207 | 208 | .ssml-help-button { 209 | display: flex; 210 | align-items: center; 211 | gap: 4px; 212 | padding: 6px 12px; 213 | border-radius: 6px; 214 | } 215 | 216 | .modern-textarea { 217 | border-radius: var(--border-radius-medium) !important; 218 | transition: all var(--transition-normal); 219 | border: 1px solid var(--border-color); 220 | background-color: var(--card-background); 221 | box-shadow: var(--shadow-light); 222 | } 223 | 224 | .modern-textarea:focus-within { 225 | box-shadow: 0 0 0 3px rgba(74, 108, 247, 0.15); 226 | border-color: var(--primary-color); 227 | } 228 | 229 | .modern-textarea .el-textarea__inner { 230 | font-family: 'Inter', 'Helvetica Neue', Helvetica, 'PingFang SC', sans-serif; 231 | font-size: 15px; 232 | line-height: 1.6; 233 | color: var(--text-primary); 234 | background-color: transparent; 235 | } 236 | 237 | /* 控制栏样式增强 */ 238 | .compact-controls-bar { 239 | background-color: rgba(0, 0, 0, 0.03); 240 | padding: 16px; 241 | border-top: 1px solid var(--border-color); 242 | display: flex; 243 | justify-content: space-between; 244 | flex-wrap: wrap; 245 | gap: 16px; 246 | } 247 | 248 | :root[theme-mode="dark"] .compact-controls-bar { 249 | background-color: rgba(255, 255, 255, 0.03); 250 | } 251 | 252 | .compact-selects { 253 | display: flex; 254 | gap: 12px; 255 | flex-wrap: wrap; 256 | } 257 | 258 | .compact-actions { 259 | display: flex; 260 | gap: 12px; 261 | flex-wrap: wrap; 262 | } 263 | 264 | .voice-option { 265 | display: flex; 266 | justify-content: space-between; 267 | align-items: center; 268 | width: 100%; 269 | } 270 | 271 | .start-button { 272 | font-weight: 600; 273 | min-width: 90px; 274 | } 275 | 276 | /* 抽屉样式增强 */ 277 | .drawer-header { 278 | background-color: var(--card-background); 279 | border-bottom: 1px solid var(--border-color); 280 | padding: 20px; /* 修改padding与内容区域一致 */ 281 | margin-bottom: 0; 282 | } 283 | 284 | .drawer-header h3 { 285 | display: flex; 286 | align-items: center; 287 | gap: 8px; 288 | margin: 0 0 8px 0; 289 | font-size: 20px; 290 | font-weight: 600; 291 | color: var(--text-primary); 292 | } 293 | 294 | .drawer-header .el-icon { 295 | font-size: 20px; 296 | color: var(--primary-color); 297 | } 298 | 299 | .drawer-description { 300 | margin: 0; 301 | color: var(--text-secondary); 302 | font-size: 14px; 303 | line-height: 1.5; 304 | } 305 | 306 | .settings-drawer-content { 307 | padding: 20px; /* 保持与header一致的padding */ 308 | height: 100%; 309 | overflow-y: auto; 310 | background-color: var(--background-color); 311 | } 312 | 313 | /* 抽屉动画优化 */ 314 | :deep(.el-drawer) { 315 | --el-drawer-padding-primary: 0; 316 | transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1); 317 | } 318 | 319 | :deep(.el-drawer__body) { 320 | padding: 0; 321 | overflow-y: auto; 322 | height: calc(100% - 80px); /* 考虑到header高度 */ 323 | } 324 | 325 | :deep(.el-drawer__header) { 326 | margin: 0; 327 | padding: 0; 328 | } 329 | 330 | :deep(.el-drawer.rtl) { 331 | box-shadow: -2px 0 8px rgba(0, 0, 0, 0.15); 332 | } 333 | 334 | /* 深色模式适配 */ 335 | .dark-theme :deep(.el-drawer) { 336 | background-color: var(--card-background); 337 | border-left: 1px solid var(--border-color); 338 | } 339 | 340 | .dark-theme :deep(.el-drawer.rtl) { 341 | box-shadow: -2px 0 8px rgba(0, 0, 0, 0.25); 342 | } 343 | 344 | /* 响应式调整 */ 345 | @media screen and (max-width: 768px) { 346 | :deep(.el-drawer) { 347 | width: 90% !important; 348 | } 349 | 350 | .drawer-header, 351 | .settings-drawer-content { 352 | padding: 16px; /* 移动端统一使用更小的padding */ 353 | } 354 | 355 | .drawer-header h3 { 356 | font-size: 20px; 357 | } 358 | 359 | .settings-drawer-content { 360 | padding: 16px; 361 | } 362 | } 363 | 364 | /* SSML帮助对话框样式 */ 365 | .ssml-help-content { 366 | padding: 0 16px; 367 | } 368 | 369 | .ssml-help-content h3 { 370 | margin: 0 0 12px 0; 371 | font-size: 18px; 372 | font-weight: 600; 373 | color: var(--primary-color); 374 | } 375 | 376 | .ssml-examples { 377 | margin: 24px 0; 378 | } 379 | 380 | .ssml-example-item { 381 | margin-bottom: 20px; 382 | padding: 12px; 383 | border-radius: var(--border-radius-medium); 384 | background-color: rgba(0, 0, 0, 0.02); 385 | border: 1px solid var(--border-color); 386 | } 387 | 388 | .dark-theme .ssml-example-item { 389 | background-color: rgba(255, 255, 255, 0.02); 390 | } 391 | 392 | .ssml-example-item h4 { 393 | margin: 0 0 12px 0; 394 | font-size: 16px; 395 | font-weight: 500; 396 | } 397 | 398 | .ssml-example-item pre { 399 | margin: 0 0 8px 0; 400 | padding: 12px; 401 | background-color: var(--card-background); 402 | border-radius: var(--border-radius-small); 403 | overflow-x: auto; 404 | font-family: 'Consolas', 'Monaco', 'Courier New', monospace; 405 | font-size: 14px; 406 | line-height: 1.5; 407 | color: var(--accent-color); 408 | border: 1px solid var(--border-color); 409 | } 410 | 411 | .ssml-example-item p { 412 | margin: 8px 0 0 0; 413 | font-size: 14px; 414 | color: var(--text-secondary); 415 | } 416 | 417 | .ssml-template { 418 | padding: 16px; 419 | background-color: rgba(0, 0, 0, 0.02); 420 | border-radius: var(--border-radius-medium); 421 | border: 1px solid var(--border-color); 422 | } 423 | 424 | .dark-theme .ssml-template { 425 | background-color: rgba(255, 255, 255, 0.02); 426 | } 427 | 428 | .ssml-template pre { 429 | margin: 0; 430 | padding: 16px; 431 | background-color: var(--card-background); 432 | border-radius: var(--border-radius-small); 433 | overflow-x: auto; 434 | font-family: 'Consolas', 'Monaco', 'Courier New', monospace; 435 | font-size: 14px; 436 | line-height: 1.5; 437 | color: var(--accent-color); 438 | border: 1px solid var(--border-color); 439 | } 440 | 441 | /* 配额进度条样式增强 */ 442 | .quota-progress-wrapper { 443 | background-color: rgba(0, 0, 0, 0.02); 444 | border-radius: var(--border-radius-medium); 445 | padding: 12px 16px; 446 | margin-top: 12px; 447 | border: 1px solid var(--border-color); 448 | } 449 | 450 | .dark-theme .quota-progress-wrapper { 451 | background-color: rgba(255, 255, 255, 0.02); 452 | } 453 | 454 | .quota-text { 455 | display: flex; 456 | justify-content: space-between; 457 | margin-bottom: 8px; 458 | font-size: 14px; 459 | color: var(--text-secondary); 460 | } 461 | 462 | .quota-highlight { 463 | color: var(--accent-color); 464 | font-weight: 500; 465 | } 466 | 467 | .quota-warning { 468 | color: var(--error-color); 469 | font-weight: 500; 470 | } 471 | 472 | /* 响应式调整 */ 473 | @media (max-width: 768px) { 474 | .compact-controls-bar { 475 | flex-direction: column; 476 | } 477 | 478 | .compact-selects, .compact-actions { 479 | width: 100%; 480 | } 481 | 482 | .text-area-header { 483 | flex-direction: column; 484 | align-items: flex-start; 485 | } 486 | 487 | .ssml-help-button { 488 | margin-left: 0; 489 | } 490 | } 491 | 492 | /* 新界面样式 */ 493 | .input-area-card { 494 | background-color: var(--card-background); 495 | border-radius: var(--border-radius-large); 496 | box-shadow: var(--shadow-medium); 497 | overflow: hidden; 498 | margin-top: 0; 499 | border: 1px solid var(--border-color); 500 | position: sticky; 501 | top: 0; /* 改为0,让它紧贴顶部 */ 502 | z-index: 10; 503 | } 504 | 505 | .card-header { 506 | padding: 12px 16px; /* 减少内边距 */ 507 | border-bottom: 1px solid var(--border-color); 508 | display: flex; 509 | justify-content: space-between; 510 | align-items: center; 511 | background-color: var(--card-background); 512 | } 513 | 514 | .card-body { 515 | padding: 16px; /* 减少内边距 */ 516 | background-color: var(--background-color); 517 | } 518 | 519 | .text-area-container { 520 | background-color: var(--card-background); 521 | border-radius: var(--border-radius-medium); 522 | padding: 16px; /* 减少内边距 */ 523 | box-shadow: var(--shadow-light); 524 | border: 1px solid var(--border-color); 525 | } 526 | 527 | .compact-controls-bar { 528 | position: sticky; 529 | bottom: 0; 530 | background-color: var(--card-background); 531 | border-top: 1px solid var(--border-color); 532 | padding: 12px 16px; /* 减少内边距 */ 533 | display: flex; 534 | justify-content: space-between; 535 | align-items: center; 536 | z-index: 11; 537 | box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.05); 538 | } 539 | 540 | /* 按钮样式优化 */ 541 | .voice-anchors-button, 542 | .settings-button, 543 | .start-button { 544 | height: 32px !important; 545 | line-height: 32px !important; 546 | font-size: 13px !important; 547 | padding: 0 12px !important; 548 | display: inline-flex !important; 549 | align-items: center !important; 550 | justify-content: center !important; 551 | gap: 4px !important; 552 | } 553 | 554 | .voice-anchors-button .el-icon, 555 | .settings-button .el-icon, 556 | .start-button .el-icon { 557 | font-size: 14px !important; 558 | } 559 | 560 | /* 移动端适配 */ 561 | @media screen and (max-width: 768px) { 562 | .input-area-card { 563 | margin-top: 0; 564 | top: 0; /* 移动端也紧贴顶部 */ 565 | } 566 | 567 | .card-header, 568 | .card-body, 569 | .text-area-container, 570 | .compact-controls-bar { 571 | padding: 10px; /* 移动端进一步减少内边距 */ 572 | } 573 | 574 | .compact-controls-bar { 575 | flex-direction: column; 576 | gap: 8px; 577 | } 578 | 579 | .compact-selects { 580 | width: 100%; 581 | flex-direction: column; 582 | gap: 6px; 583 | } 584 | 585 | .compact-select, .voice-select { 586 | width: 100% !important; 587 | } 588 | 589 | .compact-actions { 590 | width: 100%; 591 | margin-left: 0; 592 | display: grid; 593 | grid-template-columns: 1fr 1fr; 594 | gap: 6px; 595 | } 596 | 597 | .start-button { 598 | grid-column: 1 / -1; 599 | } 600 | } 601 | 602 | /* 主容器样式优化 */ 603 | .modern-main { 604 | padding: 0 !important; 605 | padding-top: 0 !important; 606 | margin: 0 !important; 607 | overflow: auto; 608 | width: 100%; 609 | box-sizing: border-box; 610 | background-color: var(--background-color); 611 | } 612 | 613 | /* 内容区域样式 */ 614 | .main-content { 615 | padding: 20px; 616 | box-sizing: border-box; 617 | width: 100%; 618 | max-width: 1200px; 619 | margin: 0 auto; 620 | } 621 | 622 | @media screen and (max-width: 768px) { 623 | .modern-main { 624 | padding: 0 !important; 625 | } 626 | 627 | .main-content { 628 | padding: 10px; 629 | } 630 | 631 | .input-area-card, 632 | .batch-area-card, 633 | .config-page-container, 634 | .doc-page-container { 635 | margin: 0 !important; 636 | border-radius: 0 !important; 637 | box-shadow: none !important; 638 | border: none !important; 639 | } 640 | 641 | .card-header { 642 | padding: 12px !important; 643 | } 644 | 645 | .card-body { 646 | padding: 10px !important; 647 | } 648 | 649 | .text-area-container { 650 | margin: 8px 0 !important; 651 | border-radius: 8px !important; 652 | } 653 | } 654 | 655 | /* 确保内容区域不会被压缩 */ 656 | .card-body { 657 | min-height: 200px; 658 | } 659 | 660 | /* 移除不必要的悬浮效果 */ 661 | .input-area-card:hover { 662 | transform: none; 663 | box-shadow: var(--shadow-medium); 664 | } 665 | 666 | .card-title { 667 | display: flex; 668 | align-items: center; 669 | gap: 8px; 670 | margin-bottom: 4px; 671 | } 672 | 673 | .card-title h2 { 674 | display: flex; 675 | align-items: center; 676 | gap: 8px; 677 | margin: 0; 678 | font-size: 18px; 679 | font-weight: 600; 680 | color: var(--text-primary); 681 | } 682 | 683 | .card-title .el-icon { 684 | font-size: 20px; 685 | color: var(--primary-color); 686 | } 687 | 688 | .card-description { 689 | color: var(--text-secondary); 690 | font-size: 14px; 691 | margin: 0; 692 | } 693 | 694 | .input-mode-toggle { 695 | display: flex; 696 | align-items: center; 697 | gap: 10px; 698 | background-color: rgba(var(--primary-color-rgb), 0.05); 699 | padding: 6px 10px; 700 | border-radius: var(--border-radius-medium); 701 | } 702 | 703 | .mode-label { 704 | font-size: 14px; 705 | color: var(--text-secondary); 706 | } 707 | 708 | .mode-switch { 709 | margin: 0 5px; 710 | } 711 | 712 | .ssml-help-button { 713 | margin-left: 5px; 714 | padding: 5px 12px; 715 | font-size: 12px; 716 | height: 28px; 717 | } 718 | 719 | .header-controls { 720 | display: flex; 721 | align-items: center; 722 | gap: 12px; 723 | } 724 | 725 | .free-quota-badge { 726 | display: flex; 727 | align-items: center; 728 | gap: 8px; 729 | padding: 8px 12px; 730 | border-radius: var(--border-radius-small); 731 | background-color: rgba(64, 158, 255, 0.1); 732 | font-size: 13px; 733 | color: #409eff; 734 | } 735 | 736 | .compact-selects { 737 | display: flex; 738 | flex-wrap: wrap; 739 | gap: 12px; 740 | align-items: center; 741 | } 742 | 743 | .compact-select, 744 | .voice-select { 745 | width: auto; 746 | min-width: 120px; 747 | } 748 | 749 | .voice-select { 750 | min-width: 180px; 751 | } 752 | 753 | .compact-actions { 754 | display: flex; 755 | gap: 10px; 756 | align-items: center; 757 | } 758 | 759 | /* 配色增强 */ 760 | :root[theme-mode="dark"] .input-area-card { 761 | box-shadow: var(--shadow-medium); 762 | border: 1px solid rgba(255, 255, 255, 0.05); 763 | } 764 | 765 | :root[theme-mode="dark"] .text-area-container { 766 | border: 1px solid rgba(255, 255, 255, 0.05); 767 | } 768 | 769 | :root[theme-mode="dark"] .input-mode-toggle { 770 | background-color: rgba(255, 255, 255, 0.05); 771 | } 772 | 773 | :root[theme-mode="dark"] .free-quota-badge { 774 | background-color: rgba(64, 158, 255, 0.15); 775 | } 776 | 777 | /* 添加导入Scoped样式 */ 778 | @import './MainScopedStyles.css'; -------------------------------------------------------------------------------- /src/components/main/emoji-config.ts: -------------------------------------------------------------------------------- 1 | 2 | // import { useI18n } from 'vue-i18n'; 3 | // const { t } = useI18n(); 4 | import i18n from '@/assets/i18n/i18n'; 5 | const { t } = i18n.global; 6 | const styleDes = [ 7 | { keyword: "assistant", emoji: "🔊", word: t('assistant') }, 8 | { keyword: "chat", emoji: "🔊", word: t('chat') }, 9 | { keyword: "customerservice", emoji: "🔊", word: t('customerservice') }, 10 | { keyword: "newscast", emoji: "🎤", word: t('newscast') }, 11 | { keyword: "affectionate", emoji: "😘", word: t('affectionate') }, 12 | { keyword: "angry", emoji: "😡", word: t('angry') }, 13 | { keyword: "calm", emoji: "😶", word: t('calm') }, 14 | { keyword: "cheerful", emoji: "😄", word: t('cheerful') }, 15 | { keyword: "disgruntled", emoji: "😠", word: t('disgruntled') }, 16 | { keyword: "fearful", emoji: "😨", word: t('fearful') }, 17 | { keyword: "gentle", emoji: "😇", word: t('gentle') }, 18 | { keyword: "lyrical", emoji: "😍", word: t('lyrical') }, 19 | { keyword: "sad", emoji: "😭", word: t('sad') }, 20 | { keyword: "serious", emoji: "😐", word: t('serious') }, 21 | { keyword: "poetry-reading", emoji: "🔊", word: t('poetry-reading') }, 22 | { keyword: "narration-professional", emoji: "👩‍💼", word: t('narration-professional') }, 23 | { keyword: "newscast-casual", emoji: "🔊", word: t('newscast-casual') }, 24 | { keyword: "embarrassed", emoji: "😓", word: t('embarrassed') }, 25 | { keyword: "depressed", emoji: "😔", word: t('depressed') }, 26 | { keyword: "envious", emoji: "😒", word: t('envious') }, 27 | { keyword: "narration-relaxed", emoji: "🎻", word: t('narration-relaxed') }, 28 | { 29 | keyword: "Advertisement_upbeat", 30 | emoji: "🗣", 31 | word: t('Advertisement_upbeat'), 32 | }, 33 | { keyword: "Narration-relaxed", emoji: "🎻", word: t('Narration-relaxed') }, 34 | { keyword: "Sports_commentary", emoji: "⛹", word: t('Sports_commentary') }, 35 | { 36 | keyword: "Sports_commentary_excited", 37 | emoji: "🥇", 38 | word: t('Sports_commentary_excited'), 39 | }, 40 | { keyword: "documentary-narration", emoji: "🎞", word: t('documentary-narration') }, 41 | { keyword: "excited", emoji: "😁", word: t('excited') }, 42 | { keyword: "friendly", emoji: "😋", word: t('friendly') }, 43 | { keyword: "terrified", emoji: "😱", word: t('terrified') }, 44 | { keyword: "shouting", emoji: "📢", word: t('shouting') }, 45 | { keyword: "unfriendly", emoji: "😤", word: t('unfriendly') }, 46 | { keyword: "whispering", emoji: "😶", word: t('whispering') }, 47 | { keyword: "hopeful", emoji: "☀️", word: t('hopeful') }, 48 | ]; 49 | const roleDes = [ 50 | { keyword: "YoungAdultFemale", emoji: "👱‍♀️", word: t('YoungAdultFemale') }, 51 | { keyword: "YoungAdultMale", emoji: "👱", word: t('YoungAdultMale') }, 52 | { keyword: "OlderAdultFemale", emoji: "👩", word: t('OlderAdultFemale') }, 53 | { keyword: "OlderAdultMale", emoji: "👨", word: t('OlderAdultMale') }, 54 | { keyword: "SeniorFemale", emoji: "👵", word: t('SeniorFemale') }, 55 | { keyword: "SeniorMale", emoji: "👴", word: t('SeniorMale') }, 56 | { keyword: "Girl", emoji: "👧", word: t('Girl') }, 57 | { keyword: "Boy", emoji: "👦", word: t('Boy') }, 58 | { keyword: "Narrator", emoji: "🔊", word: t('Narrator') }, 59 | ]; 60 | const getStyleDes = (key: string) => { 61 | return styleDes.find((item) => item.keyword === key); 62 | }; 63 | 64 | const getRoleDes = (key: string) => { 65 | return roleDes.find((item) => item.keyword === key); 66 | }; 67 | 68 | export { getStyleDes, getRoleDes }; 69 | -------------------------------------------------------------------------------- /src/components/main/options-config.ts: -------------------------------------------------------------------------------- 1 | // import { useI18n } from 'vue-i18n'; 2 | import i18n from '@/assets/i18n/i18n'; 3 | import { voices } from './../../global/voices'; 4 | const { t } = i18n.global; 5 | 6 | let msVoicesList; 7 | if (localStorage.getItem("msVoicesList") !== null) { 8 | msVoicesList = JSON.parse(localStorage.getItem("msVoicesList") || "[]"); 9 | } else { 10 | msVoicesList = voices; 11 | } 12 | 13 | const voicesList = msVoicesList.map((item: any) => { 14 | item.properties.locale = item.locale; 15 | // ZH_CN_SHANDONG有BUG很奇怪 16 | // if (lang.hasOwnProperty(item.locale.toUpperCase().replace("-", "_").replace("-", "_"))) { 17 | // item.properties.localeZH = 18 | // lang[item.locale.toUpperCase().replace("-", "_").replace("-", "_")]; 19 | // } else { 20 | // item.properties.localeZH = item.locale 21 | // } 22 | item.properties.localeZH = t('lang.' + item.locale.toUpperCase().replace("-", "_").replace("-", "_")); 23 | 24 | return item.properties; 25 | }); 26 | 27 | const list = voicesList 28 | .map((item: any) => { 29 | return { 30 | value: item.locale, 31 | label: item.localeZH, 32 | }; 33 | }) 34 | .sort((a: any, b: any) => b.value.localeCompare(a.value, "en")); 35 | 36 | const tempMap = new Map(); 37 | const languageSelect = list.filter( 38 | (item: any) => !tempMap.has(item.value) && tempMap.set(item.value, 1) 39 | ); 40 | 41 | const findVoicesByLocaleName = (localeName: any) => { 42 | const voices = voicesList.filter((item: any) => item.locale == localeName); 43 | return voices; 44 | }; 45 | 46 | const apiSelect = [ 47 | { 48 | value: 5, 49 | label: "免费TTS服务", 50 | }, 51 | { 52 | value: 4, 53 | label: "TTS88 API", 54 | }, 55 | { 56 | value: 1, 57 | label: "Microsoft Speech API", 58 | }, 59 | { 60 | value: 2, 61 | label: "Edge Speech API", 62 | }, 63 | { 64 | value: 3, 65 | label: "Azure Speech API", 66 | }, 67 | ]; 68 | 69 | export const optionsConfig = { 70 | voicesList, 71 | languageSelect, 72 | findVoicesByLocaleName, 73 | apiSelect, 74 | }; 75 | -------------------------------------------------------------------------------- /src/env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | declare module '*.vue' { 4 | import type { DefineComponent } from 'vue' 5 | // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/ban-types 6 | const component: DefineComponent<{}, {}, any> 7 | export default component 8 | } 9 | -------------------------------------------------------------------------------- /src/global-shim.d.ts: -------------------------------------------------------------------------------- 1 | import 'vue'; 2 | 3 | // 扩展全局组件实例类型 4 | declare module 'vue' { 5 | // 增强模板中的类型 6 | export interface ComponentCustomProperties { 7 | // 允许在模板中使用的全局属性 8 | [key: string]: any; 9 | } 10 | 11 | // 允许defineEmits、defineProps等API 12 | export function defineProps(): T; 13 | export function defineEmits(): T; 14 | export function defineExpose(exposed?: T): void; 15 | export function withDefaults(props: T, defaults: U): T & U; 16 | } -------------------------------------------------------------------------------- /src/global/index.ts: -------------------------------------------------------------------------------- 1 | import { App } from "vue"; 2 | import registerElement from "./registerElement"; 3 | import initLocalStore from "./initLocalStore"; 4 | 5 | export function globalRegister(app: App) { 6 | initLocalStore(); 7 | app.use(registerElement); 8 | } 9 | -------------------------------------------------------------------------------- /src/global/initLocalStore.ts: -------------------------------------------------------------------------------- 1 | import i18n from '@/assets/i18n/i18n'; 2 | import { voices } from './voices'; 3 | import WebStore from '@/store/web-store'; 4 | 5 | const store = new WebStore(); 6 | const { t } = i18n.global; 7 | 8 | export default async function initStore() { 9 | try { 10 | // Web版本直接使用预定义的voices数据 11 | localStorage.setItem("msVoicesList", JSON.stringify(voices)); 12 | } catch (error) { 13 | console.error("初始化语音列表失败:", error); 14 | // 如果localStorage的msVoicesList为空 15 | if (localStorage.getItem("msVoicesList") == null) { 16 | localStorage.setItem("msVoicesList", JSON.stringify(voices)); 17 | } 18 | } 19 | // 获取当前语言设置 20 | const locale = i18n.global.locale || 'zh'; 21 | 22 | const formConfigDefault = { 23 | es: { 24 | languageSelect: "es-MX", 25 | voiceSelect: "es-MX-DaliaNeural", 26 | voiceStyleSelect: "Default", 27 | role: "", 28 | speed: 1.0, 29 | pitch: 1.0, 30 | api: 5, // 使用免费TTS服务 31 | }, 32 | en: { 33 | languageSelect: "en-US", 34 | voiceSelect: "en-US-JennyNeural", 35 | voiceStyleSelect: "Default", 36 | role: "", 37 | speed: 1.0, 38 | pitch: 1.0, 39 | api: 5, // 使用免费TTS服务 40 | }, 41 | zh: { 42 | languageSelect: "zh-CN", 43 | voiceSelect: "zh-CN-XiaoxiaoNeural", 44 | voiceStyleSelect: "Default", 45 | role: "", 46 | speed: 1.0, 47 | pitch: 1.0, 48 | api: 5, // 使用免费TTS服务 49 | }, 50 | }; 51 | 52 | store.set("FormConfig.默认", formConfigDefault[locale]); 53 | 54 | if (!store.get("audition")) { 55 | store.set( 56 | "audition", 57 | t("initialLocalStore.audition") 58 | ); 59 | } 60 | if (!store.get("language")) { 61 | store.set("language", locale); 62 | } 63 | if (!store.get("autoplay")) { 64 | store.set("autoplay", true); 65 | } 66 | if (!store.get("updateNotification")) { 67 | store.set("updateNotification", true); 68 | } 69 | if (!store.get("titleStyle")) { 70 | store.set("titleStyle", true); 71 | } 72 | if (!store.get("speechKey")) { 73 | store.set("speechKey", ""); 74 | } 75 | if (!store.get("serviceRegion")) { 76 | store.set("serviceRegion", ""); 77 | } 78 | if (!store.get("disclaimers")) { 79 | store.set("disclaimers", false); 80 | } 81 | if (!store.get("retryCount")) { 82 | store.set("retryCount", 3); 83 | } 84 | if (!store.get("retryInterval")) { 85 | store.set("retryInterval", 1); 86 | } 87 | if (!store.get("thirdPartyApi")) { 88 | store.set("thirdPartyApi", ""); 89 | } 90 | if (!store.get("tts88Key")) { 91 | store.set("tts88Key", ""); 92 | } 93 | // 初始化OpenAI相关配置 94 | if (!store.get("openAIKey")) { 95 | store.set("openAIKey", ""); 96 | } 97 | if (!store.get("gptModel")) { 98 | store.set("gptModel", "gpt-3.5-turbo"); 99 | } 100 | if (!store.get("openAIBaseUrl")) { 101 | store.set("openAIBaseUrl", ""); 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/global/registerElement.ts: -------------------------------------------------------------------------------- 1 | import ElementPlus from 'element-plus' 2 | import 'element-plus/dist/index.css' 3 | import "element-plus/theme-chalk/display.css" 4 | import * as Icons from "@element-plus/icons-vue" 5 | 6 | export default function (app: any) { 7 | // 全局注册Element Plus(包括所有组件和指令) 8 | app.use(ElementPlus, { 9 | // 启用所有组件 10 | components: true, 11 | // 启用所有指令 12 | directives: true 13 | }) 14 | 15 | // 注册所有图标 16 | for (const name in Icons) { 17 | app.component(name, (Icons as any)[name]) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/global/voice-config.ts: -------------------------------------------------------------------------------- 1 | // 语音分类和补充配置 2 | import { voices } from './voices'; 3 | 4 | // 语音分类接口 5 | export interface VoiceCategory { 6 | id: string; 7 | name: string; 8 | label: string; 9 | voices: CategoryVoice[]; 10 | } 11 | 12 | // 分类中使用的声音接口 13 | export interface CategoryVoice { 14 | id: string; 15 | name: string; 16 | localName: string; 17 | shortName: string; 18 | locale: string; 19 | gender: string; 20 | description: string; 21 | isPro?: boolean; 22 | styles?: string[]; 23 | supportStyles?: boolean; 24 | avatar?: string; 25 | } 26 | 27 | // 转换原始voices数据为CategoryVoice格式 28 | const mapVoiceToCategory = (voice: any): CategoryVoice => { 29 | const gender = voice.properties?.Gender || 'Unknown'; 30 | return { 31 | id: voice.id || voice.shortName, 32 | name: voice.properties?.DisplayName || voice.shortName.split('-').pop().replace('Neural', ''), 33 | localName: voice.properties?.LocalName || voice.shortName.split('-').pop().replace('Neural', ''), 34 | shortName: voice.shortName, 35 | locale: voice.locale, 36 | gender: gender, 37 | description: '', 38 | isPro: isPremiumVoice(voice.shortName), 39 | styles: getVoiceStyles(voice), 40 | supportStyles: getVoiceStyles(voice)?.length > 0, 41 | }; 42 | }; 43 | 44 | // 根据shortName判断是否为高级声音 45 | const isPremiumVoice = (shortName: string): boolean => { 46 | const premiumVoices = [ 47 | "en-US-AriaNeural", 48 | "en-US-GuyNeural", 49 | "en-GB-RyanNeural", 50 | "en-GB-SoniaNeural", 51 | "zh-CN-XiaoxiaoNeural", 52 | "zh-CN-YunxiNeural", 53 | "zh-CN-YunyangNeural" 54 | ]; 55 | 56 | return premiumVoices.includes(shortName) || shortName.includes('(Pro)') || shortName.includes('(Ultra)'); 57 | }; 58 | 59 | // 获取声音支持的风格列表 60 | const getVoiceStyles = (voice: any): string[] => { 61 | const voiceStylesStr = voice.properties?.VoiceStyleNames || ''; 62 | if (!voiceStylesStr) return []; 63 | return voiceStylesStr.split(',').map((style: string) => style.trim()).filter(Boolean); 64 | }; 65 | 66 | // 根据原始数据生成分类声音 67 | const generateCategoryVoices = () => { 68 | // 过滤并分类所有声音 69 | const allVoices = voices.map(mapVoiceToCategory); 70 | 71 | // 按语言和地区分类 72 | const zhVoices = allVoices.filter(v => v.locale.startsWith('zh')); 73 | const enVoices = allVoices.filter(v => v.locale.startsWith('en')); 74 | const otherVoices = allVoices.filter(v => !v.locale.startsWith('zh') && !v.locale.startsWith('en')); 75 | 76 | // 再细分英文声音 77 | const enUsVoices = enVoices.filter(v => v.locale === 'en-US'); 78 | const enGbVoices = enVoices.filter(v => v.locale === 'en-GB'); 79 | const enOtherVoices = enVoices.filter(v => v.locale !== 'en-US' && v.locale !== 'en-GB'); 80 | 81 | return { 82 | zhVoices, 83 | enUsVoices, 84 | enGbVoices, 85 | enOtherVoices, 86 | otherVoices 87 | }; 88 | }; 89 | 90 | // 获取所有分类后的声音 91 | const { zhVoices, enUsVoices, enGbVoices, enOtherVoices, otherVoices } = generateCategoryVoices(); 92 | 93 | // 补充其他声音 (如果微软接口中没有的) 94 | const additionalVoices: CategoryVoice[] = [ 95 | // 添加在图片中看到但在原数据中没有的声音 96 | { 97 | id: 'custom-beth', 98 | name: 'Beth', 99 | localName: '美式英文女声', 100 | shortName: 'en-US-AnanyaNeural', // 根据UI显示选择一个可能的声音 101 | locale: 'en-US', 102 | gender: 'Female', 103 | description: '美式英文女声、咨询/对话', 104 | isPro: false 105 | }, 106 | { 107 | id: 'custom-donna', 108 | name: 'Donna', 109 | localName: '美式英文女声', 110 | shortName: 'en-US-JennyNeural', 111 | locale: 'en-US', 112 | gender: 'Female', 113 | description: '美式英文女声、教育/培训', 114 | isPro: false 115 | }, 116 | { 117 | id: 'custom-bert', 118 | name: 'Bert', 119 | localName: '美式英文男声', 120 | shortName: 'en-US-GuyNeural', 121 | locale: 'en-US', 122 | gender: 'Male', 123 | description: '美式英文男声、客服/对话', 124 | isPro: false 125 | }, 126 | { 127 | id: 'custom-ariana', 128 | name: 'Ariana', 129 | localName: '美式发音', 130 | shortName: 'en-US-AriaNeural', 131 | locale: 'en-US', 132 | gender: 'Female', 133 | description: '美式发音、活力女声', 134 | isPro: false 135 | }, 136 | { 137 | id: 'custom-andrew', 138 | name: 'Andrew', 139 | localName: '美式男声', 140 | shortName: 'en-US-AndrewNeural', 141 | locale: 'en-US', 142 | gender: 'Male', 143 | description: '美式男声、转换速度快、不支持功能标签', 144 | isPro: false 145 | }, 146 | { 147 | id: 'custom-andrew-pro', 148 | name: 'Andrew(Pro)', 149 | localName: '美式男声', 150 | shortName: 'en-US-AndrewMultilingualNeural', 151 | locale: 'en-US', 152 | gender: 'Male', 153 | description: '美式男声、多情感', 154 | isPro: true 155 | }, 156 | { 157 | id: 'custom-andrew-ultra', 158 | name: 'Andrew(Ultra)', 159 | localName: '美式男声', 160 | shortName: 'en-US-AndrewMultilingualNeural', 161 | locale: 'en-US', 162 | gender: 'Male', 163 | description: '美式男声、多情感、支持70多种语言', 164 | isPro: true 165 | }, 166 | { 167 | id: 'custom-ryan-pro', 168 | name: 'Ryan(Pro)', 169 | localName: '英式男声', 170 | shortName: 'en-GB-RyanNeural', 171 | locale: 'en-GB', 172 | gender: 'Male', 173 | description: '英式男声、多情感', 174 | isPro: true 175 | }, 176 | { 177 | id: 'custom-ryan-ultra', 178 | name: 'Ryan(Ultra)', 179 | localName: '英式男声', 180 | shortName: 'en-GB-RyanMultilingualNeural', 181 | locale: 'en-GB', 182 | gender: 'Male', 183 | description: '英式男声、支持70多种语音', 184 | isPro: true 185 | }, 186 | { 187 | id: 'custom-sonia', 188 | name: 'Sonia', 189 | localName: '英式女声', 190 | shortName: 'en-GB-SoniaNeural', 191 | locale: 'en-GB', 192 | gender: 'Female', 193 | description: '英式女声、转换速度快、不支持功能标签', 194 | isPro: false 195 | }, 196 | { 197 | id: 'custom-sonia-pro', 198 | name: 'Sonia(Pro)', 199 | localName: '英式女声', 200 | shortName: 'en-GB-SoniaNeural', 201 | locale: 'en-GB', 202 | gender: 'Female', 203 | description: '英式女声、多情感', 204 | isPro: true 205 | }, 206 | { 207 | id: 'custom-thomas', 208 | name: 'Thomas', 209 | localName: '英式男声', 210 | shortName: 'en-GB-ThomasNeural', 211 | locale: 'en-GB', 212 | gender: 'Male', 213 | description: '英式男声、转换速度快、不支持功能标签', 214 | isPro: false 215 | }, 216 | { 217 | id: 'custom-thomas-pro', 218 | name: 'Thomas(Pro)', 219 | localName: '英式男声', 220 | shortName: 'en-GB-ThomasNeural', 221 | locale: 'en-GB', 222 | gender: 'Male', 223 | description: '英式男声', 224 | isPro: true 225 | }, 226 | { 227 | id: 'custom-ana', 228 | name: 'Ana', 229 | localName: '美式女声', 230 | shortName: 'en-US-AnaNeural', 231 | locale: 'en-US', 232 | gender: 'Female', 233 | description: '美式女声、转换速度快、不支持功能标签', 234 | isPro: false 235 | }, 236 | { 237 | id: 'custom-ana-pro', 238 | name: 'Ana(Pro)', 239 | localName: '年轻美式女声', 240 | shortName: 'en-US-AnaNeural', 241 | locale: 'en-US', 242 | gender: 'Female', 243 | description: '年轻美式女声', 244 | isPro: true 245 | }, 246 | { 247 | id: 'custom-aria', 248 | name: 'Aria', 249 | localName: '美式女声', 250 | shortName: 'en-US-AriaNeural', 251 | locale: 'en-US', 252 | gender: 'Female', 253 | description: '美式女声、转换速度快、不支持功能标签', 254 | isPro: false 255 | }, 256 | { 257 | id: 'custom-aria-pro', 258 | name: 'Aria(Pro)', 259 | localName: '美式女声', 260 | shortName: 'en-US-AriaNeural', 261 | locale: 'en-US', 262 | gender: 'Female', 263 | description: '美式女声、多情感', 264 | isPro: true 265 | }, 266 | { 267 | id: 'custom-ava', 268 | name: 'Ava', 269 | localName: '美式女声', 270 | shortName: 'en-US-AvaNeural', 271 | locale: 'en-US', 272 | gender: 'Female', 273 | description: '美式女声、转换速度快、不支持功能标签', 274 | isPro: false 275 | }, 276 | { 277 | id: 'custom-ava-ultra', 278 | name: 'Ava(Ultra)', 279 | localName: '美式女声', 280 | shortName: 'en-US-AvaMultilingualNeural', 281 | locale: 'en-US', 282 | gender: 'Female', 283 | description: '美式女声、支持70多种语言', 284 | isPro: true 285 | } 286 | ]; 287 | 288 | // 生成最终的声音分类 289 | export const voiceCategories: VoiceCategory[] = [ 290 | { 291 | id: 'zh', 292 | name: '中文', 293 | label: '中文', 294 | voices: zhVoices 295 | }, 296 | { 297 | id: 'en', 298 | name: '英文', 299 | label: '英文', 300 | voices: [...enUsVoices, ...enGbVoices, ...enOtherVoices, ...additionalVoices.filter(v => v.locale.startsWith('en'))] 301 | }, 302 | { 303 | id: 'other', 304 | name: '其他语言', 305 | label: '其他语言', 306 | voices: otherVoices 307 | } 308 | ]; 309 | 310 | // 声音风格描述 311 | export const styleDescriptions: {[key: string]: {emoji: string, word: string}} = { 312 | 'advertisement_upbeat': { emoji: '📢', word: '广告-活泼' }, 313 | 'affectionate': { emoji: '😊', word: '亲切' }, 314 | 'angry': { emoji: '😠', word: '愤怒' }, 315 | 'assistant': { emoji: '🤖', word: '助手' }, 316 | 'calm': { emoji: '😌', word: '平静' }, 317 | 'chat': { emoji: '💬', word: '聊天' }, 318 | 'cheerful': { emoji: '😄', word: '欢快' }, 319 | 'customerservice': { emoji: '👩‍💼', word: '客服' }, 320 | 'depressed': { emoji: '😔', word: '抑郁' }, 321 | 'disgruntled': { emoji: '😒', word: '不满' }, 322 | 'embarrassed': { emoji: '😳', word: '尴尬' }, 323 | 'empathetic': { emoji: '🤗', word: '共情' }, 324 | 'envious': { emoji: '😒', word: '羡慕' }, 325 | 'excited': { emoji: '🤩', word: '兴奋' }, 326 | 'fearful': { emoji: '😨', word: '恐惧' }, 327 | 'friendly': { emoji: '🙂', word: '友好' }, 328 | 'gentle': { emoji: '🤗', word: '温柔' }, 329 | 'hopeful': { emoji: '🙏', word: '希望' }, 330 | 'lyrical': { emoji: '🎵', word: '抒情' }, 331 | 'narration-professional': { emoji: '📚', word: '专业旁白' }, 332 | 'narration-relaxed': { emoji: '🧘', word: '放松旁白' }, 333 | 'newscast': { emoji: '📰', word: '新闻播报' }, 334 | 'newscast-casual': { emoji: '📺', word: '随意新闻' }, 335 | 'newscast-formal': { emoji: '📺', word: '正式新闻' }, 336 | 'sad': { emoji: '😢', word: '悲伤' }, 337 | 'serious': { emoji: '😐', word: '严肃' }, 338 | 'shouting': { emoji: '📣', word: '喊叫' }, 339 | 'terrified': { emoji: '😱', word: '恐惧' }, 340 | 'unfriendly': { emoji: '😠', word: '不友好' }, 341 | 'whispering': { emoji: '🤫', word: '低语' }, 342 | 'Default': { emoji: '🔤', word: '默认' }, 343 | 'General': { emoji: '🔤', word: '通用' } 344 | }; 345 | 346 | // 获取风格描述 347 | export const getStyleDescription = (style: string) => { 348 | return styleDescriptions[style] || { emoji: '🔤', word: style }; 349 | }; 350 | 351 | // 根据声音标识获取头像 352 | export const getVoiceAvatar = (voice: CategoryVoice) => { 353 | // 可以根据声音名称或性别返回默认头像 354 | if (voice.gender === 'Female') { 355 | return '/avatars/female.png'; 356 | } else { 357 | return '/avatars/male.png'; 358 | } 359 | }; 360 | 361 | // 根据本地化标识获取语言名称 362 | export const getLocaleName = (locale: string): string => { 363 | const localeMap: {[key: string]: string} = { 364 | 'zh-CN': '中文(简体)', 365 | 'zh-TW': '中文(繁体)', 366 | 'zh-HK': '中文(香港)', 367 | 'en-US': '英语(美国)', 368 | 'en-GB': '英语(英国)', 369 | 'en-AU': '英语(澳大利亚)', 370 | 'en-CA': '英语(加拿大)', 371 | 'en-IN': '英语(印度)', 372 | 'ja-JP': '日语(日本)', 373 | 'ko-KR': '韩语(韩国)', 374 | 'fr-FR': '法语(法国)', 375 | 'fr-CA': '法语(加拿大)', 376 | 'de-DE': '德语(德国)', 377 | 'es-ES': '西班牙语(西班牙)', 378 | 'es-MX': '西班牙语(墨西哥)', 379 | 'it-IT': '意大利语(意大利)', 380 | 'pt-BR': '葡萄牙语(巴西)', 381 | 'pt-PT': '葡萄牙语(葡萄牙)', 382 | 'ru-RU': '俄语(俄罗斯)', 383 | 'ar-SA': '阿拉伯语(沙特阿拉伯)', 384 | 'th-TH': '泰语(泰国)', 385 | 'vi-VN': '越南语(越南)' 386 | }; 387 | 388 | return localeMap[locale] || locale; 389 | }; 390 | 391 | // 导出分类后的声音列表 392 | export default voiceCategories; -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { createApp } from "vue"; 2 | import App from "./App.vue"; 3 | import { globalRegister } from "./global"; 4 | import { createPinia } from "pinia"; 5 | import i18n from './assets/i18n/i18n'; 6 | import router from './router/router'; 7 | // 导入现代主题样式 8 | import './assets/styles/modern-theme.css'; 9 | // import { useI18n } from 'vue-i18n'; 10 | 11 | // const App = { 12 | // setup() { 13 | // const { t } = useI18n() // call `useI18n`, and spread `t` from `useI18n` returning 14 | // return { t } // return render context that included `t` 15 | // } 16 | // } 17 | const app = createApp(App) as any; 18 | const pinia = createPinia(); 19 | 20 | app.use(i18n); 21 | app.use(pinia); 22 | app.use(router); 23 | app.use(globalRegister); 24 | app.mount("#app"); 25 | -------------------------------------------------------------------------------- /src/module-shims.d.ts: -------------------------------------------------------------------------------- 1 | // Element Plus相关 2 | declare module 'element-plus' { 3 | export const ElMessage: any; 4 | export const ElMessageBox: any; 5 | } 6 | 7 | // Element Plus图标 8 | declare module '@element-plus/icons-vue' { 9 | export const Connection: any; 10 | export const ChatDotRound: any; 11 | export const Microphone: any; 12 | export const UserFilled: any; 13 | export const Mic: any; 14 | export const Aim: any; 15 | export const Timer: any; 16 | export const Headset: any; 17 | export const TrendCharts: any; 18 | export const Star: any; 19 | export const DocumentChecked: any; 20 | export const Reading: any; 21 | export const Collection: any; 22 | export const Lightning: any; 23 | export const Cloudy: any; 24 | export const Clock: any; 25 | export const RefreshRight: any; 26 | export const CaretRight: any; 27 | export const ArrowDown: any; 28 | export const Setting: any; 29 | export const Search: any; 30 | export const Avatar: any; 31 | export const Check: any; 32 | export const Close: any; 33 | export const Download: any; 34 | } 35 | 36 | // Pinia相关 37 | declare module 'pinia' { 38 | export const storeToRefs: any; 39 | } 40 | 41 | // Vue-i18n相关 42 | declare module 'vue-i18n' { 43 | export const useI18n: any; 44 | } -------------------------------------------------------------------------------- /src/router/router.ts: -------------------------------------------------------------------------------- 1 | import { createRouter, createWebHashHistory } from 'vue-router'; 2 | 3 | // 首页组件 4 | const MainComponent = () => import('@/components/main/Main.vue'); 5 | // 历史记录组件 6 | const HistoryRecord = () => import('@/components/history/HistoryRecord.vue'); 7 | 8 | // 定义路由 9 | const routes = [ 10 | { 11 | path: '/', 12 | name: 'main', 13 | component: MainComponent 14 | }, 15 | { 16 | path: '/history', 17 | name: 'history', 18 | component: HistoryRecord 19 | } 20 | ]; 21 | 22 | // 创建路由实例 23 | const router = createRouter({ 24 | history: createWebHashHistory(), 25 | routes 26 | }); 27 | 28 | export default router; -------------------------------------------------------------------------------- /src/shims-vue.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | declare module '*.vue' { 3 | import type { DefineComponent } from 'vue' 4 | const component: DefineComponent<{}, {}, any> 5 | export default component 6 | } 7 | 8 | // 声明全局组件类型 9 | declare module '@vue/runtime-core' { 10 | export interface GlobalComponents { 11 | ElButton: typeof import('element-plus')['ElButton'] 12 | ElSelect: typeof import('element-plus')['ElSelect'] 13 | ElOption: typeof import('element-plus')['ElOption'] 14 | ElMessage: typeof import('element-plus')['ElMessage'] 15 | ElIcon: typeof import('element-plus')['ElIcon'] 16 | ElTooltip: typeof import('element-plus')['ElTooltip'] 17 | ElProgress: typeof import('element-plus')['ElProgress'] 18 | ElForm: typeof import('element-plus')['ElForm'] 19 | ElFormItem: typeof import('element-plus')['ElFormItem'] 20 | ElInput: typeof import('element-plus')['ElInput'] 21 | ElSwitch: typeof import('element-plus')['ElSwitch'] 22 | ElSlider: typeof import('element-plus')['ElSlider'] 23 | ElAlert: typeof import('element-plus')['ElAlert'] 24 | ElCollapse: typeof import('element-plus')['ElCollapse'] 25 | ElCollapseItem: typeof import('element-plus')['ElCollapseItem'] 26 | ElCollapseTransition: typeof import('element-plus')['ElCollapseTransition'] 27 | ElSelectV2: typeof import('element-plus')['ElSelectV2'] 28 | } 29 | } 30 | 31 | // 声明模块 32 | declare module 'element-plus/icons-vue' 33 | declare module '@element-plus/icons-vue' 34 | declare module 'vue-i18n' 35 | declare module 'pinia' -------------------------------------------------------------------------------- /src/store/play.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { PromptGPT } from "@/types/prompGPT"; 3 | import { createBatchTask, getBatchTaskStatus, deleteBatchTask, callTTSApi } from '@/api/tts'; 4 | import * as Pinia from 'pinia'; 5 | import WebStore from './web-store'; 6 | import { 7 | LocalTTSConfig, 8 | DEFAULT_LOCAL_TTS_CONFIG, 9 | checkServerConnection, 10 | getFreeLimitInfo, 11 | } from '@/api/local-tts'; 12 | // 移除SDK导入,改用REST API 13 | // import * as SpeechSDK from 'microsoft-cognitiveservices-speech-sdk'; 14 | 15 | // 创建Web存储实例 16 | const store = new WebStore(); 17 | 18 | // 定义错误类型 19 | export enum FreeTTSErrorType { 20 | NONE = 0, 21 | QUOTA_EXCEEDED = 402, // 额度用完 22 | RATE_LIMITED = 429, // 请求频率限制 23 | BANNED = 403, // 被封禁 24 | SERVER_ERROR = 500, // 服务器错误 25 | CONNECTION_ERROR = -1 // 连接错误 26 | } 27 | 28 | // 定义freeTTS服务状态和管理 29 | export const useFreeTTSstore = Pinia.defineStore('localTTSStore', { 30 | state: () => { 31 | return { 32 | // 配置 33 | config: { 34 | enabled: store.get('localTTS.enabled') ?? true, 35 | baseUrl: store.get('localTTS.baseUrl') ?? DEFAULT_LOCAL_TTS_CONFIG.baseUrl, 36 | defaultVoice: store.get('localTTS.defaultVoice') ?? DEFAULT_LOCAL_TTS_CONFIG.defaultVoice, 37 | defaultLanguage: store.get('localTTS.defaultLanguage') ?? DEFAULT_LOCAL_TTS_CONFIG.defaultLanguage, 38 | }, 39 | // 服务器状态 40 | serverStatus: { 41 | connected: false, 42 | lastChecked: null as number | null, 43 | freeLimit: null as any | null, 44 | error: null as string | null, 45 | errorCode: FreeTTSErrorType.NONE 46 | }, 47 | // 当前音频 48 | audio: { 49 | buffer: null as ArrayBuffer | null, 50 | url: null as string | null, 51 | isPlaying: false, 52 | error: null as string | null, 53 | errorCode: FreeTTSErrorType.NONE 54 | } 55 | }; 56 | }, 57 | 58 | getters: { 59 | // 获取完整的配置对象 60 | fullConfig(): LocalTTSConfig { 61 | return { 62 | baseUrl: this.config.baseUrl, 63 | defaultVoice: this.config.defaultVoice, 64 | defaultLanguage: this.config.defaultLanguage 65 | }; 66 | }, 67 | 68 | // 返回服务器是否连接成功的状态 69 | isServerConnected(): boolean { 70 | return this.serverStatus.connected; 71 | }, 72 | 73 | // 剩余免费额度 74 | remainingFreeLimit(): number { 75 | return this.serverStatus.freeLimit?.remaining ?? 0; 76 | }, 77 | 78 | // 免费额度使用百分比 79 | freeLimitUsagePercent(): number { 80 | const freeLimit = this.serverStatus.freeLimit; 81 | if (!freeLimit) return 0; 82 | 83 | return Math.round((freeLimit.used / freeLimit.free_limit) * 100); 84 | }, 85 | 86 | // 获取当前错误状态码 87 | currentErrorCode(): FreeTTSErrorType { 88 | return this.audio.errorCode || this.serverStatus.errorCode || FreeTTSErrorType.NONE; 89 | }, 90 | 91 | // 获取当前错误信息 92 | currentErrorMessage(): string { 93 | return this.audio.error || this.serverStatus.error || ''; 94 | } 95 | }, 96 | 97 | actions: { 98 | // 保存配置到localStorage 99 | saveConfig() { 100 | store.set('localTTS.enabled', this.config.enabled); 101 | store.set('localTTS.baseUrl', this.config.baseUrl); 102 | store.set('localTTS.defaultVoice', this.config.defaultVoice); 103 | store.set('localTTS.defaultLanguage', this.config.defaultLanguage); 104 | }, 105 | 106 | // 重置配置到默认值 107 | resetConfig() { 108 | this.config.baseUrl = DEFAULT_LOCAL_TTS_CONFIG.baseUrl; 109 | this.config.defaultVoice = DEFAULT_LOCAL_TTS_CONFIG.defaultVoice; 110 | this.config.defaultLanguage = DEFAULT_LOCAL_TTS_CONFIG.defaultLanguage; 111 | this.saveConfig(); 112 | }, 113 | 114 | // 重置错误状态 115 | resetErrorState() { 116 | this.serverStatus.error = null; 117 | this.serverStatus.errorCode = FreeTTSErrorType.NONE; 118 | this.audio.error = null; 119 | this.audio.errorCode = FreeTTSErrorType.NONE; 120 | }, 121 | 122 | // 设置错误状态 123 | setErrorState(message: string, code: FreeTTSErrorType = FreeTTSErrorType.SERVER_ERROR, isAudioError: boolean = false) { 124 | if (isAudioError) { 125 | this.audio.error = message; 126 | this.audio.errorCode = code; 127 | } else { 128 | this.serverStatus.error = message; 129 | this.serverStatus.errorCode = code; 130 | } 131 | }, 132 | 133 | // 检查服务器连接 134 | async checkServerConnection() { 135 | try { 136 | this.resetErrorState(); 137 | const connected = await checkServerConnection(this.fullConfig); 138 | this.serverStatus.connected = connected; 139 | this.serverStatus.lastChecked = Date.now(); 140 | 141 | if (!connected) { 142 | this.setErrorState('无法连接到免费TTS服务器', FreeTTSErrorType.CONNECTION_ERROR); 143 | } 144 | 145 | return connected; 146 | } catch (error: any) { 147 | this.serverStatus.connected = false; 148 | this.setErrorState(error.message, FreeTTSErrorType.CONNECTION_ERROR); 149 | return false; 150 | } 151 | }, 152 | 153 | // 从错误响应中提取错误码 154 | getErrorCodeFromResponse(error: any): FreeTTSErrorType { 155 | if (!error || !error.response) { 156 | return FreeTTSErrorType.CONNECTION_ERROR; 157 | } 158 | 159 | const status = error.response.status; 160 | 161 | // 根据响应状态码确定错误类型 162 | switch (status) { 163 | case 402: 164 | return FreeTTSErrorType.QUOTA_EXCEEDED; 165 | case 403: 166 | return FreeTTSErrorType.BANNED; 167 | case 429: 168 | return FreeTTSErrorType.RATE_LIMITED; 169 | case 500: 170 | case 502: 171 | case 503: 172 | case 504: 173 | return FreeTTSErrorType.SERVER_ERROR; 174 | default: 175 | return FreeTTSErrorType.SERVER_ERROR; 176 | } 177 | }, 178 | 179 | // 获取免费额度信息 180 | async getFreeLimitInfo() { 181 | try { 182 | const freeLimit = await getFreeLimitInfo(this.fullConfig); 183 | this.serverStatus.freeLimit = freeLimit; 184 | // 如果成功获取额度信息,清除错误状态 185 | this.serverStatus.error = null; 186 | this.serverStatus.errorCode = FreeTTSErrorType.NONE; 187 | return freeLimit; 188 | } catch (error: any) { 189 | const errorCode = this.getErrorCodeFromResponse(error); 190 | this.setErrorState(`获取免费额度信息失败: ${error.message}`, errorCode); 191 | return null; 192 | } 193 | } 194 | } 195 | }); 196 | 197 | // 用于Web环境的 TTS 接口 198 | interface TTSParams { 199 | api: number; 200 | voiceData: { 201 | activeIndex: string; 202 | ssmlContent: string; 203 | inputContent: string; 204 | retryCount?: number; 205 | retryInterval?: number; 206 | }; 207 | speechKey?: string; 208 | region?: string; 209 | thirdPartyApi?: string; 210 | tts88Key?: string; 211 | } 212 | 213 | // TTS API 响应接口 214 | interface TTSResponse { 215 | buffer?: ArrayBuffer; 216 | audibleUrl?: string; 217 | error?: string; 218 | errorCode?: string; 219 | } 220 | 221 | /** 222 | * 获取TTS数据 - 业务逻辑层实现 223 | * 负责调用底层API,处理重试逻辑和特定的业务转换 224 | */ 225 | async function getTTSData(params: TTSParams): Promise { 226 | const { api, voiceData } = params; 227 | const { activeIndex, retryCount = 3, retryInterval = 1 } = voiceData; 228 | 229 | // 记录日志 230 | console.log("TTS API请求", { api, activeIndex, retryCount, retryInterval }); 231 | 232 | // 参数验证 - 全面的参数校验 233 | if (!voiceData.ssmlContent && !voiceData.inputContent) { 234 | console.error('缺少转换内容'); 235 | return { 236 | error: '没有可转换的内容', 237 | errorCode: 'EMPTY_CONTENT' 238 | }; 239 | } 240 | 241 | // API类型相关验证 242 | if (api === 1 || api === 2 || api === 3) { 243 | // 验证Azure API必要参数 244 | if (!params.speechKey) { 245 | console.error('缺少 Azure Speech API Key'); 246 | return { 247 | error: '请先在设置中配置 Azure Speech API Key', 248 | errorCode: 'MISSING_AZURE_KEY' 249 | }; 250 | } 251 | 252 | if (api === 3 && !params.region) { 253 | console.error('缺少 Azure 区域设置'); 254 | return { 255 | error: '请先在设置中配置 Azure 服务区域', 256 | errorCode: 'MISSING_AZURE_REGION' 257 | }; 258 | } 259 | } else if (api === 4) { 260 | // 验证TTS88 API必要参数 261 | if (!params.thirdPartyApi) { 262 | console.error('缺少 TTS88 API URL'); 263 | return { 264 | error: '请先在设置中配置 TTS88 API 地址', 265 | errorCode: 'MISSING_TTS88_URL' 266 | }; 267 | } 268 | 269 | if (!params.tts88Key) { 270 | console.warn('TTS88 API Key未配置,可能导致认证失败'); 271 | } 272 | } else if (api === 5) { 273 | // 免费TTS服务验证 274 | try { 275 | const localTTSStore = useFreeTTSstore(); 276 | 277 | if (!localTTSStore.config.enabled) { 278 | return { 279 | error: "本地TTS服务未启用,请在设置中启用", 280 | errorCode: "LOCAL_TTS_DISABLED" 281 | }; 282 | } 283 | 284 | if (!localTTSStore.config.baseUrl) { 285 | return { 286 | error: "本地TTS服务URL未配置,请在设置中配置", 287 | errorCode: "LOCAL_TTS_URL_MISSING" 288 | }; 289 | } 290 | 291 | // 检查连接状态 292 | const isConnected = await localTTSStore.checkServerConnection(); 293 | if (!isConnected) { 294 | return { 295 | error: "无法连接到本地TTS服务,请检查网络连接", 296 | errorCode: "LOCAL_TTS_CONNECTION_FAILED" 297 | }; 298 | } 299 | 300 | // 检查可用额度 301 | const quotaInfo = await localTTSStore.getFreeLimitInfo(); 302 | if (quotaInfo && quotaInfo.remaining <= 0) { 303 | return { 304 | error: "您的免费额度已用完,请使用TTS88API解锁无限使用", 305 | errorCode: "QUOTA_EXCEEDED" 306 | }; 307 | } 308 | } catch (error) { 309 | console.error('验证本地TTS服务状态失败:', error); 310 | } 311 | } 312 | 313 | // 处理特定业务逻辑 314 | try { 315 | // 预处理参数或执行其他业务逻辑 316 | 317 | // 根据API类型进行不同的预处理 318 | if (api === 3) { // Azure 319 | console.log("使用Azure API"); 320 | // 可以在这里添加Azure特定的处理逻辑 321 | } 322 | else if (api === 4) { // TTS88 323 | console.log("使用TTS88 API"); 324 | // 可以在这里添加TTS88特定的处理逻辑 325 | } 326 | else if (api === 5) { // 本地TTS 327 | console.log("使用本地TTS服务"); 328 | // 可以在这里添加本地TTS特定的处理逻辑 329 | 330 | // 获取当前选中的声音和配置 331 | if (api === 5) { 332 | const { useTtsStore } = await import('@/store/store'); 333 | const ttsStore = useTtsStore(); 334 | const selectedVoice = ttsStore.formConfig.voiceSelect; 335 | const speed = ttsStore.formConfig.speed; 336 | const pitch = ttsStore.formConfig.pitch; 337 | console.log("当前选择的声音:", selectedVoice, "语速:", speed, "音调:", pitch); 338 | } 339 | } 340 | 341 | // 调用API层的函数,并包含重试逻辑 342 | let retry = 0; 343 | let lastError; 344 | 345 | while (retry < retryCount) { 346 | try { 347 | console.log(`开始调用TTS API (尝试 ${retry + 1}/${retryCount})`); 348 | 349 | // 确保参数类型兼容 350 | const apiParams = { 351 | ...params, 352 | // 确保必要属性不为undefined 353 | speechKey: params.speechKey || '', 354 | region: params.region || '', 355 | thirdPartyApi: params.thirdPartyApi || '', 356 | tts88Key: params.tts88Key || '' 357 | }; 358 | 359 | const result = await callTTSApi(apiParams); 360 | 361 | // 检查是否有错误 362 | if (result.error) { 363 | // 根据错误代码提供增强的错误信息 364 | let errorMessage = result.error; 365 | let errorCode = result.errorCode || "API_ERROR"; 366 | 367 | // 特定错误类型的增强处理 368 | if (errorCode === "HTTP_401" || errorCode === "HTTP_403") { 369 | errorMessage = `认证失败: API密钥无效或未授权`; 370 | errorCode = "AUTH_ERROR"; 371 | } else if (errorCode === "HTTP_429") { 372 | errorMessage = "请求过于频繁,请稍后再试"; 373 | errorCode = "RATE_LIMITED"; 374 | } else if (errorCode && errorCode.startsWith("HTTP_5")) { 375 | errorMessage = `服务器错误: 请稍后再试`; 376 | errorCode = "SERVER_ERROR"; 377 | } else if (errorCode === "HTTP_400" && voiceData.activeIndex === "1") { 378 | errorMessage = "SSML格式错误,请检查您的SSML标记语法是否正确"; 379 | errorCode = "SSML_SYNTAX_ERROR"; 380 | console.log("检测到SSML格式错误,不再重试"); 381 | return { 382 | error: errorMessage, 383 | errorCode: errorCode 384 | }; 385 | } else if (errorCode === "LOCAL_TTS_ERROR" && result.error.includes("quota exceeded")) { 386 | errorMessage = "您的免费额度已用完,请使用TTS88API解锁无限使用"; 387 | errorCode = "QUOTA_EXCEEDED"; 388 | // 对于额度不足错误,不进行重试 389 | console.log("检测到额度不足错误,不再重试"); 390 | return { 391 | error: errorMessage, 392 | errorCode: errorCode 393 | }; 394 | } else if (errorCode === "LOCAL_TTS_ERROR" && result.error.includes("rate limited")) { 395 | errorMessage = "请求频率过高,请稍后再试"; 396 | errorCode = "RATE_LIMITED"; 397 | } 398 | 399 | throw new Error(errorMessage); 400 | } 401 | 402 | // 业务逻辑处理: 添加额外的属性或转换 403 | // 例如: 可以增加播放列表历史记录、统计使用次数等 404 | 405 | // 如果是免费TTS服务(api=5),在请求成功后刷新一次额度信息 406 | if (api === 5) { 407 | try { 408 | console.log("请求成功,刷新免费TTS额度信息"); 409 | const localTTSStore = useFreeTTSstore(); 410 | // 异步刷新额度,不阻塞主流程 411 | localTTSStore.getFreeLimitInfo().then(freeLimit => { 412 | if (freeLimit) { 413 | console.log("已更新免费额度信息:", freeLimit); 414 | } 415 | }).catch(err => { 416 | console.error("刷新额度信息失败:", err); 417 | }); 418 | } catch (err) { 419 | console.error("尝试刷新额度信息时出错:", err); 420 | } 421 | } 422 | 423 | // 存储转换历史,当前实现只返回结果 424 | return result; 425 | } catch (error: any) { 426 | console.error(`TTS API调用失败 (尝试 ${retry + 1}/${retryCount}):`, error); 427 | lastError = error; 428 | 429 | // 检查是否是HTTP 403错误,表示额度不足 430 | if (error.message && (error.message.includes('403') || 431 | error.message.includes('文本长度超出剩余配额') || 432 | error.message.includes('quota exceeded'))) { 433 | console.log("检测到HTTP 403或额度不足错误,不再重试"); 434 | return { 435 | error: "免费TTS额度不足,请使用TTS88API解锁无限使用", 436 | errorCode: "QUOTA_EXCEEDED" 437 | }; 438 | } 439 | 440 | console.log(`等待 ${retryInterval} 秒后重试...`); 441 | await sleep(retryInterval * 1000); 442 | retry++; 443 | } 444 | } 445 | 446 | console.error('达到最大重试次数,放弃请求'); 447 | return { 448 | error: lastError?.message || "达到最大重试次数,请求失败", 449 | errorCode: "MAX_RETRY_EXCEEDED" 450 | }; 451 | } catch (error: any) { 452 | console.error("TTS转换失败:", error); 453 | 454 | // 检查是否是额度不足的错误 455 | const errorMessage = error.message || ''; 456 | const responseData = error.response?.data || {}; 457 | 458 | // 判断是否是配额相关错误 - 查找关键词和HTTP状态码 459 | const isQuotaError = (errorMessage.includes('403') || 460 | errorMessage.includes('文本长度超出剩余配额') || 461 | errorMessage.includes('quota exceeded') || 462 | responseData.error?.includes('文本长度超出剩余配额') || 463 | responseData.error?.includes('quota exceeded') || 464 | error.response?.status === 403); 465 | 466 | // 如果是配额错误,返回更友好的提示信息 467 | if (isQuotaError) { 468 | let remaining = responseData.remaining || 0; 469 | let requested = responseData.requested || 0; 470 | 471 | // 构建用户友好的错误消息 472 | let quotaMessage = "免费TTS额度不足"; 473 | if (remaining > 0 && requested > 0) { 474 | quotaMessage = `免费TTS额度不足:剩余${remaining}字符,需要${requested}字符`; 475 | } 476 | 477 | return { 478 | error: quotaMessage, 479 | errorCode: "QUOTA_EXCEEDED" 480 | }; 481 | } 482 | 483 | return { 484 | error: error.message || "TTS转换失败", 485 | errorCode: "GENERAL_ERROR" 486 | }; 487 | } 488 | } 489 | 490 | // Sleep函数,用于重试间隔 491 | function sleep(ms: number) { 492 | return new Promise(resolve => setTimeout(resolve, ms)); 493 | } 494 | 495 | // GPT API调用 496 | async function getDataGPT(options: PromptGPT): Promise { 497 | const { promptGPT, model = 'gpt-3.5-turbo', key, retryCount = 3, retryInterval = 1, baseUrl } = options; 498 | 499 | if (!key) { 500 | throw new Error("OpenAI API密钥未配置"); 501 | } 502 | 503 | let retry = 0; 504 | let lastError; 505 | 506 | while (retry < retryCount) { 507 | try { 508 | console.log(`尝试调用 OpenAI API (尝试 ${retry + 1}/${retryCount})`) 509 | 510 | const apiUrl = baseUrl || 'https://api.openai.com/v1/chat/completions'; 511 | 512 | const response = await axios.post( 513 | apiUrl, 514 | { 515 | model: model, 516 | messages: [{ role: 'user', content: promptGPT }] 517 | }, 518 | { 519 | headers: { 520 | 'Content-Type': 'application/json', 521 | 'Authorization': `Bearer ${key}` 522 | } 523 | } 524 | ); 525 | 526 | if (response.data && response.data.choices && response.data.choices.length > 0) { 527 | return response.data.choices[0].message.content.trim(); 528 | } else { 529 | throw new Error("API返回的响应格式不正确"); 530 | } 531 | } catch (error) { 532 | console.error(`OpenAI API调用失败 (尝试 ${retry + 1}/${retryCount}):`, error); 533 | lastError = error; 534 | await sleep(retryInterval * 1000); 535 | retry++; 536 | } 537 | } 538 | 539 | throw lastError || new Error("未知错误"); 540 | } 541 | 542 | export { getTTSData, getDataGPT, createBatchTask, getBatchTaskStatus, deleteBatchTask }; 543 | -------------------------------------------------------------------------------- /src/store/types.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Pinia商店类型定义 3 | */ 4 | 5 | // TTS商店状态类型 6 | export interface TtsStoreState { 7 | // 配置相关 8 | config: { 9 | speechKey: string; 10 | serviceRegion: string; 11 | thirdPartyApi: string; 12 | tts88Key: string; 13 | retryCount: number; 14 | retryInterval: number; 15 | formConfigJson: Record; 16 | [key: string]: any; 17 | }; 18 | 19 | // 表单配置 20 | formConfig: { 21 | api: number; 22 | languageSelect: string; 23 | voiceSelect: string; 24 | ssmlValue?: string; 25 | voiceStyleSelect: string; 26 | role: string; 27 | speed: number; 28 | pitch: number; 29 | intensity: string; 30 | silence: string; 31 | volume: string; 32 | [key: string]: any; 33 | }; 34 | 35 | // 页面状态 36 | page: { 37 | tabIndex: number; 38 | asideIndex: string; 39 | [key: string]: any; 40 | }; 41 | 42 | // 输入相关 43 | inputs: { 44 | inputValue: string; 45 | ssmlValue: string; 46 | [key: string]: any; 47 | }; 48 | 49 | // 表格数据 50 | tableData: any[]; 51 | 52 | // 加载状态 53 | isLoading: boolean; 54 | 55 | // 其他状态 56 | [key: string]: any; 57 | } 58 | 59 | // 本地TTS商店状态类型 60 | export interface LocalTTSStoreState { 61 | config: { 62 | enabled: boolean; 63 | serverUrl: string; 64 | [key: string]: any; 65 | }; 66 | 67 | serverStatus: { 68 | connected: boolean; 69 | version?: string; 70 | supportedModels?: string[]; 71 | freeLimit?: { 72 | free_limit: number; 73 | remaining: number; 74 | reset_time?: string; 75 | }; 76 | [key: string]: any; 77 | }; 78 | 79 | freeLimitUsagePercent: number; 80 | [key: string]: any; 81 | } -------------------------------------------------------------------------------- /src/store/web-store.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Web版本的存储类,使用localStorage代替electron-store 3 | */ 4 | class WebStore { 5 | constructor() { 6 | // 初始化检查,确保必要的配置存在 7 | this.initDefaultConfig(); 8 | } 9 | 10 | // 设置键值对 11 | set(key: string, value: any): void { 12 | try { 13 | localStorage.setItem(key, JSON.stringify(value)); 14 | } catch (error) { 15 | console.error('Error saving to localStorage:', error); 16 | } 17 | } 18 | 19 | // 获取键值 20 | get(key: string): any { 21 | try { 22 | const value = localStorage.getItem(key); 23 | // 处理"undefined"和null的情况 24 | if (value === null || value === "undefined") { 25 | return null; 26 | } 27 | return JSON.parse(value); 28 | } catch (error) { 29 | console.error('Error reading from localStorage:', error); 30 | return null; 31 | } 32 | } 33 | 34 | // 删除键值对 35 | delete(key: string): void { 36 | try { 37 | localStorage.removeItem(key); 38 | } catch (error) { 39 | console.error('Error deleting from localStorage:', error); 40 | } 41 | } 42 | 43 | // 初始化默认配置 44 | private initDefaultConfig(): void { 45 | // 设置默认API - 允许用户选择其他API 46 | if (!this.get("api")) { 47 | this.set("api", 5); // 默认使用免费TTS服务 48 | } 49 | 50 | // 确保FormConfig.默认存在 51 | let defaultFormConfig = this.get("FormConfig.默认"); 52 | if (!defaultFormConfig) { 53 | defaultFormConfig = { 54 | api: 5, // 默认使用免费TTS服务 55 | languageSelect: "zh-CN", 56 | voiceSelect: "zh-CN-XiaoxiaoNeural", 57 | voiceStyleSelect: "Default", 58 | role: "", 59 | speed: 1, 60 | pitch: 1 61 | }; 62 | this.set("FormConfig.默认", defaultFormConfig); 63 | } else { 64 | // 修复布尔值问题 65 | if (defaultFormConfig.api === true || defaultFormConfig.api === 'true') { 66 | defaultFormConfig.api = 5; // 设为免费TTS服务 67 | this.set("FormConfig.默认", defaultFormConfig); 68 | } 69 | } 70 | 71 | // 设置其他默认配置 72 | if (!this.get("language")) this.set("language", "zh"); 73 | if (!this.get("formatType")) this.set("formatType", "mp3"); 74 | if (!this.get("audition")) this.set("audition", "如果你觉得这个项目还不错, 欢迎Star、Fork和PR。你的Star是对作者最好的鼓励。"); 75 | if (!this.get("autoplay")) this.set("autoplay", true); 76 | if (!this.get("updateNotification")) this.set("updateNotification", true); 77 | if (!this.get("titleStyle")) this.set("titleStyle", "custom"); 78 | if (!this.get("retryCount")) this.set("retryCount", 3); 79 | if (!this.get("retryInterval")) this.set("retryInterval", 1000); 80 | } 81 | } 82 | 83 | export default WebStore; -------------------------------------------------------------------------------- /src/types/local-tts.d.ts: -------------------------------------------------------------------------------- 1 | declare module '@/api/local-tts' { 2 | export interface LocalTTSConfig { 3 | baseUrl: string; 4 | defaultVoice: string; 5 | defaultLanguage: string; 6 | } 7 | 8 | export const DEFAULT_LOCAL_TTS_CONFIG: LocalTTSConfig; 9 | export function checkServerConnection(config: LocalTTSConfig): Promise; 10 | export function getFreeLimitInfo(config: LocalTTSConfig): Promise; 11 | } -------------------------------------------------------------------------------- /src/types/pinia.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'pinia' { 2 | export function defineStore( 3 | id: Id, 4 | options: any 5 | ): any; 6 | 7 | export function defineStore( 8 | id: Id, 9 | storeSetup: () => SS, 10 | options?: any 11 | ): any; 12 | 13 | export function createPinia(): any; 14 | export function setActivePinia(pinia: any): void; 15 | export function getActivePinia(): any; 16 | export function acceptHMRUpdate(storeToUpdate: any, hmrModule: any): any; 17 | export function storeToRefs(store: T): any; 18 | } -------------------------------------------------------------------------------- /src/types/prompGPT.ts: -------------------------------------------------------------------------------- 1 | interface PromptGPT { 2 | promptGPT: string, 3 | model: string, 4 | key: string, 5 | retryCount: number, 6 | retryInterval: number, 7 | baseUrl?: string, 8 | } 9 | 10 | export { type PromptGPT }; -------------------------------------------------------------------------------- /src/types/vue.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'vue' { 2 | export interface App {} 3 | export interface Ref {} 4 | export interface ComputedRef {} 5 | export interface DebuggerEvent {} 6 | export interface EffectScope {} 7 | export interface ToRefs {} 8 | export interface UnwrapRef {} 9 | export interface WatchOptions {} 10 | export interface WritableComputedRef {} 11 | export interface ToRef {} 12 | } -------------------------------------------------------------------------------- /src/voice-utils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 语音工具函数库 3 | * 提供与语音处理相关的实用函数 4 | */ 5 | 6 | /** 7 | * 获取语音的中文名称,格式为"拼音-中文" 8 | * @param shortName 语音的短名称,例如 zh-CN-XiaoxiaoNeural 9 | * @returns 格式化后的名称,例如 Xiaoxiao-晓晓 10 | */ 11 | export const getChineseName = (shortName: string) => { 12 | if (!shortName) return ''; 13 | 14 | // 从ShortName中提取名称部分 15 | const nameParts = shortName.split('-'); 16 | 17 | // 处理特殊情况 18 | if (nameParts.length < 3) { 19 | // 处理特殊情况,如shandong 20 | if (shortName.toLowerCase() === 'shandong') { 21 | return 'Shandong-山东'; 22 | } 23 | return shortName; 24 | } 25 | 26 | // 特殊处理方言格式:zh-CN-henan-YundengNeural 27 | if (nameParts.length >= 4) { 28 | const region = nameParts[0] + '-' + nameParts[1]; // 如zh-CN 29 | const dialect = nameParts[2].toLowerCase(); // 如henan 30 | const name = nameParts[3].replace('Neural', ''); // 如Yundeng 31 | 32 | // 方言映射 33 | const dialectMap: {[key: string]: string} = { 34 | 'shaanxi': '陕西方言', 35 | 'henan': '河南方言', 36 | 'liaoning': '东北方言', 37 | 'shandong': '山东方言', 38 | 'shanghai': '上海方言', 39 | 'sichuan': '四川方言', 40 | 'tianjin': '天津方言', 41 | 'hebei': '河北方言', 42 | 'shanxi': '山西方言', 43 | 'gansu': '甘肃方言', 44 | 'anhui': '安徽方言', 45 | 'hubei': '湖北方言', 46 | 'honghu': '洪湖方言', 47 | 'yunnan': '云南方言' 48 | }; 49 | 50 | if (dialectMap[dialect]) { 51 | // 中文名称映射 52 | const nameMap: {[key: string]: string} = { 53 | 'Yundeng': '云登', 54 | 'Yunfeng': '云枫', 55 | 'Yunhao': '云皓', 56 | 'Yunxia': '云霞', 57 | 'Yunxi': '云熙', 58 | 'Yunye': '云叶', 59 | 'Yunyang': '云阳', 60 | 'Yunxiang': '云翔', 61 | 'Xiaoxuan': '晓萱', 62 | 'Xiaochen': '晓辰', 63 | 'Xiaoshuang': '晓双' 64 | // 其他名称可以根据需要添加 65 | }; 66 | 67 | if (nameMap[name]) { 68 | return `${name}-${dialectMap[dialect]}·${nameMap[name]}`; 69 | } else { 70 | return `${name}-${dialectMap[dialect]}`; 71 | } 72 | } 73 | } 74 | 75 | // 提取区域和名称 76 | const region = nameParts[0] + '-' + nameParts[1]; // 如zh-CN, zh-TW, zh-HK, yue-CN, wuu-CN 77 | const name = nameParts[2].replace('Neural', ''); 78 | 79 | // 中文名称映射 80 | const nameMap: {[key: string]: string} = { 81 | // 中国大陆 (zh-CN) 82 | 'Xiaoxuan': '晓萱', 83 | 'Xiaochen': '晓辰', 84 | 'Xiaoxiao': '晓晓', 85 | 'Xiaohan': '晓涵', 86 | 'Xiaozhen': '晓甄', 87 | 'Yunjian': '云健', 88 | 'Xiaoyan': '晓颜', 89 | 'Xiaoyi': '晓伊', 90 | 'Yunxi': '云熙', 91 | 'Xiaomo': '晓墨', 92 | 'Yunye': '云叶', 93 | 'Yunxia': '云霞', 94 | 'Xiaorui': '晓瑞', 95 | 'Xiaoshuang': '晓双', 96 | 'Yunfeng': '云枫', 97 | 'Yunhan': '云翰', 98 | 'Kangkang': '康康', 99 | 'Zhangyu': '章宇', 100 | 'Yunhao': '云皓', 101 | 'Xiaomeng': '晓梦', 102 | 'Yunze': '云泽', 103 | 'Xiaoqiu': '晓秋', 104 | 'Xiaoyou': '晓悠', 105 | 'Yunyang': '云阳', 106 | 'Yundeng': '云登', 107 | 'Yunxiang': '云翔', 108 | 'Xiaoni': '晓妮', 109 | 'Xiaobei': '晓贝', 110 | 'Yunni': '云妮', 111 | 'Yunyi': '云怡', 112 | 'Yunxuan': '云轩', 113 | 'Xiaohui': '晓慧', 114 | 115 | // 粤语 (yue-CN) - 添加粤语声音映射 116 | 'XiaoMin': '小敏', 117 | 'YunSong': '云松', 118 | 'XiaoRong': '小蓉', 119 | 'YunZa': '云扎', 120 | 'XiaoYu': '晓瑜', 121 | 'WanLu': '婉露', 122 | 'XiuYin': '秀英', 123 | 'YunJun': '云军', 124 | 125 | // 吴语 (wuu-CN) - 添加吴语声音映射 126 | 'Xiaotong': '晓彤', 127 | 'Yunzhe': '云哲', 128 | 129 | // 方言 130 | 'Honghu': '洪湖', 131 | 'Liaoning': '辽宁', 132 | 'Shaanxi': '陕西', 133 | 'Henan': '河南', 134 | 'Yunnan': '云南', 135 | 'Sichuan': '四川', 136 | 'Tianjin': '天津', 137 | 'Shanxi': '山西', 138 | 'Hebei': '河北', 139 | 'Gansu': '甘肃', 140 | 'Anhui': '安徽', 141 | }; 142 | 143 | // 区域特定名称 144 | const regionNames: {[region: string]: {[key: string]: string}} = { 145 | 'zh-CN': { 146 | 'YunJhe': '云杰' 147 | }, 148 | 'zh-TW': { 149 | 'HsiaoChen': '曉臻', 150 | 'HsiaoYu': '曉雨', 151 | 'YunJhe': '雲哲' 152 | }, 153 | 'zh-HK': { 154 | 'HiuMaan': '曉曼', 155 | 'HiuGaai': '曉佳', 156 | 'WanLung': '雲龍' 157 | }, 158 | 'yue-CN': { 159 | 'XiaoMin': '小敏', 160 | 'YunSong': '云松', 161 | 'XiaoRong': '小蓉', 162 | 'YunZa': '云扎', 163 | 'XiaoYu': '晓瑜', 164 | 'WanLu': '婉露', 165 | 'XiuYin': '秀英', 166 | 'YunJun': '云军' 167 | }, 168 | 'wuu-CN': { 169 | 'Xiaotong': '晓彤', 170 | 'Yunzhe': '云哲' 171 | } 172 | }; 173 | 174 | // 检查是否有区域特定的名称 175 | if (regionNames[region] && regionNames[region][name]) { 176 | return `${name}-${regionNames[region][name]}`; 177 | } 178 | 179 | // 使用通用名称映射 180 | if (nameMap[name]) { 181 | // 对于粤语区域,在中文名前添加"粤语"标识 182 | if (region === 'yue-CN') { 183 | return `${name}-粤语·${nameMap[name]}`; 184 | } 185 | 186 | // 对于吴语区域,在中文名前添加"吴语"标识 187 | if (region === 'wuu-CN') { 188 | return `${name}-吴语·${nameMap[name]}`; 189 | } 190 | 191 | // 对于方言区域,添加对应方言标识 192 | if (name === 'Shaanxi' || name === 'shaanxi') { 193 | return `${name}-陕西方言`; 194 | } 195 | if (name === 'Henan' || name === 'henan') { 196 | return `${name}-河南方言`; 197 | } 198 | if (name === 'Liaoning' || name === 'liaoning') { 199 | return `${name}-东北方言`; 200 | } 201 | if (name === 'Shandong' || name === 'shandong') { 202 | return `${name}-山东方言`; 203 | } 204 | if (name === 'Shanghai' || name === 'shanghai') { 205 | return `${name}-上海方言`; 206 | } 207 | if (name === 'Sichuan' || name === 'sichuan') { 208 | return `${name}-四川方言`; 209 | } 210 | if (name === 'Tianjin' || name === 'tianjin') { 211 | return `${name}-天津方言`; 212 | } 213 | if (name === 'Hebei' || name === 'hebei') { 214 | return `${name}-河北方言`; 215 | } 216 | if (name === 'Shanxi' || name === 'shanxi') { 217 | return `${name}-山西方言`; 218 | } 219 | if (name === 'Gansu' || name === 'gansu') { 220 | return `${name}-甘肃方言`; 221 | } 222 | if (name === 'Anhui' || name === 'anhui') { 223 | return `${name}-安徽方言`; 224 | } 225 | if (name === 'Hubei' || name === 'hubei') { 226 | return `${name}-湖北方言`; 227 | } 228 | if (name === 'Honghu' || name === 'honghu') { 229 | return `${name}-洪湖方言`; 230 | } 231 | if (name === 'Yunnan' || name === 'yunnan') { 232 | return `${name}-云南方言`; 233 | } 234 | 235 | return `${name}-${nameMap[name]}`; 236 | } 237 | 238 | return shortName; 239 | }; 240 | 241 | /** 242 | * 获取语音的显示名称 243 | * 如果有中文名称映射,则返回中文名称,否则返回原始名称 244 | * @param voice 语音选项对象 245 | * @returns 格式化后的显示名称 246 | */ 247 | export const getVoiceDisplayName = (voice: any) => { 248 | if (!voice) return ''; 249 | 250 | const shortName = voice.ShortName || voice.shortName; 251 | if (!shortName) return voice.DisplayName || voice.name || ''; 252 | 253 | return getChineseName(shortName) || (voice.DisplayName || voice.name || shortName); 254 | }; -------------------------------------------------------------------------------- /src/vue-shim.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'vue' { 2 | export const ref: any; 3 | export const reactive: any; 4 | export const computed: any; 5 | export const watch: any; 6 | export const onMounted: any; 7 | export const defineComponent: any; 8 | export const createApp: any; 9 | export const nextTick: any; 10 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "module": "esnext", 5 | "moduleResolution": "bundler", 6 | "jsx": "preserve", 7 | "sourceMap": true, 8 | "resolveJsonModule": true, 9 | "esModuleInterop": true, 10 | "lib": ["esnext", "dom"], 11 | "types": ["vite/client"], 12 | "baseUrl": ".", 13 | "paths": { 14 | "@/*": ["./src/*"] 15 | }, 16 | "allowJs": true, 17 | "skipLibCheck": true, 18 | "allowSyntheticDefaultImports": true, 19 | "skipDefaultLibCheck": true, 20 | "verbatimModuleSyntax": false, 21 | 22 | // 完全关闭严格类型检查 23 | "strict": false, 24 | "noImplicitAny": false, 25 | "strictNullChecks": false, 26 | "strictFunctionTypes": false, 27 | "strictBindCallApply": false, 28 | "strictPropertyInitialization": false, 29 | "noImplicitThis": false, 30 | "alwaysStrict": false, 31 | 32 | // 禁用额外检查 33 | "noUnusedLocals": false, 34 | "noUnusedParameters": false, 35 | "noImplicitReturns": false, 36 | "noFallthroughCasesInSwitch": false, 37 | 38 | // 忽略导入检查 39 | "allowUnreachableCode": true, 40 | "noErrorTruncation": true 41 | }, 42 | "include": [ 43 | "src/**/*.ts", 44 | "src/**/*.d.ts", 45 | "src/**/*.tsx", 46 | "src/**/*.vue", 47 | "src/types/**/*.d.ts" 48 | ], 49 | "references": [{ "path": "./tsconfig.node.json" }] 50 | } 51 | -------------------------------------------------------------------------------- /tsconfig.json.bak: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "module": "esnext", 5 | "moduleResolution": "node", 6 | "jsx": "preserve", 7 | "sourceMap": true, 8 | "resolveJsonModule": true, 9 | "esModuleInterop": true, 10 | "lib": ["esnext", "dom"], 11 | "types": ["vite/client", "element-plus/global", "@vue/runtime-core", "pinia"], 12 | "baseUrl": ".", 13 | "paths": { 14 | "@/*": ["./src/*"] 15 | }, 16 | "allowJs": true, 17 | "skipLibCheck": true, 18 | "allowSyntheticDefaultImports": true, 19 | "skipDefaultLibCheck": true, 20 | "verbatimModuleSyntax": false, 21 | 22 | // 完全关闭严格类型检查 23 | "strict": false, 24 | "noImplicitAny": false, 25 | "strictNullChecks": false, 26 | "strictFunctionTypes": false, 27 | "strictBindCallApply": false, 28 | "strictPropertyInitialization": false, 29 | "noImplicitThis": false, 30 | "alwaysStrict": false, 31 | 32 | // 禁用额外检查 33 | "noUnusedLocals": false, 34 | "noUnusedParameters": false, 35 | "noImplicitReturns": false, 36 | "noFallthroughCasesInSwitch": false, 37 | 38 | // 忽略导入检查 39 | "allowUnreachableCode": true, 40 | "noErrorTruncation": true 41 | }, 42 | "include": [ 43 | "src/**/*.ts", 44 | "src/**/*.d.ts", 45 | "src/**/*.tsx", 46 | "src/**/*.vue", 47 | "src/types/**/*.d.ts" 48 | ], 49 | "references": [{ "path": "./tsconfig.node.json" }] 50 | } 51 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "composite": true, 5 | "module": "ESNext", 6 | "moduleResolution": "Node", 7 | "jsx": "preserve", 8 | "resolveJsonModule": true, 9 | "allowSyntheticDefaultImports": true 10 | }, 11 | "include": ["vite.config.ts", "electron", "package.json"] 12 | } 13 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { join } from "path"; 2 | import { defineConfig } from "vite"; 3 | import vue from "@vitejs/plugin-vue"; 4 | import pkg from "./package.json"; 5 | 6 | // https://vitejs.dev/config/ 7 | export default defineConfig({ 8 | resolve: { 9 | alias: { 10 | "@": join(__dirname, "./src"), 11 | "@components": join(__dirname, "./src/components"), 12 | }, 13 | }, 14 | plugins: [ 15 | vue(), 16 | ], 17 | server: { 18 | host: pkg.env.VITE_DEV_SERVER_HOST, 19 | port: Number(pkg.env.VITE_DEV_SERVER_PORT), 20 | }, 21 | build: { 22 | outDir: "dist", 23 | sourcemap: true, 24 | }, 25 | define: { 26 | __VUE_PROD_HYDRATION_MISMATCH_DETAILS__: 'false', 27 | __VUE_OPTIONS_API__: 'true', 28 | __VUE_PROD_DEVTOOLS__: 'false' 29 | } 30 | }); 31 | -------------------------------------------------------------------------------- /vite.config.ts.bak: -------------------------------------------------------------------------------- 1 | import { join } from "path"; 2 | import { defineConfig } from "vite"; 3 | import vue from "@vitejs/plugin-vue"; 4 | import pkg from "./package.json"; 5 | 6 | // https://vitejs.dev/config/ 7 | export default defineConfig({ 8 | resolve: { 9 | alias: { 10 | "@": join(__dirname, "./src"), 11 | "@components": join(__dirname, "./src/components"), 12 | }, 13 | }, 14 | plugins: [ 15 | vue(), 16 | ], 17 | server: { 18 | host: pkg.env.VITE_DEV_SERVER_HOST, 19 | port: pkg.env.VITE_DEV_SERVER_PORT, 20 | }, 21 | build: { 22 | outDir: "dist", 23 | sourcemap: true, 24 | } 25 | }); 26 | --------------------------------------------------------------------------------