feat(tongji): 血糖页 AI 咨询默认按住说话,松手发送、上滑取消

- 默认整条“按住 说话”,松手直接发给 AI,不用再点发送;按住时手指上滑进入取消状态(按钮和浮层变红)
- 说话时浮层实时显示识别出的文字和声波动画
- 右侧键盘图标切到文字输入(自动聚焦),有内容才出现发送按钮;麦克风图标切回语音;不支持语音的环境直接用文字
- 回答卡片移到输入栏上方,先显示“你问”方便核对语音识别结果
- 按住说话时暂时隐藏 2d 画布趋势图(层级高于普通元素会盖住浮层),松手后重绘
This commit is contained in:
2026-10-31 12:04:28 +08:00
parent 14b9c37aa4
commit 1129620c21
3 changed files with 337 additions and 176 deletions
@@ -59,6 +59,7 @@ const ICON_PATHS = {
moon: '<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/>',
sunset: '<path d="M12 10V2M18.364 5.636l-2.12 2.12M5.636 18.364l2.12-2.12M22 18h-3M5 18H2M18.364 18.364l-2.12-2.12M5.636 5.636l2.12 2.12M16 12a4 4 0 1 1-8 0 4 4 0 0 1 8 0Z"/>',
mic: '<path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" x2="12" y1="19" y2="22"/>',
keyboard: '<rect width="20" height="16" x="2" y="4" rx="2"/><path d="M6 8h.01M10 8h.01M14 8h.01M18 8h.01M8 12h.01M12 12h.01M16 12h.01M7 16h10"/>',
camera: '<path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z"/><circle cx="12" cy="13" r="3"/>',
bulb: '<path d="M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1.3.5 2.6 1.5 3.5.8.8 1.3 1.5 1.5 2.5"/><path d="M9 18h6"/><path d="M10 22h4"/>',
leaf: '<path d="M11 20A7 7 0 0 1 9.8 6.1C15.5 5 17 4.48 19 2c1 2 2 4.18 2 8 0 5.5-4.78 10-10 10Z"/><path d="M2 21c0-3 1.85-5.36 5.08-6"/>',
+144 -61
View File
@@ -82,16 +82,16 @@
</view>
<view class="st-chart-area" @click="onChartTap">
<canvas
v-if="!recordOverlayOpen"
v-if="!chartHidden"
id="bloodWaveChart"
type="2d"
class="st-chart-canvas"
@click.stop="onChartTap"
/>
<view v-if="recordOverlayOpen" class="st-chart-placeholder">
<view v-if="chartHidden" class="st-chart-placeholder">
<text>图表已暂停显示</text>
</view>
<view v-if="!chartHasDisplayData && !loading && !recordOverlayOpen" class="st-chart-empty">
<view v-if="!chartHasDisplayData && !loading && !chartHidden" class="st-chart-empty">
<text>当前时间范围暂无血糖数据</text>
</view>
</view>
@@ -190,53 +190,13 @@
<TongjiIcon name="sparkles" size="sm" color="#ffffff" />
</view>
<!-- 底部浮动 AI 输入栏 -->
<!-- 底部浮动 AI 输入栏:默认按住说话(松手发送、上滑取消),右侧图标切换文字输入 -->
<view v-if="diagnosisId && !floatAskClosed" class="st-float-ask">
<view class="st-float-close" @click="floatAskClosed = true">
<text class="st-float-close-icon">×</text>
</view>
<view class="st-float-inner" :class="{ 'is-expanded': dietAiAskExpanded }">
<view class="st-float-icon">
<TongjiIcon name="sparkles" size="sm" color="#006c49" />
<view v-if="lastAskQuestion && (dietAiAsking || dietAiAskResult.advice)" class="st-float-result">
<view class="st-float-question">
<text class="st-float-question-label">你问</text>
<text class="st-float-question-text">{{ lastAskQuestion }}</text>
</view>
<textarea
class="st-float-input"
:value="dietAiAskText"
placeholder="输入或语音咨询 AI…"
placeholder-class="st-float-placeholder"
:maxlength="-1"
auto-height
:show-confirm-bar="false"
:disable-default-padding="true"
:cursor-spacing="24"
confirm-type="send"
:confirm-hold="false"
@input="onDietAiAskInput"
@confirm="askDietAiFood"
@focus="onDietAiAskFocus"
@blur="onDietAiAskBlur"
/>
<view
class="st-float-mic"
:class="{ listening: sttListening || sttHolding, disabled: dietAiAsking || !sttSupported }"
@touchstart.stop.prevent="onDietAiSpeechStart"
@touchend.stop.prevent="onDietAiSpeechEnd"
@touchcancel.stop.prevent="onDietAiSpeechEnd"
>
<TongjiIcon name="mic" size="sm" :color="(sttListening || sttHolding) ? '#ffffff' : '#006c49'" />
</view>
<view
class="st-float-send"
:class="{ disabled: dietAiAsking }"
@click="askDietAiFood"
>
<TongjiIcon name="send" size="sm" color="#ffffff" />
</view>
</view>
<view v-if="sttHolding && !dietAiAsking" class="st-float-hold-hint">
<text>{{ sttListening ? '松手结束' : '准备录音…' }}</text>
</view>
<view v-if="dietAiAsking || dietAiAskResult.advice" class="st-float-result">
<view v-if="dietAiAskResult.level_label" class="st-float-badge">
<text>{{ dietAiAskResult.level_label }}</text>
</view>
@@ -244,16 +204,68 @@
{{ dietAiAskResult.advice || (dietAiAsking ? '正在想…' : '') }}<text v-if="dietAiAsking" class="ai-stream-cursor">▍</text>
</text>
</view>
<view class="st-float-bar">
<view class="st-float-close" @click="floatAskClosed = true">
<text class="st-float-close-icon">×</text>
</view>
<view class="st-float-inner" :class="{ 'is-expanded': !voiceMode && dietAiAskExpanded }">
<view class="st-float-icon">
<TongjiIcon name="sparkles" size="sm" color="#006c49" />
</view>
<view
v-if="voiceMode"
class="st-hold-btn"
:class="{ holding: sttHolding, cancel: sttHolding && voiceCancel, disabled: dietAiAsking }"
@touchstart.stop.prevent="onVoiceTouchStart"
@touchmove.stop.prevent="onVoiceTouchMove"
@touchend.stop.prevent="onVoiceTouchEnd"
@touchcancel.stop.prevent="onVoiceTouchEnd"
>
<TongjiIcon name="mic" size="sm" :color="sttHolding ? '#ffffff' : '#006c49'" />
<text class="st-hold-text">{{ holdBtnText }}</text>
</view>
<textarea
v-else
class="st-float-input"
:value="dietAiAskText"
:focus="askInputFocus"
placeholder="问问 AI:能吃什么、吃多少…"
placeholder-class="st-float-placeholder"
:maxlength="-1"
auto-height
:show-confirm-bar="false"
:disable-default-padding="true"
:cursor-spacing="24"
confirm-type="send"
:confirm-hold="false"
@input="onDietAiAskInput"
@confirm="sendTextAsk"
@focus="onDietAiAskFocus"
@blur="onDietAiAskBlur"
/>
<view
v-if="!voiceMode && hasAskText"
class="st-float-send"
:class="{ disabled: dietAiAsking }"
@click="sendTextAsk"
>
<TongjiIcon name="send" size="sm" color="#ffffff" />
</view>
<view v-if="sttSupported" class="st-mode-toggle" @click="toggleAskMode">
<TongjiIcon :name="voiceMode ? 'keyboard' : 'mic'" size="sm" color="#3c4a42" />
</view>
</view>
</view>
</view>
<!-- 语音长按状态浮层:居中显示,避免被键盘/底栏遮挡 -->
<!-- 按住说话浮层:实时显示识别出的文字;手指上滑进入取消状态 -->
<view v-if="sttHolding && !dietAiAsking" class="st-voice-overlay">
<view class="st-voice-card">
<view class="st-voice-icon" :class="{ active: sttListening }">
<TongjiIcon name="mic" size="lg" color="#ffffff" />
<view class="st-voice-card" :class="{ cancel: voiceCancel }">
<view class="st-voice-wave" :class="{ active: sttListening && !voiceCancel }">
<view v-for="i in 5" :key="i" class="st-voice-bar" :style="{ animationDelay: i * 0.12 + 's' }" />
</view>
<text class="st-voice-title">{{ sttListening ? '正在聆听…' : '准备录音…' }}</text>
<text class="st-voice-sub">松开手指结束并识别</text>
<text class="st-voice-live">{{ voicePartial || (sttListening ? '请说,比如“晚饭能吃西瓜吗”' : '准备录音…') }}</text>
<text class="st-voice-sub">{{ voiceCancel ? '松开手指,取消发送' : '松开发送 · 上滑取消' }}</text>
</view>
</view>
@@ -535,6 +547,7 @@ function onDietAiAskFocus() {
}
function onDietAiAskBlur() {
dietAiAskFocused.value = false
askInputFocus.value = false
}
/** 聚焦或已有内容时展开为多行输入(内容多则自动增高) */
const dietAiAskExpanded = computed(
@@ -547,6 +560,20 @@ function onKeyboardHeightChange(res) {
keyboardHeight.value = res && res.height ? Math.round(res.height) : 0
}
/** 按住说话时手指上滑超过这个距离(px)进入取消状态 */
const VOICE_CANCEL_DY = 60
/** 输入方式:默认按住说话,右侧图标切到文字 */
const askMode = ref('voice')
const askInputFocus = ref(false)
/** 上滑取消:松手后丢弃这次识别结果 */
const voiceCancel = ref(false)
/** 说话过程中的实时识别文字 */
const voicePartial = ref('')
let voiceStartY = 0
/** 最近一次提问,显示在回答卡片上,方便核对语音有没有听错 */
const lastAskQuestion = ref('')
const {
sttListening,
sttHolding,
@@ -555,19 +582,73 @@ const {
endHoldSpeech,
stopSpeechToText
} = useSpeechToText({
onResult(text) {
const prev = String(dietAiAskText.value || '').trim()
dietAiAskText.value = prev ? `${prev} ${text}` : text
onPartial(text) {
voicePartial.value = text
},
onSettle(text) {
if (voiceCancel.value) {
voiceCancel.value = false
return true
}
// 没识别到内容:交给默认流程(按得太短不打扰,否则提示没听清)
if (!text) return false
submitAsk(text)
return true
},
showToast: (msg) => showUserToast(msg)
})
function onDietAiSpeechStart() {
/** 不支持语音的环境(如开发者工具)直接用文字输入 */
const voiceMode = computed(() => sttSupported.value && askMode.value === 'voice')
const hasAskText = computed(() => String(dietAiAskText.value || '').trim().length > 0)
const holdBtnText = computed(() => {
if (dietAiAsking.value) return 'AI 正在回答…'
if (!sttHolding.value) return '按住 说话'
return voiceCancel.value ? '松开 取消' : '松开 发送'
})
function submitAsk(text) {
const q = String(text || '').trim()
if (!q || dietAiAsking.value) return
lastAskQuestion.value = q
dietAiAskText.value = q
askDietAiFood()
}
function sendTextAsk() {
submitAsk(dietAiAskText.value)
}
function toggleAskMode() {
if (askMode.value === 'voice') {
askMode.value = 'text'
askInputFocus.value = true
return
}
askMode.value = 'voice'
askInputFocus.value = false
uni.hideKeyboard()
}
function touchY(e) {
const t = (e && e.touches && e.touches[0]) || (e && e.changedTouches && e.changedTouches[0])
return t ? Number(t.clientY ?? t.pageY ?? 0) : 0
}
function onVoiceTouchStart(e) {
if (dietAiAsking.value) return
voiceStartY = touchY(e)
voiceCancel.value = false
voicePartial.value = ''
beginHoldSpeech()
}
function onDietAiSpeechEnd() {
function onVoiceTouchMove(e) {
if (!sttHolding.value) return
voiceCancel.value = voiceStartY - touchY(e) > VOICE_CANCEL_DY
}
function onVoiceTouchEnd() {
endHoldSpeech()
}
@@ -1812,9 +1893,11 @@ function closeInputForm() {
/** 任一录入弹层打开时需卸载 canvas(小程序原生组件会盖住普通 view)*/
const recordOverlayOpen = computed(() => inputForm.value.visible || phoneGateVisible.value)
/** 2d 画布层级高于普通元素,会盖住弹层:录入弹窗、授权弹窗、按住说话浮层出现时先隐藏趋势图 */
const chartHidden = computed(() => recordOverlayOpen.value || sttHolding.value)
watch(recordOverlayOpen, (open) => {
if (!open) {
watch(chartHidden, (hidden) => {
if (!hidden) {
resetCanvasCache()
nextTick(() => scheduleChartRedraw())
}
+192 -115
View File
@@ -704,7 +704,7 @@
text-align: center;
}
/* ===== 底部浮动输入 ===== */
/* ===== 底部浮动 AI 输入:默认按住说话,右侧切换文字 ===== */
.weekly-page .st-float-ask {
position: fixed;
left: var(--st-container-margin);
@@ -713,14 +713,18 @@
z-index: 210;
}
.weekly-page .st-float-bar {
position: relative;
}
.weekly-page .st-float-close {
position: absolute;
top: -18rpx;
top: -14rpx;
right: -6rpx;
width: 44rpx;
height: 44rpx;
width: 40rpx;
height: 40rpx;
border-radius: 50%;
background: rgba(0, 0, 0, 0.45);
background: rgba(22, 29, 25, 0.55);
display: flex;
align-items: center;
justify-content: center;
@@ -729,7 +733,7 @@
.weekly-page .st-float-close-icon {
color: #ffffff;
font-size: 30rpx;
font-size: 28rpx;
line-height: 1;
}
@@ -751,21 +755,21 @@
.weekly-page .st-float-inner {
display: flex;
align-items: center;
gap: 16rpx;
padding: 16rpx 16rpx 16rpx 28rpx;
gap: 14rpx;
padding: 12rpx;
border-radius: 999rpx;
background: rgba(255, 255, 255, 0.9);
background: rgba(255, 255, 255, 0.92);
backdrop-filter: blur(20rpx);
border: 1rpx solid rgba(0, 108, 73, 0.1);
box-shadow: var(--st-shadow-float);
transition: border-radius 0.22s ease, padding 0.22s ease, border-color 0.22s ease;
}
/* 聚焦/有内容时展开:底部对齐让输入框向上增高,圆角收敛为圆角矩形 */
/* 文字输入聚焦 / 有内容时展开:底部对齐让输入框向上增高,圆角收敛为圆角矩形 */
.weekly-page .st-float-inner.is-expanded {
align-items: flex-end;
border-radius: 32rpx;
padding: 18rpx 16rpx 18rpx 32rpx;
padding: 16rpx 12rpx 16rpx 16rpx;
border-color: rgba(0, 108, 73, 0.28);
}
@@ -780,6 +784,67 @@
flex-shrink: 0;
}
/* 按住说话:占满整条,按下变实心主色,上滑取消变红 */
.weekly-page .st-hold-btn {
flex: 1;
min-width: 0;
height: 80rpx;
border-radius: 999rpx;
display: flex;
align-items: center;
justify-content: center;
gap: 12rpx;
background: linear-gradient(180deg, #f3faf6 0%, #e2f2e9 100%);
border: 1rpx solid rgba(0, 108, 73, 0.16);
transition: background 0.18s ease, transform 0.15s ease, box-shadow 0.18s ease;
}
.weekly-page .st-hold-text {
font-size: 30rpx;
font-weight: 700;
letter-spacing: 4rpx;
color: var(--primary);
line-height: 1;
}
.weekly-page .st-hold-btn.holding {
background: linear-gradient(180deg, #1f8a5c 0%, #006c49 100%);
border-color: transparent;
box-shadow: 0 8rpx 24rpx rgba(0, 108, 73, 0.3);
transform: scale(0.98);
}
.weekly-page .st-hold-btn.cancel {
background: linear-gradient(180deg, #e5484d 0%, #ba1a1a 100%);
box-shadow: 0 8rpx 24rpx rgba(186, 26, 26, 0.3);
}
.weekly-page .st-hold-btn.holding .st-hold-text {
color: #ffffff;
}
.weekly-page .st-hold-btn.disabled {
opacity: 0.55;
pointer-events: none;
}
.weekly-page .st-mode-toggle {
width: 72rpx;
height: 72rpx;
border-radius: 50%;
background: var(--surface-container-low);
border: 1rpx solid rgba(0, 108, 73, 0.1);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: transform 0.15s ease;
}
.weekly-page .st-mode-toggle:active {
transform: scale(0.92);
}
.weekly-page .st-float-input {
flex: 1;
min-width: 0;
@@ -801,7 +866,7 @@
}
.weekly-page .st-float-placeholder {
color: rgba(60, 74, 66, 0.6);
color: rgba(60, 74, 66, 0.55);
font-size: 26rpx;
line-height: 40rpx;
white-space: nowrap;
@@ -809,106 +874,10 @@
text-overflow: ellipsis;
}
.weekly-page .st-float-mic {
.weekly-page .st-float-send {
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;
}
.weekly-page .st-voice-overlay {
position: fixed;
left: 0;
right: 0;
top: 0;
bottom: 0;
z-index: 3000;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.32);
pointer-events: none;
}
.weekly-page .st-voice-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 20rpx;
padding: 56rpx 64rpx;
border-radius: 32rpx;
background: rgba(20, 24, 22, 0.86);
box-shadow: 0 16rpx 48rpx rgba(0, 0, 0, 0.35);
}
.weekly-page .st-voice-icon {
width: 120rpx;
height: 120rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(180deg, #2d7340 0%, #006c49 100%);
}
.weekly-page .st-voice-icon.active {
animation: st-voice-pulse 1.1s ease-in-out infinite;
}
@keyframes st-voice-pulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(0, 108, 73, 0.45); }
50% { box-shadow: 0 0 0 20rpx rgba(0, 108, 73, 0); }
}
.weekly-page .st-voice-title {
font-size: 32rpx;
font-weight: 700;
color: #ffffff;
line-height: 1.3;
}
.weekly-page .st-voice-sub {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.7);
line-height: 1.3;
}
@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;
border-radius: 50%;
background: var(--primary);
display: flex;
align-items: center;
@@ -927,13 +896,121 @@
transform: scale(0.95);
}
/* 按住说话浮层:居中显示,避免被键盘/底栏遮挡;不拦截触摸,手指始终在按钮上 */
.weekly-page .st-voice-overlay {
position: fixed;
left: 0;
right: 0;
top: 0;
bottom: 0;
z-index: 3000;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.36);
pointer-events: none;
}
.weekly-page .st-voice-card {
width: 540rpx;
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
gap: 24rpx;
padding: 48rpx 40rpx 40rpx;
border-radius: 40rpx;
background: rgba(20, 24, 22, 0.9);
box-shadow: 0 16rpx 48rpx rgba(0, 0, 0, 0.35);
transition: background 0.18s ease;
}
.weekly-page .st-voice-card.cancel {
background: rgba(186, 26, 26, 0.92);
}
.weekly-page .st-voice-wave {
height: 72rpx;
display: flex;
align-items: center;
gap: 12rpx;
}
.weekly-page .st-voice-bar {
width: 10rpx;
height: 24rpx;
border-radius: 6rpx;
background: #4edea3;
}
.weekly-page .st-voice-card.cancel .st-voice-bar {
background: rgba(255, 255, 255, 0.85);
}
.weekly-page .st-voice-wave.active .st-voice-bar {
animation: st-voice-wave 0.9s ease-in-out infinite;
}
@keyframes st-voice-wave {
0%, 100% { transform: scaleY(0.5); }
50% { transform: scaleY(2.4); }
}
.weekly-page .st-voice-live {
min-height: 92rpx;
font-size: 32rpx;
font-weight: 600;
line-height: 1.45;
color: #ffffff;
text-align: center;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.weekly-page .st-voice-sub {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.72);
line-height: 1.3;
}
/* 回答卡片:在输入栏上方,先显示“你问”方便核对语音识别 */
.weekly-page .st-float-result {
margin-top: 16rpx;
padding: 24rpx 32rpx;
border-radius: var(--st-radius-xl);
background: rgba(255, 255, 255, 0.95);
margin-bottom: 16rpx;
max-height: 42vh;
overflow-y: auto;
padding: 24rpx 28rpx;
border-radius: 32rpx;
background: rgba(255, 255, 255, 0.97);
border: 1rpx solid var(--outline-variant);
box-shadow: var(--st-shadow-ambient);
box-shadow: var(--st-shadow-float);
}
.weekly-page .st-float-question {
display: flex;
align-items: flex-start;
gap: 12rpx;
margin-bottom: 14rpx;
}
.weekly-page .st-float-question-label {
flex-shrink: 0;
padding: 2rpx 12rpx;
border-radius: 8rpx;
font-size: 22rpx;
font-weight: 700;
color: var(--on-surface-variant);
background: var(--surface-container);
line-height: 1.6;
}
.weekly-page .st-float-question-text {
flex: 1;
min-width: 0;
font-size: 26rpx;
line-height: 1.6;
color: var(--on-surface-variant);
}
.weekly-page .st-float-badge {
@@ -950,7 +1027,7 @@
.weekly-page .st-float-advice {
font-size: 28rpx;
line-height: 1.5;
color: var(--on-surface-variant);
color: var(--on-surface);
}
.weekly-page .ai-stream-cursor {