From a38af5bb132746f8fd473039f32b7b6451099956 Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 1 Jun 2026 18:04:23 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tongji/composables/useSpeechToText.js | 260 ++++++++++++++++++ TUICallKit-Vue3/tongji/pages/weekly.vue | 72 +++-- .../tongji/styles/weekly-stitch.scss | 39 +++ 3 files changed, 355 insertions(+), 16 deletions(-) create mode 100644 TUICallKit-Vue3/tongji/composables/useSpeechToText.js diff --git a/TUICallKit-Vue3/tongji/composables/useSpeechToText.js b/TUICallKit-Vue3/tongji/composables/useSpeechToText.js new file mode 100644 index 00000000..fa79fde2 --- /dev/null +++ b/TUICallKit-Vue3/tongji/composables/useSpeechToText.js @@ -0,0 +1,260 @@ +import { ref, onUnmounted } from 'vue' + +const MIN_HOLD_MS = 280 + +/** + * 语音转文字(长按说话):微信小程序 WechatSI;H5 Web Speech API。 + * + * @param {{ onResult?: (text: string) => void, showToast?: (msg: string) => void }} options + */ +export function useSpeechToText({ onResult, showToast } = {}) { + const sttListening = ref(false) + const sttHolding = ref(false) + const sttSupported = ref(false) + + let wxRecordManager = null + let h5Recognition = null + let holdStartTs = 0 + let holdSessionId = 0 + let startRequested = false + let recordAuthorized = null + + function notify(msg) { + if (!msg) return + if (typeof showToast === 'function') { + showToast(msg) + return + } + uni.showToast({ title: msg, icon: 'none', duration: 2000 }) + } + + function emitResult(text) { + const t = String(text || '').trim() + const heldMs = Date.now() - holdStartTs + if (!t) { + if (heldMs < MIN_HOLD_MS) return + notify('没听清,请再试一次') + return + } + if (typeof onResult === 'function') { + onResult(t) + } + } + + function stopListening() { + // #ifdef MP-WEIXIN + if (wxRecordManager && (sttListening.value || startRequested)) { + try { + wxRecordManager.stop() + } catch (e) {} + } + // #endif + + // #ifdef H5 + if (h5Recognition && sttListening.value) { + try { + h5Recognition.stop() + } catch (e) {} + } + // #endif + + startRequested = false + sttListening.value = false + } + + function ensureRecordAuth(onGranted) { + // #ifdef MP-WEIXIN + if (recordAuthorized === true) { + onGranted() + return + } + uni.authorize({ + scope: 'scope.record', + success() { + recordAuthorized = true + onGranted() + }, + fail() { + recordAuthorized = false + sttHolding.value = false + uni.showModal({ + title: '需要麦克风权限', + content: '请在设置中允许使用麦克风,以便长按说话', + confirmText: '去设置', + success(res) { + if (res.confirm) { + uni.openSetting({}) + } + } + }) + } + }) + // #endif + // #ifndef MP-WEIXIN + onGranted() + // #endif + } + + function startWechatRecord(session) { + // #ifdef MP-WEIXIN + if (!wxRecordManager || session !== holdSessionId || !sttHolding.value) { + return + } + try { + startRequested = true + wxRecordManager.start({ + duration: 60000, + lang: 'zh_CN' + }) + } catch (e) { + startRequested = false + sttHolding.value = false + notify('无法开始录音') + } + // #endif + } + + function startH5Record() { + // #ifdef H5 + if (!h5Recognition) { + notify('当前浏览器不支持语音输入') + sttHolding.value = false + return + } + try { + startRequested = true + h5Recognition.start() + sttListening.value = true + } catch (e) { + startRequested = false + sttHolding.value = false + notify('无法开始语音识别,请检查麦克风权限') + } + // #endif + } + + /** 长按开始:手指按下 */ + function beginHoldSpeech() { + if (!sttSupported.value) { + notify('当前环境不支持语音输入') + return + } + if (sttHolding.value) return + + const session = ++holdSessionId + holdStartTs = Date.now() + sttHolding.value = true + + // #ifdef MP-WEIXIN + ensureRecordAuth(() => startWechatRecord(session)) + // #endif + // #ifdef H5 + startH5Record() + // #endif + // #ifndef MP-WEIXIN + // #ifndef H5 + sttHolding.value = false + notify('当前端暂不支持语音输入') + // #endif + // #endif + } + + /** 松手结束:手指抬起 */ + function endHoldSpeech() { + if (!sttHolding.value && !sttListening.value && !startRequested) { + return + } + sttHolding.value = false + if (sttListening.value || startRequested) { + stopListening() + return + } + holdSessionId += 1 + } + + // #ifdef MP-WEIXIN + try { + // eslint-disable-next-line no-undef + const plugin = requirePlugin('WechatSI') + wxRecordManager = plugin.getRecordRecognitionManager() + sttSupported.value = !!wxRecordManager + + wxRecordManager.onStart = () => { + if (!sttHolding.value) { + try { + wxRecordManager.stop() + } catch (e) {} + return + } + startRequested = false + sttListening.value = true + } + wxRecordManager.onStop = (res) => { + sttListening.value = false + startRequested = false + emitResult(res?.result) + } + wxRecordManager.onError = (res) => { + sttListening.value = false + startRequested = false + sttHolding.value = false + notify(res?.msg || '语音识别失败') + } + } catch (e) { + console.warn('WechatSI record recognition not available', e) + } + // #endif + + // #ifdef H5 + try { + if (typeof window !== 'undefined') { + const SR = window.SpeechRecognition || window.webkitSpeechRecognition + if (SR) { + h5Recognition = new SR() + h5Recognition.lang = 'zh-CN' + h5Recognition.interimResults = false + h5Recognition.continuous = false + h5Recognition.maxAlternatives = 1 + sttSupported.value = true + + h5Recognition.onresult = (event) => { + const item = event.results?.[0]?.[0] + emitResult(item?.transcript || '') + } + h5Recognition.onend = () => { + sttListening.value = false + startRequested = false + sttHolding.value = false + } + h5Recognition.onerror = (event) => { + sttListening.value = false + startRequested = false + sttHolding.value = false + if (event?.error === 'not-allowed') { + notify('请允许浏览器使用麦克风') + } else if (event?.error !== 'aborted') { + notify('语音识别失败') + } + } + } + } + } catch (e) { + console.warn('H5 SpeechRecognition not available', e) + } + // #endif + + onUnmounted(() => { + sttHolding.value = false + holdSessionId += 1 + stopListening() + }) + + return { + sttListening, + sttHolding, + sttSupported, + beginHoldSpeech, + endHoldSpeech, + stopSpeechToText: endHoldSpeech + } +} diff --git a/TUICallKit-Vue3/tongji/pages/weekly.vue b/TUICallKit-Vue3/tongji/pages/weekly.vue index 2cd37309..0625a380 100644 --- a/TUICallKit-Vue3/tongji/pages/weekly.vue +++ b/TUICallKit-Vue3/tongji/pages/weekly.vue @@ -47,21 +47,7 @@ - - - 录入今日血糖 - - - - - - - - 糖分突袭 - 消除棋盘食物,边玩边学控糖 - - - + @@ -134,6 +120,21 @@ + + + 录入今日血糖 + + + + + + + + 糖分突袭 + 消除棋盘食物,边玩边学控糖 + + + @@ -237,12 +238,21 @@ class="st-float-input" type="text" :value="dietAiAskText" - placeholder="输入食物名称或健康疑问,咨询 AI..." + placeholder="长按麦克风说话,或输入文字咨询 AI..." placeholder-class="st-float-placeholder" confirm-type="send" @input="onDietAiAskInput" @confirm="askDietAiFood" /> + + + + + {{ sttListening ? '松手结束' : '准备录音…' }} + {{ dietAiAskResult.level_label }} @@ -419,6 +432,7 @@ import { onLoad, onShow, onHide, onUnload, onPullDownRefresh, onShareAppMessage import SugarTreeGraphic from '../components/SugarTreeGraphic.vue' import TongjiIcon from '../components/TongjiIcon.vue' import { useDietAi } from '../composables/useDietAi.js' +import { useSpeechToText } from '../composables/useSpeechToText.js' const { proxy } = getCurrentInstance() @@ -487,6 +501,30 @@ const { openDietForm } = useDietAi(proxy, { diagnosisId, showUserToast, formatUserMessage }) +const { + sttListening, + sttHolding, + sttSupported, + beginHoldSpeech, + endHoldSpeech, + stopSpeechToText +} = useSpeechToText({ + onResult(text) { + const prev = String(dietAiAskText.value || '').trim() + dietAiAskText.value = prev ? `${prev} ${text}` : text + }, + showToast: (msg) => showUserToast(msg) +}) + +function onDietAiSpeechStart() { + if (dietAiAsking.value) return + beginHoldSpeech() +} + +function onDietAiSpeechEnd() { + endHoldSpeech() +} + /** AI 饮食建议依据:近7天 / 近30天 血糖统计 */ const dietAnalysisList = computed(() => { const list = dietAiRecommend.value && Array.isArray(dietAiRecommend.value.analysis) @@ -2495,10 +2533,12 @@ onPullDownRefresh(async () => { onHide(() => { ttsStop() + stopSpeechToText() }) onUnload(() => { ttsStop() + stopSpeechToText() }) // ============ 邀请观看(家人分享页)============ diff --git a/TUICallKit-Vue3/tongji/styles/weekly-stitch.scss b/TUICallKit-Vue3/tongji/styles/weekly-stitch.scss index 428d39f4..8ca45eb6 100644 --- a/TUICallKit-Vue3/tongji/styles/weekly-stitch.scss +++ b/TUICallKit-Vue3/tongji/styles/weekly-stitch.scss @@ -647,6 +647,45 @@ font-size: 28rpx; } +.weekly-page .st-float-mic { + width: 72rpx; + height: 72rpx; + border-radius: 50%; + background: rgba(0, 108, 73, 0.1); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + transition: background 0.2s ease, transform 0.15s ease; +} + +.weekly-page .st-float-mic.listening { + background: var(--primary); + animation: st-float-mic-pulse 1.2s ease-in-out infinite; +} + +.weekly-page .st-float-mic.disabled { + opacity: 0.45; + pointer-events: none; +} + +.weekly-page .st-float-mic:active { + transform: scale(0.94); +} + +.weekly-page .st-float-hold-hint { + margin-top: 12rpx; + text-align: center; + font-size: 24rpx; + color: var(--primary); + line-height: 1.4; +} + +@keyframes st-float-mic-pulse { + 0%, 100% { box-shadow: 0 0 0 0 rgba(0, 108, 73, 0.35); } + 50% { box-shadow: 0 0 0 12rpx rgba(0, 108, 73, 0); } +} + .weekly-page .st-float-send { width: 80rpx; height: 80rpx;