diff --git a/TUICallKit-Vue3/tongji/components/VoiceSpeakMascot.vue b/TUICallKit-Vue3/tongji/components/VoiceSpeakMascot.vue
new file mode 100644
index 00000000..44c2c16a
--- /dev/null
+++ b/TUICallKit-Vue3/tongji/components/VoiceSpeakMascot.vue
@@ -0,0 +1,189 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/TUICallKit-Vue3/tongji/composables/useSpeechToText.js b/TUICallKit-Vue3/tongji/composables/useSpeechToText.js
index 09f01693..9b91d47a 100644
--- a/TUICallKit-Vue3/tongji/composables/useSpeechToText.js
+++ b/TUICallKit-Vue3/tongji/composables/useSpeechToText.js
@@ -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 }
diff --git a/TUICallKit-Vue3/tongji/pages/more.vue b/TUICallKit-Vue3/tongji/pages/more.vue
index 6cfa97c8..1ee00225 100644
--- a/TUICallKit-Vue3/tongji/pages/more.vue
+++ b/TUICallKit-Vue3/tongji/pages/more.vue
@@ -1,5 +1,5 @@
-
+
@@ -375,83 +375,119 @@
加载今日数据中…
-
+
请如实填写,留空表示该项不上传。当天可多次修改。
-
-
-
-
-
-
-
-
- 语音问答录入
- {{ voiceDialog.active ? '跟着提问回答数字即可自动填写' : '点击开始,语音一问一答' }}
-
-
-
- 结束
-
-
- 开始问答
-
+
+
+
+ 语音录入
+ 选一种方式,自动填入下方字段
-
-
- {{ voiceDialog.botText }}
+
+
+ 推荐
+
+
-
- {{ voiceDialog.heardText }}
-
-
-
-
-
-
-
-
- {{ voiceDialogStatusText }}
-
-
-
- 说完了
-
-
- {{ voiceDialog.phase === 'listening' ? '重说' : '重听' }}
-
-
- 跳过本项
-
+
+ {{ isVoiceBarLive('glucose') ? '正在聆听…松手结束' : '长按说话,自动填写' }}
+ 例:空腹6.5,餐后8.2
+
-
-
-
-
-
-
- {{ (sttListening || sttHolding) && voiceTarget === 'glucose' ? '正在聆听…松手结束' : '长按说话,自动填写' }}
- 例:空腹6.5,餐后8.2
-
+
+
+
血糖(mmol/L)
@@ -543,19 +579,29 @@
×
加载中…
-
+
-
+
- {{ (sttListening || sttHolding) && voiceTarget === 'bp' ? '正在聆听…松手结束' : '长按说话,自动填写' }}
+ {{ isVoiceBarLive('bp') ? '正在聆听…松手结束' : '长按说话,自动填写' }}
例:高压120,低压80
@@ -607,7 +653,13 @@
×
加载中…
-
+
AI 推荐
早 {{ dietAiPrefill.breakfast }} · 喝 {{ dietAiPrefill.drinks }} · 午 {{ dietAiPrefill.lunch }} · 晚 {{ dietAiPrefill.dinner }}
@@ -618,16 +670,20 @@
至少填写一餐,完成后可浇水领取积分。
-
+
- {{ (sttListening || sttHolding) && voiceTarget === 'diet' ? '正在聆听…松手结束' : '长按说话,自动填写' }}
+ {{ isVoiceBarLive('diet') ? '正在聆听…松手结束' : '长按说话,自动填写' }}
例:早餐鸡蛋粥,午餐米饭炒青菜
@@ -673,20 +729,30 @@
×
加载中…
-
+
填写运动类型或时长,完成后可浇水领取积分。
-
+
- {{ (sttListening || sttHolding) && voiceTarget === 'exercise' ? '正在聆听…松手结束' : '长按说话,自动填写' }}
+ {{ isVoiceBarLive('exercise') ? '正在聆听…松手结束' : '长按说话,自动填写' }}
例:散步30分钟,中强度
@@ -723,8 +789,8 @@
-
-
+
+
@@ -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)
- voiceTarget.value = target
- beginHoldSpeech()
+ 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,29 +2466,58 @@ 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
- beginHoldSpeech()
- armListenTimeout(token)
+ 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)
+}
+
+/** 问答聆听:松手结束并识别 */
+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()
+}
+
/**
* 实时识别中间结果(仅在聆听阶段)。
* 注意:微信当前已不再触发 onRecognize,此处为「若平台支持则提速」的增益逻辑——
@@ -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;