From bd405e40fe3cd76d0050945762313ccd7f07485b Mon Sep 17 00:00:00 2001
From: sun <3371392206@qq.com>
Date: Sat, 3 Jan 2026 00:10:07 +0800
Subject: [PATCH] =?UTF-8?q?feat:=20=E5=88=9D=E5=A7=8B=E5=8C=96OpenAITTS?=
=?UTF-8?q?=E9=85=8D=E9=9F=B3=E5=8F=B0=E9=A1=B9=E7=9B=AE?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
创建了完整的文本转语音工具,包括:
- 后端:Go语言实现的HTTP服务(main.go)
- 支持自定义API地址配置
- 提供/api/tts端点
- 兼容OpenAI TTS API格式
- 前端:
- index.html:简洁大方的用户界面
- styles.css:现代渐变设计和响应式布局
- app.js:完整的交互逻辑和状态管理
- 配置:
- config.example.json:API地址和端口配置示例
- 支持配置文件、环境变量和前端直接配置三种方式
- 文档:
- README.md:详细的使用说明和API文档
功能特点:
- ✅ 自定义API地址支持
- ✅ 多种声音模型(tts-1/tts-1-hd)
- ✅ 6种语音类型选择
- ✅ 实时字符计数
- ✅ 音频播放和下载
- ✅ 本地存储API配置
- ✅ 友好的错误提示
---
README.md | 156 ++++++++++++++++++++
app.js | 221 ++++++++++++++++++++++++++++
config.example.json | 4 +
index.html | 94 ++++++++++++
main.go | 149 +++++++++++++++++++
styles.css | 344 ++++++++++++++++++++++++++++++++++++++++++++
6 files changed, 968 insertions(+)
create mode 100644 README.md
create mode 100644 app.js
create mode 100644 config.example.json
create mode 100644 index.html
create mode 100644 main.go
create mode 100644 styles.css
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..c0f1fbe
--- /dev/null
+++ b/README.md
@@ -0,0 +1,156 @@
+# OpenAITTS配音台
+
+一个简洁高效的文本转语音工具,支持自定义OpenAI API地址,基于Go后端和浏览器前端实现。
+
+## 功能特点
+
+- ✨ 简洁大方的用户界面
+- 🔧 支持自定义API地址
+- 🎤 多种声音模型选择
+- 📱 响应式设计,适配各种设备
+- 💾 本地存储API配置
+- ⚡ 快速生成和播放语音
+- 📥 支持音频下载
+
+## 技术栈
+
+- **后端**: Go 1.18+
+- **前端**: HTML5 + CSS3 + JavaScript (ES6+)
+- **API**: OpenAI TTS API
+
+## 配置说明
+
+### 1. 配置文件方式
+
+复制 `config.example.json` 为 `config.json`,然后修改其中的配置:
+
+```json
+{
+ "api_base_url": "https://your-api-address.com",
+ "port": "8080"
+}
+```
+
+### 2. 环境变量方式
+
+你也可以通过环境变量来配置:
+
+```bash
+export OPENAI_API_BASE_URL="https://your-api-address.com"
+export PORT="8080"
+```
+
+### 3. 前端直接配置
+
+在浏览器界面中,你可以直接输入API Key和API Base URL,这些配置会自动保存到浏览器本地存储中。
+
+## 快速开始
+
+### 前提条件
+
+- 已安装Go 1.18或更高版本
+- 有可用的OpenAI API Key
+- 自定义API地址(如果需要)
+
+### 启动服务
+
+在项目根目录下执行:
+
+```bash
+go run main.go
+```
+
+或者使用配置文件:
+
+```bash
+go run main.go -config=config.json
+```
+
+服务启动后,会在控制台输出:
+
+```
+2024/01/01 12:00:00 Server starting on port 8080...
+2024/01/01 12:00:00 Using API base URL: https://api.openai.com
+2024/01/01 12:00:00 Open http://localhost:8080 in your browser
+```
+
+### 使用方法
+
+1. 在浏览器中打开 `http://localhost:8080`
+2. 在API配置区域输入你的API Key和自定义API地址
+3. 在文本输入区域输入要转换为语音的文本
+4. 选择模型和声音参数
+5. 点击"生成语音"按钮
+6. 生成完成后,可以播放或下载音频
+
+## 支持的声音模型
+
+- **tts-1**: 标准模型,速度快
+- **tts-1-hd**: 高清模型,质量更高
+
+## 支持的声音类型
+
+- **alloy**: 清晰自然的声音
+- **echo**: 沉稳有力的声音
+- **fable**: 温暖亲切的声音
+- **onyx**: 成熟稳重的声音
+- **nova**: 活泼开朗的声音
+- **shimmer**: 柔美动听的声音
+
+## 项目结构
+
+```
+.
+├── main.go # Go后端主程序
+├── index.html # 前端HTML页面
+├── styles.css # 前端CSS样式
+├── app.js # 前端JavaScript
+├── config.example.json # 配置文件示例
+└── README.md # 项目说明文档
+```
+
+## API端点
+
+### POST /api/tts
+
+生成语音的API端点,请求体格式:
+
+```json
+{
+ "api_key": "sk-...",
+ "text": "要转换的文本",
+ "voice": "alloy",
+ "model": "tts-1-hd",
+ "api_base": "https://your-api-address.com"
+}
+```
+
+响应:
+- 成功:返回MP3音频文件(Content-Type: audio/mpeg)
+- 失败:返回JSON格式的错误信息
+
+## 注意事项
+
+1. 请确保你的API Key有效且有足够的配额
+2. 自定义API地址需要兼容OpenAI TTS API的接口格式
+3. 文本长度建议不超过4096个字符
+4. 生成的音频文件会自动下载到你的默认下载目录
+
+## 浏览器支持
+
+- Chrome 60+
+- Firefox 55+
+- Safari 12+
+- Edge 79+
+
+## 许可证
+
+MIT License
+
+## 贡献
+
+欢迎提交Issue和Pull Request!
+
+## 联系方式
+
+如有问题或建议,请通过GitHub Issues反馈。
diff --git a/app.js b/app.js
new file mode 100644
index 0000000..1cafae0
--- /dev/null
+++ b/app.js
@@ -0,0 +1,221 @@
+// DOM元素获取
+const apiKeyInput = document.getElementById('apiKey');
+const apiBaseInput = document.getElementById('apiBase');
+const textInput = document.getElementById('textInput');
+const charCount = document.getElementById('charCount');
+const modelSelect = document.getElementById('model');
+const voiceSelect = document.getElementById('voice');
+const generateBtn = document.getElementById('generateBtn');
+const playBtn = document.getElementById('playBtn');
+const downloadBtn = document.getElementById('downloadBtn');
+const statusDiv = document.getElementById('status');
+const audioPlayer = document.getElementById('audioPlayer');
+
+// 当前生成的音频URL
+let currentAudioURL = null;
+
+// 初始化函数
+function init() {
+ // 加载本地存储的配置
+ loadConfig();
+
+ // 绑定事件监听器
+ bindEventListeners();
+
+ // 初始状态更新
+ updateCharCount();
+}
+
+// 加载本地存储的配置
+function loadConfig() {
+ const savedAPIKey = localStorage.getItem('openai_api_key');
+ const savedAPIBase = localStorage.getItem('openai_api_base');
+
+ if (savedAPIKey) {
+ apiKeyInput.value = savedAPIKey;
+ }
+
+ if (savedAPIBase) {
+ apiBaseInput.value = savedAPIBase;
+ }
+}
+
+// 保存配置到本地存储
+function saveConfig() {
+ localStorage.setItem('openai_api_key', apiKeyInput.value);
+ localStorage.setItem('openai_api_base', apiBaseInput.value);
+}
+
+// 绑定事件监听器
+function bindEventListeners() {
+ // 文本输入事件 - 更新字符计数
+ textInput.addEventListener('input', updateCharCount);
+
+ // API配置输入事件 - 保存到本地存储
+ apiKeyInput.addEventListener('input', saveConfig);
+ apiBaseInput.addEventListener('input', saveConfig);
+
+ // 生成语音按钮点击事件
+ generateBtn.addEventListener('click', generateSpeech);
+
+ // 播放按钮点击事件
+ playBtn.addEventListener('click', togglePlay);
+
+ // 下载按钮点击事件
+ downloadBtn.addEventListener('click', downloadAudio);
+
+ // 音频播放结束事件
+ audioPlayer.addEventListener('ended', onAudioEnded);
+}
+
+// 更新字符计数
+function updateCharCount() {
+ const count = textInput.value.length;
+ charCount.textContent = count;
+}
+
+// 生成语音
+async function generateSpeech() {
+ // 验证输入
+ if (!validateInput()) {
+ return;
+ }
+
+ // 保存配置
+ saveConfig();
+
+ // 更新状态
+ setStatus('loading', '正在生成语音...');
+ disableControls();
+
+ try {
+ // 构建请求数据
+ const requestData = {
+ api_key: apiKeyInput.value,
+ text: textInput.value,
+ voice: voiceSelect.value,
+ model: modelSelect.value,
+ api_base: apiBaseInput.value
+ };
+
+ // 发送请求到后端API
+ const response = await fetch('/api/tts', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify(requestData)
+ });
+
+ if (!response.ok) {
+ const errorData = await response.json().catch(() => ({
+ error: `HTTP错误: ${response.status} ${response.statusText}`
+ }));
+ throw new Error(errorData.error || '生成语音失败');
+ }
+
+ // 获取音频Blob
+ const audioBlob = await response.blob();
+
+ // 创建音频URL
+ if (currentAudioURL) {
+ URL.revokeObjectURL(currentAudioURL);
+ }
+ currentAudioURL = URL.createObjectURL(audioBlob);
+
+ // 更新音频播放器
+ audioPlayer.src = currentAudioURL;
+
+ // 更新状态
+ setStatus('success', '语音生成成功!');
+ enableControls();
+
+ } catch (error) {
+ // 处理错误
+ setStatus('error', `生成失败: ${error.message}`);
+ disableControls();
+ }
+}
+
+// 验证输入
+function validateInput() {
+ if (!apiKeyInput.value.trim()) {
+ setStatus('error', '请输入API Key');
+ apiKeyInput.focus();
+ return false;
+ }
+
+ if (!textInput.value.trim()) {
+ setStatus('error', '请输入要转换的文本');
+ textInput.focus();
+ return false;
+ }
+
+ return true;
+}
+
+// 切换播放状态
+function togglePlay() {
+ if (audioPlayer.paused) {
+ audioPlayer.play();
+ playBtn.textContent = '暂停';
+ } else {
+ audioPlayer.pause();
+ playBtn.textContent = '播放';
+ }
+}
+
+// 音频播放结束
+function onAudioEnded() {
+ playBtn.textContent = '播放';
+}
+
+// 下载音频
+function downloadAudio() {
+ if (!currentAudioURL) {
+ return;
+ }
+
+ // 创建下载链接
+ const link = document.createElement('a');
+ link.href = currentAudioURL;
+ link.download = 'speech.mp3';
+
+ // 触发下载
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+}
+
+// 设置状态
+function setStatus(type, message) {
+ statusDiv.className = `status ${type}`;
+ statusDiv.textContent = message;
+
+ // 清除成功状态
+ if (type === 'success') {
+ setTimeout(() => {
+ statusDiv.className = 'status';
+ statusDiv.textContent = '';
+ }, 3000);
+ }
+}
+
+// 禁用控件
+function disableControls() {
+ generateBtn.disabled = true;
+ playBtn.disabled = true;
+ downloadBtn.disabled = true;
+ generateBtn.classList.add('loading');
+}
+
+// 启用控件
+function enableControls() {
+ generateBtn.disabled = false;
+ playBtn.disabled = false;
+ downloadBtn.disabled = false;
+ generateBtn.classList.remove('loading');
+}
+
+// 页面加载完成后初始化
+document.addEventListener('DOMContentLoaded', init);
diff --git a/config.example.json b/config.example.json
new file mode 100644
index 0000000..a03bb7a
--- /dev/null
+++ b/config.example.json
@@ -0,0 +1,4 @@
+{
+ "api_base_url": "https://api.openai.com",
+ "port": "8080"
+}
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..aae6241
--- /dev/null
+++ b/index.html
@@ -0,0 +1,94 @@
+
+
+
+
+
+ OpenAITTS配音台
+
+
+
+
+
+ OpenAITTS配音台
+ 简洁高效的文本转语音工具
+
+
+
+
+
+
+
+
+
+
+
+ 语音参数
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/main.go b/main.go
new file mode 100644
index 0000000..3117508
--- /dev/null
+++ b/main.go
@@ -0,0 +1,149 @@
+package main
+
+import (
+ "bytes"
+ "encoding/json"
+ "flag"
+ "io"
+ "log"
+ "net/http"
+ "os"
+ "strings"
+)
+
+// 配置结构体
+type Config struct {
+ APIBaseURL string `json:"api_base_url"`
+ Port string `json:"port"`
+}
+
+// 从配置文件加载配置
+func loadConfig() Config {
+ config := Config{
+ APIBaseURL: "https://api.openai.com", // 默认API地址
+ Port: "8080", // 默认端口
+ }
+
+ // 尝试读取配置文件
+ if _, err := os.Stat("config.json"); err == nil {
+ data, err := os.ReadFile("config.json")
+ if err == nil {
+ json.Unmarshal(data, &config)
+ }
+ }
+
+ // 环境变量覆盖配置文件
+ if envURL := os.Getenv("OPENAI_API_BASE_URL"); envURL != "" {
+ config.APIBaseURL = envURL
+ }
+ if envPort := os.Getenv("PORT"); envPort != "" {
+ config.Port = envPort
+ }
+
+ return config
+}
+
+// TTS请求结构体
+type TTSRequest struct {
+ APIKey string `json:"api_key"`
+ Text string `json:"text"`
+ Voice string `json:"voice"`
+ Model string `json:"model"`
+ APIBase string `json:"api_base"`
+}
+
+// OpenAI TTS API请求体
+type openAITTSRequest struct {
+ Model string `json:"model"`
+ Input string `json:"input"`
+ Voice string `json:"voice"`
+}
+
+func main() {
+ // 命令行参数
+ var configPath string
+ flag.StringVar(&configPath, "config", "config.json", "配置文件路径")
+ flag.Parse()
+
+ config := loadConfig()
+
+ // 静态文件服务
+ http.Handle("/", http.FileServer(http.Dir(".")))
+
+ // TTS API端点
+ http.HandleFunc("/api/tts", func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ // 解析请求体
+ var req TTSRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ http.Error(w, "Invalid request body", http.StatusBadRequest)
+ return
+ }
+
+ // 使用用户提供的API地址,如果没有则使用配置中的
+ apiBaseURL := config.APIBaseURL
+ if req.APIBase != "" {
+ apiBaseURL = req.APIBase
+ }
+
+ // 构建OpenAI TTS API请求
+ openAIReq := openAITTSRequest{
+ Model: req.Model,
+ Input: req.Text,
+ Voice: req.Voice,
+ }
+
+ reqBody, err := json.Marshal(openAIReq)
+ if err != nil {
+ http.Error(w, "Failed to marshal request", http.StatusInternalServerError)
+ return
+ }
+
+ // 创建HTTP客户端
+ client := &http.Client{}
+ openAIReq, err := http.NewRequest("POST", apiBaseURL+"/v1/audio/speech", bytes.NewBuffer(reqBody))
+ if err != nil {
+ http.Error(w, "Failed to create request", http.StatusInternalServerError)
+ return
+ }
+
+ // 设置请求头
+ openAIReq.Header.Set("Content-Type", "application/json")
+ openAIReq.Header.Set("Authorization", "Bearer "+req.APIKey)
+
+ // 发送请求
+ resp, err := client.Do(openAIReq)
+ if err != nil {
+ http.Error(w, "Failed to call OpenAI API: "+err.Error(), http.StatusInternalServerError)
+ return
+ }
+ defer resp.Body.Close()
+
+ // 检查响应状态
+ if resp.StatusCode != http.StatusOK {
+ errBody, _ := io.ReadAll(resp.Body)
+ http.Error(w, "OpenAI API error: "+string(errBody), resp.StatusCode)
+ return
+ }
+
+ // 设置响应头
+ w.Header().Set("Content-Type", "audio/mpeg")
+ w.Header().Set("Content-Disposition", "attachment; filename=speech.mp3")
+
+ // 将响应体写入客户端
+ io.Copy(w, resp.Body)
+ })
+
+ log.Printf("Server starting on port %s...", config.Port)
+ log.Printf("Using API base URL: %s", config.APIBaseURL)
+ log.Printf("Open http://localhost:%s in your browser", config.Port)
+
+ // 启动服务器
+ if err := http.ListenAndServe(":"+config.Port, nil); err != nil {
+ log.Fatal("Server failed to start: ", err)
+ }
+}
diff --git a/styles.css b/styles.css
new file mode 100644
index 0000000..a59b4f8
--- /dev/null
+++ b/styles.css
@@ -0,0 +1,344 @@
+/* 基础样式重置 */
+* {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+}
+
+/* 全局样式 */
+body {
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
+ line-height: 1.6;
+ color: #333;
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+ min-height: 100vh;
+}
+
+/* 容器样式 */
+.container {
+ max-width: 800px;
+ margin: 0 auto;
+ padding: 20px;
+ background: rgba(255, 255, 255, 0.95);
+ border-radius: 16px;
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
+ backdrop-filter: blur(10px);
+ -webkit-backdrop-filter: blur(10px);
+ margin-top: 20px;
+ margin-bottom: 20px;
+}
+
+/* 头部样式 */
+header {
+ text-align: center;
+ margin-bottom: 30px;
+ padding-bottom: 20px;
+ border-bottom: 2px solid #e0e0e0;
+}
+
+header h1 {
+ color: #2d3748;
+ font-size: 2.5rem;
+ font-weight: 700;
+ margin-bottom: 10px;
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+ -webkit-background-clip: text;
+ -webkit-text-fill-color: transparent;
+ background-clip: text;
+}
+
+.subtitle {
+ color: #718096;
+ font-size: 1.1rem;
+ font-weight: 400;
+}
+
+/* 主要内容区域 */
+main {
+ display: flex;
+ flex-direction: column;
+ gap: 25px;
+}
+
+/* 各部分通用样式 */
+section {
+ background: #f8fafc;
+ padding: 25px;
+ border-radius: 12px;
+ border: 1px solid #e2e8f0;
+ transition: all 0.3s ease;
+}
+
+section:hover {
+ transform: translateY(-2px);
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
+}
+
+section h2 {
+ color: #2d3748;
+ font-size: 1.5rem;
+ font-weight: 600;
+ margin-bottom: 20px;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+section h2::before {
+ content: '';
+ width: 4px;
+ height: 24px;
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+ border-radius: 2px;
+}
+
+/* 配置网格布局 */
+.config-grid,
+.params-grid,
+.controls-grid {
+ display: grid;
+ grid-template-columns: 1fr;
+ gap: 20px;
+}
+
+@media (min-width: 600px) {
+ .config-grid,
+ .params-grid,
+ .controls-grid {
+ grid-template-columns: repeat(2, 1fr);
+ }
+}
+
+/* 表单组样式 */
+.form-group {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.form-group label {
+ font-weight: 500;
+ color: #4a5568;
+ font-size: 0.95rem;
+}
+
+/* 表单输入样式 */
+.form-input,
+.form-select {
+ padding: 12px 16px;
+ border: 2px solid #e2e8f0;
+ border-radius: 8px;
+ font-size: 1rem;
+ transition: all 0.3s ease;
+ background: white;
+ width: 100%;
+}
+
+.form-input:focus,
+.form-select:focus {
+ outline: none;
+ border-color: #667eea;
+ box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
+}
+
+.form-input::placeholder {
+ color: #a0aec0;
+}
+
+/* 文本输入区域 */
+.text-input-container {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+
+#textInput {
+ padding: 16px;
+ border: 2px solid #e2e8f0;
+ border-radius: 8px;
+ font-size: 1rem;
+ font-family: inherit;
+ resize: vertical;
+ min-height: 150px;
+ transition: all 0.3s ease;
+ background: white;
+}
+
+#textInput:focus {
+ outline: none;
+ border-color: #667eea;
+ box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
+}
+
+.text-info {
+ text-align: right;
+ color: #718096;
+ font-size: 0.9rem;
+}
+
+/* 按钮样式 */
+.btn {
+ padding: 12px 24px;
+ border: none;
+ border-radius: 8px;
+ font-size: 1rem;
+ font-weight: 600;
+ cursor: pointer;
+ transition: all 0.3s ease;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ text-decoration: none;
+}
+
+.btn-primary {
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+ color: white;
+}
+
+.btn-primary:hover:not(:disabled) {
+ transform: translateY(-2px);
+ box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
+}
+
+.btn-secondary {
+ background: #e2e8f0;
+ color: #4a5568;
+}
+
+.btn-secondary:hover:not(:disabled) {
+ background: #cbd5e0;
+ transform: translateY(-2px);
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
+}
+
+.btn:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+ transform: none;
+}
+
+/* 控制区域 */
+.controls-section {
+ display: flex;
+ flex-direction: column;
+ gap: 15px;
+}
+
+.status {
+ padding: 12px 16px;
+ border-radius: 8px;
+ font-size: 0.95rem;
+ font-weight: 500;
+ text-align: center;
+ min-height: 44px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.status.success {
+ background: #c6f6d5;
+ color: #22543d;
+ border: 1px solid #9ae6b4;
+}
+
+.status.error {
+ background: #fed7d7;
+ color: #742a2a;
+ border: 1px solid #feb2b2;
+}
+
+.status.loading {
+ background: #ebf8ff;
+ color: #2a4365;
+ border: 1px solid #bee3f8;
+}
+
+/* 音频播放区域 */
+.audio-player-container {
+ display: flex;
+ justify-content: center;
+ padding: 20px 0;
+}
+
+.audio-player {
+ width: 100%;
+ max-width: 100%;
+ height: 50px;
+ background: white;
+ border-radius: 25px;
+ border: 2px solid #e2e8f0;
+ transition: all 0.3s ease;
+}
+
+.audio-player:focus {
+ outline: none;
+ border-color: #667eea;
+ box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
+}
+
+/* 页脚样式 */
+footer {
+ text-align: center;
+ margin-top: 30px;
+ padding-top: 20px;
+ border-top: 2px solid #e0e0e0;
+ color: #718096;
+ font-size: 0.9rem;
+}
+
+/* 响应式设计 */
+@media (max-width: 600px) {
+ .container {
+ margin: 10px;
+ padding: 15px;
+ }
+
+ header h1 {
+ font-size: 2rem;
+ }
+
+ section {
+ padding: 20px;
+ }
+
+ .btn {
+ padding: 10px 20px;
+ font-size: 0.95rem;
+ }
+}
+
+/* 动画效果 */
+@keyframes fadeIn {
+ from {
+ opacity: 0;
+ transform: translateY(10px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+section {
+ animation: fadeIn 0.5s ease-out;
+}
+
+/* 加载动画 */
+.loading::after {
+ content: '';
+ display: inline-block;
+ width: 16px;
+ height: 16px;
+ border: 2px solid rgba(255, 255, 255, 0.3);
+ border-radius: 50%;
+ border-top-color: #fff;
+ animation: spin 1s ease-in-out infinite;
+ margin-left: 10px;
+}
+
+@keyframes spin {
+ to { transform: rotate(360deg); }
+}