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;
+300
View File
@@ -0,0 +1,300 @@
/**
* 健康数据语音文本解析。
*
* 把语音识别得到的中文口语文本解析为结构化字段:
* - 血糖(空腹 / 餐后 / 其他)
* - 血压(高压 / 低压 / 西药 / 胰岛素)
* - 饮食(早 / 午 / 晚餐 + 备注)
* - 运动(类型 / 时长 / 强度)
*
* 兼容中文数字(六点五 / 一百二十)与阿拉伯数字(6.5 / 120)。
* 纯前端解析,无网络依赖,识别失败时返回空对象。
*/
const CN_DIGIT = {
: 0, : 0, : 1, : 1, : 1, : 2, : 2, : 2,
: 3, : 3, : 4, : 4, : 5, : 5, : 6, : 6,
: 7, : 7, : 8, : 8, : 9, : 9
}
const CN_UNIT = { : 10, : 10, : 100, : 100, : 1000, : 1000, : 10000, 亿: 100000000 }
/** 中文整数串 → 数字,例如 "一百二十" → 120、"八十" → 80、"十" → 10 */
function cnIntToNumber(s) {
let total = 0
let section = 0
let number = 0
let hadUnit = false
for (const ch of s) {
if (CN_DIGIT[ch] !== undefined) {
number = CN_DIGIT[ch]
} else if (CN_UNIT[ch] !== undefined) {
hadUnit = true
const unit = CN_UNIT[ch]
if (unit >= 10000) {
section = (section + number) * unit
total += section
section = 0
} else {
if (number === 0) number = 1 // 十 = 10
section += number * unit
}
number = 0
}
}
const val = total + section + number
// 没有任何单位且为纯数字串(如 "一二零")时按位拼接更符合口语
if (!hadUnit && s.length > 1) {
let joined = ''
for (const ch of s) {
if (CN_DIGIT[ch] !== undefined) joined += CN_DIGIT[ch]
}
if (joined) return Number(joined)
}
return val
}
/** 中文数字串(含小数点)→ 数字,例如 "六点五" → 6.5 */
function cnSeqToNumber(seq) {
if (seq.includes('点')) {
const parts = seq.split('点')
const intPart = parts[0]
const decPart = parts.slice(1).join('')
const intVal = intPart ? cnIntToNumber(intPart) : 0
let decStr = ''
for (const ch of decPart) {
if (CN_DIGIT[ch] !== undefined) decStr += CN_DIGIT[ch]
}
if (!decStr) return intVal
return Number(`${intVal}.${decStr}`)
}
return cnIntToNumber(seq)
}
/** 是否包含中文数字字符(用于过滤“点”“重点”等误匹配) */
function hasCnDigit(seq) {
for (const ch of seq) {
if (CN_DIGIT[ch] !== undefined || CN_UNIT[ch] !== undefined) return true
}
return false
}
/** 把文本中的中文数字段落替换为阿拉伯数字 */
export function normalizeNumbers(text) {
const raw = String(text || '')
return raw.replace(/[零〇一壹幺二贰两三叁四肆五伍六陆七柒八捌九玖十拾百佰千仟万亿点]+/g, (m) => {
if (!hasCnDigit(m)) return m
const n = cnSeqToNumber(m)
return Number.isFinite(n) ? String(n) : m
})
}
/** 在文本中找到关键词后紧随的第一个数字 */
function numAfter(text, keys) {
for (const k of keys) {
const i = text.indexOf(k)
if (i >= 0) {
const rest = text.slice(i + k.length)
const m = rest.match(/-?\d+(?:\.\d+)?/)
if (m) return m[0]
}
}
return ''
}
/** 关键词后紧随的一段文本(截止到标点或下一个分隔关键词) */
function textAfter(text, keys, stopKeys = []) {
for (const k of keys) {
const i = text.indexOf(k)
if (i >= 0) {
let rest = text.slice(i + k.length)
// 去掉口语连接词
rest = rest.replace(/^[是为吃了喝了吃的喝的有打了用了::,,、。\s]+/, '')
let end = rest.length
const stopRe = /[,。.;!?\n]/
const mStop = rest.match(stopRe)
if (mStop && mStop.index < end) end = mStop.index
for (const sk of stopKeys) {
const si = rest.indexOf(sk)
if (si >= 0 && si < end) end = si
}
const seg = rest.slice(0, end).trim()
if (seg) return seg
}
}
return ''
}
/**
* 解析血糖:返回 { fasting_blood_sugar, postprandial_blood_sugar, other_blood_sugar }
* 例:"空腹六点五餐后八点二" → { fasting:6.5, postprandial:8.2 }
*/
export function parseGlucose(raw) {
const t = normalizeNumbers(raw)
const result = {}
const fasting = numAfter(t, ['空腹'])
const post = numAfter(t, ['餐后', '饭后'])
const other = numAfter(t, ['其他', '随机', '睡前', '凌晨', '夜间', '晚上'])
if (fasting) result.fasting_blood_sugar = fasting
if (post) result.postprandial_blood_sugar = post
if (other) result.other_blood_sugar = other
// 没有关键词但只有一个数字 → 视为“其他血糖”
if (!fasting && !post && !other) {
const nums = t.match(/\d+(?:\.\d+)?/g)
if (nums && nums.length === 1) result.other_blood_sugar = nums[0]
}
return result
}
/**
* 解析血压:返回 { systolic_pressure, diastolic_pressure, western_medicine, insulin }
* 例:"高压一百二低压八十" → { systolic:120, diastolic:80 }
*/
export function parseBloodPressure(raw) {
const t = normalizeNumbers(raw)
const result = {}
let sys = numAfter(t, ['高压', '收缩压', '收缩'])
let dia = numAfter(t, ['低压', '舒张压', '舒张'])
if (!sys || !dia) {
const nums = (t.match(/\d{2,3}/g) || [])
.map(Number)
.filter((n) => n >= 30 && n <= 300)
if (!sys && !dia && nums.length >= 2) {
sys = String(nums[0])
dia = String(nums[1])
} else if (!sys && nums.length === 1 && nums[0] >= 90) {
sys = String(nums[0])
}
}
// 高压应不低于低压,顺序异常时交换
if (sys && dia && Number(sys) < Number(dia)) {
const tmp = sys
sys = dia
dia = tmp
}
if (sys) result.systolic_pressure = sys
if (dia) result.diastolic_pressure = dia
const insulin = textAfter(raw, ['胰岛素'], ['高压', '低压', '血压', '西药', '备注'])
if (insulin) result.insulin = insulin
const western = textAfter(raw, ['西药', '降糖药', '口服药'], ['胰岛素', '高压', '低压', '血压', '备注'])
if (western) result.western_medicine = western
return result
}
const DIET_MARKERS = [
{ keys: ['早餐', '早饭', '早上', '早点', '早晨'], field: 'breakfast_foods' },
{ keys: ['午餐', '午饭', '中午'], field: 'lunch_foods' },
{ keys: ['晚餐', '晚饭', '晚上', '夜宵', '夜里'], field: 'dinner_foods' }
]
function cleanFoodSeg(seg) {
return String(seg || '')
.replace(/^[是为吃了吃的喝了喝的有::,,、。\s]+/, '')
.replace(/[。.\s]+$/, '')
.trim()
}
/**
* 解析饮食:返回 { breakfast_foods, lunch_foods, dinner_foods, note }
* 例:"早餐鸡蛋粥中午米饭炒青菜晚上面条" → 分别归位
* 无餐别关键词时整句写入 note
*/
export function parseDiet(raw) {
const t = String(raw || '').trim()
const result = {}
const hits = []
DIET_MARKERS.forEach((m) => {
for (const k of m.keys) {
const idx = t.indexOf(k)
if (idx >= 0) {
hits.push({ idx, field: m.field, klen: k.length })
break
}
}
})
if (!hits.length) {
if (t) result.note = t
return result
}
hits.sort((a, b) => a.idx - b.idx)
hits.forEach((h, i) => {
const start = h.idx + h.klen
const end = i + 1 < hits.length ? hits[i + 1].idx : t.length
const seg = cleanFoodSeg(t.slice(start, end))
if (seg && !result[h.field]) result[h.field] = seg
})
// 第一个餐别关键词之前的内容作为备注
const head = cleanFoodSeg(t.slice(0, hits[0].idx))
if (head) result.note = head
return result
}
const EXERCISE_FILLERS = [
'今天', '我', '做了', '做', '进行了', '进行', '运动了', '运动', '锻炼了', '锻炼',
'了', '大概', '左右', '持续', '差不多', '一共', '总共', '的', '走了', '打了', '跑了'
]
/**
* 解析运动:返回 { exercise_type, duration, intensity }
* 例:"散步三十分钟中强度" → { type:'散步', duration:30, intensity:2 }
*/
export function parseExercise(raw) {
const t = normalizeNumbers(raw)
const result = {}
let durMatch = t.match(/(\d+(?:\.\d+)?)\s*(个小时|小时|钟头|时|分钟|分)/)
if (durMatch) {
const val = parseFloat(durMatch[1])
const isHour = /个小时|小时|钟头|时/.test(durMatch[2])
result.duration = String(Math.round(isHour ? val * 60 : val))
}
if (/高强度|剧烈|很累|大汗|气喘/.test(t)) result.intensity = 3
else if (/中强度|中等强度|中等|有点累|微微出汗|微汗/.test(t)) result.intensity = 2
else if (/低强度|轻松|轻微|溜达|缓慢/.test(t)) result.intensity = 1
// 运动类型:移除时长、强度、填充词后剩余文本
let type = t
if (durMatch) type = type.replace(durMatch[0], '')
type = type
.replace(/高强度|中强度|中等强度|低强度|剧烈|很累|大汗|气喘|有点累|微微出汗|微汗|轻松|轻微|缓慢/g, '')
.replace(/\d+(?:\.\d+)?/g, '')
.replace(/[,。.;、\s]+/g, '')
EXERCISE_FILLERS.forEach((f) => {
type = type.split(f).join('')
})
type = type.trim()
if (type && type.length <= 20) result.exercise_type = type
return result
}
const FIELD_LABELS = {
fasting_blood_sugar: '空腹',
postprandial_blood_sugar: '餐后',
other_blood_sugar: '其他血糖',
systolic_pressure: '高压',
diastolic_pressure: '低压',
western_medicine: '西药',
insulin: '胰岛素',
breakfast_foods: '早餐',
lunch_foods: '午餐',
dinner_foods: '晚餐',
note: '备注',
exercise_type: '运动',
duration: '时长',
intensity: '强度'
}
const INTENSITY_LABELS = { 1: '低强度', 2: '中强度', 3: '高强度' }
/** 把解析结果转成可读的反馈文案,例如 "空腹6.5 · 餐后8.2" */
export function summarizeParsed(parsed) {
const parts = []
Object.keys(parsed).forEach((k) => {
const label = FIELD_LABELS[k] || k
let val = parsed[k]
if (k === 'intensity') val = INTENSITY_LABELS[val] || val
parts.push(`${label}${val}`)
})
return parts.join(' · ')
}