feat(ai-chat): 增强多媒体消息处理,支持多条语音和视频同时发送,优化消息气泡显示逻辑
This commit is contained in:
@@ -39,10 +39,10 @@
|
|||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 消息列表 -->
|
<!-- 消息列表 -->
|
||||||
<!-- PR-USER-MULTI-VOICE-FILTER (2026-09-28): 补 voiceList / attachments 字段检查
|
<!-- PR-USER-MULTI-VOICE-FILTER (2026-09-28): 补 voiceList / videoList / attachments 字段检查
|
||||||
原过滤只识别顶层 fileName / audioUrl / imageUrl / videoUrl,
|
原过滤只识别顶层 fileName / audioUrl / imageUrl / videoUrl,
|
||||||
多条语音消息顶层无这些字段 (在 voiceList 里嵌套), 会被误过滤掉不渲染
|
多条语音/视频消息顶层无这些字段 (在 voiceList/videoList 里嵌套), 会被误过滤掉不渲染
|
||||||
修复: 显式判断 voiceList 非空 + attachments 非空 -->
|
修复: 显式判断 voiceList / videoList 非空 + 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'
|
||||||
@@ -51,6 +51,7 @@
|
|||||||
|| msg.audioUrl
|
|| msg.audioUrl
|
||||||
|| msg.videoUrl
|
|| msg.videoUrl
|
||||||
|| (msg.voiceList && msg.voiceList.length)
|
|| (msg.voiceList && msg.voiceList.length)
|
||||||
|
|| (msg.videoList && msg.videoList.length)
|
||||||
|| (msg.attachments && msg.attachments.length)
|
|| (msg.attachments && msg.attachments.length)
|
||||||
|| (msg.content && msg.content.trim() !== '')
|
|| (msg.content && msg.content.trim() !== '')
|
||||||
)"
|
)"
|
||||||
@@ -86,29 +87,46 @@
|
|||||||
</view>
|
</view>
|
||||||
<!-- PR-FRONTEND-MEDIA-UPLOAD (2026-09-16): 用户文件气泡
|
<!-- PR-FRONTEND-MEDIA-UPLOAD (2026-09-16): 用户文件气泡
|
||||||
tools-panel _uploadOneFile 选 kind='file'/'video'/'audio' 时,
|
tools-panel _uploadOneFile 选 kind='file'/'video'/'audio' 时,
|
||||||
m.type + m.fileName/size + m.imageUrl=本地预览(若有) / m.uploaded.url 上传后 URL -->
|
m.type + m.fileName/size + m.imageUrl=本地预览(若有) / m.uploaded.url 上传后 URL
|
||||||
<view v-else-if="message.type === 'file'" class="message-bubble user-file-bubble" @click="previewUserFile(message)">
|
PR-USER-MEDIA-TYPE-BUBBLE (2026-09-28): 文件 + 文字混合时, 配文显示在文件下方 -->
|
||||||
<view class="file-item">
|
<view v-else-if="message.type === 'file'" class="user-media-group">
|
||||||
<view class="file-icon"><text class="iconfont icon-file"></text></view>
|
<view class="message-bubble user-file-bubble" @click="previewUserFile(message)">
|
||||||
<view class="file-info">
|
<view class="file-item">
|
||||||
<text class="file-name">{{ message.fileName || '文件' }}</text>
|
<view class="file-icon"><text class="iconfont icon-file"></text></view>
|
||||||
<text class="file-size">{{ formatFileSize(message.fileSize) }}</text>
|
<view class="file-info">
|
||||||
|
<text class="file-name">{{ message.fileName || '文件' }}</text>
|
||||||
|
<text class="file-size">{{ formatFileSize(message.fileSize) }}</text>
|
||||||
|
</view>
|
||||||
|
<view v-if="message.uploading" class="upload-mask"><text class="upload-tip">上传中...</text></view>
|
||||||
|
<view v-if="message.uploadError" class="upload-mask upload-error"><text class="upload-tip">{{ message.uploadError }}</text></view>
|
||||||
</view>
|
</view>
|
||||||
<view v-if="message.uploading" class="upload-mask"><text class="upload-tip">上传中...</text></view>
|
</view>
|
||||||
<view v-if="message.uploadError" class="upload-mask upload-error"><text class="upload-tip">{{ message.uploadError }}</text></view>
|
<!-- 文件配文: 独立气泡, 显示在文件下方 -->
|
||||||
|
<view v-if="message.content" class="message-bubble image-caption-bubble">
|
||||||
|
<text class="message-text" selectable>{{ message.content }}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<!-- 用户视频消息气泡 (视频上传): 内嵌播放器 -->
|
<!-- 用户视频消息气泡 (视频上传): 内嵌播放器
|
||||||
<view v-else-if="message.type === 'video'" class="message-bubble user-video-bubble">
|
PR-USER-MEDIA-TYPE-BUBBLE (2026-09-28): 视频 + 文字混合时, 配文显示在视频下方
|
||||||
<view class="video-item">
|
(与图片的 image-caption-bubble 风格一致)
|
||||||
<video
|
PR-USER-MULTI-VIDEO (2026-09-28): 支持多条视频同时发送,
|
||||||
class="user-video"
|
每条视频独占一个 user-video-bubble, v-for 渲染多个 <video> 播放器
|
||||||
:src="message.url || message.imageUrl"
|
(与 voiceList 的多语音渲染方式一致) -->
|
||||||
:controls="true"
|
<view v-else-if="message.type === 'video'" class="user-media-group">
|
||||||
:autoplay="false"
|
<view v-for="(v, idx) in (message.videoList || [])" :key="idx" class="message-bubble user-video-bubble">
|
||||||
object-fit="contain"></video>
|
<view class="video-item">
|
||||||
<view v-if="message.uploading" class="upload-mask"><text class="upload-tip">上传中...</text></view>
|
<video
|
||||||
<view v-if="message.uploadError" class="upload-mask upload-error"><text class="upload-tip">{{ message.uploadError }}</text></view>
|
class="user-video"
|
||||||
|
:src="v.url"
|
||||||
|
controls
|
||||||
|
:autoplay="false"
|
||||||
|
preload="none"
|
||||||
|
object-fit="contain"></video>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<!-- 视频配文: 独立气泡, 显示在视频下方 -->
|
||||||
|
<view v-if="message.content" class="message-bubble image-caption-bubble">
|
||||||
|
<text class="message-text" selectable>{{ message.content }}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<!-- PR-USER-VOICE-BUBBLE (2026-09-28): 用户语音消息气泡
|
<!-- PR-USER-VOICE-BUBBLE (2026-09-28): 用户语音消息气泡
|
||||||
@@ -126,7 +144,7 @@
|
|||||||
<text class="icondiy" :class="v.playing ? 'icon-system-pause-circle-fill' : 'icon-system-play-circle-fill'"></text>
|
<text class="icondiy" :class="v.playing ? 'icon-system-pause-circle-fill' : 'icon-system-play-circle-fill'"></text>
|
||||||
</view>
|
</view>
|
||||||
<view class="voice-info">
|
<view class="voice-info">
|
||||||
<text class="voice-label">{{ v.fileName || $lang('ai-chat.index.audioMessage') || '语音' }}</text>
|
<text class="voice-label">{{ $lang('ai-chat.index.audioMessage') || '语音' }}</text>
|
||||||
<view class="voice-progress-track">
|
<view class="voice-progress-track">
|
||||||
<view class="voice-progress-bar" :style="{ width: getVoiceProgress(v) + '%' }"></view>
|
<view class="voice-progress-bar" :style="{ width: getVoiceProgress(v) + '%' }"></view>
|
||||||
</view>
|
</view>
|
||||||
@@ -1752,14 +1770,22 @@ async loadChatHistoryIfExist() {
|
|||||||
// PR-USER-VOICE-MESSAGE-TYPE (2026-09-28): 识别"语音"场景
|
// PR-USER-VOICE-MESSAGE-TYPE (2026-09-28): 识别"语音"场景
|
||||||
// 原代码对所有非图片消息都设 type='text', 把音频当成普通附件 (📎文件名) 渲染,
|
// 原代码对所有非图片消息都设 type='text', 把音频当成普通附件 (📎文件名) 渲染,
|
||||||
// 用户根本看不到自己发的语音. 改为:
|
// 用户根本看不到自己发的语音. 改为:
|
||||||
// - 有图片 → type='image' (图片气泡为主, 语音走 attachments 由后端接收)
|
// - 有图片 → type='image' (图片气泡为主, 其他走 attachments)
|
||||||
|
// - 有视频 → type='video' (视频气泡为主, 内嵌播放器)
|
||||||
// - 有语音(可多条)→ type='audio' (语音气泡为主, 复用 voice-msg 样式)
|
// - 有语音(可多条)→ type='audio' (语音气泡为主, 复用 voice-msg 样式)
|
||||||
// - 其他 → type='text' (文字/普通附件)
|
// - 有文件 → type='file' (文件气泡为主, 带文件名/大小)
|
||||||
|
// - 其他 → type='text' (纯文字)
|
||||||
// PR-USER-MULTI-VOICE (2026-09-28): 支持多条语音同时发送,
|
// PR-USER-MULTI-VOICE (2026-09-28): 支持多条语音同时发送,
|
||||||
// 把 pendingFilesList 里的音频拆出来 → voiceList 数组, 每条独立可播放.
|
// 把 pendingFilesList 里的音频拆出来 → voiceList 数组, 每条独立可播放.
|
||||||
// 优先级: 图片 > 语音 > 文字/附件 (图片永远独占气泡)
|
// PR-USER-MEDIA-TYPE-BUBBLE (2026-09-28): 修复视频/文件被错误归到 type='text'
|
||||||
|
// 渲染成空文字气泡 + 🎬/📎 卡片的 bug. 现在每种 kind 都有专属气泡.
|
||||||
|
// 优先级: 图片 > 视频 > 语音 > 文件 > 文字 (高优先级类型独占气泡, 其余挂到 attachments)
|
||||||
const voiceFiles = pendingFilesList.filter(p => p.uploaded && p.uploaded.kind === 'audio')
|
const voiceFiles = pendingFilesList.filter(p => p.uploaded && p.uploaded.kind === 'audio')
|
||||||
|
const videoFiles = pendingFilesList.filter(p => p.uploaded && p.uploaded.kind === 'video')
|
||||||
|
const fileFiles = pendingFilesList.filter(p => p.uploaded && p.uploaded.kind === 'file')
|
||||||
const hasVoice = voiceFiles.length > 0
|
const hasVoice = voiceFiles.length > 0
|
||||||
|
const hasVideo = videoFiles.length > 0
|
||||||
|
const hasFile = fileFiles.length > 0
|
||||||
|
|
||||||
const userMessage = firstPendingImage
|
const userMessage = firstPendingImage
|
||||||
? {
|
? {
|
||||||
@@ -1774,48 +1800,96 @@ async loadChatHistoryIfExist() {
|
|||||||
content: hasContent ? this.inputText.trim() : '',
|
content: hasContent ? this.inputText.trim() : '',
|
||||||
timestamp: Date.now()
|
timestamp: Date.now()
|
||||||
}
|
}
|
||||||
: hasVoice
|
: hasVideo
|
||||||
? (() => {
|
? (() => {
|
||||||
// PR-USER-MULTI-VOICE: 每条 voice 一个独立播放对象, 不再共用 message.url
|
// PR-USER-MULTI-VIDEO (2026-09-28): 支持多条视频同时发送
|
||||||
// 模板 v-for 渲染多行 voice-msg, 每行点击 openVoiceOverlay(v)
|
// 视频全部走 videoList 数组, 模板 v-for 渲染多个 <video> 播放器,
|
||||||
|
// 每条视频独占一个气泡, 跟 voiceList 类似.
|
||||||
|
// 修复了之前只取首个视频、其他被 filter 掉丢失的 bug.
|
||||||
return {
|
return {
|
||||||
id: ++this.messageId,
|
id: ++this.messageId,
|
||||||
role: 'user',
|
role: 'user',
|
||||||
type: 'audio',
|
type: 'video',
|
||||||
// 用户文本(若有)挂到 content, 模板里渲染在语音列表下方
|
// 用户文本(若有)挂到 content
|
||||||
content: userText,
|
content: userText,
|
||||||
// 多条语音逐条渲染, 每条独立可播放
|
// 多条视频逐条渲染, 每条独立内嵌播放器
|
||||||
voiceList: voiceFiles.map(a => ({
|
videoList: videoFiles.map(v => ({
|
||||||
url: (a.uploaded && a.uploaded.url) || a.url || '',
|
url: (v.uploaded && v.uploaded.url) || v.url || '',
|
||||||
fileName: a.fileName || '',
|
fileName: v.fileName || '',
|
||||||
fileSize: a.fileSize || 0,
|
fileSize: v.fileSize || 0,
|
||||||
mime: (a.uploaded && a.uploaded.mime) || a.fileType || '',
|
duration: v.duration || 0,
|
||||||
// duration 保持录音器原始单位 (ms), 模板里 /1000 转换显示
|
mime: (v.uploaded && v.uploaded.mime) || v.fileType || ''
|
||||||
duration: a.duration || 0,
|
|
||||||
playing: false,
|
|
||||||
currentTime: 0
|
|
||||||
})),
|
})),
|
||||||
// 纯语音时 attachments 留空; 语音+其他文件时把非音频文件挂到 attachments
|
// 非视频文件(语音/普通文件)挂到 attachments 由下方文件卡渲染
|
||||||
// (voiceList 已覆盖音频, 避免重复渲染 voice-msg + 📎)
|
// 注意: videoList 已覆盖所有视频, 这里 attachments 不放视频, 避免重复渲染
|
||||||
// 音频的 base64 数据已通过 fileAttachments → readyAttachments 传给后端,
|
attachments: videoFiles.length === pendingFilesList.length
|
||||||
// 这里 attachments 不放音频不影响后端通信.
|
|
||||||
attachments: voiceFiles.length === pendingFilesList.length
|
|
||||||
? []
|
? []
|
||||||
: displayAttachments.filter(a => a.kind !== 'audio'),
|
: displayAttachments.filter(a => a.kind !== 'video' && a.kind !== 'image'),
|
||||||
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' && a.kind !== 'image'),
|
||||||
|
timestamp: Date.now()
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
: hasFile
|
||||||
|
? (() => {
|
||||||
|
// PR-USER-MEDIA-TYPE-BUBBLE: 文件气泡, 带文件名 + 大小
|
||||||
|
// 取首个文件为主 (一个气泡放不下多文件, 其余走 attachments 文件卡)
|
||||||
|
const file = fileFiles[0]
|
||||||
|
return {
|
||||||
|
id: ++this.messageId,
|
||||||
|
role: 'user',
|
||||||
|
type: 'file',
|
||||||
|
// 用户文本(若有)挂到 content
|
||||||
|
content: userText,
|
||||||
|
url: (file.uploaded && file.uploaded.url) || file.url || '',
|
||||||
|
fileName: file.fileName || '',
|
||||||
|
fileSize: file.fileSize || 0,
|
||||||
|
mime: file.fileType || '',
|
||||||
|
// 非文件项挂到 attachments (但图片已在第一分支,实际只可能视频/音频混入)
|
||||||
|
attachments: displayAttachments.filter(a => a.kind !== 'file' && a.kind !== 'image'),
|
||||||
|
timestamp: Date.now()
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
: {
|
||||||
|
id: ++this.messageId,
|
||||||
|
role: 'user',
|
||||||
|
type: 'text',
|
||||||
|
// PR-FRONTEND-VOICE-INPUT (2026-09-24): 用户气泡只显示用户自己输入的文字
|
||||||
|
// 给 AI 的指令 hint 在 queryText 里, 这里不要污染用户气泡
|
||||||
|
content: userText,
|
||||||
|
// 纯文字(无任何附件)时 attachments 留空
|
||||||
|
attachments: [],
|
||||||
|
timestamp: Date.now()
|
||||||
|
}
|
||||||
this.shouldScrollToBottom = true
|
this.shouldScrollToBottom = true
|
||||||
this.messages.push(userMessage)
|
this.messages.push(userMessage)
|
||||||
this.inputText = ''
|
this.inputText = ''
|
||||||
@@ -6448,6 +6522,12 @@ _handleStreamError(msg) {
|
|||||||
font-size: 24rpx;
|
font-size: 24rpx;
|
||||||
color: rgba(255, 255, 255, 0.92);
|
color: rgba(255, 255, 255, 0.92);
|
||||||
line-height: 1;
|
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 {
|
.voice-progress-track {
|
||||||
|
|||||||
Reference in New Issue
Block a user