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;
}