更新
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user