bea288e414
== 已部署上线 (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
389 lines
13 KiB
TypeScript
389 lines
13 KiB
TypeScript
// =============================================================================
|
||
// 企微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/Edge(webkitSpeechRecognition)
|
||
// - 不支持 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 设为 false,onend 中不再重启。
|
||
*/
|
||
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,
|
||
}
|
||
}
|