更新
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
<template>
|
||||
<view class="vsm" :class="[`vsm--${size}`, { 'vsm--active': active, 'vsm--on-dark': onDark }]">
|
||||
<view class="vsm-body">
|
||||
<view class="vsm-leaf" aria-hidden="true" />
|
||||
<view class="vsm-face">
|
||||
<view class="vsm-eye vsm-eye--l" />
|
||||
<view class="vsm-eye vsm-eye--r" />
|
||||
<view class="vsm-blush vsm-blush--l" />
|
||||
<view class="vsm-blush vsm-blush--r" />
|
||||
<view class="vsm-mouth" />
|
||||
</view>
|
||||
<view v-if="active" class="vsm-waves" aria-hidden="true">
|
||||
<view class="vsm-wave vsm-wave--1" />
|
||||
<view class="vsm-wave vsm-wave--2" />
|
||||
<view class="vsm-wave vsm-wave--3" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
/** 正在按住说话 / 录音中 */
|
||||
active: { type: Boolean, default: false },
|
||||
/** 深色条背景上使用浅色卡通 */
|
||||
onDark: { type: Boolean, default: false },
|
||||
size: { type: String, default: 'md' }
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.vsm {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.vsm--md { width: 72rpx; height: 72rpx; }
|
||||
.vsm--sm { width: 64rpx; height: 64rpx; }
|
||||
|
||||
.vsm-body {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.vsm-leaf {
|
||||
position: absolute;
|
||||
top: 2rpx;
|
||||
left: 50%;
|
||||
width: 16rpx;
|
||||
height: 16rpx;
|
||||
margin-left: -8rpx;
|
||||
border-radius: 0 100% 0 100%;
|
||||
background: #34d399;
|
||||
transform: rotate(-18deg);
|
||||
z-index: 2;
|
||||
}
|
||||
.vsm--on-dark .vsm-leaf {
|
||||
background: #a7f3d0;
|
||||
}
|
||||
|
||||
.vsm-face {
|
||||
position: relative;
|
||||
width: 52rpx;
|
||||
height: 52rpx;
|
||||
border-radius: 50%;
|
||||
background: #fef9c3;
|
||||
border: 3rpx solid #047857;
|
||||
box-shadow: 0 4rpx 10rpx rgba(4, 120, 87, 0.18);
|
||||
z-index: 1;
|
||||
}
|
||||
.vsm--sm .vsm-face {
|
||||
width: 46rpx;
|
||||
height: 46rpx;
|
||||
}
|
||||
.vsm--on-dark .vsm-face {
|
||||
background: #fffbeb;
|
||||
border-color: rgba(255, 255, 255, 0.85);
|
||||
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.vsm-eye {
|
||||
position: absolute;
|
||||
top: 16rpx;
|
||||
width: 6rpx;
|
||||
height: 8rpx;
|
||||
border-radius: 50%;
|
||||
background: #064e3b;
|
||||
}
|
||||
.vsm--sm .vsm-eye { top: 14rpx; width: 5rpx; height: 7rpx; }
|
||||
.vsm--on-dark .vsm-eye { background: #134e4a; }
|
||||
.vsm-eye--l { left: 12rpx; }
|
||||
.vsm-eye--r { right: 12rpx; }
|
||||
.vsm--sm .vsm-eye--l { left: 10rpx; }
|
||||
.vsm--sm .vsm-eye--r { right: 10rpx; }
|
||||
|
||||
.vsm-blush {
|
||||
position: absolute;
|
||||
top: 24rpx;
|
||||
width: 10rpx;
|
||||
height: 6rpx;
|
||||
border-radius: 50%;
|
||||
background: rgba(251, 113, 133, 0.45);
|
||||
opacity: 0.85;
|
||||
}
|
||||
.vsm--sm .vsm-blush { top: 21rpx; width: 8rpx; height: 5rpx; }
|
||||
.vsm-blush--l { left: 6rpx; }
|
||||
.vsm-blush--r { right: 6rpx; }
|
||||
.vsm--on-dark .vsm-blush { background: rgba(255, 255, 255, 0.35); }
|
||||
|
||||
.vsm-mouth {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: 10rpx;
|
||||
width: 18rpx;
|
||||
height: 8rpx;
|
||||
margin-left: -9rpx;
|
||||
border-radius: 0 0 18rpx 18rpx;
|
||||
background: #047857;
|
||||
transform-origin: center top;
|
||||
}
|
||||
.vsm--sm .vsm-mouth {
|
||||
bottom: 9rpx;
|
||||
width: 16rpx;
|
||||
height: 7rpx;
|
||||
margin-left: -8rpx;
|
||||
}
|
||||
.vsm--on-dark .vsm-mouth { background: #ecfdf5; }
|
||||
|
||||
.vsm-waves {
|
||||
position: absolute;
|
||||
right: -2rpx;
|
||||
top: 50%;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 4rpx;
|
||||
height: 28rpx;
|
||||
margin-top: -14rpx;
|
||||
z-index: 0;
|
||||
}
|
||||
.vsm-wave {
|
||||
width: 5rpx;
|
||||
border-radius: 4rpx;
|
||||
background: #047857;
|
||||
animation: vsm-wave-jump 0.72s ease-in-out infinite;
|
||||
}
|
||||
.vsm--on-dark .vsm-wave { background: rgba(255, 255, 255, 0.9); }
|
||||
.vsm-wave--1 { height: 10rpx; animation-delay: 0s; }
|
||||
.vsm-wave--2 { height: 18rpx; animation-delay: 0.12s; }
|
||||
.vsm-wave--3 { height: 12rpx; animation-delay: 0.24s; }
|
||||
|
||||
.vsm--active .vsm-body {
|
||||
animation: vsm-bob 0.9s ease-in-out infinite;
|
||||
}
|
||||
.vsm--active .vsm-mouth {
|
||||
animation: vsm-talk 0.32s ease-in-out infinite alternate;
|
||||
}
|
||||
.vsm--active .vsm-eye {
|
||||
animation: vsm-blink 2.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes vsm-bob {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-4rpx); }
|
||||
}
|
||||
@keyframes vsm-talk {
|
||||
0% {
|
||||
transform: scaleY(0.45);
|
||||
border-radius: 0 0 18rpx 18rpx;
|
||||
}
|
||||
100% {
|
||||
transform: scaleY(1.15);
|
||||
height: 12rpx;
|
||||
border-radius: 50%;
|
||||
}
|
||||
}
|
||||
@keyframes vsm-blink {
|
||||
0%, 42%, 46%, 100% { transform: scaleY(1); }
|
||||
44% { transform: scaleY(0.15); }
|
||||
}
|
||||
@keyframes vsm-wave-jump {
|
||||
0%, 100% { transform: scaleY(0.45); opacity: 0.55; }
|
||||
50% { transform: scaleY(1); opacity: 1; }
|
||||
}
|
||||
</style>
|
||||
@@ -2,6 +2,27 @@ import { ref, onUnmounted } from 'vue'
|
||||
|
||||
const MIN_HOLD_MS = 280
|
||||
|
||||
/** 短按/过早松手时插件常见错误,不应打扰用户 */
|
||||
function isBenignSttError(msg, retcode) {
|
||||
const m = String(msg || '').toLowerCase()
|
||||
if (/internal voice data failed/i.test(m)) return true
|
||||
if (/voice data failed/i.test(m)) return true
|
||||
if (/recordfailed|record failed|record manager/i.test(m)) return true
|
||||
if (/please stop after start/i.test(m)) return true
|
||||
if (/no recognition|not start|未开始|无识别/i.test(m)) return true
|
||||
const code = Number(retcode)
|
||||
if (code === -30002 || code === -30003 || code === -30012) return true
|
||||
return false
|
||||
}
|
||||
|
||||
function friendlySttError(msg) {
|
||||
const m = String(msg || '').trim()
|
||||
if (!m || isBenignSttError(m)) return ''
|
||||
if (/not allowed|auth|permission|权限|麦克风/i.test(m)) return '请允许使用麦克风'
|
||||
if (/network|网络|timeout/i.test(m)) return '网络异常,请稍后再试'
|
||||
return '语音识别失败,请重试'
|
||||
}
|
||||
|
||||
// #ifdef MP-WEIXIN
|
||||
// WechatSI 录音管理器是全局单例:回调只在模块级绑定一次,
|
||||
// 再分发给「当前活跃实例」。否则跨页面(如 more → weekly)后,
|
||||
@@ -112,7 +133,8 @@ export function useSpeechToText({ onResult, onSettle, onPartial, onError, showTo
|
||||
function stopListening() {
|
||||
// #ifdef MP-WEIXIN
|
||||
const ownsRecording = sharedRecording && activeCtl === controller
|
||||
if (wxRecordManager && (sttListening.value || startRequested || ownsRecording)) {
|
||||
// 仅在识别已真正开始后再 stop,避免 -30012 / internal voice data failed
|
||||
if (wxRecordManager && (sttListening.value || ownsRecording)) {
|
||||
try {
|
||||
wxRecordManager.stop()
|
||||
} catch (e) {}
|
||||
@@ -128,6 +150,7 @@ export function useSpeechToText({ onResult, onSettle, onPartial, onError, showTo
|
||||
// #endif
|
||||
|
||||
startRequested = false
|
||||
sttHolding.value = false
|
||||
sttListening.value = false
|
||||
}
|
||||
|
||||
@@ -171,7 +194,6 @@ export function useSpeechToText({ onResult, onSettle, onPartial, onError, showTo
|
||||
}
|
||||
try {
|
||||
startRequested = true
|
||||
sharedRecording = true
|
||||
wxRecordManager.start({
|
||||
duration: 60000,
|
||||
lang: 'zh_CN'
|
||||
@@ -251,22 +273,28 @@ export function useSpeechToText({ onResult, onSettle, onPartial, onError, showTo
|
||||
// #endif
|
||||
}
|
||||
|
||||
/** 松手结束:手指抬起 */
|
||||
function endHoldSpeech() {
|
||||
/** 松手结束:手指抬起;force=true 时强制取消尚未真正开始的按住会话 */
|
||||
function endHoldSpeech(force = false) {
|
||||
let recording = false
|
||||
// #ifdef MP-WEIXIN
|
||||
recording = sharedRecording && activeCtl === controller
|
||||
// #endif
|
||||
if (!sttHolding.value && !sttListening.value && !startRequested && !recording) {
|
||||
if (!force && !sttHolding.value && !sttListening.value && !startRequested && !recording) {
|
||||
return
|
||||
}
|
||||
sttHolding.value = false
|
||||
// 松手后不再补开排队的录音
|
||||
pendingStartSession = 0
|
||||
if (sttListening.value || startRequested || recording) {
|
||||
if (sttListening.value || recording) {
|
||||
stopListening()
|
||||
return
|
||||
}
|
||||
if (startRequested) {
|
||||
// start 已发出但 onStart 未到:取消会话,勿调 stop()
|
||||
startRequested = false
|
||||
holdSessionId += 1
|
||||
return
|
||||
}
|
||||
holdSessionId += 1
|
||||
}
|
||||
|
||||
@@ -276,9 +304,9 @@ export function useSpeechToText({ onResult, onSettle, onPartial, onError, showTo
|
||||
const controller = {
|
||||
handleStart() {
|
||||
if (!sttHolding.value) {
|
||||
try {
|
||||
wxRecordManager.stop()
|
||||
} catch (e) {}
|
||||
// 用户已松手,忽略迟到的 onStart,不再 stop() 以免触发插件报错
|
||||
startRequested = false
|
||||
sttListening.value = false
|
||||
return
|
||||
}
|
||||
startRequested = false
|
||||
@@ -306,6 +334,7 @@ export function useSpeechToText({ onResult, onSettle, onPartial, onError, showTo
|
||||
sttListening.value = false
|
||||
startRequested = false
|
||||
const msg = (res && res.msg) || ''
|
||||
const retcode = res && (res.retcode ?? res.errCode ?? res.code)
|
||||
// 上一段未停止就再次 start 触发:停止后若仍按住则自动重试,不打扰用户
|
||||
if (/please stop after start/i.test(msg)) {
|
||||
try {
|
||||
@@ -316,12 +345,18 @@ export function useSpeechToText({ onResult, onSettle, onPartial, onError, showTo
|
||||
}
|
||||
pendingStartSession = 0
|
||||
sttHolding.value = false
|
||||
const heldMs = Date.now() - holdStartTs
|
||||
if (isBenignSttError(msg, retcode) || heldMs < MIN_HOLD_MS) {
|
||||
if (typeof onError === 'function') onError(msg || '')
|
||||
return
|
||||
}
|
||||
// 上层(如一问一答流程)可接管错误并自行重试,返回 true 时不再弹默认提示
|
||||
if (typeof onError === 'function') {
|
||||
const handled = onError(msg || '')
|
||||
if (handled === true) return
|
||||
}
|
||||
notify(msg || '语音识别失败')
|
||||
const tip = friendlySttError(msg)
|
||||
if (tip) notify(tip)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -411,3 +446,5 @@ export function useSpeechToText({ onResult, onSettle, onPartial, onError, showTo
|
||||
stopSpeechToText: endHoldSpeech
|
||||
}
|
||||
}
|
||||
|
||||
export { isBenignSttError, friendlySttError }
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<view class="more-page stitch-vitalmint">
|
||||
<view class="more-page stitch-vitalmint" @touchend="onPageVoiceTouchEnd" @touchcancel="onPageVoiceTouchEnd">
|
||||
<CelebrateBurst :show="celebrateShow" :title="celebrateTitle" :subtitle="celebrateSub" />
|
||||
|
||||
<!-- 浇水降糖粒子雨(全屏 fixed,对应 code.html sugar-particle) -->
|
||||
@@ -375,24 +375,73 @@
|
||||
<view v-if="inputForm.loading" class="input-modal-loading">
|
||||
<text>加载今日数据中…</text>
|
||||
</view>
|
||||
<scroll-view v-else scroll-y class="input-modal-body input-modal-body-ready">
|
||||
<scroll-view
|
||||
v-else
|
||||
class="input-modal-body input-modal-body-ready"
|
||||
:scroll-y="!manualVoicePressing"
|
||||
@touchend="onPageVoiceTouchEnd"
|
||||
@touchcancel="onPageVoiceTouchEnd"
|
||||
>
|
||||
<view class="input-modal-tip">
|
||||
<text>请如实填写,留空表示该项不上传。当天可多次修改。</text>
|
||||
</view>
|
||||
|
||||
<!-- 语音一问一答面板(默认开启) -->
|
||||
<view v-if="sttSupported" class="voice-dialog" :class="{ active: voiceDialog.active }">
|
||||
<!-- 语音录入区:长按为主、一问一答为辅 -->
|
||||
<view v-if="sttSupported" class="voice-entry-section">
|
||||
<view class="voice-entry-head">
|
||||
<text class="voice-entry-title">语音录入</text>
|
||||
<text class="voice-entry-desc">选一种方式,自动填入下方字段</text>
|
||||
</view>
|
||||
|
||||
<view
|
||||
class="voice-input-bar voice-input-bar--primary"
|
||||
:class="{
|
||||
active: isVoiceBarLive('glucose'),
|
||||
muted: voiceDialog.active
|
||||
}"
|
||||
@touchstart.stop="startVoiceInput('glucose')"
|
||||
@touchend="endVoiceInput"
|
||||
@touchcancel="endVoiceInput"
|
||||
@touchmove.stop.prevent="blockVoiceTouchMove"
|
||||
>
|
||||
<view class="voice-input-accent" />
|
||||
<view class="voice-input-badge">推荐</view>
|
||||
<view class="voice-input-icon">
|
||||
<VoiceSpeakMascot
|
||||
:active="isVoiceBarLive('glucose')"
|
||||
:on-dark="isVoiceBarLive('glucose')"
|
||||
/>
|
||||
</view>
|
||||
<view class="voice-input-text">
|
||||
<text class="voice-input-title">{{ isVoiceBarLive('glucose') ? '正在聆听…松手结束' : '长按说话,自动填写' }}</text>
|
||||
<text class="voice-input-hint">例:空腹6.5,餐后8.2</text>
|
||||
</view>
|
||||
<!-- <view v-if="!isManualVoiceActive('glucose')" class="voice-input-hold-pill">
|
||||
<text>按住说话</text>
|
||||
</view> -->
|
||||
</view>
|
||||
|
||||
<!-- <view class="voice-entry-or">
|
||||
<view class="voice-entry-or-line" />
|
||||
<text class="voice-entry-or-text">或</text>
|
||||
<view class="voice-entry-or-line" />
|
||||
</view> -->
|
||||
|
||||
<!-- <view class="voice-dialog voice-dialog--secondary" :class="{ active: voiceDialog.active }">
|
||||
<view class="voice-dialog-head">
|
||||
<view class="voice-dialog-title-wrap">
|
||||
<view class="voice-dialog-orb" :class="{ listening: voiceDialog.phase === 'listening', speaking: voiceDialog.phase === 'asking' }">
|
||||
<TongjiIcon name="mic" size="sm" color="#ffffff" />
|
||||
<TongjiIcon name="volume" size="sm" :color="voiceDialog.active ? '#ffffff' : '#006c49'" />
|
||||
</view>
|
||||
<view class="voice-dialog-title-text">
|
||||
<view class="voice-dialog-title-row">
|
||||
<text class="voice-dialog-title">语音问答录入</text>
|
||||
<text class="voice-dialog-tip">{{ voiceDialog.active ? '跟着提问回答数字即可自动填写' : '点击开始,语音一问一答' }}</text>
|
||||
<text v-if="!voiceDialog.active" class="voice-dialog-tag">可选</text>
|
||||
</view>
|
||||
<text class="voice-dialog-tip">{{ voiceDialog.active ? (voiceDialog.phase === 'listening' ? '按住下方作答区说话,松手结束' : '跟着提问回答数字即可自动填写') : '逐项提问,适合不方便一次性说完' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="voiceDialog.active" class="voice-dialog-stop" @click="stopVoiceDialog()">
|
||||
<view v-if="voiceDialog.active" class="voice-dialog-stop" @click.stop="stopVoiceDialog()">
|
||||
<text>结束</text>
|
||||
</view>
|
||||
<view v-else class="voice-dialog-start" @click="startGlucoseVoiceDialog">
|
||||
@@ -407,51 +456,38 @@
|
||||
<view v-if="voiceDialog.heardText" class="voice-dialog-bubble user">
|
||||
<text>{{ voiceDialog.heardText }}</text>
|
||||
</view>
|
||||
<view class="voice-dialog-status">
|
||||
<view v-if="voiceDialogRecording" class="voice-dialog-wave">
|
||||
<view class="voice-dialog-wave-bar" />
|
||||
<view class="voice-dialog-wave-bar" />
|
||||
<view class="voice-dialog-wave-bar" />
|
||||
<view class="voice-dialog-wave-bar" />
|
||||
<view
|
||||
v-if="voiceDialog.phase === 'listening'"
|
||||
class="voice-dialog-hold-pad"
|
||||
:class="{ active: sttListening || sttHolding }"
|
||||
@touchstart.stop="onDialogAnswerTouchStart"
|
||||
@touchend="onDialogAnswerTouchEnd"
|
||||
@touchcancel="onDialogAnswerTouchEnd"
|
||||
@touchmove.stop.prevent="blockVoiceTouchMove"
|
||||
>
|
||||
<view class="voice-dialog-hold-icon">
|
||||
<TongjiIcon name="mic" size="md" :color="(sttListening || sttHolding) ? '#ffffff' : '#006c49'" />
|
||||
</view>
|
||||
<text class="voice-dialog-hold-title">{{ (sttListening || sttHolding) ? '正在聆听…松手结束' : '按住这里回答' }}</text>
|
||||
<text v-if="voiceDialog.heardText" class="voice-dialog-hold-heard">{{ voiceDialog.heardText }}</text>
|
||||
</view>
|
||||
<view v-else class="voice-dialog-status">
|
||||
<text class="voice-dialog-status-text">{{ voiceDialogStatusText }}</text>
|
||||
</view>
|
||||
<view class="voice-dialog-actions">
|
||||
<view
|
||||
v-if="voiceDialogRecording"
|
||||
class="voice-dialog-btn primary"
|
||||
@click="finishListeningEarly"
|
||||
>
|
||||
<text>说完了</text>
|
||||
</view>
|
||||
<view class="voice-dialog-btn" @click="replayCurrentQuestion">
|
||||
<view class="voice-dialog-btn" @click.stop="replayCurrentQuestion">
|
||||
<text>{{ voiceDialog.phase === 'listening' ? '重说' : '重听' }}</text>
|
||||
</view>
|
||||
<view
|
||||
v-if="voiceDialog.stage !== 'confirm'"
|
||||
class="voice-dialog-btn"
|
||||
@click="skipCurrentStepManually"
|
||||
@click.stop="skipCurrentStepManually"
|
||||
>
|
||||
<text>跳过本项</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</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>
|
||||
<view class="input-section-title">血糖(mmol/L)</view>
|
||||
<view class="input-field">
|
||||
@@ -543,19 +579,29 @@
|
||||
<view class="input-modal-close" @click="closeBpForm"><text class="input-modal-close-icon">×</text></view>
|
||||
</view>
|
||||
<view v-if="bpForm.loading" class="input-modal-loading"><text>加载中…</text></view>
|
||||
<scroll-view v-else scroll-y class="input-modal-body">
|
||||
<scroll-view
|
||||
v-else
|
||||
class="input-modal-body"
|
||||
:scroll-y="!manualVoicePressing"
|
||||
@touchend="onPageVoiceTouchEnd"
|
||||
@touchcancel="onPageVoiceTouchEnd"
|
||||
>
|
||||
<view
|
||||
class="voice-input-bar"
|
||||
:class="{ active: (sttListening || sttHolding) && voiceTarget === 'bp' }"
|
||||
@touchstart.stop.prevent="startVoiceInput('bp')"
|
||||
@touchend.stop.prevent="endVoiceInput"
|
||||
@touchcancel.stop.prevent="endVoiceInput"
|
||||
:class="{ active: isVoiceBarLive('bp') }"
|
||||
@touchstart.stop="startVoiceInput('bp')"
|
||||
@touchend="endVoiceInput"
|
||||
@touchcancel="endVoiceInput"
|
||||
@touchmove.stop.prevent="blockVoiceTouchMove"
|
||||
>
|
||||
<view class="voice-input-icon">
|
||||
<TongjiIcon name="mic" size="md" :color="(sttListening || sttHolding) && voiceTarget === 'bp' ? '#ffffff' : '#006c49'" />
|
||||
<VoiceSpeakMascot
|
||||
:active="isVoiceBarLive('bp')"
|
||||
:on-dark="isVoiceBarLive('bp')"
|
||||
/>
|
||||
</view>
|
||||
<view class="voice-input-text">
|
||||
<text class="voice-input-title">{{ (sttListening || sttHolding) && voiceTarget === 'bp' ? '正在聆听…松手结束' : '长按说话,自动填写' }}</text>
|
||||
<text class="voice-input-title">{{ isVoiceBarLive('bp') ? '正在聆听…松手结束' : '长按说话,自动填写' }}</text>
|
||||
<text class="voice-input-hint">例:高压120,低压80</text>
|
||||
</view>
|
||||
</view>
|
||||
@@ -607,7 +653,13 @@
|
||||
<view class="input-modal-close" @click="closeDietForm"><text class="input-modal-close-icon">×</text></view>
|
||||
</view>
|
||||
<view v-if="dietForm.loading" class="input-modal-loading"><text>加载中…</text></view>
|
||||
<scroll-view v-else scroll-y class="input-modal-body">
|
||||
<scroll-view
|
||||
v-else
|
||||
class="input-modal-body"
|
||||
:scroll-y="!manualVoicePressing"
|
||||
@touchend="onPageVoiceTouchEnd"
|
||||
@touchcancel="onPageVoiceTouchEnd"
|
||||
>
|
||||
<view v-if="dietAiPrefill && dietAiPrefill.breakfast" class="input-modal-ai-tip">
|
||||
<text class="input-modal-ai-tip-label">AI 推荐</text>
|
||||
<text class="input-modal-ai-tip-text">早 {{ dietAiPrefill.breakfast }}<text v-if="dietAiPrefill.drinks"> · 喝 {{ dietAiPrefill.drinks }}</text> · 午 {{ dietAiPrefill.lunch }} · 晚 {{ dietAiPrefill.dinner }}</text>
|
||||
@@ -618,16 +670,20 @@
|
||||
<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"
|
||||
:class="{ active: isVoiceBarLive('diet') }"
|
||||
@touchstart.stop="startVoiceInput('diet')"
|
||||
@touchend="endVoiceInput"
|
||||
@touchcancel="endVoiceInput"
|
||||
@touchmove.stop.prevent="blockVoiceTouchMove"
|
||||
>
|
||||
<view class="voice-input-icon">
|
||||
<TongjiIcon name="mic" size="md" :color="(sttListening || sttHolding) && voiceTarget === 'diet' ? '#ffffff' : '#006c49'" />
|
||||
<VoiceSpeakMascot
|
||||
:active="isVoiceBarLive('diet')"
|
||||
:on-dark="isVoiceBarLive('diet')"
|
||||
/>
|
||||
</view>
|
||||
<view class="voice-input-text">
|
||||
<text class="voice-input-title">{{ (sttListening || sttHolding) && voiceTarget === 'diet' ? '正在聆听…松手结束' : '长按说话,自动填写' }}</text>
|
||||
<text class="voice-input-title">{{ isVoiceBarLive('diet') ? '正在聆听…松手结束' : '长按说话,自动填写' }}</text>
|
||||
<text class="voice-input-hint">例:早餐鸡蛋粥,午餐米饭炒青菜</text>
|
||||
</view>
|
||||
</view>
|
||||
@@ -673,20 +729,30 @@
|
||||
<view class="input-modal-close" @click="closeExerciseForm"><text class="input-modal-close-icon">×</text></view>
|
||||
</view>
|
||||
<view v-if="exerciseForm.loading" class="input-modal-loading"><text>加载中…</text></view>
|
||||
<scroll-view v-else scroll-y class="input-modal-body">
|
||||
<scroll-view
|
||||
v-else
|
||||
class="input-modal-body"
|
||||
:scroll-y="!manualVoicePressing"
|
||||
@touchend="onPageVoiceTouchEnd"
|
||||
@touchcancel="onPageVoiceTouchEnd"
|
||||
>
|
||||
<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"
|
||||
:class="{ active: isVoiceBarLive('exercise') }"
|
||||
@touchstart.stop="startVoiceInput('exercise')"
|
||||
@touchend="endVoiceInput"
|
||||
@touchcancel="endVoiceInput"
|
||||
@touchmove.stop.prevent="blockVoiceTouchMove"
|
||||
>
|
||||
<view class="voice-input-icon">
|
||||
<TongjiIcon name="mic" size="md" :color="(sttListening || sttHolding) && voiceTarget === 'exercise' ? '#ffffff' : '#006c49'" />
|
||||
<VoiceSpeakMascot
|
||||
:active="isVoiceBarLive('exercise')"
|
||||
:on-dark="isVoiceBarLive('exercise')"
|
||||
/>
|
||||
</view>
|
||||
<view class="voice-input-text">
|
||||
<text class="voice-input-title">{{ (sttListening || sttHolding) && voiceTarget === 'exercise' ? '正在聆听…松手结束' : '长按说话,自动填写' }}</text>
|
||||
<text class="voice-input-title">{{ isVoiceBarLive('exercise') ? '正在聆听…松手结束' : '长按说话,自动填写' }}</text>
|
||||
<text class="voice-input-hint">例:散步30分钟,中强度</text>
|
||||
</view>
|
||||
</view>
|
||||
@@ -723,8 +789,8 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 语音录音浮层:长按说话时居中提示(语音问答进行中改用面板内提示) -->
|
||||
<view v-if="(sttHolding || sttListening) && recordOverlayOpen && !voiceDialog.active" class="voice-rec-overlay">
|
||||
<!-- 语音录音浮层:仅非弹窗场景使用;录入弹窗内由卡片自身状态提示 -->
|
||||
<view v-if="(sttHolding || sttListening) && !recordOverlayOpen && !voiceDialog.active" class="voice-rec-overlay">
|
||||
<view class="voice-rec-card">
|
||||
<view class="voice-rec-icon" :class="{ active: sttListening }">
|
||||
<TongjiIcon name="mic" size="lg" color="#ffffff" />
|
||||
@@ -785,9 +851,10 @@ import { onLoad, onShow, onHide, onUnload, onPullDownRefresh, onShareAppMessage
|
||||
import SugarTreeGraphic from '../components/SugarTreeGraphic.vue'
|
||||
import TongjiIcon from '../components/TongjiIcon.vue'
|
||||
import CelebrateBurst from '../components/CelebrateBurst.vue'
|
||||
import VoiceSpeakMascot from '../components/VoiceSpeakMascot.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 { useSpeechToText, isBenignSttError } from '../composables/useSpeechToText.js'
|
||||
import { parseGlucose, parseBloodPressure, parseDiet, parseExercise, summarizeParsed, normalizeNumbers } from '../utils/voiceParse.js'
|
||||
|
||||
const { proxy } = getCurrentInstance()
|
||||
@@ -1816,21 +1883,12 @@ async function openInputForm() {
|
||||
/* 静默:拉取失败时仍允许新增录入 */
|
||||
} finally {
|
||||
inputForm.value.loading = false
|
||||
// 默认采用语音一问一答:打开后自动开始问询(手动录入仍可用)
|
||||
if (sttSupported.value) {
|
||||
nextTick(() => {
|
||||
setTimeout(() => {
|
||||
if (inputForm.value.visible && !voiceDialog.active) {
|
||||
startGlucoseVoiceDialog()
|
||||
}
|
||||
}, 360)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function closeInputForm() {
|
||||
if (inputForm.value.submitting) return
|
||||
cancelVoiceHold()
|
||||
stopVoiceDialog(true)
|
||||
inputForm.value.visible = false
|
||||
}
|
||||
@@ -1962,6 +2020,7 @@ function numToStr(v) {
|
||||
|
||||
function closeBpForm() {
|
||||
if (bpForm.value.submitting) return
|
||||
cancelVoiceHold()
|
||||
bpForm.value.visible = false
|
||||
// 关闭后 canvas 会重新挂载,需触发一次重绘
|
||||
nextTick(() => {})
|
||||
@@ -2037,6 +2096,7 @@ async function openDietForm(options = {}) {
|
||||
|
||||
function closeDietForm() {
|
||||
if (dietForm.value.submitting) return
|
||||
cancelVoiceHold()
|
||||
dietForm.value.visible = false
|
||||
dietAiPrefill.value = null
|
||||
nextTick(() => {})
|
||||
@@ -2124,8 +2184,9 @@ const {
|
||||
return false
|
||||
},
|
||||
onError(msg) {
|
||||
if (isBenignSttError(msg)) return true
|
||||
if (voiceDialog.active) {
|
||||
handleDialogError(msg)
|
||||
handleDialogError()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
@@ -2136,7 +2197,55 @@ const {
|
||||
showToast: (msg) => showUserToast(msg)
|
||||
})
|
||||
|
||||
/** 长按麦克风开始录音 */
|
||||
/** 长按麦克风开始录音(短按不启动,避免误触) */
|
||||
const VOICE_HOLD_DELAY_MS = 320
|
||||
let voiceHoldTimer = null
|
||||
let voiceHoldPendingTarget = ''
|
||||
const manualVoicePressing = ref(false)
|
||||
/** 按住期间锁定目标,避免 timer 与松手竞态 */
|
||||
let manualVoicePressTarget = ''
|
||||
|
||||
function blockVoiceTouchMove() {
|
||||
/* 阻止 scroll-view 抢走 touch 序列,保证 touchend 能回到按住区域 */
|
||||
}
|
||||
|
||||
function isManualVoiceActive(target) {
|
||||
return voiceTarget.value === target && (sttHolding.value || sttListening.value)
|
||||
}
|
||||
|
||||
/** 按住尚未开录时也展示说话动画 */
|
||||
function isVoicePressing(target) {
|
||||
return manualVoicePressing.value && manualVoicePressTarget === target
|
||||
}
|
||||
|
||||
function isVoiceBarLive(target) {
|
||||
return isManualVoiceActive(target) || isVoicePressing(target)
|
||||
}
|
||||
|
||||
function clearVoiceHoldTimer() {
|
||||
if (voiceHoldTimer) {
|
||||
clearTimeout(voiceHoldTimer)
|
||||
voiceHoldTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function cancelVoiceHold() {
|
||||
manualVoicePressing.value = false
|
||||
manualVoicePressTarget = ''
|
||||
clearVoiceHoldTimer()
|
||||
voiceHoldPendingTarget = ''
|
||||
forceEndHoldSpeech()
|
||||
}
|
||||
|
||||
/** 无论底层回调是否滞后,都清掉 UI 与按住态 */
|
||||
function forceEndHoldSpeech() {
|
||||
if (sttHolding.value || sttListening.value) {
|
||||
endHoldSpeech()
|
||||
} else {
|
||||
endHoldSpeech(true)
|
||||
}
|
||||
}
|
||||
|
||||
function startVoiceInput(target) {
|
||||
if (!sttSupported.value) {
|
||||
showUserToast('当前环境不支持语音输入')
|
||||
@@ -2144,13 +2253,52 @@ function startVoiceInput(target) {
|
||||
}
|
||||
// 长按手动录入与一问一答互斥:手动开始时先停掉对话
|
||||
if (voiceDialog.active) stopVoiceDialog(true)
|
||||
manualVoicePressing.value = true
|
||||
manualVoicePressTarget = target
|
||||
clearVoiceHoldTimer()
|
||||
voiceHoldPendingTarget = target
|
||||
voiceHoldTimer = setTimeout(() => {
|
||||
voiceHoldTimer = null
|
||||
if (!manualVoicePressing.value || manualVoicePressTarget !== target || voiceHoldPendingTarget !== target) return
|
||||
voiceTarget.value = target
|
||||
beginHoldSpeech()
|
||||
}, VOICE_HOLD_DELAY_MS)
|
||||
}
|
||||
|
||||
/** 松手结束录音 */
|
||||
function endVoiceInput() {
|
||||
endHoldSpeech()
|
||||
const wasPressing = manualVoicePressing.value
|
||||
const hadPendingOnly = !!voiceHoldPendingTarget
|
||||
const wasRecording = sttHolding.value || sttListening.value
|
||||
|
||||
manualVoicePressing.value = false
|
||||
manualVoicePressTarget = ''
|
||||
clearVoiceHoldTimer()
|
||||
voiceHoldPendingTarget = ''
|
||||
|
||||
if (wasRecording || wasPressing) {
|
||||
forceEndHoldSpeech()
|
||||
}
|
||||
|
||||
if (hadPendingOnly && !wasRecording) {
|
||||
showUserToast('请长按说话', { icon: 'none', duration: 1500 })
|
||||
}
|
||||
}
|
||||
|
||||
/** 页面 / scroll-view 级 touchend 兜底 */
|
||||
function onPageVoiceTouchEnd() {
|
||||
if (manualVoicePressing.value) {
|
||||
if (voiceDialog.active && voiceDialog.phase === 'listening') {
|
||||
onDialogAnswerTouchEnd()
|
||||
} else {
|
||||
endVoiceInput()
|
||||
}
|
||||
return
|
||||
}
|
||||
// 按住态已丢但底层仍在录:强制收尾
|
||||
if ((sttHolding.value || sttListening.value) && voiceTarget.value && !voiceDialog.active) {
|
||||
endVoiceInput()
|
||||
}
|
||||
}
|
||||
|
||||
/** 把识别文本解析后回填到对应表单 */
|
||||
@@ -2211,7 +2359,7 @@ function appendToField(formRef, field, value) {
|
||||
formRef.value[field] = prev ? `${prev};${v}` : v
|
||||
}
|
||||
|
||||
// ============ 语音一问一答录入(默认开启,逐项语音问询自动回填) ============
|
||||
// ============ 语音一问一答录入(默认关闭,逐项语音问询自动回填) ============
|
||||
const VOICE_DIALOG_LISTEN_MS = 4000 // 问题播完后给用户的说话时长(兜底窗口)
|
||||
const VOICE_DIALOG_PRE_MS = 250 // 读完问题到开始录音的缓冲,避开 TTS 尾音
|
||||
const VOICE_DIALOG_ANSWER_MS = 550 // 听到数字后,用户停顿多久即定格(静默防抖,越小越快)
|
||||
@@ -2296,8 +2444,7 @@ function startGlucoseVoiceDialog() {
|
||||
/**
|
||||
* 播报当前步骤的问题,读完后再开始聆听。
|
||||
* 微信平台已移除实时识别回调(onRecognize),无法做"读题中打断",
|
||||
* 因此采用「读完短问题 → 干净录音(无回声)→ 说完点"说完了"立即下一题,
|
||||
* 否则到时自动结束」的可靠模型。录音不与播报并行,避免回声污染识别。
|
||||
* 因此采用「读完短问题 → 按住作答区说话 → 松手结束并识别」的可靠模型。
|
||||
*/
|
||||
function askCurrentStep(prefix = '') {
|
||||
const step = currentDialogStep()
|
||||
@@ -2319,27 +2466,56 @@ function askCurrentStep(prefix = '') {
|
||||
})
|
||||
}
|
||||
|
||||
/** 到时自动结束录音并识别 */
|
||||
/** 到时自动结束录音(最长按住窗口,防止一直录) */
|
||||
function armListenTimeout(token) {
|
||||
clearVoiceListenTimer()
|
||||
voiceListenTimer = setTimeout(() => {
|
||||
if (token !== voiceFlowToken || !voiceDialog.active) return
|
||||
endHoldSpeech()
|
||||
if (voiceDialog.phase !== 'listening') return
|
||||
finishListeningEarly()
|
||||
}, VOICE_DIALOG_LISTEN_MS)
|
||||
}
|
||||
|
||||
/** 读完问题后开始录音:留极短缓冲避开 TTS 尾音,再开录并启动聆听窗口 */
|
||||
/** 问完题后进入聆听:等用户按住作答区再开录(不自动开麦) */
|
||||
function listenForAnswer() {
|
||||
if (!voiceDialog.active) return
|
||||
const token = voiceFlowToken
|
||||
voiceDialog.phase = 'listening'
|
||||
voiceDialog.pendingValue = ''
|
||||
voiceDialog.heardText = ''
|
||||
clearVoiceTimers()
|
||||
voiceDialog.phase = 'asking'
|
||||
voiceListenTimer = setTimeout(() => {
|
||||
if (token !== voiceFlowToken || !voiceDialog.active || voiceDialog.phase !== 'listening') return
|
||||
if (token !== voiceFlowToken || !voiceDialog.active) return
|
||||
voiceDialog.phase = 'listening'
|
||||
}, VOICE_DIALOG_PRE_MS)
|
||||
}
|
||||
|
||||
/** 问答聆听:按住作答区开始录音 */
|
||||
function onDialogAnswerTouchStart() {
|
||||
if (!voiceDialog.active || voiceDialog.phase !== 'listening') return
|
||||
if (sttHolding.value || sttListening.value) return
|
||||
manualVoicePressing.value = true
|
||||
manualVoicePressTarget = 'dialog'
|
||||
clearVoiceTimers()
|
||||
const token = voiceFlowToken
|
||||
beginHoldSpeech()
|
||||
armListenTimeout(token)
|
||||
}, VOICE_DIALOG_PRE_MS)
|
||||
}
|
||||
|
||||
/** 问答聆听:松手结束并识别 */
|
||||
function onDialogAnswerTouchEnd() {
|
||||
manualVoicePressing.value = false
|
||||
manualVoicePressTarget = ''
|
||||
if (!voiceDialog.active || voiceDialog.phase !== 'listening') {
|
||||
if (sttHolding.value || sttListening.value) forceEndHoldSpeech()
|
||||
return
|
||||
}
|
||||
clearVoiceTimers()
|
||||
if (sttHolding.value || sttListening.value) {
|
||||
finishListeningEarly()
|
||||
return
|
||||
}
|
||||
forceEndHoldSpeech()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2607,12 +2783,9 @@ function stopVoiceDialog() {
|
||||
voiceDialog.heardText = ''
|
||||
}
|
||||
|
||||
// 是否处于「正在录音/可作答」状态:仅聆听阶段(读题时不录音,避免回声)
|
||||
const voiceDialogRecording = computed(() => voiceDialog.phase === 'listening')
|
||||
|
||||
const voiceDialogStatusText = computed(() => {
|
||||
if (voiceDialog.phase === 'listening') {
|
||||
return voiceDialog.stage === 'confirm' ? '请回答:提交 或 取消' : '请说出数字,说完点「说完了」'
|
||||
return voiceDialog.stage === 'confirm' ? '按住作答区:说提交或取消,松手结束' : '按住下方作答区说话,松手结束'
|
||||
}
|
||||
if (voiceDialog.phase === 'asking') {
|
||||
return '正在提问…'
|
||||
@@ -2658,6 +2831,7 @@ async function openExerciseForm() {
|
||||
|
||||
function closeExerciseForm() {
|
||||
if (exerciseForm.value.submitting) return
|
||||
cancelVoiceHold()
|
||||
exerciseForm.value.visible = false
|
||||
nextTick(() => {})
|
||||
}
|
||||
@@ -3555,11 +3729,13 @@ onPullDownRefresh(async () => {
|
||||
})
|
||||
|
||||
onHide(() => {
|
||||
cancelVoiceHold()
|
||||
stopVoiceDialog(true)
|
||||
ttsStop()
|
||||
})
|
||||
|
||||
onUnload(() => {
|
||||
cancelVoiceHold()
|
||||
stopVoiceDialog(true)
|
||||
ttsStop()
|
||||
clearWateringFx()
|
||||
@@ -6176,6 +6352,48 @@ function onFamilyLikeStripTap() {
|
||||
margin-bottom: 22rpx;
|
||||
}
|
||||
|
||||
/* === 语音录入区(血糖弹窗) === */
|
||||
.voice-entry-section {
|
||||
margin-bottom: 24rpx;
|
||||
padding: 22rpx 20rpx 20rpx;
|
||||
border-radius: 22rpx;
|
||||
background: #f8fafc;
|
||||
border: 1rpx solid #e2e8f0;
|
||||
}
|
||||
.voice-entry-head {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6rpx;
|
||||
margin-bottom: 18rpx;
|
||||
}
|
||||
.voice-entry-title {
|
||||
font-size: 28rpx;
|
||||
font-weight: 700;
|
||||
color: #334155;
|
||||
letter-spacing: 1rpx;
|
||||
}
|
||||
.voice-entry-desc {
|
||||
font-size: 24rpx;
|
||||
color: #64748b;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.voice-entry-or {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
margin: 16rpx 0;
|
||||
}
|
||||
.voice-entry-or-line {
|
||||
flex: 1;
|
||||
height: 1rpx;
|
||||
background: #e2e8f0;
|
||||
}
|
||||
.voice-entry-or-text {
|
||||
font-size: 22rpx;
|
||||
color: #94a3b8;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* === 语音录入条 === */
|
||||
.voice-input-bar {
|
||||
display: flex;
|
||||
@@ -6186,7 +6404,8 @@ function onFamilyLikeStripTap() {
|
||||
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;
|
||||
transition: transform 0.18s ease, box-shadow 0.18s ease, background 0.18s ease, opacity 0.18s ease;
|
||||
position: relative;
|
||||
&:active {
|
||||
transform: scale(0.985);
|
||||
}
|
||||
@@ -6196,10 +6415,67 @@ function onFamilyLikeStripTap() {
|
||||
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-hint { color: rgba(255, 255, 255, 0.88); }
|
||||
.voice-input-badge { background: rgba(255, 255, 255, 0.22); color: #ffffff; }
|
||||
.voice-input-accent { background: rgba(255, 255, 255, 0.35); }
|
||||
}
|
||||
&.muted {
|
||||
opacity: 0.55;
|
||||
}
|
||||
}
|
||||
.voice-entry-section .voice-input-bar {
|
||||
margin-bottom: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.voice-input-bar--primary {
|
||||
min-height: 112rpx;
|
||||
padding: 36rpx 20rpx 22rpx 28rpx;
|
||||
box-shadow: 0 6rpx 20rpx rgba(0, 108, 73, 0.08);
|
||||
align-items: center;
|
||||
.voice-input-icon,
|
||||
.voice-input-text,
|
||||
.voice-input-hold-pill {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
}
|
||||
.voice-input-accent {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 6rpx;
|
||||
background: #006c49;
|
||||
z-index: 1;
|
||||
}
|
||||
.voice-input-badge {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
z-index: 2;
|
||||
padding: 6rpx 16rpx;
|
||||
border-radius: 0 16rpx 0 14rpx;
|
||||
background: #006c49;
|
||||
color: #ffffff;
|
||||
font-size: 20rpx;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1rpx;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.voice-input-hold-pill {
|
||||
flex-shrink: 0;
|
||||
padding: 14rpx 22rpx;
|
||||
border-radius: 999rpx;
|
||||
background: #ffffff;
|
||||
border: 2rpx solid #a7f3d0;
|
||||
box-shadow: 0 4rpx 12rpx rgba(0, 108, 73, 0.08);
|
||||
text {
|
||||
font-size: 24rpx;
|
||||
font-weight: 700;
|
||||
color: #006c49;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
.voice-input-icon {
|
||||
@@ -6212,23 +6488,25 @@ function onFamilyLikeStripTap() {
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 4rpx 12rpx rgba(0, 108, 73, 0.12);
|
||||
overflow: visible;
|
||||
}
|
||||
.voice-input-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4rpx;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.voice-input-title {
|
||||
font-size: 30rpx;
|
||||
font-weight: 700;
|
||||
color: #006c49;
|
||||
letter-spacing: 1rpx;
|
||||
color: #134e4a;
|
||||
letter-spacing: 0.5rpx;
|
||||
}
|
||||
.voice-input-hint {
|
||||
font-size: 24rpx;
|
||||
color: #0f766e;
|
||||
opacity: 0.78;
|
||||
color: #475569;
|
||||
line-height: 1.4;
|
||||
}
|
||||
@keyframes voice-icon-pulse {
|
||||
0%, 100% { transform: scale(1); }
|
||||
@@ -6303,6 +6581,49 @@ function onFamilyLikeStripTap() {
|
||||
box-shadow: 0 10rpx 28rpx rgba(0, 108, 73, 0.16);
|
||||
}
|
||||
}
|
||||
.voice-entry-section .voice-dialog {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.voice-dialog--secondary {
|
||||
background: #ffffff;
|
||||
border: 2rpx solid #e2e8f0;
|
||||
&.active {
|
||||
background: linear-gradient(180deg, #f0fdf4 0%, #ffffff 100%);
|
||||
border-color: #006c49;
|
||||
}
|
||||
.voice-dialog-orb {
|
||||
background: #ecfdf5;
|
||||
border: 2rpx solid #a7f3d0;
|
||||
&.speaking {
|
||||
background: #0d9488;
|
||||
border-color: #0d9488;
|
||||
}
|
||||
&.listening {
|
||||
background: #006c49;
|
||||
border-color: #006c49;
|
||||
}
|
||||
}
|
||||
.voice-dialog-start {
|
||||
background: #ffffff;
|
||||
color: #006c49;
|
||||
border: 2rpx solid #006c49;
|
||||
}
|
||||
}
|
||||
.voice-dialog-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10rpx;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.voice-dialog-tag {
|
||||
padding: 2rpx 12rpx;
|
||||
border-radius: 999rpx;
|
||||
font-size: 20rpx;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
background: #f1f5f9;
|
||||
border: 1rpx solid #e2e8f0;
|
||||
}
|
||||
.voice-dialog-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -6314,6 +6635,7 @@ function onFamilyLikeStripTap() {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.voice-dialog-orb {
|
||||
@@ -6340,35 +6662,85 @@ function onFamilyLikeStripTap() {
|
||||
.voice-dialog-title {
|
||||
font-size: 30rpx;
|
||||
font-weight: 700;
|
||||
color: #006c49;
|
||||
letter-spacing: 1rpx;
|
||||
color: #134e4a;
|
||||
letter-spacing: 0.5rpx;
|
||||
}
|
||||
.voice-dialog-tip {
|
||||
font-size: 23rpx;
|
||||
color: #0f766e;
|
||||
opacity: 0.8;
|
||||
color: #64748b;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.voice-dialog-start,
|
||||
.voice-dialog-stop {
|
||||
flex-shrink: 0;
|
||||
padding: 12rpx 26rpx;
|
||||
min-height: 64rpx;
|
||||
padding: 0 28rpx;
|
||||
border-radius: 999rpx;
|
||||
font-size: 26rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
.voice-dialog-start {
|
||||
background: #006c49;
|
||||
color: #ffffff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
&:active { transform: scale(0.97); }
|
||||
}
|
||||
.voice-dialog-stop {
|
||||
background: rgba(15, 23, 42, 0.06);
|
||||
background: #f1f5f9;
|
||||
color: #475569;
|
||||
border: 2rpx solid #e2e8f0;
|
||||
}
|
||||
.voice-dialog-body {
|
||||
padding: 0 24rpx 22rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14rpx;
|
||||
border-top: 1rpx solid #e2e8f0;
|
||||
margin-top: 4rpx;
|
||||
padding-top: 18rpx;
|
||||
}
|
||||
.voice-dialog-hold-pad {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12rpx;
|
||||
min-height: 168rpx;
|
||||
padding: 28rpx 24rpx;
|
||||
border-radius: 18rpx;
|
||||
background: linear-gradient(135deg, #ecfdf5, #d1fae5);
|
||||
border: 2rpx solid #a7f3d0;
|
||||
transition: background 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease;
|
||||
&:active,
|
||||
&.active {
|
||||
background: linear-gradient(135deg, #006c49, #0d9488);
|
||||
border-color: #006c49;
|
||||
box-shadow: 0 10rpx 28rpx rgba(0, 108, 73, 0.24);
|
||||
.voice-dialog-hold-title,
|
||||
.voice-dialog-hold-heard { color: #ffffff; }
|
||||
.voice-dialog-hold-icon { background: rgba(255, 255, 255, 0.22); }
|
||||
}
|
||||
}
|
||||
.voice-dialog-hold-icon {
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
border-radius: 50%;
|
||||
background: #ffffff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 4rpx 12rpx rgba(0, 108, 73, 0.12);
|
||||
}
|
||||
.voice-dialog-hold-title {
|
||||
font-size: 28rpx;
|
||||
font-weight: 700;
|
||||
color: #134e4a;
|
||||
letter-spacing: 0.5rpx;
|
||||
}
|
||||
.voice-dialog-hold-heard {
|
||||
font-size: 24rpx;
|
||||
color: #475569;
|
||||
max-width: 100%;
|
||||
text-align: center;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.voice-dialog-bubble {
|
||||
max-width: 86%;
|
||||
@@ -6398,7 +6770,7 @@ function onFamilyLikeStripTap() {
|
||||
}
|
||||
.voice-dialog-status-text {
|
||||
font-size: 24rpx;
|
||||
color: #0f766e;
|
||||
color: #475569;
|
||||
}
|
||||
.voice-dialog-wave {
|
||||
display: flex;
|
||||
|
||||
Reference in New Issue
Block a user