From 5f4dc370f8edcc557551a0952fa33d93ffbb35a6 Mon Sep 17 00:00:00 2001 From: jing <1683105490@qq.com> Date: Wed, 30 Sep 2026 09:28:22 +0800 Subject: [PATCH] =?UTF-8?q?chore=EF=BC=9A=E8=AF=AD=E9=9F=B3=E6=B0=94?= =?UTF-8?q?=E6=B3=A1=E6=AD=A3=E5=B8=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- common/js/voice-recorder.js | 103 ++++++++++-- pages_tool/ai-chat/ai-chat-message.vue | 215 +++++++++++++++---------- 2 files changed, 219 insertions(+), 99 deletions(-) diff --git a/common/js/voice-recorder.js b/common/js/voice-recorder.js index d8570af..5298f10 100644 --- a/common/js/voice-recorder.js +++ b/common/js/voice-recorder.js @@ -73,12 +73,12 @@ function _setSingletonBusy(instance, maxDurationMs = DEFAULT_MAX_DURATION_MS) { _singletonIdleResolver = resolve }) _singletonBusyInstance = instance - // 保险: 即使 onStop/onError 因异常没触发, 最多等 maxDuration + 5s 也要释放, + // 保险: 即使 onStop/onError 因异常没触发, 最多等 10s 也要释放, // 避免死锁导致后续所有 start() 永远挂起。 _singletonIdleTimer = setTimeout(() => { console.warn('[voice-recorder:singleton] idle timeout, force release') _setSingletonIdle(instance) - }, maxDurationMs + 5000) + }, Math.min(maxDurationMs + 5000, 10000)) } function _setSingletonIdle(instance) { @@ -254,6 +254,7 @@ class VoiceRecorder { this._startTs = 0 this._id = Math.random().toString(36).slice(2, 8) this._autoStopTimer = null + this._stopFallbackTimer = null // PR-FRONTEND-VOICE-FALLBACK: stop()/cancel() 兜底定时器 this._earlyStopResult = null // PR-FRONTEND-VOICE-RACE: onStop 在 stop() 之前触发时缓存结果 // 平台相关句柄 @@ -296,13 +297,22 @@ class VoiceRecorder { return Promise.reject(_err('ALREADY_STARTED', 'recorder is starting')) } + this._isStarting = true + let p = null if (isH5()) { - return this._startH5() + p = this._startH5() + } else if (isMpOrApp()) { + p = this._startUni() + } else { + this._isStarting = false + return Promise.reject(_err('NOT_SUPPORTED', 'voice input not supported on this platform')) } - if (isMpOrApp()) { - return this._startUni() - } - return Promise.reject(_err('NOT_SUPPORTED', 'voice input not supported on this platform')) + // 启动失败时复位状态, 避免后续 stop()/cancel() 一直挂起 + p.catch((e) => { + this._isStarting = false + return Promise.reject(e) + }) + return p } /** @@ -316,7 +326,7 @@ class VoiceRecorder { // onStart 尚未回调 (录音未真正开始) 就调用 stop(), 原逻辑直接返回 null // 导致上层判定"录音失败,请重试"。这里改为挂起, 等 onStart 触发后立即停, // 产物仍走 onStop 正常返回 (太短的音轨后端可自行拒绝)。 - if (this._isStarting && this._uniRec) { + if (this._isStarting) { console.log(`[voice-recorder:${this._id}] stop() deferred: waiting for onStart`) return new Promise((resolve) => { this._pendingStop = true @@ -341,6 +351,16 @@ class VoiceRecorder { * 中断录音并丢弃产物。 */ cancel() { + if (this._isStarting) { + // PR-FRONTEND-VOICE-CANCEL-RACE (2026-09-29): onStart 尚未触发就取消, + // 不能立即返回, 否则录音器启动后会继续录制。挂起 pendingStop, + // 等 onStart 触发后立即 stop(), 产物被丢弃。 + console.log(`[voice-recorder:${this._id}] cancel() deferred: waiting for onStart`) + return new Promise((resolve) => { + this._pendingStop = true + this._stopResolve = () => resolve() + }) + } if (!this._isRecording) { this._clearAutoStopTimer() return Promise.resolve() @@ -524,7 +544,13 @@ class VoiceRecorder { // PR-FRONTEND-VOICE-SINGLETON: 串行化所有对微信录音单例的访问, // 防止"上一个 onStop 还没触发, 下一个 start() 就报 audio is recording"。 return _singletonIdlePromise.then(() => { + // 防竞态: 注册 .then() 后到回调执行前, 可能有其他实例先占用了单例, 需要重新排队。 + if (_singletonBusyInstance && _singletonBusyInstance !== this) { + console.log(`[voice-recorder:${this._id}] _startUni: singleton busy by ${_singletonBusyInstance._id}, re-queue`) + return this._startUni() + } _setSingletonBusy(this, this._maxDuration) + console.log(`[voice-recorder:${this._id}] _startUni: singleton busy acquired`) return this._doStartUni() }) } @@ -567,6 +593,7 @@ class VoiceRecorder { rec.onStop((res) => { // res = { tempFilePath, duration, fileSize } console.log(`[voice-recorder:${this._id}] onStop fired`, res, 'hasStopResolve=', !!this._stopResolve) + this._clearStopFallbackTimer() this._uniRec = null this._isRecording = false this._isStarting = false @@ -610,6 +637,7 @@ class VoiceRecorder { }) rec.onError((err) => { + this._clearStopFallbackTimer() const e = _err('UNI_RECORD_ERROR', (err && (err.errMsg || err.message)) || 'record failed') console.error(`[voice-recorder:${this._id}] UNI_RECORD_ERROR raw:`, err, 'errMsg=', err && err.errMsg, 'message=', err && err.message) this._uniRec = null @@ -662,9 +690,20 @@ class VoiceRecorder { // 可能导致录音被提前中断。 }) } catch (e) { + this._clearStopFallbackTimer() this._uniRec = null + this._isRecording = false this._isStarting = false + this._clearAutoStopTimer() _setSingletonIdle(this) + // 启动阶段就被 stop()/cancel() 抢占, 需要 Fulfill 挂起的 Promise, 而不是 reject + if (this._stopResolve && this._pendingStop) { + const r = this._stopResolve + this._stopResolve = null + this._pendingStop = false + r(null) + return + } reject(e) } }) @@ -677,7 +716,30 @@ class VoiceRecorder { if (!rec) { resolve(null); return } this._earlyStopResult = null this._stopResolve = resolve - try { rec.stop() } catch (e) { resolve(null) } + this._clearStopFallbackTimer() + // PR-FRONTEND-VOICE-FALLBACK: 真机偶发 onStop 不回调, 导致 stop() 永远挂起, + // UI 无法释放旧实例, 新录音提示"recorder already exists"。最多等 3s 强制 resolve。 + this._stopFallbackTimer = setTimeout(() => { + console.warn(`[voice-recorder:${this._id}] _stopUni fallback timeout, force resolve`) + this._stopResolve = null + this._pendingStop = false + this._isRecording = false + this._isStarting = false + this._clearAutoStopTimer() + this._uniRec = null + _setSingletonIdle(this) + resolve(null) + }, 3000) + try { rec.stop() } catch (e) { + this._clearStopFallbackTimer() + this._stopResolve = null + this._isRecording = false + this._isStarting = false + this._clearAutoStopTimer() + this._uniRec = null + _setSingletonIdle(this) + resolve(null) + } }) } @@ -703,10 +765,24 @@ class VoiceRecorder { } this._stopResolve = () => resolve() this._pendingStop = false - try { rec.stop() } catch (e) { + this._clearStopFallbackTimer() + // PR-FRONTEND-VOICE-FALLBACK: cancel() 同样可能因 onStop 不回调而挂起。 + this._stopFallbackTimer = setTimeout(() => { + console.warn(`[voice-recorder:${this._id}] _cancelUni fallback timeout, force resolve`) + this._stopResolve = null this._isRecording = false this._isStarting = false this._clearAutoStopTimer() + this._uniRec = null + _setSingletonIdle(this) + resolve() + }, 3000) + try { rec.stop() } catch (e) { + this._clearStopFallbackTimer() + this._isRecording = false + this._isStarting = false + this._clearAutoStopTimer() + this._uniRec = null _setSingletonIdle(this) resolve() } @@ -731,6 +807,13 @@ class VoiceRecorder { this._autoStopTimer = null } } + + _clearStopFallbackTimer() { + if (this._stopFallbackTimer) { + clearTimeout(this._stopFallbackTimer) + this._stopFallbackTimer = null + } + } } export default createVoiceRecorder \ No newline at end of file diff --git a/pages_tool/ai-chat/ai-chat-message.vue b/pages_tool/ai-chat/ai-chat-message.vue index 57555a8..a603256 100644 --- a/pages_tool/ai-chat/ai-chat-message.vue +++ b/pages_tool/ai-chat/ai-chat-message.vue @@ -148,9 +148,10 @@ {{ message.content }} - @@ -3273,6 +3273,24 @@ _handleStreamError(msg) { return Math.min(100, Math.max(0, (cur / dur) * 100)) }, + // PR-USER-VOICE-BUBBLE-WECHAT (2026-09-29): 微信风格语音气泡宽度按时长比例伸缩, + // 短语音窄、长语音宽, 更像微信原生体验。 + getVoiceBubbleWidth(durationMs) { + const dur = Number(durationMs) || 0 + const seconds = dur / 1000 + const minW = 200 + const maxW = 420 + const maxDuration = 60 + const ratio = Math.min(1, Math.max(0, seconds / maxDuration)) + return `${Math.round(minW + ratio * (maxW - minW))}rpx` + }, + + // PR-USER-VOICE-BUBBLE-WECHAT (2026-09-29): 微信语音气泡时长显示为 X" 格式 + getVoiceDurationText(durationMs) { + const seconds = Math.round((Number(durationMs) || 0) / 1000) + return seconds + '"' + }, + // 格式化 AI 处理耗时 (秒 -> "XmXXs" 风格, 0 时显示 "<1s") formatElapsed(seconds) { if (seconds === undefined || seconds === null || isNaN(seconds)) return '1s' @@ -5425,6 +5443,12 @@ _handleStreamError(msg) { display: none !important; content: none !important; } + + /* 语音气泡保持与文字气泡一致的颜色 */ + &.user-audio-bubble { + background: #f5f5f5 !important; + padding: 14rpx 22rpx !important; + } } } } @@ -6953,24 +6977,40 @@ _handleStreamError(msg) { } } -/* PR-USER-VOICE-BUBBLE (2026-09-28): 用户语音消息气泡 - 模仿微信/Telegram 语音消息样式: 主色实心圆角胶囊 + 播放按钮 + 进度条 + 时长 +/* PR-USER-VOICE-BUBBLE (2026-09-29): 用户语音消息气泡 —— 微信原生风格 + - 左侧显示时长 X", 右侧显示扬声器 + 声波动画 + - 气泡右侧带小三角尾巴 + - 宽度按时长比例伸缩 (短语音窄、长语音宽) 与 .user-file-bubble 复用基础结构但视觉完全不同, 这里覆盖其样式 PR-USER-MULTI-VOICE (2026-09-28): 改为 flex-column 让多条 voice-msg 垂直堆叠 */ .user-audio-bubble { /* 覆盖 .user-file-bubble 的 padding/file-item */ - padding: 18rpx 24rpx !important; - min-width: 320rpx; - max-width: 480rpx; - background: $color-primary !important; - border-radius: 24rpx !important; - color: #fff !important; + padding: 14rpx 22rpx !important; + min-width: 180rpx; + max-width: 460rpx; + background: #f5f5f5 !important; + border-radius: 16rpx !important; + color: #1f1f1f !important; cursor: pointer; position: relative; - overflow: hidden; - display: flex !important; + overflow: visible; + display: inline-flex !important; flex-direction: column; - gap: 16rpx; + gap: 12rpx; + width: auto !important; + + /* 微信气泡右侧小三角尾巴 */ + &::after { + content: ''; + position: absolute; + right: -12rpx; + top: 20rpx; + width: 0; + height: 0; + border: 12rpx solid transparent; + border-left-color: $color-primary; + border-right: 0; + } /* 上传中半透明 */ &.is-uploading { @@ -6978,93 +7018,90 @@ _handleStreamError(msg) { } &.is-error { background: #c0c4cc !important; + &::after { + border-left-color: #c0c4cc !important; + } } .voice-msg { display: flex; align-items: center; + justify-content: space-between; gap: 16rpx; - - .voice-play-btn { - flex-shrink: 0; - width: 64rpx; - height: 64rpx; - border-radius: 50%; - background: rgba(255, 255, 255, 0.22); - display: flex; - align-items: center; - justify-content: center; - transition: background 0.2s ease, transform 0.15s ease; - - .icondiy { - color: #fff; - font-size: 32rpx; - font-weight: 600; - line-height: 1; - } - - /* 播放中按钮背景加深 */ - &.playing { - background: rgba(255, 255, 255, 0.4); - } - &.disabled { - opacity: 0.5; - } - } - - .voice-info { - flex: 1; - min-width: 0; - display: flex; - flex-direction: column; - gap: 8rpx; - - .voice-label { - font-size: 24rpx; - color: rgba(255, 255, 255, 0.92); - line-height: 1; - /* PR-CROSS-PLATFORM-VOICE-LABEL (2026-09-28): 异常长 i18n 文案或 - user-agent 自带文本时, 不撑破气泡宽度, 跨平台一致 */ - max-width: 240rpx; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } - - .voice-progress-track { - height: 8rpx; - background: rgba(255, 255, 255, 0.28); - border-radius: 4rpx; - overflow: hidden; - - .voice-progress-bar { - height: 100%; - background: #fff; - border-radius: 4rpx; - /* 进度条用 transition 平滑过渡, 配合 innerAudioCtx.onTimeUpdate 的秒级刷新 */ - transition: width 0.15s linear; - } - } - } + width: 100%; + min-height: 44rpx; .voice-duration { flex-shrink: 0; - font-size: 26rpx; - color: #fff; + font-size: 30rpx; + color: #1f1f1f; font-variant-numeric: tabular-nums; line-height: 1; } + + /* 微信风格语音图标: 扬声器 + 右侧声波 */ + .voice-speaker { + flex-shrink: 0; + display: flex; + align-items: center; + height: 36rpx; + position: relative; + + /* 扬声器后身 */ + .voice-speaker-body { + width: 8rpx; + height: 14rpx; + background: #1f1f1f; + border-radius: 2rpx 0 0 2rpx; + } + + /* 扬声器喇叭口(指向右侧) */ + .voice-speaker-cone { + width: 0; + height: 0; + border-top: 10rpx solid transparent; + border-bottom: 10rpx solid transparent; + border-right: 14rpx solid #1f1f1f; + margin-right: 4rpx; + } + + /* 右侧两条声波弧线 */ + .voice-speaker-waves { + display: flex; + align-items: center; + margin-left: 2rpx; + + .voice-speaker-wave { + border-top: 3rpx solid transparent; + border-right: 3rpx solid #1f1f1f; + border-bottom: 3rpx solid transparent; + border-left: 3rpx solid transparent; + border-radius: 0 50% 50% 0; + + &:nth-child(1) { + width: 8rpx; + height: 14rpx; + } + + &:nth-child(2) { + width: 14rpx; + height: 22rpx; + margin-left: -8rpx; + } + } + } + } } /* PR-USER-MULTI-VOICE (2026-09-28): 语音 + 文字混合时, 文字写在语音列表下方 与语音行共用主色背景, 顶部分割线划开两块内容 */ .voice-bubble-text { padding-top: 12rpx; - border-top: 1rpx solid rgba(255, 255, 255, 0.2); + border-top: 1rpx solid rgba(0, 0, 0, 0.12); .message-text { font-size: 28rpx; - color: #fff; + color: #1f1f1f; line-height: 1.5; word-break: break-word; }