diff --git a/TUICallKit-Vue3/tongji/pages/more.vue b/TUICallKit-Vue3/tongji/pages/more.vue index dab2d1e7..8803e9fa 100644 --- a/TUICallKit-Vue3/tongji/pages/more.vue +++ b/TUICallKit-Vue3/tongji/pages/more.vue @@ -378,6 +378,21 @@ 请如实填写,留空表示该项不上传。当天可多次修改。 + + + + + + {{ (sttListening || sttHolding) && voiceTarget === 'glucose' ? '正在聆听…松手结束' : '长按说话,自动填写' }} + 例:空腹6.5,餐后8.2 + + 血糖(mmol/L) @@ -469,6 +484,21 @@ 加载中… + + + + + + {{ (sttListening || sttHolding) && voiceTarget === 'bp' ? '正在聆听…松手结束' : '长按说话,自动填写' }} + 例:高压120,低压80 + + 血压(mmHg) @@ -526,6 +556,21 @@ 至少填写一餐,完成后可浇水领取积分。 + + + + + + {{ (sttListening || sttHolding) && voiceTarget === 'diet' ? '正在聆听…松手结束' : '长按说话,自动填写' }} + 例:早餐鸡蛋粥,午餐米饭炒青菜 + + 早餐 @@ -570,6 +615,21 @@ 加载中… 填写运动类型或时长,完成后可浇水领取积分。 + + + + + + {{ (sttListening || sttHolding) && voiceTarget === 'exercise' ? '正在聆听…松手结束' : '长按说话,自动填写' }} + 例:散步30分钟,中强度 + + 运动类型 @@ -603,6 +663,17 @@ + + + + + + + {{ sttListening ? '正在聆听…' : '准备录音…' }} + 松开手指结束并识别 + + + @@ -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; diff --git a/TUICallKit-Vue3/tongji/utils/voiceParse.js b/TUICallKit-Vue3/tongji/utils/voiceParse.js new file mode 100644 index 00000000..95daec44 --- /dev/null +++ b/TUICallKit-Vue3/tongji/utils/voiceParse.js @@ -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(' · ') +}