更新
This commit is contained in:
@@ -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
|
||||
|
||||
/**
|
||||
* 语音转文字(长按说话):微信小程序 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 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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user