🎨 docs: 更新部署文档和口语页面样式

This commit is contained in:
cc 2026-03-23 20:29:04 +08:00
parent c8229ced26
commit 00f4447bc9
2 changed files with 94 additions and 35 deletions

View File

@ -39,12 +39,13 @@ npm install
| 配置项 | 说明 |
|--------|------|
| `GRS_API_KEY` | GRS AI 接口密钥(作文批改 / 试题分析) |
| `GRS_API_KEY` | GRS AI 接口密钥(作文批改) |
| `DOUBAO_KEY` | 豆包 API 密钥 |
| `DOUBAO_APP_ID` | 豆包语音 App ID |
| `DOUBAO_ACCESS_TOKEN` | 豆包语音访问令牌 |
| `DOUBAO_RESOURCE_ID` | 豆包 TTS 资源 ID |
| `DOUBAO_ASR_RESOURCE_ID` | 豆包 ASR 资源 ID |
| `ARK_API_KEY` | 火山引擎 Ark 大模型密钥 |
| `ARK_API_KEY` | 火山引擎 Ark 大模型密钥(口语对话) |
| `ARK_MODEL` | Ark 模型名称(如 doubao-pro-4k |
### 2.2 跨域响应头要求(重要)
@ -290,7 +291,7 @@ DOUBAO_APP_ID=xxx DOUBAO_ACCESS_TOKEN=xxx node server.js
### 方案 CEdgeOne Pages / Vercel / Netlify纯静态托管
> **注意**:此类平台**不支持服务端代理**`/tts-api`、`/ark-api`、`/dashscope-api`、`/asr-ws` 等接口将因跨域或缺少鉴权 Header 而失败。
> **注意**:此类平台**不支持服务端代理**`/tts-api`、`/ark-api`、`/dashscope-api`、`/asr-ws` 等接口将因跨域或缺少鉴权 Header 而失败。
> 建议仅用于演示或配合独立后端服务使用。
#### 部署步骤(以 Vercel 为例)
@ -321,14 +322,14 @@ DOUBAO_APP_ID=xxx DOUBAO_ACCESS_TOKEN=xxx node server.js
| 验证项 | 检查方法 |
|--------|----------|
| 页面正常加载 | 访问首页,确认 7 个功能卡片显示正常 |
| 页面正常加载 | 访问首页,确认 6 个功能卡片显示正常 |
| 路由刷新不 404 | 直接访问 `/pronunciation`、`/speaking` 等子路由 |
| COOP/COEP 响应头 | 浏览器 DevTools → Network → 查看 index.html 响应头 |
| TTS 语音合成 | 进入发音练习页,测试语音播放 |
| ASR 语音识别 | 进入口语对话页,测试麦克风录音识别 |
| 作文批改 | 进入作文批改页,提交一段英文作文 |
| 视频讲解FFmpeg | 进入视频讲解页,上传视频文件测试 |
| HTTPS 证书 | 确认浏览器地址栏显示锁形图标 |
| 试题分析 | 进入试题分析页,上传图片或输入题目 |
| 单词拼写练习 | 进入单词拼写页,测试拼写检测功能 |
---
@ -336,27 +337,27 @@ DOUBAO_APP_ID=xxx DOUBAO_ACCESS_TOKEN=xxx node server.js
### Q1视频讲解页面报错 `SharedArrayBuffer is not defined`
**原因**:服务器未返回 `Cross-Origin-Opener-Policy: same-origin``Cross-Origin-Embedder-Policy: require-corp` 响应头。
**原因**:服务器未返回 `Cross-Origin-Opener-Policy: same-origin``Cross-Origin-Embedder-Policy: require-corp` 响应头。
**解决**:参考第二节 2.2,在 Nginx 或 Node.js 服务器中添加对应响应头。
### Q2刷新页面出现 404
**原因**Vue Router 使用 History 模式,服务器未配置路由回退。
**原因**Vue Router 使用 History 模式,服务器未配置路由回退。
**解决**Nginx 添加 `try_files $uri $uri/ /index.html;`Node.js 添加通配路由返回 `index.html`
### Q3API 请求跨域报错CORS
**原因**:生产环境未配置反向代理,浏览器直接请求第三方 API 被跨域拦截。
**原因**:生产环境未配置反向代理,浏览器直接请求第三方 API 被跨域拦截。
**解决**:参考第二节 2.3,在 Nginx 或 Node.js 中配置对应代理规则。
### Q4ASR 语音识别 WebSocket 连接失败
**原因**:代理未注入豆包 ASR 鉴权 Header或 WebSocket 升级配置缺失。
**原因**:代理未注入豆包 ASR 鉴权 Header或 WebSocket 升级配置缺失。
**解决**:确认 Nginx 配置中包含 `proxy_set_header Upgrade $http_upgrade;` 及三个 `X-Api-*` Header。
### Q5构建时内存不足
**原因**:项目包含 FFmpeg WASM构建产物较大。
**原因**:项目包含 FFmpeg WASM构建产物较大。
**解决**
```bash
@ -366,7 +367,35 @@ NODE_OPTIONS=--max-old-space-size=4096 npm run build
---
## 七、目录结构参考
## 七、页面路由清单
| 路由路径 | 页面名称 | 功能说明 |
|----------|----------|----------|
| `/` | 首页 | 功能导航卡片 |
| `/pronunciation` | 发音练习 | TTS 语音合成,音标学习 |
| `/speaking` | 口语对话 | ASR 语音识别AI 对话 |
| `/essay-correction` | 作文批改 | 图片/文本作文批改 |
| `/exam-analysis` | 试题分析 | 图片题目分析解答 |
| `/spell-practice` | 单词拼写 | 单词拼写练习检测 |
---
## 八、依赖版本参考
| 依赖包 | 版本 |
|--------|------|
| vue | ^3.5.30 |
| vue-router | ^5.0.3 |
| vite | ^8.0.0 |
| @ffmpeg/ffmpeg | ^0.12.15 |
| @ffmpeg/util | ^0.12.2 |
| axios | ^1.13.6 |
| marked | ^17.0.5 |
| pako | ^2.1.0 |
---
## 九、目录结构参考
```
AI_Demo/
@ -374,7 +403,16 @@ AI_Demo/
├── src/
│ ├── config/index.js # API 密钥配置(部署前确认)
│ ├── router/index.js # 路由定义
│ └── views/ # 页面组件
│ ├── views/ # 页面组件
│ │ ├── HomePage.vue # 首页
│ │ ├── Pronunciation.vue # 发音练习
│ │ ├── Speaking.vue # 口语对话
│ │ ├── EssayCorrection.vue # 作文批改
│ │ ├── ExamAnalysis.vue # 试题分析
│ │ └── SpellPractice.vue # 单词拼写
│ ├── components/ # 公共组件
│ ├── assets/ # 静态资源
│ └── MainLayout.vue # 布局组件
├── vite.config.js # 开发代理配置(生产环境需在服务器复现)
├── package.json
└── DEPLOY.md # 本文档

View File

@ -189,7 +189,8 @@ const scrollToBottom = async () => {
};
// TTS
const synthesizeAndPlay = async (text, msgId) => {
// autoPlay: true
const synthesizeAndPlay = async (text, msgId, autoPlay = true) => {
const payload = {
user: { uid: "speaking_" + Date.now() },
req_params: {
@ -264,7 +265,9 @@ const synthesizeAndPlay = async (text, msgId) => {
const msg = messages.value.find((m) => m.id === msgId);
if (msg) msg.audioUrl = url;
playAudio(url, msgId);
if (autoPlay) {
playAudio(url, msgId);
}
};
//
@ -345,8 +348,8 @@ const sendMessage = async () => {
messages.value.push(userMsg);
await scrollToBottom();
// TTS
synthesizeAndPlay(text, userMsgId).catch((e) => console.error("User TTS error:", e));
// TTS
synthesizeAndPlay(text, userMsgId, false).catch((e) => console.error("User TTS error:", e));
// AI loading
const aiMsgId = ++msgIdCounter;
@ -643,8 +646,17 @@ const startRecording = async () => {
};
onUnmounted(() => {
if (currentAudioInstance) { currentAudioInstance.pause(); currentAudioInstance = null; }
//
if (currentAudioInstance) {
currentAudioInstance.pause();
currentAudioInstance = null;
}
//
messages.value.forEach((m) => (m.isPlaying = false));
// blob URL
blobUrls.forEach((url) => URL.revokeObjectURL(url));
blobUrls.length = 0;
//
stopRecording();
});
</script>
@ -703,21 +715,20 @@ onUnmounted(() => {
<span></span><span></span><span></span>
</div>
<span v-else>{{ msg.content }}</span>
</div>
<!-- 消息播放按钮AI 和用户消息均显示 -->
<div v-if="!msg.isLoading && msg.content" class="audio-controls" :class="{ 'user-audio-controls': msg.role === 'user' }">
<span v-if="!msg.audioUrl" class="tts-hint">合成中...</span>
<button v-else 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>
<!-- 消息播放按钮AI 和用户消息均显示 -->
<div v-if="!msg.isLoading && msg.content" class="audio-controls" :class="{ 'user-audio-controls': msg.role === 'user' }">
<span v-if="!msg.audioUrl" class="tts-hint">合成中...</span>
<button v-else 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>
</div>
</div>
</div>
@ -901,7 +912,11 @@ onUnmounted(() => {
align-items: flex-end;
gap: 0.75rem;
}
.message-row.user { flex-direction: row-reverse; }
.message-row.user {
justify-content: flex-end;
}
.message-row.user .avatar { order: 2; }
.message-row.user .bubble-wrap { order: 1; }
.avatar {
width: 38px;
@ -914,24 +929,27 @@ onUnmounted(() => {
justify-content: center;
font-size: 1.2rem;
flex-shrink: 0;
order: 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%;
order: 1;
}
.message-row.user .bubble-wrap { align-items: flex-end; }
.bubble {
padding: 0.875rem 1.125rem;
padding-bottom: 2.25rem;
border-radius: 18px;
font-size: 1rem;
line-height: 1.6;
word-break: break-word;
animation: bubbleIn 0.25s ease-out;
position: relative;
}
.bubble.assistant {
background: var(--card-bg);
@ -972,6 +990,9 @@ onUnmounted(() => {
/* Audio Controls */
.audio-controls {
position: absolute;
bottom: 0.5rem;
right: 0.75rem;
display: flex;
align-items: center;
gap: 0.5rem;