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;