feat(ai-chat): 增强语音消息处理,支持多条语音同时发送及中央播放面板
This commit is contained in:
@@ -39,6 +39,10 @@
|
|||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 消息列表 -->
|
<!-- 消息列表 -->
|
||||||
|
<!-- PR-USER-MULTI-VOICE-FILTER (2026-09-28): 补 voiceList / attachments 字段检查
|
||||||
|
原过滤只识别顶层 fileName / audioUrl / imageUrl / videoUrl,
|
||||||
|
多条语音消息顶层无这些字段 (在 voiceList 里嵌套), 会被误过滤掉不渲染
|
||||||
|
修复: 显式判断 voiceList 非空 + attachments 非空 -->
|
||||||
<view
|
<view
|
||||||
v-for="(message, index) in messages.filter(msg =>
|
v-for="(message, index) in messages.filter(msg =>
|
||||||
msg.type === 'loading'
|
msg.type === 'loading'
|
||||||
@@ -46,6 +50,8 @@
|
|||||||
|| msg.fileName
|
|| msg.fileName
|
||||||
|| msg.audioUrl
|
|| msg.audioUrl
|
||||||
|| msg.videoUrl
|
|| msg.videoUrl
|
||||||
|
|| (msg.voiceList && msg.voiceList.length)
|
||||||
|
|| (msg.attachments && msg.attachments.length)
|
||||||
|| (msg.content && msg.content.trim() !== '')
|
|| (msg.content && msg.content.trim() !== '')
|
||||||
)"
|
)"
|
||||||
:key="getMessageKey(message, index)"
|
:key="getMessageKey(message, index)"
|
||||||
@@ -105,16 +111,31 @@
|
|||||||
<view v-if="message.uploadError" class="upload-mask upload-error"><text class="upload-tip">{{ message.uploadError }}</text></view>
|
<view v-if="message.uploadError" class="upload-mask upload-error"><text class="upload-tip">{{ message.uploadError }}</text></view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<!-- 用户音频消息气泡 (音频上传): 点击播放/暂停 -->
|
<!-- PR-USER-VOICE-BUBBLE (2026-09-28): 用户语音消息气泡
|
||||||
<view v-else-if="message.type === 'audio'" class="message-bubble user-audio-bubble" @click="playUserAudio(message)">
|
模仿微信/Telegram 的语音消息样式:
|
||||||
<view class="file-item">
|
[▶播放] [语音 + 进度条] [时长]
|
||||||
<view class="file-icon"><text class="icondiy" :class="message.playing ? 'icon-system-pause-circle-fill' : 'icon-system-play-circle-fill'"></text></view>
|
PR-USER-MULTI-VOICE (2026-09-28): 支持多条语音同时发送,
|
||||||
<view class="file-info">
|
每条 voice 项都有自己的 url/duration/playing 字段,
|
||||||
<text class="file-name">{{ message.fileName || $lang('ai-chat.index.audioMessage') }}</text>
|
模板 v-for 渲染多个 .voice-msg 行, 每行点击 → openVoiceOverlay(v)
|
||||||
<text class="file-size">{{ formatFileSize(message.fileSize) }}</text>
|
→ 中央播放面板, 不同语音独立可播放互不干扰 -->
|
||||||
|
<view
|
||||||
|
v-else-if="message.type === 'audio'"
|
||||||
|
class="message-bubble user-audio-bubble">
|
||||||
|
<view v-for="(v, idx) in (message.voiceList || [])" :key="idx" class="voice-msg" @click.stop="openVoiceOverlay(v)">
|
||||||
|
<view class="voice-play-btn" :class="{ playing: v.playing }">
|
||||||
|
<text class="icondiy" :class="v.playing ? 'icon-system-pause-circle-fill' : 'icon-system-play-circle-fill'"></text>
|
||||||
</view>
|
</view>
|
||||||
<view v-if="message.uploading" class="upload-mask"><text class="upload-tip">上传中...</text></view>
|
<view class="voice-info">
|
||||||
<view v-if="message.uploadError" class="upload-mask upload-error"><text class="upload-tip">{{ message.uploadError }}</text></view>
|
<text class="voice-label">{{ v.fileName || $lang('ai-chat.index.audioMessage') || '语音' }}</text>
|
||||||
|
<view class="voice-progress-track">
|
||||||
|
<view class="voice-progress-bar" :style="{ width: getVoiceProgress(v) + '%' }"></view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<text class="voice-duration">{{ formatDuration((v.duration || 0) / 1000) }}</text>
|
||||||
|
</view>
|
||||||
|
<!-- 语音 + 文字混合时, 文字写在语音列表下方 -->
|
||||||
|
<view v-if="message.content" class="voice-bubble-text">
|
||||||
|
<text class="message-text" selectable>{{ message.content }}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view v-else class="message-bubble">
|
<view v-else class="message-bubble">
|
||||||
@@ -235,18 +256,18 @@
|
|||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 音频消息 -->
|
<!-- 音频消息 -->
|
||||||
<view v-else-if="message.type === 'audio'" class="audio-content">
|
<view v-else-if="message.type === 'audio'" class="audio-content" @click="openVoiceOverlay(message)">
|
||||||
<view class="audio-player">
|
<view class="audio-player">
|
||||||
<view class="audio-info">
|
<view class="audio-info">
|
||||||
<text class="audio-title">{{ message.title || $lang('ai-chat.index.audioMessage') }}</text>
|
<text class="audio-title">{{ message.title || $lang('ai-chat.index.audioMessage') }}</text>
|
||||||
<text class="audio-duration">{{ formatDuration(message.duration) }}</text>
|
<text class="audio-duration">{{ formatDuration(message.duration) }}</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="audio-controls">
|
<view class="audio-controls">
|
||||||
<button
|
<button
|
||||||
class="play-btn"
|
class="play-btn"
|
||||||
:class="{ playing: message.playing }"
|
:class="{ playing: message.playing }"
|
||||||
@click="toggleAudio(message)">
|
@click.stop="openVoiceOverlay(message)">
|
||||||
<text class="iconfont" :class="message.playing ? 'icon-pause' : 'icon-play'"></text>
|
<text class="icondiy" :class="message.playing ? 'icon-system-pause-circle-fill' : 'icon-system-play-circle-fill'"></text>
|
||||||
</button>
|
</button>
|
||||||
<slider
|
<slider
|
||||||
class="audio-slider"
|
class="audio-slider"
|
||||||
@@ -421,8 +442,23 @@
|
|||||||
</view>
|
</view>
|
||||||
<view class="pending-remove" @tap="removePendingImage(p.localId)">×</view>
|
<view class="pending-remove" @tap="removePendingImage(p.localId)">×</view>
|
||||||
</view>
|
</view>
|
||||||
<view v-for="f in pendingFiles" :key="f.localId" class="pending-item">
|
<view v-for="f in pendingFiles" :key="f.localId" class="pending-item" :class="{ 'pending-item-voice': f.type === 'audio' }">
|
||||||
<image v-if="f.type === 'image' && (f.uploaded ? f.uploaded.url : f.imageUrl)" :src="f.uploaded ? f.uploaded.url : f.imageUrl" mode="aspectFill" class="pending-image" />
|
<image v-if="f.type === 'image' && (f.uploaded ? f.uploaded.url : f.imageUrl)" :src="f.uploaded ? f.uploaded.url : f.imageUrl" mode="aspectFill" class="pending-image" />
|
||||||
|
<!-- PR-PENDING-VOICE-PREVIEW (2026-09-28): 待发送语音预览也支持播放 + 显示时长
|
||||||
|
复用既有 innerAudioCtx (与发送出去的语音气泡共享同一引擎),
|
||||||
|
直接调 toggleAudio(f) 即可, pending 文件已有 url/playing/currentTime/duration 字段 -->
|
||||||
|
<view v-else-if="f.type === 'audio'" class="pending-voice-thumb" @tap.stop="openVoiceOverlay(f)">
|
||||||
|
<view class="pending-voice-play" :class="{ playing: f.playing, disabled: f.uploading || f.uploadError }">
|
||||||
|
<text class="icondiy" :class="f.playing ? 'icon-system-pause-circle-fill' : 'icon-system-play-circle-fill'"></text>
|
||||||
|
</view>
|
||||||
|
<view class="pending-voice-info">
|
||||||
|
<text class="pending-voice-label">{{ $lang('ai-chat.index.audioMessage') || '语音' }}</text>
|
||||||
|
<view class="pending-voice-progress-track">
|
||||||
|
<view class="pending-voice-progress-bar" :style="{ width: getVoiceProgress(f) + '%' }"></view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<text class="pending-voice-duration">{{ formatDuration((f.duration || 0) / 1000) }}</text>
|
||||||
|
</view>
|
||||||
<view v-else class="pending-file-thumb">
|
<view v-else class="pending-file-thumb">
|
||||||
<text class="pending-file-icon">{{ f.type === 'video' ? '🎬' : (f.type === 'audio' ? '🎵' : '📄') }}</text>
|
<text class="pending-file-icon">{{ f.type === 'video' ? '🎬' : (f.type === 'audio' ? '🎵' : '📄') }}</text>
|
||||||
<text class="pending-file-name">{{ f.fileName }}</text>
|
<text class="pending-file-name">{{ f.fileName }}</text>
|
||||||
@@ -621,6 +657,34 @@
|
|||||||
:nickname="localUserNickname"
|
:nickname="localUserNickname"
|
||||||
@nickname-change="onNicknameChange"
|
@nickname-change="onNicknameChange"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- PR-VOICE-OVERLAY (2026-09-28): 中央语音播放面板
|
||||||
|
点击任意语音气泡/预览时弹出, 半透明背景 + 中央控制卡
|
||||||
|
(大播放按钮 + 时长 + 进度条), 覆盖在所有内容之上 -->
|
||||||
|
<view v-if="voiceOverlayVisible && voiceOverlay" class="voice-overlay" @tap="closeVoiceOverlay">
|
||||||
|
<view class="voice-overlay-mask"></view>
|
||||||
|
<view class="voice-overlay-panel" @tap.stop>
|
||||||
|
<view class="voice-overlay-card">
|
||||||
|
<view class="voice-overlay-close" @tap="closeVoiceOverlay">×</view>
|
||||||
|
<view class="voice-overlay-icon">
|
||||||
|
<text class="icondiy" :class="voiceOverlay.playing ? 'icon-system-pause-circle-fill' : 'icon-system-play-circle-fill'"></text>
|
||||||
|
</view>
|
||||||
|
<text class="voice-overlay-title">{{ $lang('ai-chat.index.audioMessage') || '语音' }}</text>
|
||||||
|
<view class="voice-overlay-time">
|
||||||
|
<text class="time-current">{{ formatDuration(voiceOverlay.currentTime || 0) }}</text>
|
||||||
|
<text class="time-sep"> / </text>
|
||||||
|
<text class="time-total">{{ formatDuration((voiceOverlay.duration || 0) / 1000) }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="voice-overlay-progress-track">
|
||||||
|
<view class="voice-overlay-progress-bar" :style="{ width: getVoiceProgress(voiceOverlay) + '%' }"></view>
|
||||||
|
</view>
|
||||||
|
<view class="voice-overlay-play-btn" :class="{ playing: voiceOverlay.playing }" @tap="toggleOverlayAudio">
|
||||||
|
<text class="icondiy" :class="voiceOverlay.playing ? 'icon-system-pause-circle-fill' : 'icon-system-play-circle-fill'"></text>
|
||||||
|
</view>
|
||||||
|
<text class="voice-overlay-hint">点击外部关闭</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
<script>
|
<script>
|
||||||
@@ -741,6 +805,11 @@ export default {
|
|||||||
showToolsPanel: false,
|
showToolsPanel: false,
|
||||||
enableVoicePanelShow: false,
|
enableVoicePanelShow: false,
|
||||||
showEmojiPanel: false,
|
showEmojiPanel: false,
|
||||||
|
// PR-VOICE-OVERLAY (2026-09-28): 中央语音播放面板状态
|
||||||
|
// 点击任意语音气泡/预览 → 弹出半透明面板, 显示大播放按钮 + 实时进度
|
||||||
|
// voiceOverlay 持有当前正在面板播放的对象 (可能是 message 或 pending file)
|
||||||
|
voiceOverlay: null,
|
||||||
|
voiceOverlayVisible: false,
|
||||||
// 扩大编辑器相关
|
// 扩大编辑器相关
|
||||||
showExpandEditor: false,
|
showExpandEditor: false,
|
||||||
expandInputText: '',
|
expandInputText: '',
|
||||||
@@ -1106,12 +1175,30 @@ export default {
|
|||||||
// 移除待发送文件附件 (按 localId)
|
// 移除待发送文件附件 (按 localId)
|
||||||
removePendingFile(localId) {
|
removePendingFile(localId) {
|
||||||
if (localId === undefined || localId === null) {
|
if (localId === undefined || localId === null) {
|
||||||
|
// 全部清空: 先停掉任何正在播放的语音预览
|
||||||
|
this._stopPendingAudioIfPlaying()
|
||||||
this.pendingFiles = []
|
this.pendingFiles = []
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// 单个删除: 如果删的是正在播放的, 先停掉
|
||||||
|
const target = this.pendingFiles.find(p => p.localId === localId)
|
||||||
|
if (target && target.playing) {
|
||||||
|
this._stopPendingAudioIfPlaying(target)
|
||||||
|
}
|
||||||
this.pendingFiles = this.pendingFiles.filter(p => p.localId !== localId)
|
this.pendingFiles = this.pendingFiles.filter(p => p.localId !== localId)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// PR-PENDING-VOICE-PREVIEW (2026-09-28): 停掉正在播放的待发送语音预览
|
||||||
|
// 共用 innerAudioCtx, 只可能有一个 pending voice 在播放
|
||||||
|
// target 不传时停掉任意正在播放的 pending voice
|
||||||
|
_stopPendingAudioIfPlaying(target) {
|
||||||
|
const playing = target
|
||||||
|
|| this.pendingFiles.find(p => p && p.playing)
|
||||||
|
if (!playing) return
|
||||||
|
// 走 pauseAudio 共用引擎: 它会 innerAudioCtx.pause() 并 playing=false
|
||||||
|
this.pauseAudio(playing)
|
||||||
|
},
|
||||||
|
|
||||||
onLocationSelected(locationMessage) {
|
onLocationSelected(locationMessage) {
|
||||||
this.messages.push(locationMessage)
|
this.messages.push(locationMessage)
|
||||||
this.$emit('location-selected', locationMessage)
|
this.$emit('location-selected', locationMessage)
|
||||||
@@ -1662,6 +1749,18 @@ async loadChatHistoryIfExist() {
|
|||||||
...(a.data ? { data: a.data, encoding: a.encoding } : {}),
|
...(a.data ? { data: a.data, encoding: a.encoding } : {}),
|
||||||
})),
|
})),
|
||||||
]
|
]
|
||||||
|
// PR-USER-VOICE-MESSAGE-TYPE (2026-09-28): 识别"语音"场景
|
||||||
|
// 原代码对所有非图片消息都设 type='text', 把音频当成普通附件 (📎文件名) 渲染,
|
||||||
|
// 用户根本看不到自己发的语音. 改为:
|
||||||
|
// - 有图片 → type='image' (图片气泡为主, 语音走 attachments 由后端接收)
|
||||||
|
// - 有语音(可多条)→ type='audio' (语音气泡为主, 复用 voice-msg 样式)
|
||||||
|
// - 其他 → type='text' (文字/普通附件)
|
||||||
|
// PR-USER-MULTI-VOICE (2026-09-28): 支持多条语音同时发送,
|
||||||
|
// 把 pendingFilesList 里的音频拆出来 → voiceList 数组, 每条独立可播放.
|
||||||
|
// 优先级: 图片 > 语音 > 文字/附件 (图片永远独占气泡)
|
||||||
|
const voiceFiles = pendingFilesList.filter(p => p.uploaded && p.uploaded.kind === 'audio')
|
||||||
|
const hasVoice = voiceFiles.length > 0
|
||||||
|
|
||||||
const userMessage = firstPendingImage
|
const userMessage = firstPendingImage
|
||||||
? {
|
? {
|
||||||
id: ++this.messageId,
|
id: ++this.messageId,
|
||||||
@@ -1675,20 +1774,54 @@ async loadChatHistoryIfExist() {
|
|||||||
content: hasContent ? this.inputText.trim() : '',
|
content: hasContent ? this.inputText.trim() : '',
|
||||||
timestamp: Date.now()
|
timestamp: Date.now()
|
||||||
}
|
}
|
||||||
: {
|
: hasVoice
|
||||||
id: ++this.messageId,
|
? (() => {
|
||||||
role: 'user',
|
// PR-USER-MULTI-VOICE: 每条 voice 一个独立播放对象, 不再共用 message.url
|
||||||
type: 'text',
|
// 模板 v-for 渲染多行 voice-msg, 每行点击 openVoiceOverlay(v)
|
||||||
// PR-FRONTEND-VOICE-INPUT (2026-09-24): 用户气泡只显示用户自己输入的文字
|
return {
|
||||||
// 给 AI 的指令 hint 在 queryText 里, 这里不要污染用户气泡
|
id: ++this.messageId,
|
||||||
content: userText,
|
role: 'user',
|
||||||
// 纯文件(无图)时把文件挂到 attachments, 用户消息气泡下方展示文件卡片
|
type: 'audio',
|
||||||
attachments: displayAttachments,
|
// 用户文本(若有)挂到 content, 模板里渲染在语音列表下方
|
||||||
timestamp: Date.now()
|
content: userText,
|
||||||
}
|
// 多条语音逐条渲染, 每条独立可播放
|
||||||
|
voiceList: voiceFiles.map(a => ({
|
||||||
|
url: (a.uploaded && a.uploaded.url) || a.url || '',
|
||||||
|
fileName: a.fileName || '',
|
||||||
|
fileSize: a.fileSize || 0,
|
||||||
|
mime: (a.uploaded && a.uploaded.mime) || a.fileType || '',
|
||||||
|
// duration 保持录音器原始单位 (ms), 模板里 /1000 转换显示
|
||||||
|
duration: a.duration || 0,
|
||||||
|
playing: false,
|
||||||
|
currentTime: 0
|
||||||
|
})),
|
||||||
|
// 纯语音时 attachments 留空; 语音+其他文件时把非音频文件挂到 attachments
|
||||||
|
// (voiceList 已覆盖音频, 避免重复渲染 voice-msg + 📎)
|
||||||
|
// 音频的 base64 数据已通过 fileAttachments → readyAttachments 传给后端,
|
||||||
|
// 这里 attachments 不放音频不影响后端通信.
|
||||||
|
attachments: voiceFiles.length === pendingFilesList.length
|
||||||
|
? []
|
||||||
|
: displayAttachments.filter(a => a.kind !== 'audio'),
|
||||||
|
timestamp: Date.now()
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
: {
|
||||||
|
id: ++this.messageId,
|
||||||
|
role: 'user',
|
||||||
|
type: 'text',
|
||||||
|
// PR-FRONTEND-VOICE-INPUT (2026-09-24): 用户气泡只显示用户自己输入的文字
|
||||||
|
// 给 AI 的指令 hint 在 queryText 里, 这里不要污染用户气泡
|
||||||
|
content: userText,
|
||||||
|
// 纯文件(无图)时把文件挂到 attachments, 用户消息气泡下方展示文件卡片
|
||||||
|
attachments: displayAttachments,
|
||||||
|
timestamp: Date.now()
|
||||||
|
}
|
||||||
this.shouldScrollToBottom = true
|
this.shouldScrollToBottom = true
|
||||||
this.messages.push(userMessage)
|
this.messages.push(userMessage)
|
||||||
this.inputText = ''
|
this.inputText = ''
|
||||||
|
// PR-PENDING-VOICE-PREVIEW (2026-09-28): 清空 pending 前先停掉预览中的语音
|
||||||
|
// 不停的话 innerAudioCtx 会继续播, 用户听觉上"语音还在响"但气泡已发出去, 体感很怪
|
||||||
|
this._stopPendingAudioIfPlaying()
|
||||||
this.pendingImages = []
|
this.pendingImages = []
|
||||||
this.pendingFiles = []
|
this.pendingFiles = []
|
||||||
|
|
||||||
@@ -2583,6 +2716,18 @@ _handleStreamError(msg) {
|
|||||||
return mins.toString().padStart(2, '0') + ':' + secs.toString().padStart(2, '0')
|
return mins.toString().padStart(2, '0') + ':' + secs.toString().padStart(2, '0')
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// PR-USER-VOICE-BUBBLE (2026-09-28): 语音消息播放进度 (0~100)
|
||||||
|
// message.duration 是录音器返回的毫秒数, message.currentTime 由 innerAudioCtx.onTimeUpdate 更新 (秒)
|
||||||
|
// 进度条宽度用 % 表示, 边界情况 (无 duration / currentTime=0) 返回 0
|
||||||
|
getVoiceProgress(message) {
|
||||||
|
if (!message) return 0
|
||||||
|
const dur = Number(message.duration) || 0
|
||||||
|
if (dur <= 0) return 0
|
||||||
|
// currentTime 由 innerAudioCtx 返回秒; duration 是毫秒, 统一为毫秒比较
|
||||||
|
const cur = (Number(message.currentTime) || 0) * 1000
|
||||||
|
return Math.min(100, Math.max(0, (cur / dur) * 100))
|
||||||
|
},
|
||||||
|
|
||||||
// 格式化 AI 处理耗时 (秒 -> "XmXXs" 风格, 0 时显示 "<1s")
|
// 格式化 AI 处理耗时 (秒 -> "XmXXs" 风格, 0 时显示 "<1s")
|
||||||
formatElapsed(seconds) {
|
formatElapsed(seconds) {
|
||||||
if (seconds === undefined || seconds === null || isNaN(seconds)) return '1s'
|
if (seconds === undefined || seconds === null || isNaN(seconds)) return '1s'
|
||||||
@@ -2735,7 +2880,47 @@ _handleStreamError(msg) {
|
|||||||
uni.showToast({ title: this.$lang('ai-chat.index.audioNotReady'), icon: 'none' })
|
uni.showToast({ title: this.$lang('ai-chat.index.audioNotReady'), icon: 'none' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
this.toggleAudio(message)
|
// PR-VOICE-OVERLAY (2026-09-28): 点击语音气泡弹出中央面板
|
||||||
|
// 替代原来直接调 toggleAudio 的紧凑交互, 面板里能看实时进度
|
||||||
|
this.openVoiceOverlay(message)
|
||||||
|
},
|
||||||
|
|
||||||
|
// PR-VOICE-OVERLAY (2026-09-28): 打开中央语音播放面板
|
||||||
|
// 同一个 target 重复点击: 若已在播放则只打开面板 (不重启);
|
||||||
|
// 若在暂停则打开面板并继续播放
|
||||||
|
openVoiceOverlay(target) {
|
||||||
|
if (!target || (!target.url && !target.imageUrl)) return
|
||||||
|
const src = target.url || target.imageUrl
|
||||||
|
const isSameTarget = this.voiceOverlay === target
|
||||||
|
&& this.innerAudioCtx
|
||||||
|
&& this._innerAudioSrc === src
|
||||||
|
|
||||||
|
this.voiceOverlay = target
|
||||||
|
this.voiceOverlayVisible = true
|
||||||
|
|
||||||
|
// 已经在播放同一个: 保持播放, 只开面板
|
||||||
|
if (isSameTarget && target.playing) return
|
||||||
|
|
||||||
|
// 不在播放: 通过 toggleAudio 启动 (会自动暂停其他正在播放的)
|
||||||
|
this.toggleAudio(target)
|
||||||
|
},
|
||||||
|
|
||||||
|
// PR-VOICE-OVERLAY (2026-09-28): 关闭面板
|
||||||
|
closeVoiceOverlay() {
|
||||||
|
if (!this.voiceOverlayVisible) return
|
||||||
|
// 关闭时停止播放, 避免后台音频继续
|
||||||
|
if (this.voiceOverlay && this.voiceOverlay.playing) {
|
||||||
|
this.pauseAudio(this.voiceOverlay)
|
||||||
|
}
|
||||||
|
this.voiceOverlayVisible = false
|
||||||
|
// 延迟清空对象, 避免关闭动画期间数据为 null 触发其他 UI 闪烁
|
||||||
|
setTimeout(() => { this.voiceOverlay = null }, 300)
|
||||||
|
},
|
||||||
|
|
||||||
|
// PR-VOICE-OVERLAY (2026-09-28): 面板里的播放/暂停切换
|
||||||
|
toggleOverlayAudio() {
|
||||||
|
if (!this.voiceOverlay) return
|
||||||
|
this.toggleAudio(this.voiceOverlay)
|
||||||
},
|
},
|
||||||
// 切换音频播放
|
// 切换音频播放
|
||||||
toggleAudio(message) {
|
toggleAudio(message) {
|
||||||
@@ -2771,14 +2956,23 @@ _handleStreamError(msg) {
|
|||||||
this.innerAudioCtx.onTimeUpdate(() => {
|
this.innerAudioCtx.onTimeUpdate(() => {
|
||||||
if (!this.innerAudioCtx) return
|
if (!this.innerAudioCtx) return
|
||||||
message.currentTime = this.innerAudioCtx.currentTime
|
message.currentTime = this.innerAudioCtx.currentTime
|
||||||
if (this.innerAudioCtx.duration && !isNaN(this.innerAudioCtx.duration)) {
|
// PR-USER-VOICE-BUBBLE-FIX (2026-09-28): 不再覆盖 message.duration
|
||||||
message.duration = this.innerAudioCtx.duration
|
// 原写法: message.duration = innerAudioCtx.duration (秒)
|
||||||
}
|
// 问题: 用户语音消息的 duration 是录音器返回的 ms (单位约定),
|
||||||
|
// innerAudioCtx.duration 是 s, 直接覆盖会破坏气泡时长显示
|
||||||
|
// (5000ms → 5s → formatDuration(5/1000) → "00:00").
|
||||||
|
// AI 音频时长已由后端 / 录音器在创建消息时设好, 不需要 innerAudioCtx 修正.
|
||||||
|
// 进度计算在 getVoiceProgress 里统一处理单位转换.
|
||||||
})
|
})
|
||||||
this.innerAudioCtx.onEnded(() => {
|
this.innerAudioCtx.onEnded(() => {
|
||||||
message.playing = false
|
message.playing = false
|
||||||
message.currentTime = 0
|
message.currentTime = 0
|
||||||
this.currentAudio = null
|
this.currentAudio = null
|
||||||
|
// PR-VOICE-OVERLAY (2026-09-28): 播放结束自动关闭中央面板
|
||||||
|
// 用户点开后想听完整段, 听完后不需要再保持面板占视野
|
||||||
|
if (this.voiceOverlay === message) {
|
||||||
|
this.closeVoiceOverlay()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
this.innerAudioCtx.onError((e) => {
|
this.innerAudioCtx.onError((e) => {
|
||||||
message.playing = false
|
message.playing = false
|
||||||
@@ -4198,6 +4392,12 @@ _handleStreamError(msg) {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
box-shadow: 0 4rpx 12rpx rgba(108, 142, 191, 0.3);
|
box-shadow: 0 4rpx 12rpx rgba(108, 142, 191, 0.3);
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
.icondiy {
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 36rpx;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
&.playing {
|
&.playing {
|
||||||
background-color: $color-primary-light;
|
background-color: $color-primary-light;
|
||||||
@@ -5933,6 +6133,170 @@ _handleStreamError(msg) {
|
|||||||
&.n4 .user-image { width: 108rpx; height: 108rpx; }
|
&.n4 .user-image { width: 108rpx; height: 108rpx; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* PR-VOICE-OVERLAY (2026-09-28): 中央语音播放面板
|
||||||
|
半透明背景 + 中央卡片 (大播放按钮 + 时长 + 进度条)
|
||||||
|
与现有弹窗 (emoji/avatar/nickname) 风格一致 */
|
||||||
|
.voice-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 999;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
.voice-overlay-mask {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.55);
|
||||||
|
/* 入场淡入 */
|
||||||
|
animation: voiceOverlayFadeIn 0.18s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voice-overlay-panel {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
/* 卡片弹入 */
|
||||||
|
animation: voiceOverlayPopIn 0.22s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.voice-overlay-card {
|
||||||
|
position: relative;
|
||||||
|
width: 560rpx;
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 32rpx;
|
||||||
|
padding: 60rpx 48rpx 44rpx;
|
||||||
|
box-shadow: 0 16rpx 48rpx rgba(0, 0, 0, 0.18);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
.voice-overlay-close {
|
||||||
|
position: absolute;
|
||||||
|
top: 18rpx;
|
||||||
|
right: 18rpx;
|
||||||
|
width: 56rpx;
|
||||||
|
height: 56rpx;
|
||||||
|
line-height: 50rpx;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 40rpx;
|
||||||
|
color: #b0b8c4;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voice-overlay-icon {
|
||||||
|
width: 140rpx;
|
||||||
|
height: 140rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(108, 142, 191, 0.12);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
|
||||||
|
.icondiy {
|
||||||
|
font-size: 84rpx;
|
||||||
|
color: $color-primary;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.voice-overlay-title {
|
||||||
|
font-size: 30rpx;
|
||||||
|
color: #1f1f1f;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voice-overlay-time {
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #6b7c93;
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
|
||||||
|
.time-current {
|
||||||
|
color: $color-primary;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.time-sep {
|
||||||
|
color: #b0b8c4;
|
||||||
|
margin: 0 4rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.voice-overlay-progress-track {
|
||||||
|
width: 100%;
|
||||||
|
height: 10rpx;
|
||||||
|
background: #e8edf3;
|
||||||
|
border-radius: 5rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 36rpx;
|
||||||
|
|
||||||
|
.voice-overlay-progress-bar {
|
||||||
|
height: 100%;
|
||||||
|
background: $color-primary;
|
||||||
|
border-radius: 5rpx;
|
||||||
|
transition: width 0.15s linear;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.voice-overlay-play-btn {
|
||||||
|
width: 96rpx;
|
||||||
|
height: 96rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: $color-primary;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-shadow: 0 6rpx 16rpx rgba(108, 142, 191, 0.35);
|
||||||
|
transition: transform 0.15s ease, background 0.2s ease;
|
||||||
|
|
||||||
|
.icondiy {
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 40rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.playing {
|
||||||
|
background: #4f73a8;
|
||||||
|
/* 播放中轻微脉动 */
|
||||||
|
animation: voiceOverlayPulse 1.6s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: scale(0.94);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.voice-overlay-hint {
|
||||||
|
margin-top: 24rpx;
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #b0b8c4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes voiceOverlayFadeIn {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
@keyframes voiceOverlayPopIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.85);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes voiceOverlayPulse {
|
||||||
|
0%, 100% { transform: scale(1); }
|
||||||
|
50% { transform: scale(1.04); }
|
||||||
|
}
|
||||||
|
|
||||||
/* 用户图片气泡 (提高选择器权重, 覆盖 H5/移动端下 .user-message .message-content .message-bubble 的大内边距) */
|
/* 用户图片气泡 (提高选择器权重, 覆盖 H5/移动端下 .user-message .message-content .message-bubble 的大内边距) */
|
||||||
.user-message .message-content .message-bubble.user-image-bubble {
|
.user-message .message-content .message-bubble.user-image-bubble {
|
||||||
padding: 6rpx !important;
|
padding: 6rpx !important;
|
||||||
@@ -6005,6 +6369,130 @@ _handleStreamError(msg) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* PR-USER-VOICE-BUBBLE (2026-09-28): 用户语音消息气泡
|
||||||
|
模仿微信/Telegram 语音消息样式: 主色实心圆角胶囊 + 播放按钮 + 进度条 + 时长
|
||||||
|
与 .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;
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex !important;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16rpx;
|
||||||
|
|
||||||
|
/* 上传中半透明 */
|
||||||
|
&.is-uploading {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
&.is-error {
|
||||||
|
background: #c0c4cc !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voice-msg {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.voice-duration {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #fff;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* PR-USER-MULTI-VOICE (2026-09-28): 语音 + 文字混合时, 文字写在语音列表下方
|
||||||
|
与语音行共用主色背景, 顶部分割线划开两块内容 */
|
||||||
|
.voice-bubble-text {
|
||||||
|
padding-top: 12rpx;
|
||||||
|
border-top: 1rpx solid rgba(255, 255, 255, 0.2);
|
||||||
|
|
||||||
|
.message-text {
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #fff;
|
||||||
|
line-height: 1.5;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-mask {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
pointer-events: none;
|
||||||
|
.upload-tip { color: #fff; font-size: 24rpx; }
|
||||||
|
&.upload-error .upload-tip { color: #ffb3b3; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* 用户视频气泡 (视频上传): 内嵌播放器, 与图片气泡一样紧凑 */
|
/* 用户视频气泡 (视频上传): 内嵌播放器, 与图片气泡一样紧凑 */
|
||||||
.user-video-bubble {
|
.user-video-bubble {
|
||||||
padding: 6rpx !important;
|
padding: 6rpx !important;
|
||||||
@@ -6093,6 +6581,88 @@ _handleStreamError(msg) {
|
|||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* PR-PENDING-VOICE-PREVIEW (2026-09-28): 待发送语音预览, 复用发送后气泡的语音消息样式
|
||||||
|
容器改为横向矩形, 让 play + 进度 + 时长排成一行更紧凑 */
|
||||||
|
&.pending-item-voice {
|
||||||
|
width: 360rpx;
|
||||||
|
height: 80rpx;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
}
|
||||||
|
.pending-voice-thumb {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: $color-primary;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 14rpx;
|
||||||
|
gap: 12rpx;
|
||||||
|
color: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
.pending-voice-play {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 56rpx;
|
||||||
|
height: 56rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(255, 255, 255, 0.22);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: background 0.2s ease;
|
||||||
|
|
||||||
|
.icondiy {
|
||||||
|
color: #fff;
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.playing {
|
||||||
|
background: rgba(255, 255, 255, 0.4);
|
||||||
|
}
|
||||||
|
&.disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pending-voice-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6rpx;
|
||||||
|
|
||||||
|
.pending-voice-label {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: rgba(255, 255, 255, 0.92);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pending-voice-progress-track {
|
||||||
|
height: 6rpx;
|
||||||
|
background: rgba(255, 255, 255, 0.28);
|
||||||
|
border-radius: 3rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
.pending-voice-progress-bar {
|
||||||
|
height: 100%;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 3rpx;
|
||||||
|
transition: width 0.15s linear;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pending-voice-duration {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #fff;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
/* 用户消息里的文件附件卡片 */
|
/* 用户消息里的文件附件卡片 */
|
||||||
.user-attachments { margin-top: 8rpx; }
|
.user-attachments { margin-top: 8rpx; }
|
||||||
.pending-mask {
|
.pending-mask {
|
||||||
|
|||||||
Reference in New Issue
Block a user