更新
This commit is contained in:
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -47,21 +47,7 @@
|
|||||||
|
|
||||||
<view class="st-main">
|
<view class="st-main">
|
||||||
<!-- 1. 录入今日血糖 -->
|
<!-- 1. 录入今日血糖 -->
|
||||||
<view class="st-cta" @click="openInputForm()">
|
|
||||||
<TongjiIcon name="plus" size="md" color="#FFFFFF" />
|
|
||||||
<text>录入今日血糖</text>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view class="st-game-entry" @click="goGamePage">
|
|
||||||
<view class="st-game-entry-icon">
|
|
||||||
<TongjiIcon name="activity" size="md" color="#006c49" />
|
|
||||||
</view>
|
|
||||||
<view class="st-game-entry-body">
|
|
||||||
<text class="st-game-entry-title">糖分突袭</text>
|
|
||||||
<text class="st-game-entry-sub">消除棋盘食物,边玩边学控糖</text>
|
|
||||||
</view>
|
|
||||||
<TongjiIcon name="chevron-right" size="sm" color="#64748b" />
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<!-- 2. 血糖趋势 -->
|
<!-- 2. 血糖趋势 -->
|
||||||
<view v-if="diagnosisId" class="st-chart-card">
|
<view v-if="diagnosisId" class="st-chart-card">
|
||||||
@@ -134,6 +120,21 @@
|
|||||||
</cover-view>
|
</cover-view>
|
||||||
</cover-view>
|
</cover-view>
|
||||||
</view>
|
</view>
|
||||||
|
<view class="st-cta" @click="openInputForm()">
|
||||||
|
<TongjiIcon name="plus" size="md" color="#FFFFFF" />
|
||||||
|
<text>录入今日血糖</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="st-game-entry" @click="goGamePage">
|
||||||
|
<view class="st-game-entry-icon">
|
||||||
|
<TongjiIcon name="activity" size="md" color="#006c49" />
|
||||||
|
</view>
|
||||||
|
<view class="st-game-entry-body">
|
||||||
|
<text class="st-game-entry-title">糖分突袭</text>
|
||||||
|
<text class="st-game-entry-sub">消除棋盘食物,边玩边学控糖</text>
|
||||||
|
</view>
|
||||||
|
<TongjiIcon name="chevron-right" size="sm" color="#64748b" />
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 3. 最新测量 & 概览 -->
|
<!-- 3. 最新测量 & 概览 -->
|
||||||
@@ -237,12 +238,21 @@
|
|||||||
class="st-float-input"
|
class="st-float-input"
|
||||||
type="text"
|
type="text"
|
||||||
:value="dietAiAskText"
|
:value="dietAiAskText"
|
||||||
placeholder="输入食物名称或健康疑问,咨询 AI..."
|
placeholder="长按麦克风说话,或输入文字咨询 AI..."
|
||||||
placeholder-class="st-float-placeholder"
|
placeholder-class="st-float-placeholder"
|
||||||
confirm-type="send"
|
confirm-type="send"
|
||||||
@input="onDietAiAskInput"
|
@input="onDietAiAskInput"
|
||||||
@confirm="askDietAiFood"
|
@confirm="askDietAiFood"
|
||||||
/>
|
/>
|
||||||
|
<view
|
||||||
|
class="st-float-mic"
|
||||||
|
:class="{ listening: sttListening || sttHolding, disabled: dietAiAsking || !sttSupported }"
|
||||||
|
@touchstart.stop.prevent="onDietAiSpeechStart"
|
||||||
|
@touchend.stop.prevent="onDietAiSpeechEnd"
|
||||||
|
@touchcancel.stop.prevent="onDietAiSpeechEnd"
|
||||||
|
>
|
||||||
|
<TongjiIcon name="mic" size="sm" :color="(sttListening || sttHolding) ? '#ffffff' : '#006c49'" />
|
||||||
|
</view>
|
||||||
<view
|
<view
|
||||||
class="st-float-send"
|
class="st-float-send"
|
||||||
:class="{ disabled: dietAiAsking }"
|
:class="{ disabled: dietAiAsking }"
|
||||||
@@ -251,6 +261,9 @@
|
|||||||
<TongjiIcon name="send" size="sm" color="#ffffff" />
|
<TongjiIcon name="send" size="sm" color="#ffffff" />
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
<view v-if="sttHolding && !dietAiAsking" class="st-float-hold-hint">
|
||||||
|
<text>{{ sttListening ? '松手结束' : '准备录音…' }}</text>
|
||||||
|
</view>
|
||||||
<view v-if="dietAiAsking || dietAiAskResult.advice" class="st-float-result">
|
<view v-if="dietAiAsking || dietAiAskResult.advice" class="st-float-result">
|
||||||
<view v-if="dietAiAskResult.level_label" class="st-float-badge">
|
<view v-if="dietAiAskResult.level_label" class="st-float-badge">
|
||||||
<text>{{ dietAiAskResult.level_label }}</text>
|
<text>{{ dietAiAskResult.level_label }}</text>
|
||||||
@@ -419,6 +432,7 @@ import { onLoad, onShow, onHide, onUnload, onPullDownRefresh, onShareAppMessage
|
|||||||
import SugarTreeGraphic from '../components/SugarTreeGraphic.vue'
|
import SugarTreeGraphic from '../components/SugarTreeGraphic.vue'
|
||||||
import TongjiIcon from '../components/TongjiIcon.vue'
|
import TongjiIcon from '../components/TongjiIcon.vue'
|
||||||
import { useDietAi } from '../composables/useDietAi.js'
|
import { useDietAi } from '../composables/useDietAi.js'
|
||||||
|
import { useSpeechToText } from '../composables/useSpeechToText.js'
|
||||||
|
|
||||||
const { proxy } = getCurrentInstance()
|
const { proxy } = getCurrentInstance()
|
||||||
|
|
||||||
@@ -487,6 +501,30 @@ const {
|
|||||||
openDietForm
|
openDietForm
|
||||||
} = useDietAi(proxy, { diagnosisId, showUserToast, formatUserMessage })
|
} = 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天 血糖统计 */
|
/** AI 饮食建议依据:近7天 / 近30天 血糖统计 */
|
||||||
const dietAnalysisList = computed(() => {
|
const dietAnalysisList = computed(() => {
|
||||||
const list = dietAiRecommend.value && Array.isArray(dietAiRecommend.value.analysis)
|
const list = dietAiRecommend.value && Array.isArray(dietAiRecommend.value.analysis)
|
||||||
@@ -2495,10 +2533,12 @@ onPullDownRefresh(async () => {
|
|||||||
|
|
||||||
onHide(() => {
|
onHide(() => {
|
||||||
ttsStop()
|
ttsStop()
|
||||||
|
stopSpeechToText()
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnload(() => {
|
onUnload(() => {
|
||||||
ttsStop()
|
ttsStop()
|
||||||
|
stopSpeechToText()
|
||||||
})
|
})
|
||||||
|
|
||||||
// ============ 邀请观看(家人分享页)============
|
// ============ 邀请观看(家人分享页)============
|
||||||
|
|||||||
@@ -647,6 +647,45 @@
|
|||||||
font-size: 28rpx;
|
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 {
|
.weekly-page .st-float-send {
|
||||||
width: 80rpx;
|
width: 80rpx;
|
||||||
height: 80rpx;
|
height: 80rpx;
|
||||||
|
|||||||
Reference in New Issue
Block a user