Files
wecom_it_smart_desk/frontend-agent/src/composables/useSpeechRecognition.ts
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

389 lines
13 KiB
TypeScript
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智能服务台 — Web Speech API 语音识别 composable(坐席端)
// =============================================================================
// 说明:封装浏览器 Web Speech API 的实时语音转文字功能,提供:
// 1. start():开始语音识别(实时转写)
// 2. stop():停止语音识别
// 3. reset():重置状态(清空已识别的文字)
//
// 交互流程:
// 用户点击语音按钮 → start() → 实时转写显示在 textarea 中...
// 用户再次点击 → stop() → 最终文字填入输入框 → reset()
//
// 关键陷阱(已在代码中处理):
// 1. resultIndex 遍历:onresult 必须从 event.resultIndex 开始,否则重复处理
// 2. onend 自动重启:用户未主动停止时(如超时停止),自动重启识别
// 3. 实例不能重复 start:需等待 onend 后再重启,否则抛错
// 4. 错误类型处理:not-allowed/no-speech/network/aborted 各有不同处理方式
//
// 限制:
// - 仅支持 Chrome/EdgewebkitSpeechRecognition
// - 不支持 Firefox(坐席端限定 Chrome/Edge
// =============================================================================
import { reactive } from 'vue'
// ---------------------------------------------------------------------------
// 类型定义
// ---------------------------------------------------------------------------
/**
* composable 返回的响应式状态
*/
interface SpeechRecognitionState {
/** 是否正在聆听(识别中) */
isListening: boolean
/** 临时识别文字(还在变化中的中间结果,尚未确认) */
interimText: string
/** 最终识别文字(已确认的结果,不会再变化) */
finalText: string
/** 最近一次错误信息(null 表示无错误) */
error: string | null
/** 浏览器是否支持 Web Speech API */
isSupported: boolean
}
/**
* composable 返回的完整接口
*/
interface UseSpeechRecognitionReturn {
/** 响应式状态对象 */
state: SpeechRecognitionState
/** 开始语音识别 */
start: () => void
/** 停止语音识别 */
stop: () => void
/** 重置状态(清空 finalText 和 interimText */
reset: () => void
}
// ---------------------------------------------------------------------------
// composable 实现
// ---------------------------------------------------------------------------
/**
* Web Speech API 语音识别 composable
*
* 做什么:封装浏览器原生的语音识别功能,提供响应式状态和简单 API
*
* 为什么用 composable 模式:
* - 遵循 Vue3 组合式 API,与项目其他 composable 风格一致
* - 将复杂的 Web Speech API 逻辑封装在独立文件中,组件只关心 UI
* - 状态是响应式的,组件可直接绑定到模板
*
* 使用示例:
* ```typescript
* const { state, start, stop, reset } = useSpeechRecognition()
*
* // 点击语音按钮
* function handleVoiceToggle() {
* if (state.isListening) {
* stop()
* // state.finalText 包含全部识别结果
* inputText.value += state.finalText
* reset()
* } else {
* start()
* }
* }
* ```
*/
export function useSpeechRecognition(): UseSpeechRecognitionReturn {
// 响应式状态:组件可绑定到模板
const state = reactive<SpeechRecognitionState>({
isListening: false,
interimText: '',
finalText: '',
error: null,
// 检测浏览器是否支持 Web Speech API
// Chrome/Edge 使用 webkitSpeechRecognition,部分浏览器使用标准 SpeechRecognition
isSupported:
typeof window !== 'undefined' &&
(!!window.SpeechRecognition || !!window.webkitSpeechRecognition),
})
// --------------------------------------------------------------------------
// 内部变量(非响应式,组件不需要感知)
// --------------------------------------------------------------------------
/** SpeechRecognition 实例(通过构造器创建) */
let recognition: SpeechRecognition | null = null
/**
* 标记用户是否主动要求继续聆听
*
* 做什么:区分"用户主动停止"和"浏览器自动停止(如超时)"
*
* 为什么需要这个标记:
* Web Speech API 会在一段时间无语音后自动触发 onend。
* 如果用户没有主动点停止,我们应该自动重启识别(保持持续聆听)。
* 如果用户主动点了停止,shouldKeepListening 设为 falseonend 中不再重启。
*/
let shouldKeepListening = false
// --------------------------------------------------------------------------
// 内部方法
// --------------------------------------------------------------------------
/**
* 创建 SpeechRecognition 实例并绑定事件回调
*
* 做什么:
* 1. 获取构造器(Chrome 用 webkit 前缀)
* 2. 创建实例
* 3. 设置识别参数(语言、连续模式、中间结果等)
* 4. 绑定 onresult / onerror / onend 回调
*
* 识别参数说明:
* - lang = 'zh-CN':中文识别
* - continuous = true:持续识别(不会说一句就停)
* - interimResults = true:返回中间结果(实时显示)
* - maxAlternatives = 1:只返回最佳结果(不需要多个候选项)
*/
function createRecognition(): void {
// 获取构造器(Chrome/Edge 用 webkit 前缀,标准浏览器用无前缀)
const SpeechRecognitionClass =
window.SpeechRecognition || window.webkitSpeechRecognition
if (!SpeechRecognitionClass) {
state.error = '当前浏览器不支持语音识别,请使用 Chrome 或 Edge'
return
}
// 创建实例
recognition = new SpeechRecognitionClass()
// 设置识别参数
recognition.lang = 'zh-CN' // 中文识别
recognition.continuous = true // 持续识别模式
recognition.interimResults = true // 返回中间结果(实时转写)
recognition.maxAlternatives = 1 // 只返回最佳结果
// 绑定 onresult:处理识别结果
recognition.onresult = (event: SpeechRecognitionEvent) => {
let interim = ''
// 【关键】从 event.resultIndex 开始遍历,不是从 0
// 为什么:event.results 包含从开始到现在的所有结果,
// 但之前的结果已经处理过了,只有 resultIndex 之后的是新增的。
// 如果从 0 开始遍历,会导致 finalText 重复累加!
for (let i = event.resultIndex; i < event.results.length; i++) {
const result = event.results[i]
const transcript = result[0].transcript
if (result.isFinal) {
// 最终结果(已确认):追加到 finalText
state.finalText += transcript
} else {
// 临时结果(还在变化):收集到 interim
interim += transcript
}
}
// 更新临时文字(每次 onresult 都会覆盖上一次的临时结果)
state.interimText = interim
}
// 绑定 onerror:处理识别错误
recognition.onerror = (event: SpeechRecognitionErrorEvent) => {
switch (event.error) {
case 'not-allowed':
// 麦克风权限被拒绝(用户在浏览器弹窗中点了"拒绝")
state.error = '麦克风权限被拒绝,请在浏览器设置中允许使用麦克风'
shouldKeepListening = false // 权限被拒,不再自动重启
state.isListening = false
break
case 'service-not-allowed':
// 语音识别服务不可用(可能是浏览器策略限制)
state.error = '语音识别服务不可用,请检查浏览器设置'
shouldKeepListening = false
state.isListening = false
break
case 'network':
// 网络错误(Web Speech API 需要联网,因为识别在云端进行)
state.error = '网络错误,语音识别服务不可用,请检查网络连接'
shouldKeepListening = false
state.isListening = false
break
case 'no-speech':
// 没有检测到语音输入(用户长时间没说话)
// 不报错,onend 会自动处理重启
break
case 'aborted':
// 识别被中止(主动调用 abort/stop 导致)
// 静默处理,不显示错误
break
case 'audio-capture':
// 音频采集失败(可能没有麦克风设备)
state.error = '音频采集失败,请检查麦克风设备'
shouldKeepListening = false
state.isListening = false
break
default:
// 其他未知错误
state.error = `语音识别错误: ${event.error}`
break
}
}
// 绑定 onend:识别结束回调
recognition.onend = () => {
// 清除临时文字(onend 表示一段识别结束了)
state.interimText = ''
// 判断是否需要自动重启
if (shouldKeepListening) {
// 用户未主动停止(可能是超时或 no-speech 导致的自动结束)
// 自动重启识别,保持持续聆听
try {
recognition!.start()
} catch {
// start() 可能失败(实例还在 stopping 状态)
// 延迟 100ms 后重试一次
setTimeout(() => {
if (shouldKeepListening && recognition) {
try {
recognition.start()
} catch (retryErr) {
console.error('[SpeechRecognition] 自动重启失败:', retryErr)
state.isListening = false
state.error = '语音识别自动恢复失败,请重新点击语音按钮'
}
}
}, 100)
}
} else {
// 用户已主动停止,不再重启
state.isListening = false
}
}
}
// --------------------------------------------------------------------------
// 公开方法
// --------------------------------------------------------------------------
/**
* 开始语音识别
*
* 做什么:
* 1. 检查是否已支持、是否已在聆听
* 2. 清空之前的状态(error, finalText, interimText
* 3. 创建(或复用)SpeechRecognition 实例
* 4. 调用 start() 开始识别
*
* 陷阱处理:
* - 不能重复 start():如果实例正在运行,直接返回
* - start() 可能抛错(上一次还没完全停止):延迟 200ms 重试
*/
function start(): void {
// 防止重复开始
if (state.isListening) {
return
}
// 浏览器不支持则不操作
if (!state.isSupported) {
state.error = '当前浏览器不支持语音识别,请使用 Chrome 或 Edge'
return
}
// 清空状态,准备新一轮识别
state.error = null
state.finalText = ''
state.interimText = ''
// 标记用户要求持续聆听(onend 中检查此标记决定是否重启)
shouldKeepListening = true
// 创建实例(如果还没创建过)
if (!recognition) {
createRecognition()
}
if (!recognition) {
state.error = '语音识别器创建失败'
return
}
// 调用 start() 开始识别
try {
recognition.start()
state.isListening = true
} catch {
// start() 失败:可能上一次识别还没完全停止(onend 未触发)
// 延迟 200ms 后重试一次
setTimeout(() => {
if (shouldKeepListening && recognition && !state.isListening) {
try {
recognition.start()
state.isListening = true
} catch (retryErr) {
console.error('[SpeechRecognition] 启动失败:', retryErr)
state.error = '语音识别启动失败,请重试'
shouldKeepListening = false
}
}
}, 200)
}
}
/**
* 停止语音识别
*
* 做什么:
* 1. 标记用户已主动停止(shouldKeepListening = false
* 2. 调用 recognition.stop() 停止识别
* 3. 清除临时文字
*
* 注意:
* - stop() 后 onend 会异步触发,isListening 会在 onend 中设为 false
* - 但为了 UI 立即响应,这里也同步设置 isListening = false
* - finalText 保留(组件需要读取最终结果)
*/
function stop(): void {
// 标记用户主动停止(onend 中不再自动重启)
shouldKeepListening = false
if (recognition) {
try {
recognition.stop()
} catch (err) {
// stop() 失败通常可以忽略(可能实例已经停止了)
console.warn('[SpeechRecognition] stop 警告:', err)
}
}
// 同步更新状态(UI 立即响应)
state.isListening = false
// 清除临时文字(最终文字保留,供组件读取)
state.interimText = ''
}
/**
* 重置状态
*
* 做什么:清空 finalText 和 interimText,清除 error
*
* 什么时候调用:
* 组件在读取完 finalText 并填入输入框后调用,为下一次识别准备干净状态
*/
function reset(): void {
state.finalText = ''
state.interimText = ''
state.error = null
}
return {
state,
start,
stop,
reset,
}
}