更新
This commit is contained in:
@@ -2,25 +2,84 @@ import { ref, onUnmounted } from 'vue'
|
|||||||
|
|
||||||
const MIN_HOLD_MS = 280
|
const MIN_HOLD_MS = 280
|
||||||
|
|
||||||
|
// #ifdef MP-WEIXIN
|
||||||
|
// WechatSI 录音管理器是全局单例:回调只在模块级绑定一次,
|
||||||
|
// 再分发给「当前活跃实例」。否则跨页面(如 more → weekly)后,
|
||||||
|
// 回调仍指向已销毁页面的闭包,新页面收不到 onStart/onStop,
|
||||||
|
// 其本地状态卡在 recording,表现为一直录制/无法再次语音。
|
||||||
|
let sharedManager = null
|
||||||
|
let sharedManagerInited = false
|
||||||
|
let sharedRecording = false
|
||||||
|
let activeCtl = null
|
||||||
|
|
||||||
|
function getSharedRecordManager() {
|
||||||
|
if (sharedManagerInited) return sharedManager
|
||||||
|
sharedManagerInited = true
|
||||||
|
try {
|
||||||
|
// eslint-disable-next-line no-undef
|
||||||
|
const plugin = requirePlugin('WechatSI')
|
||||||
|
sharedManager = plugin.getRecordRecognitionManager()
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('WechatSI record recognition not available', e)
|
||||||
|
sharedManager = null
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (!sharedManager) return null
|
||||||
|
|
||||||
|
sharedManager.onStart = () => {
|
||||||
|
sharedRecording = true
|
||||||
|
if (activeCtl) activeCtl.handleStart()
|
||||||
|
}
|
||||||
|
sharedManager.onRecognize = (res) => {
|
||||||
|
if (activeCtl) activeCtl.handleRecognize(res)
|
||||||
|
}
|
||||||
|
sharedManager.onStop = (res) => {
|
||||||
|
sharedRecording = false
|
||||||
|
if (activeCtl) activeCtl.handleStop(res)
|
||||||
|
}
|
||||||
|
sharedManager.onError = (res) => {
|
||||||
|
sharedRecording = false
|
||||||
|
if (activeCtl) activeCtl.handleError(res)
|
||||||
|
}
|
||||||
|
return sharedManager
|
||||||
|
}
|
||||||
|
// #endif
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 语音转文字(长按说话):微信小程序 WechatSI;H5 Web Speech API。
|
* 语音转文字(长按说话):微信小程序 WechatSI;H5 Web Speech API。
|
||||||
*
|
*
|
||||||
* @param {{ onResult?: (text: string) => void, showToast?: (msg: string) => void }} options
|
* @param {{
|
||||||
|
* onResult?: (text: string) => void,
|
||||||
|
* onSettle?: (text: string, info: { heldMs: number }) => boolean | void,
|
||||||
|
* onPartial?: (text: string) => void,
|
||||||
|
* onError?: (msg: string) => boolean | void,
|
||||||
|
* showToast?: (msg: string) => void
|
||||||
|
* }} options
|
||||||
|
*
|
||||||
|
* onSettle 在每次录音结束时都会触发(含空结果),用于语音一问一答等需要
|
||||||
|
* 完全接管识别结果的场景。若 onSettle 返回 true,则跳过默认的 onResult 回调
|
||||||
|
* 与「没听清」提示,避免与上层流程重复处理。
|
||||||
|
*
|
||||||
|
* onPartial 在录音过程中实时返回中间识别结果(微信 onRecognize / H5 interim),
|
||||||
|
* 用于「抢答打断」等场景:检测到用户已开口作答即可提前结束播报/录音。
|
||||||
|
*
|
||||||
|
* onError 在录音/识别出错时触发(如 "record manager recordfailed")。
|
||||||
|
* 若返回 true,则跳过默认的错误 Toast,由上层自行处理(如自动重试)。
|
||||||
*/
|
*/
|
||||||
export function useSpeechToText({ onResult, showToast } = {}) {
|
export function useSpeechToText({ onResult, onSettle, onPartial, onError, showToast } = {}) {
|
||||||
const sttListening = ref(false)
|
const sttListening = ref(false)
|
||||||
const sttHolding = ref(false)
|
const sttHolding = ref(false)
|
||||||
const sttSupported = ref(false)
|
const sttSupported = ref(false)
|
||||||
|
|
||||||
let wxRecordManager = null
|
let wxRecordManager = null
|
||||||
let h5Recognition = null
|
let h5Recognition = null
|
||||||
|
let h5GotResult = false
|
||||||
let holdStartTs = 0
|
let holdStartTs = 0
|
||||||
let holdSessionId = 0
|
let holdSessionId = 0
|
||||||
let startRequested = false
|
let startRequested = false
|
||||||
let recordAuthorized = null
|
let recordAuthorized = null
|
||||||
// WechatSI 录音管理器是全局单例:跨页面/快速点按可能残留会话,
|
// 快速点按可能残留会话:用排队标记把 start/stop 串行化,
|
||||||
// 用以下两个标记把 start/stop 串行化,避免 "please stop after start"
|
// 避免 "please stop after start"
|
||||||
let managerRecording = false
|
|
||||||
let pendingStartSession = 0
|
let pendingStartSession = 0
|
||||||
|
|
||||||
function notify(msg) {
|
function notify(msg) {
|
||||||
@@ -35,6 +94,11 @@ export function useSpeechToText({ onResult, showToast } = {}) {
|
|||||||
function emitResult(text) {
|
function emitResult(text) {
|
||||||
const t = String(text || '').trim()
|
const t = String(text || '').trim()
|
||||||
const heldMs = Date.now() - holdStartTs
|
const heldMs = Date.now() - holdStartTs
|
||||||
|
// onSettle 总会收到结果(含空),返回 true 表示已完全接管
|
||||||
|
if (typeof onSettle === 'function') {
|
||||||
|
const handled = onSettle(t, { heldMs })
|
||||||
|
if (handled === true) return
|
||||||
|
}
|
||||||
if (!t) {
|
if (!t) {
|
||||||
if (heldMs < MIN_HOLD_MS) return
|
if (heldMs < MIN_HOLD_MS) return
|
||||||
notify('没听清,请再试一次')
|
notify('没听清,请再试一次')
|
||||||
@@ -47,7 +111,8 @@ export function useSpeechToText({ onResult, showToast } = {}) {
|
|||||||
|
|
||||||
function stopListening() {
|
function stopListening() {
|
||||||
// #ifdef MP-WEIXIN
|
// #ifdef MP-WEIXIN
|
||||||
if (wxRecordManager && (sttListening.value || startRequested || managerRecording)) {
|
const ownsRecording = sharedRecording && activeCtl === controller
|
||||||
|
if (wxRecordManager && (sttListening.value || startRequested || ownsRecording)) {
|
||||||
try {
|
try {
|
||||||
wxRecordManager.stop()
|
wxRecordManager.stop()
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
@@ -106,14 +171,14 @@ export function useSpeechToText({ onResult, showToast } = {}) {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
startRequested = true
|
startRequested = true
|
||||||
managerRecording = true
|
sharedRecording = true
|
||||||
wxRecordManager.start({
|
wxRecordManager.start({
|
||||||
duration: 60000,
|
duration: 60000,
|
||||||
lang: 'zh_CN'
|
lang: 'zh_CN'
|
||||||
})
|
})
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
startRequested = false
|
startRequested = false
|
||||||
managerRecording = false
|
sharedRecording = false
|
||||||
sttHolding.value = false
|
sttHolding.value = false
|
||||||
notify('无法开始录音')
|
notify('无法开始录音')
|
||||||
}
|
}
|
||||||
@@ -127,7 +192,7 @@ export function useSpeechToText({ onResult, showToast } = {}) {
|
|||||||
}
|
}
|
||||||
// 上一段会话还没结束(含全局单例残留):先停止并排队,
|
// 上一段会话还没结束(含全局单例残留):先停止并排队,
|
||||||
// 待 onStop 回调后再真正开始,避免 "please stop after start"
|
// 待 onStop 回调后再真正开始,避免 "please stop after start"
|
||||||
if (managerRecording || startRequested) {
|
if (sharedRecording || startRequested) {
|
||||||
pendingStartSession = session
|
pendingStartSession = session
|
||||||
try {
|
try {
|
||||||
wxRecordManager.stop()
|
wxRecordManager.stop()
|
||||||
@@ -147,6 +212,7 @@ export function useSpeechToText({ onResult, showToast } = {}) {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
startRequested = true
|
startRequested = true
|
||||||
|
h5GotResult = false
|
||||||
h5Recognition.start()
|
h5Recognition.start()
|
||||||
sttListening.value = true
|
sttListening.value = true
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -170,6 +236,8 @@ export function useSpeechToText({ onResult, showToast } = {}) {
|
|||||||
sttHolding.value = true
|
sttHolding.value = true
|
||||||
|
|
||||||
// #ifdef MP-WEIXIN
|
// #ifdef MP-WEIXIN
|
||||||
|
// 把全局录音回调切到当前实例
|
||||||
|
activeCtl = controller
|
||||||
ensureRecordAuth(() => startWechatRecord(session))
|
ensureRecordAuth(() => startWechatRecord(session))
|
||||||
// #endif
|
// #endif
|
||||||
// #ifdef H5
|
// #ifdef H5
|
||||||
@@ -185,13 +253,17 @@ export function useSpeechToText({ onResult, showToast } = {}) {
|
|||||||
|
|
||||||
/** 松手结束:手指抬起 */
|
/** 松手结束:手指抬起 */
|
||||||
function endHoldSpeech() {
|
function endHoldSpeech() {
|
||||||
if (!sttHolding.value && !sttListening.value && !startRequested && !managerRecording) {
|
let recording = false
|
||||||
|
// #ifdef MP-WEIXIN
|
||||||
|
recording = sharedRecording && activeCtl === controller
|
||||||
|
// #endif
|
||||||
|
if (!sttHolding.value && !sttListening.value && !startRequested && !recording) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
sttHolding.value = false
|
sttHolding.value = false
|
||||||
// 松手后不再补开排队的录音
|
// 松手后不再补开排队的录音
|
||||||
pendingStartSession = 0
|
pendingStartSession = 0
|
||||||
if (sttListening.value || startRequested || managerRecording) {
|
if (sttListening.value || startRequested || recording) {
|
||||||
stopListening()
|
stopListening()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -199,14 +271,10 @@ export function useSpeechToText({ onResult, showToast } = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// #ifdef MP-WEIXIN
|
// #ifdef MP-WEIXIN
|
||||||
try {
|
// 当前实例的回调控制器:全局单例 manager 的事件由模块级分发器
|
||||||
// eslint-disable-next-line no-undef
|
// 转发给 activeCtl(最后一次 beginHoldSpeech 的实例)
|
||||||
const plugin = requirePlugin('WechatSI')
|
const controller = {
|
||||||
wxRecordManager = plugin.getRecordRecognitionManager()
|
handleStart() {
|
||||||
sttSupported.value = !!wxRecordManager
|
|
||||||
|
|
||||||
wxRecordManager.onStart = () => {
|
|
||||||
managerRecording = true
|
|
||||||
if (!sttHolding.value) {
|
if (!sttHolding.value) {
|
||||||
try {
|
try {
|
||||||
wxRecordManager.stop()
|
wxRecordManager.stop()
|
||||||
@@ -215,9 +283,13 @@ export function useSpeechToText({ onResult, showToast } = {}) {
|
|||||||
}
|
}
|
||||||
startRequested = false
|
startRequested = false
|
||||||
sttListening.value = true
|
sttListening.value = true
|
||||||
}
|
},
|
||||||
wxRecordManager.onStop = (res) => {
|
handleRecognize(res) {
|
||||||
managerRecording = false
|
if (typeof onPartial !== 'function') return
|
||||||
|
const t = String(res?.result || '').trim()
|
||||||
|
if (t) onPartial(t)
|
||||||
|
},
|
||||||
|
handleStop(res) {
|
||||||
sttListening.value = false
|
sttListening.value = false
|
||||||
startRequested = false
|
startRequested = false
|
||||||
// 有排队的开始请求(残留会话已停止 / 用户仍按住):现在再真正开始
|
// 有排队的开始请求(残留会话已停止 / 用户仍按住):现在再真正开始
|
||||||
@@ -229,9 +301,8 @@ export function useSpeechToText({ onResult, showToast } = {}) {
|
|||||||
}
|
}
|
||||||
pendingStartSession = 0
|
pendingStartSession = 0
|
||||||
emitResult(res?.result)
|
emitResult(res?.result)
|
||||||
}
|
},
|
||||||
wxRecordManager.onError = (res) => {
|
handleError(res) {
|
||||||
managerRecording = false
|
|
||||||
sttListening.value = false
|
sttListening.value = false
|
||||||
startRequested = false
|
startRequested = false
|
||||||
const msg = (res && res.msg) || ''
|
const msg = (res && res.msg) || ''
|
||||||
@@ -245,11 +316,17 @@ export function useSpeechToText({ onResult, showToast } = {}) {
|
|||||||
}
|
}
|
||||||
pendingStartSession = 0
|
pendingStartSession = 0
|
||||||
sttHolding.value = false
|
sttHolding.value = false
|
||||||
|
// 上层(如一问一答流程)可接管错误并自行重试,返回 true 时不再弹默认提示
|
||||||
|
if (typeof onError === 'function') {
|
||||||
|
const handled = onError(msg || '')
|
||||||
|
if (handled === true) return
|
||||||
|
}
|
||||||
notify(msg || '语音识别失败')
|
notify(msg || '语音识别失败')
|
||||||
}
|
}
|
||||||
} catch (e) {
|
|
||||||
console.warn('WechatSI record recognition not available', e)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
wxRecordManager = getSharedRecordManager()
|
||||||
|
sttSupported.value = !!wxRecordManager
|
||||||
// #endif
|
// #endif
|
||||||
|
|
||||||
// #ifdef H5
|
// #ifdef H5
|
||||||
@@ -259,27 +336,49 @@ export function useSpeechToText({ onResult, showToast } = {}) {
|
|||||||
if (SR) {
|
if (SR) {
|
||||||
h5Recognition = new SR()
|
h5Recognition = new SR()
|
||||||
h5Recognition.lang = 'zh-CN'
|
h5Recognition.lang = 'zh-CN'
|
||||||
h5Recognition.interimResults = false
|
h5Recognition.interimResults = true
|
||||||
h5Recognition.continuous = false
|
h5Recognition.continuous = false
|
||||||
h5Recognition.maxAlternatives = 1
|
h5Recognition.maxAlternatives = 1
|
||||||
sttSupported.value = true
|
sttSupported.value = true
|
||||||
|
|
||||||
h5Recognition.onresult = (event) => {
|
h5Recognition.onresult = (event) => {
|
||||||
const item = event.results?.[0]?.[0]
|
let interim = ''
|
||||||
emitResult(item?.transcript || '')
|
let finalText = ''
|
||||||
|
for (let i = event.resultIndex; i < event.results.length; i++) {
|
||||||
|
const r = event.results[i]
|
||||||
|
const txt = r?.[0]?.transcript || ''
|
||||||
|
if (r.isFinal) finalText += txt
|
||||||
|
else interim += txt
|
||||||
|
}
|
||||||
|
if (interim && typeof onPartial === 'function') onPartial(interim)
|
||||||
|
if (finalText) {
|
||||||
|
h5GotResult = true
|
||||||
|
emitResult(finalText)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
h5Recognition.onend = () => {
|
h5Recognition.onend = () => {
|
||||||
sttListening.value = false
|
sttListening.value = false
|
||||||
startRequested = false
|
startRequested = false
|
||||||
sttHolding.value = false
|
sttHolding.value = false
|
||||||
|
// 无识别结果也要兜底触发一次 settle,便于一问一答流程重试
|
||||||
|
if (!h5GotResult) {
|
||||||
|
emitResult('')
|
||||||
|
}
|
||||||
|
h5GotResult = false
|
||||||
}
|
}
|
||||||
h5Recognition.onerror = (event) => {
|
h5Recognition.onerror = (event) => {
|
||||||
sttListening.value = false
|
sttListening.value = false
|
||||||
startRequested = false
|
startRequested = false
|
||||||
sttHolding.value = false
|
sttHolding.value = false
|
||||||
if (event?.error === 'not-allowed') {
|
const err = event?.error || ''
|
||||||
|
if (err === 'aborted') return
|
||||||
|
if (typeof onError === 'function') {
|
||||||
|
const handled = onError(err)
|
||||||
|
if (handled === true) return
|
||||||
|
}
|
||||||
|
if (err === 'not-allowed') {
|
||||||
notify('请允许浏览器使用麦克风')
|
notify('请允许浏览器使用麦克风')
|
||||||
} else if (event?.error !== 'aborted') {
|
} else {
|
||||||
notify('语音识别失败')
|
notify('语音识别失败')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -295,6 +394,12 @@ export function useSpeechToText({ onResult, showToast } = {}) {
|
|||||||
holdSessionId += 1
|
holdSessionId += 1
|
||||||
pendingStartSession = 0
|
pendingStartSession = 0
|
||||||
stopListening()
|
stopListening()
|
||||||
|
// #ifdef MP-WEIXIN
|
||||||
|
// 释放全局回调指向,避免事件继续派发给已销毁的实例
|
||||||
|
if (activeCtl === controller) {
|
||||||
|
activeCtl = null
|
||||||
|
}
|
||||||
|
// #endif
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -378,6 +378,65 @@
|
|||||||
<view class="input-modal-tip">
|
<view class="input-modal-tip">
|
||||||
<text>请如实填写,留空表示该项不上传。当天可多次修改。</text>
|
<text>请如实填写,留空表示该项不上传。当天可多次修改。</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
<!-- 语音一问一答面板(默认开启) -->
|
||||||
|
<view v-if="sttSupported" class="voice-dialog" :class="{ active: voiceDialog.active }">
|
||||||
|
<view class="voice-dialog-head">
|
||||||
|
<view class="voice-dialog-title-wrap">
|
||||||
|
<view class="voice-dialog-orb" :class="{ listening: voiceDialog.phase === 'listening', speaking: voiceDialog.phase === 'asking' }">
|
||||||
|
<TongjiIcon name="mic" size="sm" color="#ffffff" />
|
||||||
|
</view>
|
||||||
|
<view class="voice-dialog-title-text">
|
||||||
|
<text class="voice-dialog-title">语音问答录入</text>
|
||||||
|
<text class="voice-dialog-tip">{{ voiceDialog.active ? '跟着提问回答数字即可自动填写' : '点击开始,语音一问一答' }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view v-if="voiceDialog.active" class="voice-dialog-stop" @click="stopVoiceDialog()">
|
||||||
|
<text>结束</text>
|
||||||
|
</view>
|
||||||
|
<view v-else class="voice-dialog-start" @click="startGlucoseVoiceDialog">
|
||||||
|
<text>开始问答</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="voiceDialog.active" class="voice-dialog-body">
|
||||||
|
<view class="voice-dialog-bubble bot">
|
||||||
|
<text>{{ voiceDialog.botText }}</text>
|
||||||
|
</view>
|
||||||
|
<view v-if="voiceDialog.heardText" class="voice-dialog-bubble user">
|
||||||
|
<text>{{ voiceDialog.heardText }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="voice-dialog-status">
|
||||||
|
<view v-if="voiceDialogRecording" class="voice-dialog-wave">
|
||||||
|
<view class="voice-dialog-wave-bar" />
|
||||||
|
<view class="voice-dialog-wave-bar" />
|
||||||
|
<view class="voice-dialog-wave-bar" />
|
||||||
|
<view class="voice-dialog-wave-bar" />
|
||||||
|
</view>
|
||||||
|
<text class="voice-dialog-status-text">{{ voiceDialogStatusText }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="voice-dialog-actions">
|
||||||
|
<view
|
||||||
|
v-if="voiceDialogRecording"
|
||||||
|
class="voice-dialog-btn primary"
|
||||||
|
@click="finishListeningEarly"
|
||||||
|
>
|
||||||
|
<text>说完了</text>
|
||||||
|
</view>
|
||||||
|
<view class="voice-dialog-btn" @click="replayCurrentQuestion">
|
||||||
|
<text>{{ voiceDialog.phase === 'listening' ? '重说' : '重听' }}</text>
|
||||||
|
</view>
|
||||||
|
<view
|
||||||
|
v-if="voiceDialog.stage !== 'confirm'"
|
||||||
|
class="voice-dialog-btn"
|
||||||
|
@click="skipCurrentStepManually"
|
||||||
|
>
|
||||||
|
<text>跳过本项</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
<view
|
<view
|
||||||
class="voice-input-bar"
|
class="voice-input-bar"
|
||||||
:class="{ active: (sttListening || sttHolding) && voiceTarget === 'glucose' }"
|
:class="{ active: (sttListening || sttHolding) && voiceTarget === 'glucose' }"
|
||||||
@@ -663,8 +722,8 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 语音录音浮层:长按说话时居中提示 -->
|
<!-- 语音录音浮层:长按说话时居中提示(语音问答进行中改用面板内提示) -->
|
||||||
<view v-if="(sttHolding || sttListening) && recordOverlayOpen" class="voice-rec-overlay">
|
<view v-if="(sttHolding || sttListening) && recordOverlayOpen && !voiceDialog.active" class="voice-rec-overlay">
|
||||||
<view class="voice-rec-card">
|
<view class="voice-rec-card">
|
||||||
<view class="voice-rec-icon" :class="{ active: sttListening }">
|
<view class="voice-rec-icon" :class="{ active: sttListening }">
|
||||||
<TongjiIcon name="mic" size="lg" color="#ffffff" />
|
<TongjiIcon name="mic" size="lg" color="#ffffff" />
|
||||||
@@ -720,7 +779,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, getCurrentInstance, nextTick, watch } from 'vue'
|
import { ref, reactive, computed, getCurrentInstance, nextTick } from 'vue'
|
||||||
import { onLoad, onShow, onHide, onUnload, onPullDownRefresh, onShareAppMessage } from '@dcloudio/uni-app'
|
import { onLoad, onShow, onHide, onUnload, onPullDownRefresh, onShareAppMessage } from '@dcloudio/uni-app'
|
||||||
import SugarTreeGraphic from '../components/SugarTreeGraphic.vue'
|
import SugarTreeGraphic from '../components/SugarTreeGraphic.vue'
|
||||||
import TongjiIcon from '../components/TongjiIcon.vue'
|
import TongjiIcon from '../components/TongjiIcon.vue'
|
||||||
@@ -728,7 +787,7 @@ import CelebrateBurst from '../components/CelebrateBurst.vue'
|
|||||||
import { TREE_LEVELS, TREE_MAX_LEVEL, calcTreeFromPoints, pickTreeWhisper } from '../utils/treeLevels.js'
|
import { TREE_LEVELS, TREE_MAX_LEVEL, calcTreeFromPoints, pickTreeWhisper } from '../utils/treeLevels.js'
|
||||||
import { consumeDietAiPrefill } from '../composables/useDietAi.js'
|
import { consumeDietAiPrefill } from '../composables/useDietAi.js'
|
||||||
import { useSpeechToText } from '../composables/useSpeechToText.js'
|
import { useSpeechToText } from '../composables/useSpeechToText.js'
|
||||||
import { parseGlucose, parseBloodPressure, parseDiet, parseExercise, summarizeParsed } from '../utils/voiceParse.js'
|
import { parseGlucose, parseBloodPressure, parseDiet, parseExercise, summarizeParsed, normalizeNumbers } from '../utils/voiceParse.js'
|
||||||
|
|
||||||
const { proxy } = getCurrentInstance()
|
const { proxy } = getCurrentInstance()
|
||||||
|
|
||||||
@@ -1718,6 +1777,7 @@ const emptyBpForm = () => ({
|
|||||||
const bpForm = ref(emptyBpForm())
|
const bpForm = ref(emptyBpForm())
|
||||||
|
|
||||||
function prepareRecordOverlay() {
|
function prepareRecordOverlay() {
|
||||||
|
stopVoiceDialog(true)
|
||||||
ttsStop()
|
ttsStop()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1755,16 +1815,28 @@ async function openInputForm() {
|
|||||||
/* 静默:拉取失败时仍允许新增录入 */
|
/* 静默:拉取失败时仍允许新增录入 */
|
||||||
} finally {
|
} finally {
|
||||||
inputForm.value.loading = false
|
inputForm.value.loading = false
|
||||||
|
// 默认采用语音一问一答:打开后自动开始问询(手动录入仍可用)
|
||||||
|
if (sttSupported.value) {
|
||||||
|
nextTick(() => {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (inputForm.value.visible && !voiceDialog.active) {
|
||||||
|
startGlucoseVoiceDialog()
|
||||||
|
}
|
||||||
|
}, 360)
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeInputForm() {
|
function closeInputForm() {
|
||||||
if (inputForm.value.submitting) return
|
if (inputForm.value.submitting) return
|
||||||
|
stopVoiceDialog(true)
|
||||||
inputForm.value.visible = false
|
inputForm.value.visible = false
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submitInputForm() {
|
async function submitInputForm() {
|
||||||
if (inputForm.value.submitting || inputForm.value.loading) return
|
if (inputForm.value.submitting || inputForm.value.loading) return
|
||||||
|
if (voiceDialog.active) stopVoiceDialog(true)
|
||||||
const f = inputForm.value
|
const f = inputForm.value
|
||||||
const hasAny = [
|
const hasAny = [
|
||||||
f.fasting_blood_sugar, f.postprandial_blood_sugar, f.other_blood_sugar
|
f.fasting_blood_sugar, f.postprandial_blood_sugar, f.other_blood_sugar
|
||||||
@@ -1804,6 +1876,7 @@ async function submitInputForm() {
|
|||||||
|
|
||||||
function deleteInputRecord() {
|
function deleteInputRecord() {
|
||||||
if (!inputForm.value.id || inputForm.value.submitting) return
|
if (!inputForm.value.id || inputForm.value.submitting) return
|
||||||
|
if (voiceDialog.active) stopVoiceDialog(true)
|
||||||
uni.showModal({
|
uni.showModal({
|
||||||
title: '删除确认',
|
title: '删除确认',
|
||||||
content: '确定删除今天已录入的血糖数据吗?删除后无法恢复。',
|
content: '确定删除今天已录入的血糖数据吗?删除后无法恢复。',
|
||||||
@@ -2038,8 +2111,27 @@ const {
|
|||||||
endHoldSpeech
|
endHoldSpeech
|
||||||
} = useSpeechToText({
|
} = useSpeechToText({
|
||||||
onResult(text) {
|
onResult(text) {
|
||||||
|
// 语音一问一答进行中时,结果由 onSettle → 对话流程接管
|
||||||
|
if (voiceDialog.active) return
|
||||||
applyVoiceResult(text)
|
applyVoiceResult(text)
|
||||||
},
|
},
|
||||||
|
onSettle(text) {
|
||||||
|
if (voiceDialog.active) {
|
||||||
|
handleDialogSettle(text)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
onError(msg) {
|
||||||
|
if (voiceDialog.active) {
|
||||||
|
handleDialogError(msg)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
onPartial(text) {
|
||||||
|
if (voiceDialog.active) handleDialogPartial(text)
|
||||||
|
},
|
||||||
showToast: (msg) => showUserToast(msg)
|
showToast: (msg) => showUserToast(msg)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -2049,6 +2141,8 @@ function startVoiceInput(target) {
|
|||||||
showUserToast('当前环境不支持语音输入')
|
showUserToast('当前环境不支持语音输入')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// 长按手动录入与一问一答互斥:手动开始时先停掉对话
|
||||||
|
if (voiceDialog.active) stopVoiceDialog(true)
|
||||||
voiceTarget.value = target
|
voiceTarget.value = target
|
||||||
beginHoldSpeech()
|
beginHoldSpeech()
|
||||||
}
|
}
|
||||||
@@ -2116,6 +2210,415 @@ function appendToField(formRef, field, value) {
|
|||||||
formRef.value[field] = prev ? `${prev};${v}` : v
|
formRef.value[field] = prev ? `${prev};${v}` : v
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============ 语音一问一答录入(默认开启,逐项语音问询自动回填) ============
|
||||||
|
const VOICE_DIALOG_LISTEN_MS = 4000 // 问题播完后给用户的说话时长(兜底窗口)
|
||||||
|
const VOICE_DIALOG_PRE_MS = 250 // 读完问题到开始录音的缓冲,避开 TTS 尾音
|
||||||
|
const VOICE_DIALOG_ANSWER_MS = 550 // 听到数字后,用户停顿多久即定格(静默防抖,越小越快)
|
||||||
|
const VOICE_DIALOG_ANSWER_MAX_MS = 1800 // 听到数字后最长收尾时间(防止实时回调不断刷新而不定格)
|
||||||
|
const VOICE_DIALOG_MAX_RETRY = 2 // 没听清的最大重试次数
|
||||||
|
const VOICE_DIALOG_ERR_MAX_RETRY = 3 // 录音启动失败的最大自动重试次数
|
||||||
|
const VOICE_DIALOG_RETRY_MS = 600 // 录音失败后重试的间隔
|
||||||
|
|
||||||
|
/** 血糖语音问答的步骤定义 */
|
||||||
|
// 注意:采集类问题与录音并行,麦克风会录到问题回声,
|
||||||
|
// 因此问句中不要出现「跳过/没有」等指令词,否则回声会被误判为跳过。
|
||||||
|
const GLUCOSE_DIALOG_STEPS = [
|
||||||
|
{ field: 'fasting_blood_sugar', label: '空腹血糖', question: '请问空腹血糖是多少?' },
|
||||||
|
{ field: 'postprandial_blood_sugar', label: '餐后血糖', question: '餐后血糖是多少?' },
|
||||||
|
{ field: 'other_blood_sugar', label: '其他时段血糖', question: '其他时段血糖是多少?' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const voiceDialog = reactive({
|
||||||
|
active: false, // 对话流程是否进行中
|
||||||
|
phase: 'idle', // idle | asking | listening (仅表示麦克风/播报状态)
|
||||||
|
stage: 'collect', // collect(逐项采集)| confirm(确认提交)
|
||||||
|
stepIndex: 0,
|
||||||
|
retries: 0,
|
||||||
|
errorRetries: 0, // 录音启动失败的自动重试计数
|
||||||
|
barged: false, // 用户是否已在问题播报中抢答
|
||||||
|
pendingValue: '', // 实时识别已捕获、待定格的数值
|
||||||
|
botText: '', // 当前机器人提示/问题
|
||||||
|
heardText: '', // 最近一次识别到的用户回答
|
||||||
|
steps: GLUCOSE_DIALOG_STEPS,
|
||||||
|
collected: [] // [{ label, value }]
|
||||||
|
})
|
||||||
|
|
||||||
|
let voiceListenTimer = null
|
||||||
|
let voiceAnswerTimer = null
|
||||||
|
let voiceFlowToken = 0 // 递增令牌,关闭/重启时让旧的异步回调失效
|
||||||
|
|
||||||
|
function clearVoiceListenTimer() {
|
||||||
|
if (voiceListenTimer) {
|
||||||
|
clearTimeout(voiceListenTimer)
|
||||||
|
voiceListenTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearAnswerTimer() {
|
||||||
|
if (voiceAnswerTimer) {
|
||||||
|
clearTimeout(voiceAnswerTimer)
|
||||||
|
voiceAnswerTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearVoiceTimers() {
|
||||||
|
clearVoiceListenTimer()
|
||||||
|
clearAnswerTimer()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 当前步骤 */
|
||||||
|
function currentDialogStep() {
|
||||||
|
return voiceDialog.steps[voiceDialog.stepIndex] || null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 启动血糖语音问答 */
|
||||||
|
function startGlucoseVoiceDialog() {
|
||||||
|
if (!sttSupported.value) {
|
||||||
|
showUserToast('当前环境不支持语音输入')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 先停掉可能进行中的手动录音/旧对话
|
||||||
|
endHoldSpeech()
|
||||||
|
clearVoiceListenTimer()
|
||||||
|
voiceFlowToken += 1
|
||||||
|
voiceDialog.active = true
|
||||||
|
voiceDialog.phase = 'asking'
|
||||||
|
voiceDialog.stage = 'collect'
|
||||||
|
voiceDialog.stepIndex = 0
|
||||||
|
voiceDialog.retries = 0
|
||||||
|
voiceDialog.errorRetries = 0
|
||||||
|
voiceDialog.heardText = ''
|
||||||
|
voiceDialog.collected = []
|
||||||
|
askCurrentStep()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 播报当前步骤的问题,读完后再开始聆听。
|
||||||
|
* 微信平台已移除实时识别回调(onRecognize),无法做"读题中打断",
|
||||||
|
* 因此采用「读完短问题 → 干净录音(无回声)→ 说完点"说完了"立即下一题,
|
||||||
|
* 否则到时自动结束」的可靠模型。录音不与播报并行,避免回声污染识别。
|
||||||
|
*/
|
||||||
|
function askCurrentStep(prefix = '') {
|
||||||
|
const step = currentDialogStep()
|
||||||
|
if (!step) { finishVoiceDialog(); return }
|
||||||
|
const token = voiceFlowToken
|
||||||
|
voiceDialog.phase = 'asking'
|
||||||
|
voiceDialog.errorRetries = 0
|
||||||
|
voiceDialog.barged = false
|
||||||
|
voiceDialog.pendingValue = ''
|
||||||
|
voiceDialog.heardText = ''
|
||||||
|
voiceDialog.botText = `${prefix}${step.question}`
|
||||||
|
clearVoiceTimers()
|
||||||
|
ttsSpeak(voiceDialog.botText, {
|
||||||
|
force: true,
|
||||||
|
onDone: () => {
|
||||||
|
if (token !== voiceFlowToken || !voiceDialog.active) return
|
||||||
|
listenForAnswer()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 到时自动结束录音并识别 */
|
||||||
|
function armListenTimeout(token) {
|
||||||
|
clearVoiceListenTimer()
|
||||||
|
voiceListenTimer = setTimeout(() => {
|
||||||
|
if (token !== voiceFlowToken || !voiceDialog.active) return
|
||||||
|
endHoldSpeech()
|
||||||
|
}, VOICE_DIALOG_LISTEN_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 读完问题后开始录音:留极短缓冲避开 TTS 尾音,再开录并启动聆听窗口 */
|
||||||
|
function listenForAnswer() {
|
||||||
|
if (!voiceDialog.active) return
|
||||||
|
const token = voiceFlowToken
|
||||||
|
voiceDialog.phase = 'listening'
|
||||||
|
voiceDialog.pendingValue = ''
|
||||||
|
clearVoiceTimers()
|
||||||
|
voiceListenTimer = setTimeout(() => {
|
||||||
|
if (token !== voiceFlowToken || !voiceDialog.active || voiceDialog.phase !== 'listening') return
|
||||||
|
beginHoldSpeech()
|
||||||
|
armListenTimeout(token)
|
||||||
|
}, VOICE_DIALOG_PRE_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 实时识别中间结果(仅在聆听阶段)。
|
||||||
|
* 注意:微信当前已不再触发 onRecognize,此处为「若平台支持则提速」的增益逻辑——
|
||||||
|
* 一旦实时拿到数字,用户停顿即定格、立即下一题;不支持时则走聆听窗口/「说完了」兜底。
|
||||||
|
*/
|
||||||
|
function handleDialogPartial(text) {
|
||||||
|
if (!voiceDialog.active) return
|
||||||
|
if (voiceDialog.stage !== 'collect') return
|
||||||
|
if (voiceDialog.phase !== 'listening') return
|
||||||
|
const value = extractGlucoseValue(text)
|
||||||
|
if (value === null) return
|
||||||
|
const token = voiceFlowToken
|
||||||
|
voiceDialog.pendingValue = value
|
||||||
|
voiceDialog.heardText = text
|
||||||
|
if (!voiceDialog.barged) {
|
||||||
|
// 首次拿到数字:取消默认长窗口,改为「停顿即定格」,并设绝对收尾上限
|
||||||
|
voiceDialog.barged = true
|
||||||
|
clearVoiceListenTimer()
|
||||||
|
voiceListenTimer = setTimeout(() => {
|
||||||
|
if (token !== voiceFlowToken || !voiceDialog.active) return
|
||||||
|
finalizeCollectAnswer()
|
||||||
|
}, VOICE_DIALOG_ANSWER_MAX_MS)
|
||||||
|
}
|
||||||
|
clearAnswerTimer()
|
||||||
|
voiceAnswerTimer = setTimeout(() => {
|
||||||
|
if (token !== voiceFlowToken || !voiceDialog.active) return
|
||||||
|
finalizeCollectAnswer()
|
||||||
|
}, VOICE_DIALOG_ANSWER_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 以实时识别捕获到的数值定格当前采集项,并立即进入下一题 */
|
||||||
|
function finalizeCollectAnswer() {
|
||||||
|
if (!voiceDialog.active) return
|
||||||
|
const step = currentDialogStep()
|
||||||
|
const value = voiceDialog.pendingValue
|
||||||
|
voiceDialog.pendingValue = ''
|
||||||
|
clearVoiceTimers()
|
||||||
|
endHoldSpeech() // 停掉录音;其最终结果在切到下一题后由阶段判断忽略
|
||||||
|
if (!step || !value) return
|
||||||
|
inputForm.value[step.field] = value
|
||||||
|
voiceDialog.collected.push({ label: step.label, value })
|
||||||
|
voiceDialog.retries = 0
|
||||||
|
gotoNextStep(`好的,${step.label}已记录。`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 录音/识别启动失败(如 record manager recordfailed):自动重试本题录音 */
|
||||||
|
function handleDialogError() {
|
||||||
|
if (!voiceDialog.active) return
|
||||||
|
clearVoiceTimers()
|
||||||
|
if (voiceDialog.errorRetries < VOICE_DIALOG_ERR_MAX_RETRY) {
|
||||||
|
voiceDialog.errorRetries += 1
|
||||||
|
const token = voiceFlowToken
|
||||||
|
// 暂离 listening,避免失败回调被当作答案处理;稍后自动重新录音(不重播问题)
|
||||||
|
voiceDialog.phase = 'asking'
|
||||||
|
voiceListenTimer = setTimeout(() => {
|
||||||
|
if (token !== voiceFlowToken || !voiceDialog.active) return
|
||||||
|
listenForAnswer()
|
||||||
|
}, VOICE_DIALOG_RETRY_MS)
|
||||||
|
} else {
|
||||||
|
voiceDialog.errorRetries = 0
|
||||||
|
// 多次启动失败:温和提示并跳过当前项,避免卡死(措辞不含「跳过」以防回声误判)
|
||||||
|
gotoNextStep('录音没成功,这项先空着。')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 用户点「说完了」:立即结束录音并识别(仅聆听阶段可用) */
|
||||||
|
function finishListeningEarly() {
|
||||||
|
if (!voiceDialog.active || voiceDialog.phase !== 'listening') return
|
||||||
|
// 采集类若已实时捕获到数字,直接定格(更快、避免丢失)
|
||||||
|
if (voiceDialog.stage === 'collect' && voiceDialog.pendingValue) {
|
||||||
|
finalizeCollectAnswer()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
clearVoiceTimers()
|
||||||
|
endHoldSpeech()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 识别结束(含空结果)统一入口 */
|
||||||
|
function handleDialogSettle(text) {
|
||||||
|
if (!voiceDialog.active) return
|
||||||
|
// 仅在「聆听」阶段才采纳结果;重听/跳过/停止后产生的残留回调忽略
|
||||||
|
if (voiceDialog.phase !== 'listening') return
|
||||||
|
clearVoiceTimers()
|
||||||
|
// 抢答收尾过快导致最终结果为空时,回退使用最后一次实时识别(中间结果)
|
||||||
|
const partial = voiceDialog.heardText
|
||||||
|
let raw = String(text || '').trim()
|
||||||
|
if (!raw && voiceDialog.barged && partial) raw = partial
|
||||||
|
voiceDialog.heardText = raw
|
||||||
|
|
||||||
|
if (voiceDialog.stage === 'confirm') {
|
||||||
|
handleConfirmAnswer(raw)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const step = currentDialogStep()
|
||||||
|
if (!step) { finishVoiceDialog(); return }
|
||||||
|
|
||||||
|
// 先解析数字:最终结果优先,其次回退到实时识别已捕获的数值
|
||||||
|
// (只要听到了数值就以它为准,避免问题回声里的「跳过/没有」抢先误判为跳过)
|
||||||
|
const value = extractGlucoseValue(raw) ?? (voiceDialog.pendingValue || null)
|
||||||
|
voiceDialog.pendingValue = ''
|
||||||
|
if (value !== null) {
|
||||||
|
// 命中数值:回填并确认。注意:过渡播报里不要带数字,
|
||||||
|
// 否则下一题并行录音会把这句回声里的数字当成抢答而误触发。
|
||||||
|
inputForm.value[step.field] = value
|
||||||
|
voiceDialog.collected.push({ label: step.label, value })
|
||||||
|
voiceDialog.retries = 0
|
||||||
|
gotoNextStep(`好的,${step.label}已记录。`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 没有数字时再判断意图:取消 / 退出
|
||||||
|
if (raw && /退出|取消|关闭/.test(raw)) {
|
||||||
|
speakDialog('已为您关闭语音问答,可以手动填写。', () => stopVoiceDialog())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 跳过当前项
|
||||||
|
if (raw && /跳过|没有|不测|没测|没量|不用|下一/.test(raw)) {
|
||||||
|
gotoNextStep(`好的,${step.label}先空着。`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 既没数字也没指令:没听清,重试或跳过
|
||||||
|
if (voiceDialog.retries < VOICE_DIALOG_MAX_RETRY) {
|
||||||
|
voiceDialog.retries += 1
|
||||||
|
askCurrentStep('没听清,')
|
||||||
|
} else {
|
||||||
|
voiceDialog.retries = 0
|
||||||
|
gotoNextStep(`暂时没听清${step.label},先空着。`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从口语文本中提取血糖数值,失败返回 null。
|
||||||
|
* 与手动输入保持一致:只要识别到正数即采纳,不做医学范围限制
|
||||||
|
* (避免「80」这类数值被误判为没听清)。
|
||||||
|
*/
|
||||||
|
function extractGlucoseValue(raw) {
|
||||||
|
if (!raw) return null
|
||||||
|
const norm = normalizeNumbers(raw)
|
||||||
|
const m = norm.match(/\d+(?:\.\d+)?/)
|
||||||
|
if (!m) return null
|
||||||
|
const num = Number(m[0])
|
||||||
|
if (!Number.isFinite(num) || num <= 0) return null
|
||||||
|
return m[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 进入下一步(带一句过渡播报) */
|
||||||
|
function gotoNextStep(prefix = '') {
|
||||||
|
voiceDialog.stepIndex += 1
|
||||||
|
voiceDialog.retries = 0
|
||||||
|
voiceDialog.errorRetries = 0
|
||||||
|
if (voiceDialog.stepIndex >= voiceDialog.steps.length) {
|
||||||
|
finishVoiceDialog(prefix)
|
||||||
|
} else {
|
||||||
|
askCurrentStep(prefix)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 全部问完:汇总并询问是否提交 */
|
||||||
|
function finishVoiceDialog(prefix = '') {
|
||||||
|
if (!voiceDialog.collected.length) {
|
||||||
|
speakDialog(`${prefix}还没有录入任何血糖数据,您可以手动填写或重新开始语音问答。`, () => stopVoiceDialog())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const summary = voiceDialog.collected.map((c) => `${c.label}${c.value}`).join(',')
|
||||||
|
voiceDialog.stage = 'confirm'
|
||||||
|
voiceDialog.phase = 'asking'
|
||||||
|
voiceDialog.retries = 0
|
||||||
|
voiceDialog.heardText = ''
|
||||||
|
voiceDialog.botText = `${prefix}已为您填写:${summary}。要现在提交吗?说"提交"确认,或说"取消"。`
|
||||||
|
const token = voiceFlowToken
|
||||||
|
ttsSpeak(voiceDialog.botText, {
|
||||||
|
force: true,
|
||||||
|
onDone: () => {
|
||||||
|
if (token !== voiceFlowToken || !voiceDialog.active) return
|
||||||
|
listenForAnswer()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 处理确认环节的回答 */
|
||||||
|
function handleConfirmAnswer(raw) {
|
||||||
|
if (raw && /提交|确认|可以|好的|好了|是的|对|要|提交吧|保存/.test(raw)) {
|
||||||
|
speakDialog('好的,正在为您提交。', () => {
|
||||||
|
stopVoiceDialog(true)
|
||||||
|
submitInputForm()
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (raw && /取消|不提交|不要|先不|算了|退出|关闭/.test(raw)) {
|
||||||
|
speakDialog('已为您填好,请核对后手动提交。', () => stopVoiceDialog())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 没听清,重问一次
|
||||||
|
if (voiceDialog.retries < VOICE_DIALOG_MAX_RETRY) {
|
||||||
|
voiceDialog.retries += 1
|
||||||
|
const token = voiceFlowToken
|
||||||
|
voiceDialog.stage = 'confirm'
|
||||||
|
voiceDialog.phase = 'asking'
|
||||||
|
voiceDialog.botText = '没听清,要现在提交吗?说"提交"或"取消"。'
|
||||||
|
ttsSpeak(voiceDialog.botText, {
|
||||||
|
force: true,
|
||||||
|
onDone: () => {
|
||||||
|
if (token !== voiceFlowToken || !voiceDialog.active) return
|
||||||
|
listenForAnswer()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
speakDialog('已为您填好,请核对后手动提交。', () => stopVoiceDialog())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 仅播报一句话并在结束后回调(不进入聆听) */
|
||||||
|
function speakDialog(text, onDone) {
|
||||||
|
voiceDialog.botText = text
|
||||||
|
const token = voiceFlowToken
|
||||||
|
ttsSpeak(text, {
|
||||||
|
force: true,
|
||||||
|
onDone: () => {
|
||||||
|
if (token !== voiceFlowToken) return
|
||||||
|
if (typeof onDone === 'function') onDone()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 重听/重说当前问题 */
|
||||||
|
function replayCurrentQuestion() {
|
||||||
|
if (!voiceDialog.active) return
|
||||||
|
if (ttsSpeaking.value) ttsStop()
|
||||||
|
endHoldSpeech()
|
||||||
|
clearVoiceTimers()
|
||||||
|
voiceFlowToken += 1
|
||||||
|
if (voiceDialog.stage === 'confirm') {
|
||||||
|
finishVoiceDialog()
|
||||||
|
} else {
|
||||||
|
askCurrentStep()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 手动跳过当前项 */
|
||||||
|
function skipCurrentStepManually() {
|
||||||
|
if (!voiceDialog.active || voiceDialog.stage === 'confirm') return
|
||||||
|
if (ttsSpeaking.value) ttsStop()
|
||||||
|
endHoldSpeech()
|
||||||
|
clearVoiceTimers()
|
||||||
|
voiceFlowToken += 1
|
||||||
|
const step = currentDialogStep()
|
||||||
|
gotoNextStep(step ? `${step.label}先空着。` : '')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 停止/退出语音问答(始终停掉录音与播报);silent=true 时不再额外提示 */
|
||||||
|
function stopVoiceDialog() {
|
||||||
|
clearVoiceTimers()
|
||||||
|
voiceFlowToken += 1
|
||||||
|
endHoldSpeech()
|
||||||
|
ttsStop()
|
||||||
|
voiceDialog.active = false
|
||||||
|
voiceDialog.phase = 'idle'
|
||||||
|
voiceDialog.stage = 'collect'
|
||||||
|
voiceDialog.barged = false
|
||||||
|
voiceDialog.pendingValue = ''
|
||||||
|
voiceDialog.heardText = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// 是否处于「正在录音/可作答」状态:仅聆听阶段(读题时不录音,避免回声)
|
||||||
|
const voiceDialogRecording = computed(() => voiceDialog.phase === 'listening')
|
||||||
|
|
||||||
|
const voiceDialogStatusText = computed(() => {
|
||||||
|
if (voiceDialog.phase === 'listening') {
|
||||||
|
return voiceDialog.stage === 'confirm' ? '请回答:提交 或 取消' : '请说出数字,说完点「说完了」'
|
||||||
|
}
|
||||||
|
if (voiceDialog.phase === 'asking') {
|
||||||
|
return '正在提问…'
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
})
|
||||||
|
|
||||||
function onExerciseField(field, e) {
|
function onExerciseField(field, e) {
|
||||||
exerciseForm.value[field] = e?.detail?.value ?? ''
|
exerciseForm.value[field] = e?.detail?.value ?? ''
|
||||||
}
|
}
|
||||||
@@ -2287,6 +2790,7 @@ const ttsAutoPlay = ref(false)
|
|||||||
const ttsSpeaking = ref(false)
|
const ttsSpeaking = ref(false)
|
||||||
let ttsAudioCtx = null
|
let ttsAudioCtx = null
|
||||||
let ttsAutoToken = 0
|
let ttsAutoToken = 0
|
||||||
|
let ttsPlayToken = 0 // 每次 ttsStop/ttsSpeak 自增,作废被打断的异步播放(如抢答时仍在合成中的语音)
|
||||||
|
|
||||||
function teardownTtsAudio() {
|
function teardownTtsAudio() {
|
||||||
if (ttsAudioCtx) {
|
if (ttsAudioCtx) {
|
||||||
@@ -2297,6 +2801,7 @@ function teardownTtsAudio() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ttsStop() {
|
function ttsStop() {
|
||||||
|
ttsPlayToken += 1
|
||||||
teardownTtsAudio()
|
teardownTtsAudio()
|
||||||
// #ifdef H5
|
// #ifdef H5
|
||||||
try {
|
try {
|
||||||
@@ -2329,12 +2834,30 @@ function normalizeTtsText(text) {
|
|||||||
return formatUserMessage(text, '')
|
return formatUserMessage(text, '')
|
||||||
}
|
}
|
||||||
|
|
||||||
function ttsSpeak(text) {
|
/**
|
||||||
if (!ttsEnabled.value) return
|
* 文字转语音播报。
|
||||||
|
* @param {string|object|Array} text 播报内容
|
||||||
|
* @param {{ force?: boolean, onDone?: () => void }} [opts]
|
||||||
|
* - force: 忽略 ttsEnabled 开关强制播报(语音问答流程使用)
|
||||||
|
* - onDone: 播报结束/失败后回调(仅触发一次),用于串联「问→听」节奏
|
||||||
|
*/
|
||||||
|
function ttsSpeak(text, opts = {}) {
|
||||||
|
const { force = false, onDone } = opts || {}
|
||||||
|
let doneCalled = false
|
||||||
|
const fireDone = () => {
|
||||||
|
if (doneCalled) return
|
||||||
|
doneCalled = true
|
||||||
|
if (typeof onDone === 'function') {
|
||||||
|
try { onDone() } catch (e) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ttsEnabled.value && !force) { fireDone(); return }
|
||||||
const content = normalizeTtsText(text)
|
const content = normalizeTtsText(text)
|
||||||
if (!content) return
|
if (!content) { fireDone(); return }
|
||||||
|
|
||||||
ttsStop()
|
ttsStop()
|
||||||
|
const myToken = ++ttsPlayToken
|
||||||
|
|
||||||
// H5:浏览器 SpeechSynthesis(慢速适老化)
|
// H5:浏览器 SpeechSynthesis(慢速适老化)
|
||||||
// #ifdef H5
|
// #ifdef H5
|
||||||
@@ -2346,8 +2869,8 @@ function ttsSpeak(text) {
|
|||||||
u.pitch = 1
|
u.pitch = 1
|
||||||
u.volume = 1
|
u.volume = 1
|
||||||
u.onstart = () => { ttsSpeaking.value = true }
|
u.onstart = () => { ttsSpeaking.value = true }
|
||||||
u.onend = () => { ttsSpeaking.value = false }
|
u.onend = () => { ttsSpeaking.value = false; fireDone() }
|
||||||
u.onerror = () => { ttsSpeaking.value = false }
|
u.onerror = () => { ttsSpeaking.value = false; fireDone() }
|
||||||
window.speechSynthesis.speak(u)
|
window.speechSynthesis.speak(u)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -2365,8 +2888,14 @@ function ttsSpeak(text) {
|
|||||||
tts: true,
|
tts: true,
|
||||||
content,
|
content,
|
||||||
success: (res) => {
|
success: (res) => {
|
||||||
|
// 已被打断(如用户抢答触发 ttsStop):不要再播放这段迟到的语音
|
||||||
|
if (myToken !== ttsPlayToken) {
|
||||||
|
fireDone()
|
||||||
|
return
|
||||||
|
}
|
||||||
if (!res || !res.filename) {
|
if (!res || !res.filename) {
|
||||||
ttsSpeaking.value = false
|
ttsSpeaking.value = false
|
||||||
|
fireDone()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
teardownTtsAudio()
|
teardownTtsAudio()
|
||||||
@@ -2374,15 +2903,16 @@ function ttsSpeak(text) {
|
|||||||
ttsAudioCtx.src = res.filename
|
ttsAudioCtx.src = res.filename
|
||||||
ttsAudioCtx.obeyMuteSwitch = false
|
ttsAudioCtx.obeyMuteSwitch = false
|
||||||
ttsAudioCtx.onPlay(() => { ttsSpeaking.value = true })
|
ttsAudioCtx.onPlay(() => { ttsSpeaking.value = true })
|
||||||
ttsAudioCtx.onEnded(() => { ttsSpeaking.value = false })
|
ttsAudioCtx.onEnded(() => { ttsSpeaking.value = false; fireDone() })
|
||||||
ttsAudioCtx.onError(() => { ttsSpeaking.value = false })
|
ttsAudioCtx.onError(() => { ttsSpeaking.value = false; fireDone() })
|
||||||
ttsAudioCtx.onStop(() => { ttsSpeaking.value = false })
|
ttsAudioCtx.onStop(() => { ttsSpeaking.value = false; fireDone() })
|
||||||
ttsAudioCtx.play()
|
ttsAudioCtx.play()
|
||||||
},
|
},
|
||||||
fail: (err) => {
|
fail: (err) => {
|
||||||
ttsSpeaking.value = false
|
ttsSpeaking.value = false
|
||||||
console.warn('WechatSI textToSpeech failed', err)
|
console.warn('WechatSI textToSpeech failed', err)
|
||||||
uni.showToast({ title: '语音不可用,请检查网络/插件', icon: 'none', duration: 2000 })
|
uni.showToast({ title: '语音不可用,请检查网络/插件', icon: 'none', duration: 2000 })
|
||||||
|
fireDone()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
@@ -2393,6 +2923,8 @@ function ttsSpeak(text) {
|
|||||||
|
|
||||||
// 其它端兜底:轻提示,勿用 modal 遮挡录入等操作
|
// 其它端兜底:轻提示,勿用 modal 遮挡录入等操作
|
||||||
showUserToast(content, { duration: 3200 })
|
showUserToast(content, { duration: 3200 })
|
||||||
|
// 兜底估算播报时长后回调(按字数粗略估时)
|
||||||
|
setTimeout(fireDone, Math.min(6000, 1200 + content.length * 180))
|
||||||
}
|
}
|
||||||
|
|
||||||
function ttsToggleEnabled() {
|
function ttsToggleEnabled() {
|
||||||
@@ -3022,10 +3554,12 @@ onPullDownRefresh(async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onHide(() => {
|
onHide(() => {
|
||||||
|
stopVoiceDialog(true)
|
||||||
ttsStop()
|
ttsStop()
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnload(() => {
|
onUnload(() => {
|
||||||
|
stopVoiceDialog(true)
|
||||||
ttsStop()
|
ttsStop()
|
||||||
clearWateringFx()
|
clearWateringFx()
|
||||||
})
|
})
|
||||||
@@ -5755,6 +6289,156 @@ function onFamilyLikeStripTap() {
|
|||||||
box-shadow: 0 0 0 24rpx rgba(255, 255, 255, 0);
|
box-shadow: 0 0 0 24rpx rgba(255, 255, 255, 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* === 语音一问一答面板 === */
|
||||||
|
.voice-dialog {
|
||||||
|
margin-bottom: 22rpx;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
background: linear-gradient(135deg, #f0fdf4, #ecfeff);
|
||||||
|
border: 2rpx solid #bbf7d0;
|
||||||
|
overflow: hidden;
|
||||||
|
&.active {
|
||||||
|
border-color: #006c49;
|
||||||
|
box-shadow: 0 10rpx 28rpx rgba(0, 108, 73, 0.16);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.voice-dialog-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 20rpx 24rpx;
|
||||||
|
gap: 16rpx;
|
||||||
|
}
|
||||||
|
.voice-dialog-title-wrap {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16rpx;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.voice-dialog-orb {
|
||||||
|
width: 64rpx;
|
||||||
|
height: 64rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #0d9488;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
&.speaking { animation: voice-icon-pulse 1.3s ease-in-out infinite; }
|
||||||
|
&.listening {
|
||||||
|
background: #006c49;
|
||||||
|
animation: voice-rec-wave 1.1s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.voice-dialog-title-text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4rpx;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.voice-dialog-title {
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #006c49;
|
||||||
|
letter-spacing: 1rpx;
|
||||||
|
}
|
||||||
|
.voice-dialog-tip {
|
||||||
|
font-size: 23rpx;
|
||||||
|
color: #0f766e;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
.voice-dialog-start,
|
||||||
|
.voice-dialog-stop {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 12rpx 26rpx;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
font-size: 26rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.voice-dialog-start {
|
||||||
|
background: #006c49;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
.voice-dialog-stop {
|
||||||
|
background: rgba(15, 23, 42, 0.06);
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
.voice-dialog-body {
|
||||||
|
padding: 0 24rpx 22rpx;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14rpx;
|
||||||
|
}
|
||||||
|
.voice-dialog-bubble {
|
||||||
|
max-width: 86%;
|
||||||
|
padding: 16rpx 20rpx;
|
||||||
|
border-radius: 18rpx;
|
||||||
|
font-size: 28rpx;
|
||||||
|
line-height: 1.5;
|
||||||
|
&.bot {
|
||||||
|
align-self: flex-start;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #1f2937;
|
||||||
|
border: 2rpx solid #d1fae5;
|
||||||
|
border-bottom-left-radius: 6rpx;
|
||||||
|
}
|
||||||
|
&.user {
|
||||||
|
align-self: flex-end;
|
||||||
|
background: #006c49;
|
||||||
|
color: #ffffff;
|
||||||
|
border-bottom-right-radius: 6rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.voice-dialog-status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14rpx;
|
||||||
|
min-height: 36rpx;
|
||||||
|
}
|
||||||
|
.voice-dialog-status-text {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #0f766e;
|
||||||
|
}
|
||||||
|
.voice-dialog-wave {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 6rpx;
|
||||||
|
height: 32rpx;
|
||||||
|
}
|
||||||
|
.voice-dialog-wave-bar {
|
||||||
|
width: 6rpx;
|
||||||
|
border-radius: 4rpx;
|
||||||
|
background: #006c49;
|
||||||
|
animation: voice-dialog-wave-jump 0.9s ease-in-out infinite;
|
||||||
|
&:nth-child(1) { height: 14rpx; animation-delay: 0s; }
|
||||||
|
&:nth-child(2) { height: 26rpx; animation-delay: 0.15s; }
|
||||||
|
&:nth-child(3) { height: 20rpx; animation-delay: 0.3s; }
|
||||||
|
&:nth-child(4) { height: 30rpx; animation-delay: 0.45s; }
|
||||||
|
}
|
||||||
|
@keyframes voice-dialog-wave-jump {
|
||||||
|
0%, 100% { transform: scaleY(0.5); }
|
||||||
|
50% { transform: scaleY(1); }
|
||||||
|
}
|
||||||
|
.voice-dialog-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 14rpx;
|
||||||
|
margin-top: 4rpx;
|
||||||
|
}
|
||||||
|
.voice-dialog-btn {
|
||||||
|
flex: 1;
|
||||||
|
text-align: center;
|
||||||
|
padding: 16rpx 0;
|
||||||
|
border-radius: 14rpx;
|
||||||
|
font-size: 26rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #006c49;
|
||||||
|
background: rgba(0, 108, 73, 0.08);
|
||||||
|
&:active { transform: scale(0.98); }
|
||||||
|
&.primary {
|
||||||
|
color: #ffffff;
|
||||||
|
background: #006c49;
|
||||||
|
}
|
||||||
|
}
|
||||||
.input-section-title {
|
.input-section-title {
|
||||||
font-size: 28rpx;
|
font-size: 28rpx;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
|||||||
Reference in New Issue
Block a user