This commit is contained in:
Your Name
2026-06-10 09:49:46 +08:00
parent 072c0c3663
commit abc78f4291
2 changed files with 833 additions and 44 deletions
@@ -2,25 +2,84 @@ import { ref, onUnmounted } from 'vue'
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
/**
* 语音转文字(长按说话):微信小程序 WechatSIH5 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 sttHolding = ref(false)
const sttSupported = ref(false)
let wxRecordManager = null
let h5Recognition = null
let h5GotResult = false
let holdStartTs = 0
let holdSessionId = 0
let startRequested = false
let recordAuthorized = null
// WechatSI 录音管理器是全局单例:跨页面/快速点按可能残留会话,
// 用以下两个标记把 start/stop 串行化,避免 "please stop after start"
let managerRecording = false
// 快速点按可能残留会话:用排队标记把 start/stop 串行化
// 避免 "please stop after start"
let pendingStartSession = 0
function notify(msg) {
@@ -35,6 +94,11 @@ export function useSpeechToText({ onResult, showToast } = {}) {
function emitResult(text) {
const t = String(text || '').trim()
const heldMs = Date.now() - holdStartTs
// onSettle 总会收到结果(含空),返回 true 表示已完全接管
if (typeof onSettle === 'function') {
const handled = onSettle(t, { heldMs })
if (handled === true) return
}
if (!t) {
if (heldMs < MIN_HOLD_MS) return
notify('没听清,请再试一次')
@@ -47,7 +111,8 @@ export function useSpeechToText({ onResult, showToast } = {}) {
function stopListening() {
// #ifdef MP-WEIXIN
if (wxRecordManager && (sttListening.value || startRequested || managerRecording)) {
const ownsRecording = sharedRecording && activeCtl === controller
if (wxRecordManager && (sttListening.value || startRequested || ownsRecording)) {
try {
wxRecordManager.stop()
} catch (e) {}
@@ -106,14 +171,14 @@ export function useSpeechToText({ onResult, showToast } = {}) {
}
try {
startRequested = true
managerRecording = true
sharedRecording = true
wxRecordManager.start({
duration: 60000,
lang: 'zh_CN'
})
} catch (e) {
startRequested = false
managerRecording = false
sharedRecording = false
sttHolding.value = false
notify('无法开始录音')
}
@@ -127,7 +192,7 @@ export function useSpeechToText({ onResult, showToast } = {}) {
}
// 上一段会话还没结束(含全局单例残留):先停止并排队,
// 待 onStop 回调后再真正开始,避免 "please stop after start"
if (managerRecording || startRequested) {
if (sharedRecording || startRequested) {
pendingStartSession = session
try {
wxRecordManager.stop()
@@ -147,6 +212,7 @@ export function useSpeechToText({ onResult, showToast } = {}) {
}
try {
startRequested = true
h5GotResult = false
h5Recognition.start()
sttListening.value = true
} catch (e) {
@@ -170,6 +236,8 @@ export function useSpeechToText({ onResult, showToast } = {}) {
sttHolding.value = true
// #ifdef MP-WEIXIN
// 把全局录音回调切到当前实例
activeCtl = controller
ensureRecordAuth(() => startWechatRecord(session))
// #endif
// #ifdef H5
@@ -185,13 +253,17 @@ export function useSpeechToText({ onResult, showToast } = {}) {
/** 松手结束:手指抬起 */
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
}
sttHolding.value = false
// 松手后不再补开排队的录音
pendingStartSession = 0
if (sttListening.value || startRequested || managerRecording) {
if (sttListening.value || startRequested || recording) {
stopListening()
return
}
@@ -199,14 +271,10 @@ export function useSpeechToText({ onResult, showToast } = {}) {
}
// #ifdef MP-WEIXIN
try {
// eslint-disable-next-line no-undef
const plugin = requirePlugin('WechatSI')
wxRecordManager = plugin.getRecordRecognitionManager()
sttSupported.value = !!wxRecordManager
wxRecordManager.onStart = () => {
managerRecording = true
// 当前实例的回调控制器:全局单例 manager 的事件由模块级分发器
// 转发给 activeCtl(最后一次 beginHoldSpeech 的实例)
const controller = {
handleStart() {
if (!sttHolding.value) {
try {
wxRecordManager.stop()
@@ -215,9 +283,13 @@ export function useSpeechToText({ onResult, showToast } = {}) {
}
startRequested = false
sttListening.value = true
}
wxRecordManager.onStop = (res) => {
managerRecording = false
},
handleRecognize(res) {
if (typeof onPartial !== 'function') return
const t = String(res?.result || '').trim()
if (t) onPartial(t)
},
handleStop(res) {
sttListening.value = false
startRequested = false
// 有排队的开始请求(残留会话已停止 / 用户仍按住):现在再真正开始
@@ -229,9 +301,8 @@ export function useSpeechToText({ onResult, showToast } = {}) {
}
pendingStartSession = 0
emitResult(res?.result)
}
wxRecordManager.onError = (res) => {
managerRecording = false
},
handleError(res) {
sttListening.value = false
startRequested = false
const msg = (res && res.msg) || ''
@@ -245,11 +316,17 @@ export function useSpeechToText({ onResult, showToast } = {}) {
}
pendingStartSession = 0
sttHolding.value = false
// 上层(如一问一答流程)可接管错误并自行重试,返回 true 时不再弹默认提示
if (typeof onError === 'function') {
const handled = onError(msg || '')
if (handled === true) return
}
notify(msg || '语音识别失败')
}
} catch (e) {
console.warn('WechatSI record recognition not available', e)
}
wxRecordManager = getSharedRecordManager()
sttSupported.value = !!wxRecordManager
// #endif
// #ifdef H5
@@ -259,27 +336,49 @@ export function useSpeechToText({ onResult, showToast } = {}) {
if (SR) {
h5Recognition = new SR()
h5Recognition.lang = 'zh-CN'
h5Recognition.interimResults = false
h5Recognition.interimResults = true
h5Recognition.continuous = false
h5Recognition.maxAlternatives = 1
sttSupported.value = true
h5Recognition.onresult = (event) => {
const item = event.results?.[0]?.[0]
emitResult(item?.transcript || '')
let interim = ''
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 = () => {
sttListening.value = false
startRequested = false
sttHolding.value = false
// 无识别结果也要兜底触发一次 settle,便于一问一答流程重试
if (!h5GotResult) {
emitResult('')
}
h5GotResult = false
}
h5Recognition.onerror = (event) => {
sttListening.value = false
startRequested = 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('请允许浏览器使用麦克风')
} else if (event?.error !== 'aborted') {
} else {
notify('语音识别失败')
}
}
@@ -295,6 +394,12 @@ export function useSpeechToText({ onResult, showToast } = {}) {
holdSessionId += 1
pendingStartSession = 0
stopListening()
// #ifdef MP-WEIXIN
// 释放全局回调指向,避免事件继续派发给已销毁的实例
if (activeCtl === controller) {
activeCtl = null
}
// #endif
})
return {
+696 -12
View File
@@ -378,6 +378,65 @@
<view class="input-modal-tip">
<text>请如实填写留空表示该项不上传当天可多次修改</text>
</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
class="voice-input-bar"
:class="{ active: (sttListening || sttHolding) && voiceTarget === 'glucose' }"
@@ -663,8 +722,8 @@
</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-icon" :class="{ active: sttListening }">
<TongjiIcon name="mic" size="lg" color="#ffffff" />
@@ -720,7 +779,7 @@
</template>
<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 SugarTreeGraphic from '../components/SugarTreeGraphic.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 { consumeDietAiPrefill } from '../composables/useDietAi.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()
@@ -1718,6 +1777,7 @@ const emptyBpForm = () => ({
const bpForm = ref(emptyBpForm())
function prepareRecordOverlay() {
stopVoiceDialog(true)
ttsStop()
}
@@ -1755,16 +1815,28 @@ async function openInputForm() {
/* 静默:拉取失败时仍允许新增录入 */
} finally {
inputForm.value.loading = false
// 默认采用语音一问一答:打开后自动开始问询(手动录入仍可用)
if (sttSupported.value) {
nextTick(() => {
setTimeout(() => {
if (inputForm.value.visible && !voiceDialog.active) {
startGlucoseVoiceDialog()
}
}, 360)
})
}
}
}
function closeInputForm() {
if (inputForm.value.submitting) return
stopVoiceDialog(true)
inputForm.value.visible = false
}
async function submitInputForm() {
if (inputForm.value.submitting || inputForm.value.loading) return
if (voiceDialog.active) stopVoiceDialog(true)
const f = inputForm.value
const hasAny = [
f.fasting_blood_sugar, f.postprandial_blood_sugar, f.other_blood_sugar
@@ -1804,6 +1876,7 @@ async function submitInputForm() {
function deleteInputRecord() {
if (!inputForm.value.id || inputForm.value.submitting) return
if (voiceDialog.active) stopVoiceDialog(true)
uni.showModal({
title: '删除确认',
content: '确定删除今天已录入的血糖数据吗?删除后无法恢复。',
@@ -2038,8 +2111,27 @@ const {
endHoldSpeech
} = useSpeechToText({
onResult(text) {
// 语音一问一答进行中时,结果由 onSettle → 对话流程接管
if (voiceDialog.active) return
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)
})
@@ -2049,6 +2141,8 @@ function startVoiceInput(target) {
showUserToast('当前环境不支持语音输入')
return
}
// 长按手动录入与一问一答互斥:手动开始时先停掉对话
if (voiceDialog.active) stopVoiceDialog(true)
voiceTarget.value = target
beginHoldSpeech()
}
@@ -2116,6 +2210,415 @@ function appendToField(formRef, field, value) {
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) {
exerciseForm.value[field] = e?.detail?.value ?? ''
}
@@ -2287,6 +2790,7 @@ const ttsAutoPlay = ref(false)
const ttsSpeaking = ref(false)
let ttsAudioCtx = null
let ttsAutoToken = 0
let ttsPlayToken = 0 // 每次 ttsStop/ttsSpeak 自增,作废被打断的异步播放(如抢答时仍在合成中的语音)
function teardownTtsAudio() {
if (ttsAudioCtx) {
@@ -2297,6 +2801,7 @@ function teardownTtsAudio() {
}
function ttsStop() {
ttsPlayToken += 1
teardownTtsAudio()
// #ifdef H5
try {
@@ -2329,12 +2834,30 @@ function normalizeTtsText(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)
if (!content) return
if (!content) { fireDone(); return }
ttsStop()
const myToken = ++ttsPlayToken
// H5:浏览器 SpeechSynthesis(慢速适老化)
// #ifdef H5
@@ -2346,8 +2869,8 @@ function ttsSpeak(text) {
u.pitch = 1
u.volume = 1
u.onstart = () => { ttsSpeaking.value = true }
u.onend = () => { ttsSpeaking.value = false }
u.onerror = () => { ttsSpeaking.value = false }
u.onend = () => { ttsSpeaking.value = false; fireDone() }
u.onerror = () => { ttsSpeaking.value = false; fireDone() }
window.speechSynthesis.speak(u)
return
}
@@ -2365,8 +2888,14 @@ function ttsSpeak(text) {
tts: true,
content,
success: (res) => {
// 已被打断(如用户抢答触发 ttsStop):不要再播放这段迟到的语音
if (myToken !== ttsPlayToken) {
fireDone()
return
}
if (!res || !res.filename) {
ttsSpeaking.value = false
fireDone()
return
}
teardownTtsAudio()
@@ -2374,15 +2903,16 @@ function ttsSpeak(text) {
ttsAudioCtx.src = res.filename
ttsAudioCtx.obeyMuteSwitch = false
ttsAudioCtx.onPlay(() => { ttsSpeaking.value = true })
ttsAudioCtx.onEnded(() => { ttsSpeaking.value = false })
ttsAudioCtx.onError(() => { ttsSpeaking.value = false })
ttsAudioCtx.onStop(() => { ttsSpeaking.value = false })
ttsAudioCtx.onEnded(() => { ttsSpeaking.value = false; fireDone() })
ttsAudioCtx.onError(() => { ttsSpeaking.value = false; fireDone() })
ttsAudioCtx.onStop(() => { ttsSpeaking.value = false; fireDone() })
ttsAudioCtx.play()
},
fail: (err) => {
ttsSpeaking.value = false
console.warn('WechatSI textToSpeech failed', err)
uni.showToast({ title: '语音不可用,请检查网络/插件', icon: 'none', duration: 2000 })
fireDone()
}
})
return
@@ -2393,6 +2923,8 @@ function ttsSpeak(text) {
// 其它端兜底:轻提示,勿用 modal 遮挡录入等操作
showUserToast(content, { duration: 3200 })
// 兜底估算播报时长后回调(按字数粗略估时)
setTimeout(fireDone, Math.min(6000, 1200 + content.length * 180))
}
function ttsToggleEnabled() {
@@ -3022,10 +3554,12 @@ onPullDownRefresh(async () => {
})
onHide(() => {
stopVoiceDialog(true)
ttsStop()
})
onUnload(() => {
stopVoiceDialog(true)
ttsStop()
clearWateringFx()
})
@@ -5755,6 +6289,156 @@ function onFamilyLikeStripTap() {
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 {
font-size: 28rpx;
font-weight: 700;