Files
wecom_it_smart_desk/docs/03-技术架构/voice-stt-system-design.md
T
Simon bea288e414 feat: 2026-07-11 全量更新 - 代办集成+会议室预定+知识迭代修复+UI统一+Bug修复
== 已部署上线 (9项) ==
- 代办事项真实数据源集成 (企微审批API 8bug修复链)
- H5/坐席端 Logo样式统一+绿色背景
- 视频引导页修复 (localStorage key v2)
- 坐席端 v9 Vue版本修复 (ElMessage._context)
- 截图按钮 v10 修复 (getDisplayMedia user gesture)
- 扫码样式恢复+H5扫码登录跳转修复
- H5截图快捷键提示

== 代码完成待部署 (3项) ==
- 知识迭代3Bug修复 (#8 POST端点/#7 MERGE幂等/#6 过期检查)
- 会议室预定-小鱼易联终端 (40文件, 40/40测试通过)
- IT资产升级审批推送 (asset_service.py)

== 需求文档 (2项) ==
- 坐席端AI辅助消息框-PRD (4项新功能确认)
- 坐席端布局优化建议 v2.0 (7天计划)

== 新增文档 ==
- 日报-2026-07-11.md
- 知识迭代Bug修复报告-20260711.md
- 会议室预定-部署指南.md
- CHANGELOG.md 更新

== 测试 ==
- test_todo_integration.py: 40/40
- test_meetingroom.py: 40/40
- test_bugfix_ki_suggestions.py: 21/21
2026-07-11 23:13:10 +08:00

925 lines
32 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 语音识别转文字 — 技术方案设计
> **项目**: IT 智能服务台(税友集团企微内嵌应用)
> **功能**: 语音输入转文字,填入聊天输入框(非发送语音消息)
> **日期**: 2026-07-15
> **架构师**: 高见远(Bob
---
## 1. 实现方案概述
### 1.1 方案C — H5端(企微 JS-SDK
H5 端运行在企微客户端 WebView 内,利用企微 JS-SDK 提供的录音和语音识别能力:
| 步骤 | API | 说明 |
|------|-----|------|
| 1. 鉴权 | `wx.config()` | 用后端签名初始化 JS-SDK |
| 2. 开始录音 | `wx.startRecord()` | 用户按下语音按钮时调用 |
| 3. 停止录音 | `wx.stopRecord()` | 用户松开按钮时调用,返回 `localId` |
| 4. 语音转文字 | `wx.translateVoice()` | 将录音识别为文字,返回 `translateResult` |
| 5. 填入输入框 | `inputText.value += result` | 识别文字追加到 van-field |
**核心优势**:企微原生能力,识别精度高,支持中英文混合,无需额外服务器资源。
**关键发现**:后端 **已存在** JS-SDK 签名接口 `GET /api/wecom/jsapi-config?url=xxx`(位于 `backend/app/api/wecom_jsapi.py`),且 `WecomService` 已实现 `get_jsapi_ticket()``generate_jsapi_signature()`。**后端无需任何改动。**
### 1.2 方案A — 坐席端(Web Speech API
坐席端运行在桌面 Chrome/Edge 浏览器,利用浏览器原生 Web Speech API
| 步骤 | API | 说明 |
|------|-----|------|
| 1. 创建识别器 | `new SpeechRecognition()` | 创建识别实例 |
| 2. 配置参数 | `continuous=true, interimResults=true` | 连续识别 + 实时中间结果 |
| 3. 启动识别 | `recognition.start()` | 用户点击语音按钮 |
| 4. 实时回调 | `onresult` 事件 | 边说边出字,实时更新 textarea |
| 5. 停止识别 | `recognition.stop()` | 用户再次点击或自动停止 |
| 6. 填入输入框 | `inputText.value = finalTranscript` | 最终文字填入 textarea |
**核心优势**:纯前端实现,零后端依赖,实时转写(边说边出字),体验流畅。
### 1.3 框架选型与依赖
| 端 | 技术 | 新增依赖 | 说明 |
|----|------|----------|------|
| H5端 | 企微 JS-SDK (`jweixin-1.2.0.js`) | 无(CDN 引入) | 已在 EmergencyDispatcher.vue 中使用 |
| 坐席端 | Web Speech API(浏览器原生) | 无 | Chrome/Edge 内置支持 |
**无需新增任何 npm 依赖。** 两个方案都使用平台原生能力,通过 CDN script 标签(H5)或浏览器内置 API(Agent)实现。
---
## 2. 文件列表及路径
### 2.1 H5端(frontend-h5
| # | 操作 | 文件路径 | 说明 |
|---|------|----------|------|
| 1 | [新建] | `src/types/wecom-jssdk.d.ts` | 企微 JS-SDK TypeScript 类型声明 |
| 2 | [新建] | `src/api/wecom.ts` | JS-SDK 签名 API 封装(调 `/api/wecom/jsapi-config` |
| 3 | [新建] | `src/composables/useWecomVoice.ts` | 语音识别 composable(录音+转文字+状态管理) |
| 4 | [修改] | `src/components/chat/InputBar.vue` | 工具栏添加语音按钮,集成 composable |
### 2.2 坐席端(frontend-agent
| # | 操作 | 文件路径 | 说明 |
|---|------|----------|------|
| 5 | [新建] | `src/types/speech-recognition.d.ts` | Web Speech API TypeScript 类型声明 |
| 6 | [新建] | `src/composables/useSpeechRecognition.ts` | 语音识别 composable(实时转写+状态管理) |
| 7 | [修改] | `src/components/chat/ReplyBox.vue` | 工具栏添加语音按钮,替换占位代码 |
### 2.3 后端(backend
| # | 操作 | 文件路径 | 说明 |
|---|------|----------|------|
| — | 无改动 | — | JS-SDK 签名接口已存在(`app/api/wecom_jsapi.py` |
### 2.4 文件关系图
```
frontend-h5/src/
├── types/
│ └── wecom-jssdk.d.ts [新建] 类型声明(Wx, WxConfig, WxError 等)
├── api/
│ └── wecom.ts [新建] getJsapiConfig(url) → {corp_id, signature, ...}
├── composables/
│ └── useWecomVoice.ts [新建] useWecomVoice() → { isRecording, start, stop, ... }
└── components/chat/
└── InputBar.vue [修改] +语音按钮 🎤, 集成 useWecomVoice
frontend-agent/src/
├── types/
│ └── speech-recognition.d.ts [新建] 类型声明(SpeechRecognition, SpeechRecognitionEvent 等)
├── composables/
│ └── useSpeechRecognition.ts [新建] useSpeechRecognition() → { isListening, start, stop, ... }
└── components/chat/
└── ReplyBox.vue [修改] +语音按钮 🎤, 替换 voice 占位代码
```
---
## 3. 接口设计
### 3.1 H5端 TypeScript 接口
#### 3.1.1 企微 JS-SDK 类型声明(`wecom-jssdk.d.ts`
```typescript
/** 企微 JS-SDK 全局对象 */
interface Wx {
config(config: WxConfigOptions): void
ready(callback: () => void): void
error(callback: (res: WxError) => void): void
// 录音相关
startRecord(): void
stopRecord(options: WxStopRecordOptions): void
translateVoice(options: WxTranslateVoiceOptions): void
// 工具
checkJsApi(options: WxCheckJsApiOptions): void
}
interface WxConfigOptions {
beta?: boolean
debug?: boolean
appId: string // corp_id
timestamp: number
nonceStr: string
signature: string
jsApiList: string[] // ['startRecord', 'stopRecord', 'translateVoice']
}
interface WxError {
errMsg: string
}
interface WxStopRecordOptions {
success: (res: { localId: string }) => void
fail: (res: WxError) => void
}
interface WxTranslateVoiceOptions {
localId: string
isShowProgressTips?: number // 0=不显示, 1=显示
success: (res: { translateResult: string }) => void
fail: (res: WxError) => void
}
interface WxCheckJsApiOptions {
jsApiList: string[]
success: (res: { checkResult: Record<string, boolean> }) => void
}
interface Window {
wx: Wx
}
```
#### 3.1.2 JS-SDK 签名 API 封装(`wecom.ts`
```typescript
/** JS-SDK 签名配置响应 */
interface JsapiConfig {
corp_id: string
agent_id: string
timestamp: number
nonce_str: string
signature: string
}
/**
* 获取企微 JS-SDK 签名配置
* @param url 当前页面 URL(不含 # 及其后)
* @returns 签名配置
*/
async function getJsapiConfig(url: string): Promise<JsapiConfig>
```
#### 3.1.3 语音识别 composable`useWecomVoice.ts`
```typescript
/** 语音识别状态 */
interface VoiceState {
isReady: boolean // JS-SDK 是否已初始化
isRecording: boolean // 是否正在录音
isTranslating: boolean // 是否正在转文字
error: string | null // 错误信息
}
/** useWecomVoice 返回值 */
interface UseWecomVoiceReturn {
state: VoiceState
/** 初始化 JS-SDK(幂等,多次调用安全) */
init(): Promise<void>
/** 开始录音 */
startRecording(): Promise<void>
/** 停止录音并转文字 */
stopAndTranslate(): Promise<string>
/** 检查是否支持语音识别 */
isSupported(): boolean
}
/**
* 企微语音识别 composable
* 封装 JS-SDK 录音+转文字的完整流程
*/
function useWecomVoice(): UseWecomVoiceReturn
```
### 3.2 坐席端 TypeScript 接口
#### 3.2.1 Web Speech API 类型声明(`speech-recognition.d.ts`
```typescript
interface SpeechRecognition extends EventTarget {
lang: string
continuous: boolean
interimResults: boolean
maxAlternatives: number
start(): void
stop(): void
abort(): void
onresult: ((event: SpeechRecognitionEvent) => void) | null
onerror: ((event: SpeechRecognitionErrorEvent) => void) | null
onend: (() => void) | null
onstart: (() => void) | null
}
interface SpeechRecognitionEvent extends Event {
resultIndex: number
results: SpeechRecognitionResultList
}
interface SpeechRecognitionResultList {
length: number
item(index: number): SpeechRecognitionResult
[index: number]: SpeechRecognitionResult
}
interface SpeechRecognitionResult {
length: number
isFinal: boolean
item(index: number): SpeechRecognitionAlternative
[index: number]: SpeechRecognitionAlternative
}
interface SpeechRecognitionAlternative {
transcript: string
confidence: number
}
interface SpeechRecognitionErrorEvent extends Event {
error: string
message: string
}
interface SpeechRecognitionStatic {
new (): SpeechRecognition
}
interface Window {
SpeechRecognition?: SpeechRecognitionStatic
webkitSpeechRecognition?: SpeechRecognitionStatic
}
```
#### 3.2.2 语音识别 composable`useSpeechRecognition.ts`
```typescript
/** 语音识别状态 */
interface SpeechState {
isListening: boolean // 是否正在监听
interimText: string // 实时中间结果(未确定)
finalText: string // 已确定的最终结果
error: string | null // 错误信息
isSupported: boolean // 浏览器是否支持
}
/** useSpeechRecognition 返回值 */
interface UseSpeechRecognitionReturn {
state: SpeechState
/** 开始语音识别 */
start(): void
/** 停止语音识别 */
stop(): void
/** 重置状态 */
reset(): void
}
/**
* Web Speech API 语音识别 composable
* 支持实时转写(边说边出字)
* @param lang 识别语言,默认 'zh-CN'
*/
function useSpeechRecognition(lang?: string): UseSpeechRecognitionReturn
```
### 3.3 后端 API 接口(已存在,无需修改)
```python
# backend/app/api/wecom_jsapi.py — 已存在
@router.get("/wecom/jsapi-config")
async def get_jsapi_config(url: str = Query(...)) -> dict:
"""返回 JS-SDK 签名配置
Response: { code: 0, data: { corp_id, agent_id, timestamp, nonce_str, signature } }
"""
```
---
## 4. 时序图
### 4.1 方案C — H5端企微 JS-SDK 录音转文字
```mermaid
sequenceDiagram
participant U as 用户
participant IB as InputBar.vue
participant UWV as useWecomVoice
participant API as /api/wecom/jsapi-config
participant WX as 企微服务器
participant WV as 企微WebView
Note over U,WV: 阶段1:初始化(页面加载时,幂等)
IB->>UWV: init()
UWV->>WV: 检查 window.wx 是否存在
alt JS-SDK 未加载
UWV->>WV: 动态加载 jweixin-1.2.0.js
end
UWV->>API: GET /api/wecom/jsapi-config?url=当前页面URL
API->>API: get_jsapi_ticket() + sha1 签名
API-->>UWV: { corp_id, agent_id, timestamp, nonce_str, signature }
UWV->>WV: wx.config({ appId, timestamp, ..., jsApiList: ['startRecord','stopRecord','translateVoice'] })
WV-->>UWV: wx.ready() 回调
UWV-->>IB: state.isReady = true
Note over U,WV: 阶段2:录音
U->>IB: 按下语音按钮 🎤
IB->>UWV: startRecording()
UWV->>WV: wx.startRecord()
UWV-->>IB: state.isRecording = true
IB->>IB: UI 切换为录音状态(按钮变红+提示"松开发送"
Note over U,WV: 阶段3:停止录音+转文字
U->>IB: 松开语音按钮
IB->>UWV: stopAndTranslate()
UWV->>WV: wx.stopRecord()
WV-->>UWV: { localId: "wxLocalId_xxx" }
UWV-->>IB: state.isTranslating = true
UWV->>WV: wx.translateVoice({ localId, isShowProgressTips: 1 })
WV->>WX: 上传录音+请求语音识别
WX-->>WV: 返回识别文字
WV-->>UWV: { translateResult: "帮我重置密码" }
UWV-->>IB: 返回识别文字
IB->>IB: inputText.value += translateResult
IB->>IB: state.isTranslating = false, state.isRecording = false
Note over U,WV: 异常分支
alt 录音超过60秒
WV-->>UWV: 自动触发 stopRecord 回调
UWV->>UWV: 自动执行 translateVoice
end
alt 转文字失败
WV-->>UWV: fail 回调 { errMsg }
UWV-->>IB: showToast("语音识别失败")
end
```
### 4.2 方案A — 坐席端 Web Speech API 实时转写
```mermaid
sequenceDiagram
participant U as 坐席
participant RB as ReplyBox.vue
participant USR as useSpeechRecognition
participant SR as SpeechRecognition
Note over U,SR: 阶段1:初始化
RB->>USR: useSpeechRecognition('zh-CN')
USR->>USR: 检查 window.SpeechRecognition || webkitSpeechRecognition
alt 浏览器不支持
USR-->>RB: state.isSupported = false
RB->>RB: 语音按钮显示为禁用/隐藏
else 浏览器支持
USR->>USR: state.isSupported = true
end
Note over U,SR: 阶段2:开始识别
U->>RB: 点击语音按钮 🎤
RB->>USR: start()
USR->>SR: new SpeechRecognition()
USR->>SR: lang='zh-CN', continuous=true, interimResults=true
USR->>SR: 注册 onresult / onerror / onend 回调
USR->>SR: start()
SR-->>USR: onstart 触发
USR-->>RB: state.isListening = true
RB->>RB: 按钮变红 + textarea 显示光标 + 提示"正在聆听..."
Note over U,SR: 阶段3:实时转写(边说边出字)
U->>SR: 说话:"帮我查一下"
SR-->>USR: onresult(event) — interimResults (isFinal=false)
USR->>USR: state.interimText = "帮我查一下"
USR-->>RB: 实时更新 textarea(灰色临时文字)
RB->>RB: inputText.value = finalText + interimText
U->>SR: 继续说话:"帮我查一下密码"
SR-->>USR: onresult(event) — final result (isFinal=true)
USR->>USR: state.finalText += "帮我查一下密码"
USR-->>RB: 确定文字追加到 textarea
RB->>RB: inputText.value = finalText
Note over U,SR: 阶段4:停止识别
U->>RB: 再次点击语音按钮
RB->>USR: stop()
USR->>SR: stop()
SR-->>USR: onend 触发
USR-->>RB: state.isListening = false
RB->>RB: 按钮恢复 + textarea 保持已识别文字
Note over U,SR: 异常分支
alt 识别出错
SR-->>USR: onerror(event) — error='not-allowed'
USR-->>RB: showToast("麦克风权限被拒绝")
end
alt 用户长时间不说话
SR-->>USR: onend 自动触发(超时停止)
USR-->>RB: state.isListening = false
end
```
---
## 5. 任务列表
### T01: H5端语音识别功能(企微JS-SDK方案)
| 属性 | 值 |
|------|-----|
| **Task ID** | T01 |
| **任务名** | H5端语音识别功能(企微JS-SDK方案) |
| **源文件** | `frontend-h5/src/types/wecom-jssdk.d.ts` [新建]<br>`frontend-h5/src/api/wecom.ts` [新建]<br>`frontend-h5/src/composables/useWecomVoice.ts` [新建]<br>`frontend-h5/src/components/chat/InputBar.vue` [修改] |
| **依赖** | 无 |
| **优先级** | P0 |
| **复杂度** | 中等 |
| **预估工时** | 4-6 小时 |
**实现要点**
1. 创建 `wecom-jssdk.d.ts`:声明 `Wx` 接口及 `startRecord`/`stopRecord`/`translateVoice` 等方法类型
2. 创建 `wecom.ts`:封装 `getJsapiConfig(url)` 调用后端 `/api/wecom/jsapi-config`
3. 创建 `useWecomVoice.ts`:核心 composable,封装 JS-SDK 加载、wx.config 鉴权、录音、转文字完整流程
- 参考 `EmergencyDispatcher.vue` 中已有的 `loadWeworkSDK()` / `wxConfig()` 模式
- `init()` 幂等设计:多次调用安全,避免重复加载 SDK
- `jsApiList` 需包含 `['startRecord', 'stopRecord', 'translateVoice']`
4. 修改 `InputBar.vue`
- 工具栏添加语音按钮(🎤),放在文件按钮后
- 按下开始录音,松开停止+转文字
- 录音中 UI 状态:按钮变红 + 底部提示"松开识别文字"
- 识别结果追加到 `inputText.value`
### T02: 坐席端语音识别功能(Web Speech API方案)
| 属性 | 值 |
|------|-----|
| **Task ID** | T02 |
| **任务名** | 坐席端语音识别功能(Web Speech API方案) |
| **源文件** | `frontend-agent/src/types/speech-recognition.d.ts` [新建]<br>`frontend-agent/src/composables/useSpeechRecognition.ts` [新建]<br>`frontend-agent/src/components/chat/ReplyBox.vue` [修改] |
| **依赖** | 无(与 T01 完全独立) |
| **优先级** | P0 |
| **复杂度** | 中等 |
| **预估工时** | 3-4 小时 |
**实现要点**
1. 创建 `speech-recognition.d.ts`:声明 `SpeechRecognition` 接口及事件类型
2. 创建 `useSpeechRecognition.ts`:核心 composable
- `continuous=true`:连续识别模式
- `interimResults=true`:启用实时中间结果
- `lang='zh-CN'`:中文识别
- `onresult` 回调中区分 `isFinal`(最终结果)和 interim(中间结果)
- 最终结果追加到 `finalText`,中间结果实时更新 `interimText`
3. 修改 `ReplyBox.vue`
- 工具栏添加语音按钮(🎤),放在文件按钮和分隔线之间
- 替换现有占位代码 `voice: '语音消息功能开发中'`
- 点击开始识别,再次点击停止
- 实时转写:textarea 中显示 `finalText + interimText`
- 识别中 UI 状态:按钮变红 + 麦克风动画
### 任务依赖图
```mermaid
graph LR
T01[T01: H5端语音识别<br>企微JS-SDK方案]
T02[T02: 坐席端语音识别<br>Web Speech API方案]
T01 --- T02
style T01 fill:#e1f5fe,stroke:#0288d1,stroke-width:2px
style T02 fill:#e8f5e9,stroke:#388e3c,stroke-width:2px
```
> **说明**:T01 和 T02 完全独立,可并行开发。两者无文件交叉,无接口依赖。
---
## 6. 后端改动
### 6.1 JS-SDK 签名接口 — 已存在,无需修改
**已有接口**`GET /api/wecom/jsapi-config?url=xxx`
**文件位置**`backend/app/api/wecom_jsapi.py`
**返回格式**
```json
{
"code": 0,
"data": {
"corp_id": "wwa8c87970b2011f41",
"agent_id": "1000133",
"timestamp": 1718500000,
"nonce_str": "5K8264ILTKCH...",
"signature": "f7c8e9..."
}
}
```
**底层实现**
- `WecomService.get_jsapi_ticket()`:从 Redis 缓存或企微 API 获取 jsapi_ticketTTL 6900s
- `WecomService.generate_jsapi_signature()``sha1(jsapi_ticket={ticket}&noncestr={nonce}&timestamp={ts}&url={url})`
**已在路由注册**`router.py` 第 224 行 `api_router.include_router(wecom_jsapi_router, tags=["企微JS-SDK"])`
### 6.2 企微配置 — 已存在
以下配置已在 `backend/app/config.py` 中定义,通过环境变量注入:
| 配置项 | 环境变量 | 说明 |
|--------|----------|------|
| `wecom_corp_id` | `WECOM_CORP_ID` | 企业ID |
| `wecom_agent_id` | `WECOM_AGENT_ID` | 应用AgentId |
| `wecom_secret` | `WECOM_SECRET` | 应用Secret(用于获取 access_token → jsapi_ticket |
### 6.3 上传接口白名单 — 无需修改
**原因**:企微 `translateVoice()` 在内部完成录音上传和识别,音频数据不经过我们的后端上传接口。识别结果直接以文字形式返回前端。因此 **不需要**`ALLOWED_EXTENSIONS` 中添加音频格式。
> 如果未来需要将录音文件保存到服务器(如审计需求),才需要在 `upload.py` 的 `ALLOWED_EXTENSIONS` 中添加 `mp3`、`amr`、`wav` 等格式。
### 6.4 企微管理后台配置 — 需确认
在企微管理后台(work.weixin.qq.com)需要确保以下配置:
1. **应用可信域名**`itsupport.servyou.com.cn` 已配置(OAuth2 已用)
2. **JS-SDK 权限**:应用管理 → 自建应用 → 开发者接口 → 确认已开启"语音识别"权限
3. **可信域名验证**:域名根目录需放置企微验证文件(如 `WW_verify_xxx.txt`)— 应已配置
> ⚠️ **需确认**:企微管理后台是否已为当前应用开启 `startRecord`/`stopRecord`/`translateVoice` 的 JS-SDK 权限。部分接口需要在应用详情页单独申请。
---
## 7. 错误处理与降级策略
### 7.1 H5端(企微 JS-SDK
| 场景 | 检测方式 | 降级策略 |
|------|----------|----------|
| **非企微环境** | `navigator.userAgent` 不含 `wxwork` | 语音按钮隐藏或显示 Toast"请在企业微信中打开"index.html 已有环境拦截) |
| **JS-SDK 加载失败** | `script.onerror` 回调 | Toast"企微 SDK 加载失败",语音按钮禁用 |
| **wx.config 鉴权失败** | `wx.error()` 回调 | Toast"企微授权失败",记录 errMsg 到日志,语音按钮禁用 |
| **录音权限被拒绝** | `wx.startRecord()` 无响应或 `wx.error` | Toast"请允许麦克风权限" |
| **translateVoice 失败** | `fail` 回调 `{ errMsg }` | Toast"语音识别失败,请重试",保留输入框已有内容 |
| **录音超 60 秒** | 企微自动触发 `stopRecord` | composable 内监听自动停止,自动执行转文字 |
| **网络错误** | API 请求超时/失败 | Toast"网络异常,请检查网络后重试" |
| **jsapi_ticket 获取失败** | 后端返回 code=5001 | Toast"签名服务暂时不可用",语音按钮禁用 |
### 7.2 坐席端(Web Speech API
| 场景 | 检测方式 | 降级策略 |
|------|----------|----------|
| **浏览器不支持** | `!window.SpeechRecognition && !window.webkitSpeechRecognition` | 语音按钮隐藏,控制台 warning |
| **麦克风权限被拒绝** | `onerror` 事件 `error='not-allowed'` | ElMessage.error"请允许麦克风权限后重试" |
| **网络错误**Web Speech API 依赖网络) | `onerror` 事件 `error='network'` | ElMessage.error"网络异常,语音识别不可用" |
| **未检测到语音** | `onerror` 事件 `error='no-speech'` | ElMessage.info"未检测到语音,请重试" |
| **识别中断** | `onerror` 事件 `error='aborted'` | 静默处理,不提示(用户主动中断) |
| **识别服务不可用** | `onerror` 事件 `error='service-not-allowed'` | ElMessage.error"语音识别服务不可用" |
| **长时间无语音输入** | `onend` 自动触发(超时停止) | 保留已识别文字,按钮恢复初始状态 |
### 7.3 通用降级原则
1. **语音功能是辅助输入手段**,不是核心功能 — 降级不影响正常文字输入
2. **所有错误都 Toast/ElMessage 提示**,不阻塞 UI
3. **语音按钮在不支持时隐藏而非禁用**,避免用户困惑
4. **识别失败时保留输入框已有内容**,不清空
---
## 8. 关键实现细节与陷阱
### 8.1 企微 JS-SDK 陷阱
#### 8.1.1 JS-SDK 文件加载顺序
```
EmergencyDispatcher.vue 现有加载方式(可参考):
1. 先加载 wwLogin-1.2.7.js(企微登录SDK
2. 再加载 jweixin-1.2.0.js(企微JS-SDK
```
**陷阱**`jweixin-1.2.0.js` 文件名含"1.2.0"但实际是企微最新版 JS-SDK。不要被版本号误导。
**建议**`useWecomVoice.ts``init()` 中只需加载 `jweixin-1.2.0.js`,不需要加载 `wwLogin-1.2.7.js`(那是登录用的)。
#### 8.1.2 wx.config 的 jsApiList
```javascript
wx.config({
beta: true, // 必须开启,否则部分接口不可用
debug: false,
appId: config.corp_id, // 注意:企微用 corp_id,不是 agent_id
timestamp: config.timestamp,
nonceStr: config.nonce_str, // 注意大小写:nonceStr(驼峰)
signature: config.signature,
jsApiList: [
'startRecord',
'stopRecord',
'translateVoice',
// 如果已有 agentConfig 需求,也加上
'agentConfig'
]
})
```
**陷阱**
- `appId` 字段传 `corp_id`(企业ID),不是 `agent_id`
- `nonceStr` 是驼峰命名,后端返回的是 `nonce_str`(下划线),前端需要映射
- `beta: true` 必须设置,否则 `translateVoice` 等接口可能不可用
#### 8.1.3 translateVoice 的 60 秒限制
企微 `startRecord()` 最长录音时间为 **60 秒**。超过 60 秒,企微会自动触发 `stopRecord` 回调。
**处理方案**
```typescript
// 在 startRecording() 中设置定时器
let autoStopTimer: ReturnType<typeof setTimeout> | null = null
function startRecording() {
wx.startRecord()
// 59 秒自动停止(留 1 秒余量)
autoStopTimer = setTimeout(() => {
if (state.isRecording) {
stopAndTranslate()
}
}, 59000)
}
function stopAndTranslate() {
if (autoStopTimer) {
clearTimeout(autoStopTimer)
autoStopTimer = null
}
// ... stopRecord + translateVoice
}
```
#### 8.1.4 translateVoice 返回格式
`translateVoice``success` 回调返回 `{ translateResult: string }`
**陷阱**`translateResult` 可能包含换行符或前后空格,需要 `trim()` 处理。
#### 8.1.5 页面 URL 传参
签名接口需要传入当前页面 URL(不含 `#` 及其后):
```typescript
const url = window.location.href.split('#')[0]
```
**陷阱**Vue Router 使用 hash 模式时,URL 格式为 `https://xxx/#/h5/chat`。必须去掉 `#` 后面的部分,否则签名不匹配。
> **需确认**H5 端 Vue Router 使用的是 hash 模式还是 history 模式。如果是 history 模式,URL 本身不含 `#`,但仍需确保传入的是完整 URL。
#### 8.1.6 多次 init 幂等性
用户可能多次进入聊天页面,`init()` 会被多次调用。需要确保:
- JS-SDK script 只加载一次(检查 `window.wx` 是否存在)
- `wx.config()` 可以多次调用(企微允许重复 config)
- 使用 `Promise` 缓存避免并发 init
### 8.2 Web Speech API 陷阱
#### 8.2.1 continuous 和 interimResults 参数
```typescript
recognition.continuous = true // 连续识别,不会在第一个结果后自动停止
recognition.interimResults = true // 返回中间结果(未确定的文字)
recognition.lang = 'zh-CN' // 中文识别
recognition.maxAlternatives = 1 // 只返回最佳结果
```
**陷阱**
- `continuous=false`(默认)时,识别到第一个停顿就自动停止 — 不适合长文本输入
- `interimResults=false` 时,只有最终结果才回调 — 用户体验差(说完才出字)
- 两个参数必须同时为 `true` 才能实现"边说边出字"
#### 8.2.2 onresult 事件处理
```typescript
recognition.onresult = (event: SpeechRecognitionEvent) => {
let interimText = ''
let finalText = ''
// 从 resultIndex 开始遍历新的结果
for (let i = event.resultIndex; i < event.results.length; i++) {
const result = event.results[i]
if (result.isFinal) {
finalText += result[0].transcript
} else {
interimText += result[0].transcript
}
}
// 更新状态
state.interimText = interimText
if (finalText) {
state.finalText += finalText
}
}
```
**陷阱**
- 必须从 `event.resultIndex` 开始遍历,而不是从 0,否则会重复处理旧结果
- `result[0]` 是最佳候选,`result[1]` 是次佳候选(如果 `maxAlternatives > 1`
- `isFinal=true` 的结果不会改变,可以安全追加
#### 8.2.3 onend 自动重启
Web Speech API 在以下情况会自动触发 `onend`
- 用户长时间不说话(约 10-15 秒静默)
- 网络中断
- 浏览器内部超时
**处理方案**:如果用户仍然处于"聆听"状态(未主动停止),自动重启识别:
```typescript
recognition.onend = () => {
if (shouldKeepListening) {
// 自动重启(短暂延迟避免竞态)
setTimeout(() => {
try {
recognition.start()
} catch (e) {
// 可能报错 "recognition has already started"
console.warn('重启识别失败:', e)
}
}, 100)
} else {
state.isListening = false
}
}
```
#### 8.2.4 HTTPS 要求
Web Speech API 要求页面必须在 **HTTPS**`localhost` 下运行。
**当前环境**
- 正式地址 `https://itsupport.servyou.com.cn` — ✅ HTTPS
- 本地开发 `http://localhost:5174` — ✅ localhost 豁免
- 坐席端 `http://localhost:5175` — ✅ localhost 豁免
**陷阱**:如果坐席端通过 HTTP IP 访问(如 `http://10.80.0.x:5175`),Web Speech API 不可用。
#### 8.2.5 Chrome 隐身模式限制
Chrome 隐身模式下 Web Speech API 可能不可用(取决于 Chrome 版本和隐私设置)。
**处理方案**`onerror` 中检测 `error='service-not-allowed'`,提示用户使用正常模式。
#### 8.2.6 识别器实例管理
**陷阱**`SpeechRecognition` 实例不能重复调用 `start()`。如果在 `onend` 后需要重启,必须确保上一个实例已完全停止。
```typescript
// 错误:重复 start
recognition.start()
recognition.start() // 抛出 InvalidStateError
// 正确:等待 onend 后再 start
recognition.stop()
recognition.onend = () => {
recognition.start() // 安全
}
```
### 8.3 CSP 安全策略
H5 端 `index.html` 已有 CSP 配置:
```html
<meta http-equiv="Content-Security-Policy" content="
default-src 'self';
script-src 'self' 'unsafe-eval' 'unsafe-inline' https://res.wx.qq.com;
...
connect-src 'self' https://qyapi.weixin.qq.com wss://* ...;
" />
```
**确认**
- `script-src` 已包含 `https://res.wx.qq.com` — ✅ 可加载企微 JS-SDK
- `connect-src` 已包含 `https://qyapi.weixin.qq.com` — ✅ JS-SDK 可与企微服务器通信
- 坐席端无 CSP 限制(或限制较宽松)— ✅ Web Speech API 不受 CSP 影响
**无需修改 CSP 配置。**
---
## 9. 待明确事项
### 9.1 需用户确认的技术决策
| # | 问题 | 影响 | 默认假设 |
|---|------|------|----------|
| 1 | 企微管理后台是否已为当前应用开启 `startRecord`/`stopRecord`/`translateVoice` 的 JS-SDK 权限? | H5 端语音功能是否可用 | 已开启(因为 EmergencyDispatcher 已在使用 agentConfig |
| 2 | H5 端 Vue Router 使用 hash 模式还是 history 模式? | 签名 URL 传参方式 | hash 模式(需去掉 `#` 后部分) |
| 3 | 坐席端是否需要支持 Firefox | Firefox 使用 `mozSpeechRecognition`(实验性) | 不需要,坐席端限定 Chrome/Edge |
| 4 | 是否需要将语音识别的文字自动发送,还是仅填入输入框等待用户确认? | 交互设计 | 仅填入输入框(用户确认后手动发送) |
| 5 | H5 端语音按钮交互方式:按住说话(press-to-talk)还是点击开始/点击结束? | UX 交互设计 | 按住说话(移动端常见交互) |
| 6 | 坐席端语音按钮交互方式:点击开始/再次点击结束? | UX 交互设计 | 点击开始/再次点击结束(桌面端常见交互) |
### 9.2 假设说明
1. **后端 JS-SDK 签名接口稳定可用**:已验证 `wecom_jsapi.py` 代码完整,`WecomService``get_jsapi_ticket()` 有 Redis 缓存+降级机制
2. **企微应用已配置可信域名**`itsupport.servyou.com.cn` 已用于 OAuth2 登录,JS-SDK 共用同一域名配置
3. **坐席端浏览器版本足够新**Chrome 25+ / Edge 79+ 支持 Web Speech API(当前主流版本均满足)
4. **企微客户端版本支持 translateVoice**:企微 iOS/Android 客户端 2.4+ 支持(当前版本远超)
---
## 附录:类图
```mermaid
classDiagram
class Wx {
<<interface>>
+config(options: WxConfigOptions) void
+ready(callback: Function) void
+error(callback: Function) void
+startRecord() void
+stopRecord(options: object) void
+translateVoice(options: object) void
}
class JsapiConfig {
+corp_id: string
+agent_id: string
+timestamp: number
+nonce_str: string
+signature: string
}
class UseWecomVoiceReturn {
+state: VoiceState
+init() Promise~void~
+startRecording() Promise~void~
+stopAndTranslate() Promise~string~
+isSupported() boolean
}
class VoiceState {
+isReady: boolean
+isRecording: boolean
+isTranslating: boolean
+error: string|null
}
class SpeechRecognition {
<<interface>>
+lang: string
+continuous: boolean
+interimResults: boolean
+start() void
+stop() void
+abort() void
+onresult: Function
+onerror: Function
+onend: Function
}
class UseSpeechRecognitionReturn {
+state: SpeechState
+start() void
+stop() void
+reset() void
}
class SpeechState {
+isListening: boolean
+interimText: string
+finalText: string
+error: string|null
+isSupported: boolean
}
class InputBar {
+inputText: string
+handleVoiceStart() void
+handleVoiceEnd() void
}
class ReplyBox {
+inputText: string
+toggleVoice() void
}
UseWecomVoiceReturn --> VoiceState
UseWecomVoiceReturn ..> Wx : uses
UseWecomVoiceReturn ..> JsapiConfig : fetches
InputBar --> UseWecomVoiceReturn : composes
UseSpeechRecognitionReturn --> SpeechState
UseSpeechRecognitionReturn ..> SpeechRecognition : wraps
ReplyBox --> UseSpeechRecognitionReturn : composes
```