This commit is contained in:
Your Name
2026-06-09 10:11:40 +08:00
parent bd7002e176
commit f8ccd8b0af
2 changed files with 578 additions and 0 deletions
+278
View File
@@ -378,6 +378,21 @@
<view class="input-modal-tip">
<text>请如实填写留空表示该项不上传当天可多次修改</text>
</view>
<view
class="voice-input-bar"
:class="{ active: (sttListening || sttHolding) && voiceTarget === 'glucose' }"
@touchstart.stop.prevent="startVoiceInput('glucose')"
@touchend.stop.prevent="endVoiceInput"
@touchcancel.stop.prevent="endVoiceInput"
>
<view class="voice-input-icon">
<TongjiIcon name="mic" size="md" :color="(sttListening || sttHolding) && voiceTarget === 'glucose' ? '#ffffff' : '#006c49'" />
</view>
<view class="voice-input-text">
<text class="voice-input-title">{{ (sttListening || sttHolding) && voiceTarget === 'glucose' ? '正在聆听…松手结束' : '长按说话,自动填写' }}</text>
<text class="voice-input-hint">空腹6.5餐后8.2</text>
</view>
</view>
<view class="input-section-title">血糖mmol/L</view>
<view class="input-field">
<view class="input-field-label">
@@ -469,6 +484,21 @@
</view>
<view v-if="bpForm.loading" class="input-modal-loading"><text>加载中</text></view>
<scroll-view v-else scroll-y class="input-modal-body">
<view
class="voice-input-bar"
:class="{ active: (sttListening || sttHolding) && voiceTarget === 'bp' }"
@touchstart.stop.prevent="startVoiceInput('bp')"
@touchend.stop.prevent="endVoiceInput"
@touchcancel.stop.prevent="endVoiceInput"
>
<view class="voice-input-icon">
<TongjiIcon name="mic" size="md" :color="(sttListening || sttHolding) && voiceTarget === 'bp' ? '#ffffff' : '#006c49'" />
</view>
<view class="voice-input-text">
<text class="voice-input-title">{{ (sttListening || sttHolding) && voiceTarget === 'bp' ? '正在聆听…松手结束' : '长按说话,自动填写' }}</text>
<text class="voice-input-hint">高压120低压80</text>
</view>
</view>
<view class="input-section-title">血压mmHg</view>
<view class="input-field-row">
<view class="input-field input-field-half">
@@ -526,6 +556,21 @@
</view>
</view>
<view class="input-modal-tip"><text>至少填写一餐完成后可浇水领取积分</text></view>
<view
class="voice-input-bar"
:class="{ active: (sttListening || sttHolding) && voiceTarget === 'diet' }"
@touchstart.stop.prevent="startVoiceInput('diet')"
@touchend.stop.prevent="endVoiceInput"
@touchcancel.stop.prevent="endVoiceInput"
>
<view class="voice-input-icon">
<TongjiIcon name="mic" size="md" :color="(sttListening || sttHolding) && voiceTarget === 'diet' ? '#ffffff' : '#006c49'" />
</view>
<view class="voice-input-text">
<text class="voice-input-title">{{ (sttListening || sttHolding) && voiceTarget === 'diet' ? '正在聆听…松手结束' : '长按说话,自动填写' }}</text>
<text class="voice-input-hint">早餐鸡蛋粥午餐米饭炒青菜</text>
</view>
</view>
<view class="input-field">
<view class="input-field-label"><text>早餐</text></view>
<view class="input-field-input">
@@ -570,6 +615,21 @@
<view v-if="exerciseForm.loading" class="input-modal-loading"><text>加载中</text></view>
<scroll-view v-else scroll-y class="input-modal-body">
<view class="input-modal-tip"><text>填写运动类型或时长完成后可浇水领取积分</text></view>
<view
class="voice-input-bar"
:class="{ active: (sttListening || sttHolding) && voiceTarget === 'exercise' }"
@touchstart.stop.prevent="startVoiceInput('exercise')"
@touchend.stop.prevent="endVoiceInput"
@touchcancel.stop.prevent="endVoiceInput"
>
<view class="voice-input-icon">
<TongjiIcon name="mic" size="md" :color="(sttListening || sttHolding) && voiceTarget === 'exercise' ? '#ffffff' : '#006c49'" />
</view>
<view class="voice-input-text">
<text class="voice-input-title">{{ (sttListening || sttHolding) && voiceTarget === 'exercise' ? '正在聆听…松手结束' : '长按说话,自动填写' }}</text>
<text class="voice-input-hint">散步30分钟中强度</text>
</view>
</view>
<view class="input-field">
<view class="input-field-label"><text>运动类型</text></view>
<view class="input-field-input">
@@ -603,6 +663,17 @@
</view>
</view>
<!-- 语音录音浮层长按说话时居中提示 -->
<view v-if="(sttHolding || sttListening) && recordOverlayOpen" class="voice-rec-overlay">
<view class="voice-rec-card">
<view class="voice-rec-icon" :class="{ active: sttListening }">
<TongjiIcon name="mic" size="lg" color="#ffffff" />
</view>
<text class="voice-rec-title">{{ sttListening ? '正在聆听…' : '准备录音…' }}</text>
<text class="voice-rec-sub">松开手指结束并识别</text>
</view>
</view>
<!-- 本周战报脱敏可分享-->
<view v-if="weeklyReportVisible" class="report-mask" @click="closeWeeklyReport">
<view class="report-panel" @click.stop>
@@ -656,6 +727,8 @@ import TongjiIcon from '../components/TongjiIcon.vue'
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'
const { proxy } = getCurrentInstance()
@@ -1953,6 +2026,96 @@ const recordOverlayOpen = computed(() =>
inputForm.value.visible || bpForm.value.visible || dietForm.value.visible || exerciseForm.value.visible
)
// ============ 语音录入(长按说话,本地解析回填) ============
/** 当前正在语音录入的目标表单:'glucose' | 'bp' | 'diet' | 'exercise' */
const voiceTarget = ref('')
const {
sttListening,
sttHolding,
sttSupported,
beginHoldSpeech,
endHoldSpeech
} = useSpeechToText({
onResult(text) {
applyVoiceResult(text)
},
showToast: (msg) => showUserToast(msg)
})
/** 长按麦克风开始录音 */
function startVoiceInput(target) {
if (!sttSupported.value) {
showUserToast('当前环境不支持语音输入')
return
}
voiceTarget.value = target
beginHoldSpeech()
}
/** 松手结束录音 */
function endVoiceInput() {
endHoldSpeech()
}
/** 把识别文本解析后回填到对应表单 */
function applyVoiceResult(text) {
const raw = String(text || '').trim()
if (!raw) return
const target = voiceTarget.value
let parsed = {}
let formRef = null
if (target === 'glucose') {
parsed = parseGlucose(raw)
formRef = inputForm
} else if (target === 'bp') {
parsed = parseBloodPressure(raw)
formRef = bpForm
} else if (target === 'diet') {
parsed = parseDiet(raw)
formRef = dietForm
} else if (target === 'exercise') {
parsed = parseExercise(raw)
formRef = exerciseForm
}
if (!formRef) return
const keys = Object.keys(parsed)
if (!keys.length) {
// 没解析出结构化字段:饮食/血糖把原文写入备注,避免丢失
if (target === 'diet') {
appendToField(formRef, 'note', raw)
showUserToast('已记入饮食备注,请确认')
} else if (target === 'glucose') {
appendToField(formRef, 'remark', raw)
showUserToast('未识别到血糖数值,已记入备注')
} else if (target === 'bp') {
showUserToast('未识别到血压数值,请重试或手动填写')
} else {
showUserToast('没听清,请再说一次')
}
return
}
keys.forEach((k) => {
if (k === 'note' || k === 'remark' || k === 'western_medicine' || k === 'insulin') {
appendToField(formRef, k, parsed[k])
} else {
formRef.value[k] = parsed[k]
}
})
const summary = summarizeParsed(parsed)
showUserToast(summary ? `已识别:${summary}` : '已填写', { icon: 'none', duration: 2200 })
}
/** 文本类字段追加(避免覆盖已有内容) */
function appendToField(formRef, field, value) {
const v = String(value || '').trim()
if (!v) return
const prev = String(formRef.value[field] || '').trim()
formRef.value[field] = prev ? `${prev}${v}` : v
}
function onExerciseField(field, e) {
exerciseForm.value[field] = e?.detail?.value ?? ''
}
@@ -5477,6 +5640,121 @@ function onFamilyLikeStripTap() {
line-height: 1.5;
margin-bottom: 22rpx;
}
/* === 语音录入条 === */
.voice-input-bar {
display: flex;
align-items: center;
gap: 18rpx;
padding: 20rpx 24rpx;
margin-bottom: 22rpx;
border-radius: 18rpx;
background: linear-gradient(135deg, #ecfdf5, #d1fae5);
border: 2rpx solid #a7f3d0;
transition: transform 0.18s ease, box-shadow 0.18s ease, background 0.18s ease;
&:active {
transform: scale(0.985);
}
&.active {
background: linear-gradient(135deg, #006c49, #0d9488);
border-color: #006c49;
box-shadow: 0 10rpx 28rpx rgba(0, 108, 73, 0.3);
.voice-input-icon {
background: rgba(255, 255, 255, 0.22);
animation: voice-icon-pulse 1.2s ease-in-out infinite;
}
.voice-input-title { color: #ffffff; }
.voice-input-hint { color: rgba(255, 255, 255, 0.82); }
}
}
.voice-input-icon {
width: 72rpx;
height: 72rpx;
border-radius: 50%;
background: #ffffff;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
box-shadow: 0 4rpx 12rpx rgba(0, 108, 73, 0.12);
}
.voice-input-text {
display: flex;
flex-direction: column;
gap: 4rpx;
min-width: 0;
}
.voice-input-title {
font-size: 30rpx;
font-weight: 700;
color: #006c49;
letter-spacing: 1rpx;
}
.voice-input-hint {
font-size: 24rpx;
color: #0f766e;
opacity: 0.78;
}
@keyframes voice-icon-pulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.12); }
}
/* === 语音录音浮层 === */
.voice-rec-overlay {
position: fixed;
left: 0;
right: 0;
top: 0;
bottom: 0;
z-index: 12000;
display: flex;
align-items: center;
justify-content: center;
background: rgba(15, 23, 42, 0.45);
}
.voice-rec-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 18rpx;
padding: 56rpx 64rpx;
border-radius: 28rpx;
background: rgba(0, 108, 73, 0.94);
box-shadow: 0 20rpx 60rpx rgba(0, 0, 0, 0.3);
}
.voice-rec-icon {
width: 132rpx;
height: 132rpx;
border-radius: 50%;
background: rgba(255, 255, 255, 0.18);
display: flex;
align-items: center;
justify-content: center;
&.active {
animation: voice-rec-wave 1.1s ease-in-out infinite;
}
}
.voice-rec-title {
font-size: 34rpx;
font-weight: 700;
color: #ffffff;
letter-spacing: 2rpx;
}
.voice-rec-sub {
font-size: 26rpx;
color: rgba(255, 255, 255, 0.8);
}
@keyframes voice-rec-wave {
0%, 100% {
transform: scale(1);
box-shadow: 0 0 0 0 rgba(255, 255, 255, 0.4);
}
50% {
transform: scale(1.06);
box-shadow: 0 0 0 24rpx rgba(255, 255, 255, 0);
}
}
.input-section-title {
font-size: 28rpx;
font-weight: 700;