feat: 新增 AI 口语对话页面及路由配置

This commit is contained in:
cc 2026-03-19 19:18:44 +08:00
parent e2df374367
commit 15d22bb7d1
5 changed files with 966 additions and 1 deletions

View File

@ -0,0 +1,168 @@
---
name: ai-speaking-dialog
overview: 新建 AI 口语对话页面 SpeakingDialog.vue复用豆包 TTS API同 Pronunciation.vue实现用户输入文字/语音 → AI 外教回复 → TTS 朗读的完整对话流程,并在首页和路由中注册入口。
design:
architecture:
framework: vue
styleKeywords:
- Glassmorphism
- 深色背景
- 紫蓝渐变
- 聊天气泡
- 毛玻璃面板
- 微动效
fontSystem:
fontFamily: PingFang SC
heading:
size: 1.5rem
weight: 600
subheading:
size: 1rem
weight: 600
body:
size: 1rem
weight: 400
colorSystem:
primary:
- "#6366f1"
- "#8b5cf6"
background:
- var(--card-bg)
- rgba(0,0,0,0.2)
text:
- var(--text-primary)
- var(--text-secondary)
functional:
- "#22c55e"
- "#ef4444"
- "#f59e0b"
todos:
- id: config-proxy-and-router
content: 在 vite.config.js 中新增 /ark-api 代理,在 router/index.js 中注册 /speaking 路由,在 HomePage.vue 中将 card-2 的 route 改为 /speaking
status: completed
- id: create-speaking-view
content: 新建 src/views/Speaking.vue实现完整的 AI 口语对话页面场景选择、音色选择、聊天气泡布局、Ark API 调用与降级模板、TTS 自动朗读与重播
status: completed
dependencies:
- config-proxy-and-router
---
## 用户需求
基于现有项目的豆包 TTS API新建一个 AI 口语对话页面,实现与 AI 英语外教的实时对话练习功能。
## 产品概述
页面采用聊天气泡布局用户在底部输入框输入英语文本AI 外教自动回复并通过豆包 TTS 合成语音播放。页面风格与现有项目保持一致(深色玻璃拟态风格),并接入首页 card-2 入口。
## 核心功能
- **对话界面**聊天气泡布局用户消息靠右蓝紫色AI 消息靠左(带头像),支持滚动历史记录
- **AI 回复**:调用字节跳动 Ark 大模型 API`/ark-api/api/v3/chat/completions`)生成英语对话回复;未配置 API Key 时降级为预设模板回复
- **TTS 朗读**AI 每条回复自动调用豆包 TTS 合成语音播放,消息旁提供重播按钮,播放时显示动态音波动画
- **音色选择**顶部可选英语音色Dacey 美音、Tim 美音、Tina 英音等)
- **场景选择**:提供日常对话、面试练习、旅游英语、购物场景等预设场景,切换场景重置对话并注入对应系统提示词
- **路由与入口**:注册 `/speaking` 路由,首页 card-2 的 `route` 改为 `/speaking`
## 技术栈
- Vue 3 Composition API`<script setup>`+ Vite
- Vue Router 4复用现有路由配置模式
- 原生 CSSscoped复用现有 CSS 变量(`--card-bg`、`--accent-1` 等)
- 豆包 TTS V3 API已有代理 `/tts-api`
- 字节跳动 Ark Chat API新增代理 `/ark-api`
## 实现方案
### AI 对话回复
调用 Ark API`POST /ark-api/api/v3/chat/completions`),携带系统提示词(根据场景动态注入)和对话历史,模型使用 `doubao-pro-4k`。用户需在页面顶部配置 `ARK_API_KEY`;若为空则使用预设英语回复模板降级,保证页面可用性。
### TTS 朗读
复用 Pronunciation.vue 中已验证的流式 Chunked JSON 解析逻辑AI 消息生成后自动触发 TTS 合成并播放。每条 AI 消息独立维护播放状态,支持重播,同一时刻只允许一个音频实例播放。
### 对话历史管理
维护 `messages` 数组(`{ role, content, audioUrl, isPlaying, isLoading }`AI 回复时先插入 loading 占位气泡,完成后替换内容并触发 TTS避免界面跳动。
### 性能考量
- TTS 音频 Blob URL 在组件卸载时统一 `revokeObjectURL`,防止内存泄漏
- 对话历史最多保留最近 20 条发送给 API避免 token 超限
- 消息新增后自动滚动到底部(`nextTick` + `scrollIntoView`
## 架构设计
```mermaid
graph TD
A[用户输入文本] --> B[发送消息]
B --> C{ARK_API_KEY 是否配置?}
C -- 是 --> D[调用 Ark Chat API]
C -- 否 --> E[预设模板回复]
D --> F[AI 回复文本]
E --> F
F --> G[插入 AI 消息气泡]
G --> H[调用豆包 TTS API]
H --> I[流式解析 Chunked JSON]
I --> J[合并音频 Blob 自动播放]
J --> K[消息旁显示重播按钮]
```
## 目录结构
```
src/
├── views/
│ ├── HomePage.vue # [MODIFY] card-2 的 route: null 改为 route: '/speaking'
│ ├── Pronunciation.vue # [不变]
│ └── Speaking.vue # [NEW] AI 口语对话页面主组件
│ # 包含:场景选择栏、音色选择、聊天气泡列表、底部输入框
│ # 实现Ark API 调用、TTS 合成复用逻辑、消息状态管理、音频生命周期管理
└── router/
└── index.js # [MODIFY] 新增 /speaking 路由指向 Speaking.vue
vite.config.js # [MODIFY] 新增 /ark-api 代理指向 https://ark.cn-beijing.volces.com
```
## 关键接口定义
```js
// 消息数据结构
{
id: Number, // 唯一标识
role: 'user' | 'assistant',
content: String, // 消息文本
audioUrl: String | null, // TTS 生成的 Blob URL
isPlaying: Boolean,
isLoading: Boolean // AI 回复生成中占位
}
// 场景配置
{
id: String,
name: String, // 显示名称
systemPrompt: String // 注入给 Ark API 的系统提示词
}
```
## 设计风格
延续现有项目的深色玻璃拟态Glassmorphism风格与 Pronunciation.vue 视觉语言保持一致。整体背景深色,面板使用 `--card-bg` 半透明毛玻璃效果,强调色使用紫蓝渐变(`--accent-1` / `#8b5cf6`)。
## 页面布局
### 顶部导航栏
返回按钮(左)+ 页面标题"AI 口语对话"(居中)+ 音色选择下拉(右)。与 Pronunciation.vue 导航栏结构一致。
### 场景选择栏
横向滚动 Tab 条,提供"日常对话 / 面试练习 / 旅游英语 / 购物场景 / 自由练习"5 个场景,选中态使用 accent 色高亮,切换时重置对话。
### 聊天消息区域
占据页面主体垂直滚动。AI 消息靠左(头像 + 气泡用户消息靠右气泡。AI 气泡下方显示播放/重播按钮及音波动画。Loading 状态显示三点跳动动画。
### 底部输入区域
固定在底部,输入框 + 发送按钮。发送中禁用输入,按钮显示 loading 状态。支持 Enter 发送Shift+Enter 换行)。

View File

@ -1,6 +1,7 @@
import { createRouter, createWebHistory } from 'vue-router'
import Home from '../views/HomePage.vue'
import Pronunciation from '../views/Pronunciation.vue'
import Speaking from '../views/Speaking.vue'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
@ -14,6 +15,11 @@ const router = createRouter({
path: '/pronunciation',
name: 'pronunciation',
component: Pronunciation
},
{
path: '/speaking',
name: 'speaking',
component: Speaking
}
]
})

View File

@ -18,7 +18,7 @@ const features = ref([
desc: '全天候1对1外教级对练实时纠正发音与语法快速提升口语表达。',
class: 'card-2',
icon: 'mic',
route: null
route: '/speaking'
},
{
id: 3,

786
src/views/Speaking.vue Normal file
View File

@ -0,0 +1,786 @@
<script setup>
import { ref, computed, nextTick, onUnmounted } from "vue";
import { useRouter } from "vue-router";
// ==========================================
// TTS API Pronunciation.vue
// ==========================================
const DOUBAO_APP_ID = "2542859186";
const DOUBAO_ACCESS_TOKEN = "a4h5fT3cVlBi82u93iEQlqT3c4MP6_8V";
const DOUBAO_RESOURCE_ID = "seed-tts-2.0";
// ==========================================
// Ark API
// Ark API Key使
// ==========================================
const ARK_API_KEY = "";
const ARK_MODEL = "doubao-pro-4k";
const router = useRouter();
//
const scenes = [
{
id: "school",
name: "校园生活",
emoji: "🏫",
greeting: "Hi there! I'm your English buddy. Let's chat about school life — classes, teachers, friends, or anything that happens at school. It's a great way to practice everyday English! So, what subject do you like most at school?",
systemPrompt:
"You are a friendly English tutor for middle and high school students. Chat about school life topics like classes, homework, teachers, friends, and campus activities. Use simple, clear English suitable for teenagers. Keep responses to 2-3 sentences. Gently correct grammar mistakes and be encouraging.",
},
{
id: "hobby",
name: "兴趣爱好",
emoji: "🎮",
greeting: "Hey! I'd love to know more about you. Let's talk about your hobbies and interests — games, sports, music, drawing, reading... anything you enjoy! What do you like to do in your free time?",
systemPrompt:
"You are a friendly English tutor for middle and high school students. Discuss hobbies and interests like games, sports, music, art, and reading. Use casual, teen-friendly English. Keep responses to 2-3 sentences. Encourage the student to express themselves and gently fix any errors.",
},
{
id: "exam",
name: "英语考试",
emoji: "📝",
greeting: "Hello! Let's get ready for your English exam. I can help you practice speaking topics that often appear in school exams — like describing pictures, giving opinions, or talking about your daily routine. Let's start: Can you describe what you did last weekend?",
systemPrompt:
"You are an English exam coach for middle and high school students. Practice common exam speaking tasks: describing pictures, expressing opinions, talking about daily life, and answering topic questions. Use language appropriate for school exams. Give brief feedback after each response. Keep your replies concise.",
},
{
id: "movie",
name: "影视讨论",
emoji: "🎬",
greeting: "Lights, camera, English! Let's talk about movies, TV shows, or cartoons you enjoy. Discussing stories and characters is a super fun way to improve your English. Have you watched any good movies or shows recently?",
systemPrompt:
"You are a fun English tutor for teenagers. Discuss movies, TV shows, animations, and pop culture. Use engaging, youth-friendly language. Ask about plots, characters, and opinions. Keep responses to 2-3 sentences and gently correct any grammar mistakes.",
},
{
id: "travel",
name: "旅游英语",
emoji: "✈️",
greeting: "Welcome, young traveler! Imagine you're on a trip abroad — you might need to ask for directions, order food, or buy souvenirs. Let's practice! Pretend you just arrived in London. What's the first thing you'd like to do?",
systemPrompt:
"You are a helpful travel guide for teenage students. Role-play travel scenarios suitable for young learners: asking for directions, ordering at a restaurant, buying tickets, and checking into a hotel. Use simple and practical English. Keep responses short and easy to follow.",
},
];
//
const voiceOptions = [
{ id: "en_female_dacey_uranus_bigtts", name: "Dacey (美音女)", avatar: "👩‍🏫" },
{ id: "en_male_tim_uranus_bigtts", name: "Tim (美音男)", avatar: "👨‍🏫" },
{ id: "en_female_stokie_uranus_bigtts", name: "Stokie (美音女)", avatar: "👩‍💼" },
{ id: "zh_female_yingyujiaoxue_uranus_bigtts", name: "Tina (英音女)", avatar: "👩‍🎓" },
];
const currentVoiceAvatar = computed(
() => voiceOptions.find((v) => v.id === selectedVoice.value)?.avatar ?? "🤖"
);
//
const fallbackReplies = {
school: [
"That's interesting! Which teacher do you like the most and why? Talking about your favorite teacher is great English practice!",
"I see! Do you have a lot of homework these days? Tell me about your daily school routine.",
"Cool! What clubs or activities do you join at school? Extracurricular activities are a big part of school life.",
"Great answer! By the way, do you prefer studying alone or with friends? Both have their advantages!",
"Nice! Is there any subject you find really difficult? Don't worry — everyone has their challenges at school.",
],
hobby: [
"That sounds really fun! How long have you been doing that hobby? Practice makes perfect!",
"Awesome! Do you enjoy doing that with friends or by yourself? Hobbies are a great way to relax.",
"Cool choice! Have you ever joined a club or competition related to your hobby? It could be a great experience!",
"I love that! What got you started with that hobby in the first place? There's always an interesting story behind it.",
"Great! Do you think your hobby helps you in any way at school or in life? Many hobbies build useful skills!",
],
exam: [
"Good effort! Try to use more connecting words like 'firstly', 'however', and 'in conclusion' to make your answer sound more organized.",
"Nice try! Remember to give specific examples when answering opinion questions — it makes your answer much stronger.",
"Well done! One tip: speak at a steady pace and don't rush. Clear pronunciation matters more than speed in exams.",
"That's a solid answer! Try expanding it a little — add one more detail or reason to make it more complete.",
"Good job! In English exams, it helps to restate the question in your answer. For example, start with 'I think that...' or 'In my opinion...'",
],
movie: [
"Oh, that sounds like a great movie! Who is your favorite character in it and why? Describing characters is excellent English practice.",
"Interesting pick! What kind of movies do you usually enjoy — action, comedy, romance, or something else?",
"Nice! If you could change one thing about the ending of that movie, what would it be? Use your imagination!",
"Cool! Have you watched any English movies without subtitles? It's a great way to improve your listening skills.",
"Great taste! Would you recommend that movie to a friend? Try to describe it in 2-3 sentences — like a mini review!",
],
travel: [
"Great choice! When visiting a new city, it helps to say: 'Excuse me, could you recommend any must-see attractions nearby?'",
"Nice! At a restaurant abroad, you can order by saying: 'I'd like to have the pasta, please. Could I also get some water?'",
"Good thinking! If you get lost, try: 'Excuse me, I'm looking for the train station. Could you point me in the right direction?'",
"Awesome! When buying souvenirs, you can ask: 'How much does this cost? Do you have any discounts?'",
"Well done! Traveling is the best way to practice real English. Remember: a smile and 'please' and 'thank you' go a long way!",
],
};
const activeScene = ref("school");
const selectedVoice = ref("en_female_dacey_uranus_bigtts");
const messages = ref([]);
const inputText = ref("");
const isSending = ref(false);
const messagesContainer = ref(null);
let msgIdCounter = 0;
let currentAudioInstance = null;
const blobUrls = [];
const currentScene = computed(() => scenes.find((s) => s.id === activeScene.value));
//
const sendGreeting = async (scene) => {
isSending.value = true;
const greetingId = ++msgIdCounter;
const greetingMsg = { id: greetingId, role: "assistant", content: "", audioUrl: null, isPlaying: false, isLoading: true };
messages.value.push(greetingMsg);
await scrollToBottom();
// ""
await new Promise((r) => setTimeout(r, 500));
const msg = messages.value.find((m) => m.id === greetingId);
if (msg) { msg.content = scene.greeting; msg.isLoading = false; }
await scrollToBottom();
try {
await synthesizeAndPlay(scene.greeting, greetingId);
} catch (e) {
console.error("Greeting TTS error:", e);
} finally {
isSending.value = false;
}
};
//
const switchScene = (sceneId) => {
if (activeScene.value === sceneId) return;
activeScene.value = sceneId;
//
if (currentAudioInstance) {
currentAudioInstance.pause();
currentAudioInstance = null;
}
messages.value = [];
const scene = scenes.find((s) => s.id === sceneId);
if (scene) sendGreeting(scene);
};
//
const scrollToBottom = async () => {
await nextTick();
if (messagesContainer.value) {
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight;
}
};
// TTS
const synthesizeAndPlay = async (text, msgId) => {
const payload = {
user: { uid: "speaking_" + Date.now() },
req_params: {
text,
speaker: selectedVoice.value,
audio_params: { format: "mp3", sample_rate: 24000 },
},
};
const response = await fetch("/tts-api/api/v3/tts/unidirectional", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Api-App-Id": DOUBAO_APP_ID,
"X-Api-Access-Key": DOUBAO_ACCESS_TOKEN,
"X-Api-Resource-Id": DOUBAO_RESOURCE_ID,
"X-Api-Request-Id": crypto.randomUUID(),
},
body: JSON.stringify(payload),
});
if (!response.ok) throw new Error(`TTS HTTP ${response.status}`);
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
const audioChunks = [];
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop();
for (const line of lines) {
if (!line.trim()) continue;
try {
const resData = JSON.parse(line);
if (resData.code === 0 && resData.data) {
const bin = window.atob(resData.data);
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
audioChunks.push(bytes);
}
} catch (e) {}
}
}
if (buffer.trim()) {
try {
const resData = JSON.parse(buffer);
if (resData.code === 0 && resData.data) {
const bin = window.atob(resData.data);
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
audioChunks.push(bytes);
}
} catch (e) {}
}
if (audioChunks.length === 0) return;
const totalLen = audioChunks.reduce((a, v) => a + v.length, 0);
const allBytes = new Uint8Array(totalLen);
let offset = 0;
for (const chunk of audioChunks) { allBytes.set(chunk, offset); offset += chunk.length; }
const blob = new Blob([allBytes], { type: "audio/mp3" });
const url = URL.createObjectURL(blob);
blobUrls.push(url);
// audioUrl
const msg = messages.value.find((m) => m.id === msgId);
if (msg) msg.audioUrl = url;
playAudio(url, msgId);
};
//
const playAudio = (url, msgId) => {
if (currentAudioInstance) {
currentAudioInstance.pause();
currentAudioInstance = null;
}
//
messages.value.forEach((m) => (m.isPlaying = false));
const audio = new Audio(url);
currentAudioInstance = audio;
const msg = messages.value.find((m) => m.id === msgId);
if (msg) msg.isPlaying = true;
audio.onended = () => {
if (msg) msg.isPlaying = false;
if (currentAudioInstance === audio) currentAudioInstance = null;
};
audio.onerror = () => {
if (msg) msg.isPlaying = false;
};
audio.play();
};
//
const replayMessage = (msg) => {
if (!msg.audioUrl) return;
if (msg.isPlaying) {
if (currentAudioInstance) { currentAudioInstance.pause(); currentAudioInstance = null; }
msg.isPlaying = false;
return;
}
playAudio(msg.audioUrl, msg.id);
};
// Ark
const callArkAPI = async (history) => {
const response = await fetch("/ark-api/api/v3/chat/completions", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${ARK_API_KEY}`,
},
body: JSON.stringify({
model: ARK_MODEL,
messages: [
{ role: "system", content: currentScene.value.systemPrompt },
...history,
],
max_tokens: 200,
}),
});
if (!response.ok) throw new Error(`Ark HTTP ${response.status}`);
const data = await response.json();
return data.choices[0].message.content.trim();
};
//
const getFallbackReply = () => {
const replies = fallbackReplies[activeScene.value] || fallbackReplies.free;
return replies[Math.floor(Math.random() * replies.length)];
};
//
const sendMessage = async () => {
const text = inputText.value.trim();
if (!text || isSending.value) return;
inputText.value = "";
isSending.value = true;
//
const userMsg = { id: ++msgIdCounter, role: "user", content: text, audioUrl: null, isPlaying: false, isLoading: false };
messages.value.push(userMsg);
await scrollToBottom();
// AI loading
const aiMsgId = ++msgIdCounter;
const aiMsg = { id: aiMsgId, role: "assistant", content: "", audioUrl: null, isPlaying: false, isLoading: true };
messages.value.push(aiMsg);
await scrollToBottom();
try {
// 20
const history = messages.value
.filter((m) => !m.isLoading && m.content)
.slice(-20)
.map((m) => ({ role: m.role, content: m.content }));
let replyText = "";
if (ARK_API_KEY) {
replyText = await callArkAPI(history.slice(0, -1).concat([{ role: "user", content: text }]));
} else {
//
await new Promise((r) => setTimeout(r, 800));
replyText = getFallbackReply();
}
// AI
const msg = messages.value.find((m) => m.id === aiMsgId);
if (msg) { msg.content = replyText; msg.isLoading = false; }
await scrollToBottom();
// TTS
await synthesizeAndPlay(replyText, aiMsgId);
} catch (err) {
console.error("Speaking error:", err);
const msg = messages.value.find((m) => m.id === aiMsgId);
if (msg) { msg.content = "Sorry, something went wrong. Please try again."; msg.isLoading = false; }
} finally {
isSending.value = false;
await scrollToBottom();
}
};
const handleKeydown = (e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
};
onUnmounted(() => {
if (currentAudioInstance) { currentAudioInstance.pause(); currentAudioInstance = null; }
blobUrls.forEach((url) => URL.revokeObjectURL(url));
});
</script>
<template>
<div class="page-container">
<!-- 顶部导航 -->
<nav class="nav-bar">
<button @click="router.push('/')" class="back-btn">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18" />
</svg>
返回首页
</button>
<div class="nav-title">AI 口语对话</div>
<div class="voice-select-wrap">
<select v-model="selectedVoice" class="voice-select">
<option v-for="v in voiceOptions" :key="v.id" :value="v.id">{{ v.name }}</option>
</select>
</div>
</nav>
<!-- 场景选择 -->
<div class="scene-tabs">
<button
v-for="scene in scenes"
:key="scene.id"
class="scene-tab"
:class="{ active: activeScene === scene.id }"
@click="switchScene(scene.id)"
>
<span class="scene-emoji">{{ scene.emoji }}</span>
{{ scene.name }}
</button>
</div>
<!-- 聊天区域 -->
<div class="chat-area" ref="messagesContainer">
<!-- 欢迎提示 -->
<div v-if="messages.length === 0" class="welcome-hint">
<div class="welcome-icon">{{ currentScene.emoji }}</div>
<h3>{{ currentScene.name }}</h3>
<p>开始输入 AI 外教展开英语对话练习吧</p>
</div>
<!-- 消息列表 -->
<div v-for="msg in messages" :key="msg.id" class="message-row" :class="msg.role">
<!-- AI 头像 -->
<div v-if="msg.role === 'assistant'" class="avatar">{{ currentVoiceAvatar }}</div>
<div class="bubble-wrap">
<!-- 气泡 -->
<div class="bubble" :class="msg.role">
<!-- loading 动画 -->
<div v-if="msg.isLoading" class="loading-dots">
<span></span><span></span><span></span>
</div>
<span v-else>{{ msg.content }}</span>
</div>
<!-- AI 消息播放按钮 -->
<div v-if="msg.role === 'assistant' && !msg.isLoading && msg.content" class="audio-controls">
<button class="play-btn" :class="{ playing: msg.isPlaying }" @click="replayMessage(msg)" :title="msg.isPlaying ? '暂停' : '播放'">
<!-- 播放中音波动画 -->
<div v-if="msg.isPlaying" class="wave-bars">
<span></span><span></span><span></span><span></span>
</div>
<!-- 未播放播放图标 -->
<svg v-else xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.347a1.125 1.125 0 0 1 0 1.972l-11.54 6.347a1.125 1.125 0 0 1-1.667-.986V5.653Z" />
</svg>
</button>
<span v-if="!msg.audioUrl && !msg.isLoading" class="tts-hint">合成中...</span>
</div>
</div>
<!-- 用户头像 -->
<div v-if="msg.role === 'user'" class="avatar user-avatar">🧑💻</div>
</div>
</div>
<!-- 底部输入区 -->
<div class="input-area">
<textarea
v-model="inputText"
class="input-box"
placeholder="输入英语内容,按 Enter 发送Shift+Enter 换行..."
:disabled="isSending"
@keydown="handleKeydown"
rows="1"
></textarea>
<button class="send-btn" :disabled="!inputText.trim() || isSending" @click="sendMessage">
<svg v-if="!isSending" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 12 3.269 3.125A59.769 59.769 0 0 1 21.485 12 59.768 59.768 0 0 1 3.27 20.875L5.999 12Zm0 0h7.5" />
</svg>
<div v-else class="send-spinner"></div>
</button>
</div>
</div>
</template>
<style scoped>
* { box-sizing: border-box; }
.page-container {
max-width: 900px;
margin: 0 auto;
padding: 2rem 2rem 0;
height: 100vh;
display: flex;
flex-direction: column;
}
/* Nav */
.nav-bar {
display: flex;
align-items: center;
margin-bottom: 1.25rem;
gap: 1rem;
}
.back-btn {
display: flex;
align-items: center;
gap: 0.5rem;
background: none;
border: none;
color: var(--text-secondary);
font-size: 1rem;
cursor: pointer;
padding: 0.5rem 1rem;
border-radius: 8px;
transition: all 0.2s;
white-space: nowrap;
}
.back-btn:hover { background: var(--card-bg); color: var(--text-primary); }
.back-btn svg { width: 20px; height: 20px; }
.nav-title {
flex: 1;
text-align: center;
font-size: 1.5rem;
font-weight: 600;
}
.voice-select-wrap { min-width: 180px; }
.voice-select {
appearance: none;
-webkit-appearance: none;
background: var(--card-bg) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23a0a0b8' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E") no-repeat right 0.6rem center;
border: 1px solid var(--card-border);
color: var(--text-primary);
padding: 0.4rem 2rem 0.4rem 0.75rem;
border-radius: 8px;
font-size: 0.875rem;
cursor: pointer;
outline: none;
width: 100%;
transition: border-color 0.2s;
}
.voice-select:hover { border-color: var(--accent-1); }
.voice-select option {
background: #1e1e2e;
color: var(--text-primary);
}
/* Scene Tabs */
.scene-tabs {
display: flex;
gap: 0.5rem;
margin-bottom: 1.25rem;
overflow-x: auto;
padding-bottom: 0.25rem;
}
.scene-tabs::-webkit-scrollbar { height: 4px; }
.scene-tabs::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.1); border-radius: 2px; }
.scene-tab {
display: flex;
align-items: center;
gap: 0.4rem;
background: rgba(255,255,255,0.05);
border: 1px solid transparent;
color: var(--text-secondary);
padding: 0.5rem 1rem;
border-radius: 20px;
cursor: pointer;
white-space: nowrap;
font-size: 0.9rem;
transition: all 0.2s;
}
.scene-tab:hover { background: rgba(255,255,255,0.1); color: var(--text-primary); }
.scene-tab.active {
background: rgba(99,102,241,0.15);
border-color: rgba(99,102,241,0.4);
color: var(--accent-1);
}
.scene-emoji { font-size: 1rem; }
/* Chat Area */
.chat-area {
flex: 1;
overflow-y: auto;
padding: 1rem 0.5rem;
display: flex;
flex-direction: column;
gap: 1.25rem;
min-height: 0;
}
.chat-area::-webkit-scrollbar { width: 6px; }
.chat-area::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.1); border-radius: 3px; }
/* Welcome */
.welcome-hint {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex: 1;
gap: 0.75rem;
color: var(--text-secondary);
text-align: center;
padding: 3rem 0;
}
.welcome-icon { font-size: 3.5rem; }
.welcome-hint h3 { margin: 0; font-size: 1.25rem; color: var(--text-primary); }
.welcome-hint p { margin: 0; font-size: 0.95rem; }
/* Message Row */
.message-row {
display: flex;
align-items: flex-end;
gap: 0.75rem;
}
.message-row.user { flex-direction: row-reverse; }
.avatar {
width: 38px;
height: 38px;
border-radius: 50%;
background: rgba(99,102,241,0.15);
border: 1px solid rgba(99,102,241,0.3);
display: flex;
align-items: center;
justify-content: center;
font-size: 1.2rem;
flex-shrink: 0;
}
.user-avatar { background: rgba(139,92,246,0.15); border-color: rgba(139,92,246,0.3); }
.bubble-wrap {
display: flex;
flex-direction: column;
gap: 0.4rem;
max-width: 70%;
}
.message-row.user .bubble-wrap { align-items: flex-end; }
.bubble {
padding: 0.875rem 1.125rem;
border-radius: 18px;
font-size: 1rem;
line-height: 1.6;
word-break: break-word;
animation: bubbleIn 0.25s ease-out;
}
.bubble.assistant {
background: var(--card-bg);
border: 1px solid var(--card-border);
border-bottom-left-radius: 4px;
color: var(--text-primary);
}
.bubble.user {
background: linear-gradient(135deg, var(--accent-1), #7c3aed);
border-bottom-right-radius: 4px;
color: white;
}
@keyframes bubbleIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
/* Loading dots */
.loading-dots {
display: flex;
gap: 5px;
align-items: center;
padding: 0.25rem 0;
}
.loading-dots span {
width: 8px; height: 8px;
background: var(--text-secondary);
border-radius: 50%;
animation: dotBounce 1.2s infinite ease-in-out;
}
.loading-dots span:nth-child(2) { animation-delay: 0.2s; }
.loading-dots span:nth-child(3) { animation-delay: 0.4s; }
@keyframes dotBounce {
0%, 80%, 100% { transform: scale(0.7); opacity: 0.5; }
40% { transform: scale(1); opacity: 1; }
}
/* Audio Controls */
.audio-controls {
display: flex;
align-items: center;
gap: 0.5rem;
}
.play-btn {
width: 30px; height: 30px;
border-radius: 50%;
background: rgba(99,102,241,0.15);
border: 1px solid rgba(99,102,241,0.3);
color: var(--accent-1);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s;
}
.play-btn:hover { background: rgba(99,102,241,0.3); transform: scale(1.1); }
.play-btn.playing { background: rgba(99,102,241,0.25); border-color: var(--accent-1); }
.play-btn svg { width: 14px; height: 14px; }
.tts-hint { font-size: 0.75rem; color: var(--text-secondary); }
/* Wave bars animation */
.wave-bars {
display: flex;
gap: 2px;
height: 14px;
align-items: center;
}
.wave-bars span {
width: 3px;
background: var(--accent-1);
border-radius: 2px;
animation: waveBounce 0.6s infinite alternate;
}
.wave-bars span:nth-child(2) { animation-delay: 0.15s; }
.wave-bars span:nth-child(3) { animation-delay: 0.3s; }
.wave-bars span:nth-child(4) { animation-delay: 0.45s; }
@keyframes waveBounce {
from { height: 3px; }
to { height: 14px; }
}
/* Input Area */
.input-area {
display: flex;
align-items: flex-end;
gap: 0.75rem;
padding: 1.25rem 0 1.5rem;
border-top: 1px solid var(--card-border);
margin-top: 0.5rem;
}
.input-box {
flex: 1;
background: var(--card-bg);
border: 1px solid var(--card-border);
border-radius: 14px;
padding: 0.875rem 1.25rem;
color: var(--text-primary);
font-family: inherit;
font-size: 1rem;
line-height: 1.5;
resize: none;
outline: none;
transition: border-color 0.2s;
max-height: 120px;
overflow-y: auto;
}
.input-box:focus { border-color: var(--accent-1); }
.input-box:disabled { opacity: 0.6; cursor: not-allowed; }
.send-btn {
width: 46px; height: 46px;
border-radius: 50%;
background: linear-gradient(135deg, var(--accent-1), #7c3aed);
border: none;
color: white;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s;
flex-shrink: 0;
box-shadow: 0 4px 12px rgba(99,102,241,0.35);
}
.send-btn:hover:not(:disabled) { transform: scale(1.08); box-shadow: 0 6px 16px rgba(99,102,241,0.5); }
.send-btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
.send-btn svg { width: 20px; height: 20px; }
.send-spinner {
width: 18px; height: 18px;
border: 2px solid rgba(255,255,255,0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
@media (max-width: 600px) {
.page-container { padding: 1rem 1rem 0; }
.bubble-wrap { max-width: 85%; }
.nav-title { font-size: 1.2rem; }
}
</style>

View File

@ -10,6 +10,11 @@ export default defineConfig({
target: 'https://openspeech.bytedance.com',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/tts-api/, '')
},
'/ark-api': {
target: 'https://ark.cn-beijing.volces.com',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/ark-api/, '')
}
}
}