chore:上传图片可以支持3张,正常显示
This commit is contained in:
@@ -113,8 +113,14 @@ export const lang = {
|
||||
// ====== ai-chat-tools-panel ======
|
||||
moreFunctions: 'More Features',
|
||||
image: 'Image',
|
||||
video: 'Video/Audio',
|
||||
file: 'File',
|
||||
voice: 'Voice',
|
||||
recordVoice: 'Record Voice',
|
||||
uploadAudio: 'Upload Audio',
|
||||
chooseAudioFail: 'Failed to select audio',
|
||||
chooseVideoFail: 'Failed to select video',
|
||||
audioNotReady: 'Audio not ready',
|
||||
location: 'Location',
|
||||
|
||||
// ====== ai-chat-voice-panel ======
|
||||
|
||||
@@ -113,8 +113,14 @@ export const lang = {
|
||||
// ====== ai-chat-tools-panel ======
|
||||
moreFunctions: '更多功能',
|
||||
image: '图片',
|
||||
video: '视频/音频',
|
||||
file: '文件',
|
||||
voice: '语音',
|
||||
recordVoice: '录制语音',
|
||||
uploadAudio: '上传音频',
|
||||
chooseAudioFail: '选择音频失败',
|
||||
chooseVideoFail: '选择视频失败',
|
||||
audioNotReady: '音频尚未就绪',
|
||||
location: '位置',
|
||||
|
||||
// ====== ai-chat-voice-panel ======
|
||||
|
||||
@@ -54,11 +54,21 @@
|
||||
<view v-if="message.role === 'user'" class="user-message">
|
||||
<view class="message-content">
|
||||
<view v-if="message.type === 'image'" class="user-media-group">
|
||||
<!-- 图片气泡 (在上, 与下方文字气泡同列右对齐) -->
|
||||
<view class="message-bubble user-image-bubble">
|
||||
<image :src="message.imageUrl" mode="aspectFill" class="user-image" />
|
||||
<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>
|
||||
<!-- 图片气泡 (在上, 与下方文字气泡同列右对齐)
|
||||
PR-FRONTEND-MULTI-IMAGE-ROW (2026-09-17): 上传几张就显示几张, 横向排列 -->
|
||||
<view class="user-image-row" :class="'n' + Math.min(messageImages(message).length || 1, 4)">
|
||||
<view
|
||||
v-for="(img, imgIndex) in messageImages(message)"
|
||||
:key="imgIndex"
|
||||
class="message-bubble user-image-bubble">
|
||||
<image
|
||||
:src="img.url"
|
||||
mode="aspectFill"
|
||||
class="user-image"
|
||||
@click="previewUserImage(message, imgIndex)" />
|
||||
<view v-if="imgIndex === 0 && message.uploading" class="upload-mask"><text class="upload-tip">上传中...</text></view>
|
||||
<view v-if="imgIndex === 0 && message.uploadError" class="upload-mask upload-error"><text class="upload-tip">{{ message.uploadError }}</text></view>
|
||||
</view>
|
||||
</view>
|
||||
<!-- 图片配文: 独立气泡, 显示在图片下方 -->
|
||||
<view v-if="message.content" class="message-bubble image-caption-bubble">
|
||||
@@ -66,9 +76,8 @@
|
||||
</view>
|
||||
</view>
|
||||
<!-- PR-FRONTEND-MEDIA-UPLOAD (2026-09-16): 用户文件气泡
|
||||
tools-panel _uploadOneFile 选 kind='file' (audio/video 当前未走
|
||||
用户侧, 走 AI 侧 attachments) 时, m.type='file' + m.fileName/size
|
||||
+ m.imageUrl=本地预览图(若有) / m.uploaded.url 上传后 URL -->
|
||||
tools-panel _uploadOneFile 选 kind='file'/'video'/'audio' 时,
|
||||
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)">
|
||||
<view class="file-item">
|
||||
<view class="file-icon"><text class="iconfont icon-file"></text></view>
|
||||
@@ -80,6 +89,31 @@
|
||||
<view v-if="message.uploadError" class="upload-mask upload-error"><text class="upload-tip">{{ message.uploadError }}</text></view>
|
||||
</view>
|
||||
</view>
|
||||
<!-- 用户视频消息气泡 (视频上传): 内嵌播放器 -->
|
||||
<view v-else-if="message.type === 'video'" class="message-bubble user-video-bubble">
|
||||
<view class="video-item">
|
||||
<video
|
||||
class="user-video"
|
||||
:src="message.url || message.imageUrl"
|
||||
:controls="true"
|
||||
:autoplay="false"
|
||||
object-fit="contain"></video>
|
||||
<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-else-if="message.type === 'audio'" class="message-bubble user-audio-bubble" @click="playUserAudio(message)">
|
||||
<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>
|
||||
<view class="file-info">
|
||||
<text class="file-name">{{ message.fileName || $lang('ai-chat.index.audioMessage') }}</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-else class="message-bubble">
|
||||
<rich-text v-if="searchKeyword" :nodes="highlightContent(message.content)" class="message-rich-text" />
|
||||
<text v-else class="message-text" selectable>{{ message.content }}</text>
|
||||
@@ -1079,8 +1113,7 @@ async loadChatHistoryIfExist() {
|
||||
m.sendWithBatch = true
|
||||
return {
|
||||
// PR-FRONTEND-MEDIA-UPLOAD (2026-09-16): 不再把 audio/video 强转 file
|
||||
// m.uploaded.kind 是后端响应 (image/file); m.type 是前端分类
|
||||
// (audio/video 当前未走用户侧, 但保留路径供未来 audio recorder 接入)
|
||||
// m.uploaded.kind 是后端响应 (image/file/audio/video); m.type 是前端分类
|
||||
type: m.uploaded.kind || m.type || 'file',
|
||||
url: m.uploaded.url,
|
||||
mime: m.uploaded.mime,
|
||||
@@ -1511,6 +1544,21 @@ startQuestionTimer(msg) {
|
||||
}, 1000)
|
||||
},
|
||||
|
||||
// PR-FRONTEND-MULTI-IMAGE-ROW (2026-09-17): 取出用户消息里所有图片 (按发送顺序)
|
||||
// 优先用 attachments (多图批量发送), 只有单张时退回 imageUrl
|
||||
messageImages(message) {
|
||||
const list = (message.attachments || []).filter(a => !a.kind || a.kind === 'image' || a.type === 'image')
|
||||
if (list.length) return list
|
||||
return message.imageUrl ? [{ url: message.imageUrl }] : []
|
||||
},
|
||||
|
||||
// 点击用户图片: 在全部图片中预览 (左右可滑动切换)
|
||||
previewUserImage(message, index) {
|
||||
const urls = this.messageImages(message).map(img => img.url).filter(Boolean)
|
||||
if (!urls.length) return
|
||||
uni.previewImage({ urls, current: urls[index] || urls[0] })
|
||||
},
|
||||
|
||||
previewAttachment(att) {
|
||||
if (att.type === 'image') {
|
||||
uni.previewImage({ urls: [att.url], current: att.url })
|
||||
@@ -2101,6 +2149,18 @@ _handleStreamError(msg) {
|
||||
// 复用 AI 侧文件预览: H5 new tab; APP plus.runtime.openFile
|
||||
this.previewFile({ url })
|
||||
},
|
||||
// 用户上传的音频消息: 点击播放/暂停 (上传中/未就绪时给出提示)
|
||||
playUserAudio(message) {
|
||||
if (message.uploading) {
|
||||
uni.showToast({ title: '上传中,请稍候', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (!message.url) {
|
||||
uni.showToast({ title: this.$lang('ai-chat.index.audioNotReady'), icon: 'none' })
|
||||
return
|
||||
}
|
||||
this.toggleAudio(message)
|
||||
},
|
||||
// 切换音频播放
|
||||
toggleAudio(message) {
|
||||
if (message.playing) {
|
||||
@@ -4657,11 +4717,29 @@ _handleStreamError(msg) {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* 用户图片气泡 */
|
||||
.user-image-bubble {
|
||||
/* PR-FRONTEND-MULTI-IMAGE-ROW (2026-09-17): 用户多图横向排列容器 */
|
||||
.user-image-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
align-items: flex-end;
|
||||
max-width: 600rpx;
|
||||
|
||||
/* 图片数量越多单张越小, 保证一行放得下; 超过 4 张自动换行 */
|
||||
&.n1 .user-image { width: 280rpx; height: 280rpx; }
|
||||
&.n2 .user-image { width: 200rpx; height: 200rpx; }
|
||||
&.n3 .user-image { width: 145rpx; height: 145rpx; }
|
||||
&.n4 .user-image { width: 108rpx; height: 108rpx; }
|
||||
}
|
||||
|
||||
/* 用户图片气泡 (提高选择器权重, 覆盖 H5/移动端下 .user-message .message-content .message-bubble 的大内边距) */
|
||||
.user-message .message-content .message-bubble.user-image-bubble {
|
||||
padding: 6rpx !important;
|
||||
overflow: hidden;
|
||||
.user-image { width: 280rpx; height: 280rpx; border-radius: 12rpx; display: block; }
|
||||
position: relative;
|
||||
margin-left: 8rpx;
|
||||
.user-image { border-radius: 12rpx; display: block; }
|
||||
.upload-mask {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
@@ -4676,7 +4754,8 @@ _handleStreamError(msg) {
|
||||
|
||||
/* PR-FRONTEND-MEDIA-UPLOAD (2026-09-16): 用户文件气泡
|
||||
复用 AI 侧 .file-item 样式 (line 2943), 仅包一层 user-file-bubble 容器 */
|
||||
.user-file-bubble {
|
||||
.user-file-bubble,
|
||||
.user-audio-bubble {
|
||||
padding: 12rpx 16rpx !important;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
@@ -4691,7 +4770,8 @@ _handleStreamError(msg) {
|
||||
height: 56rpx;
|
||||
margin-right: 12rpx;
|
||||
flex-shrink: 0;
|
||||
.iconfont { font-size: 48rpx; color: #6b7c93; }
|
||||
.iconfont,
|
||||
.icondiy { font-size: 48rpx; color: #6b7c93; }
|
||||
}
|
||||
.file-info {
|
||||
flex: 1;
|
||||
@@ -4725,6 +4805,31 @@ _handleStreamError(msg) {
|
||||
}
|
||||
}
|
||||
|
||||
/* 用户视频气泡 (视频上传): 内嵌播放器, 与图片气泡一样紧凑 */
|
||||
.user-video-bubble {
|
||||
padding: 6rpx !important;
|
||||
overflow: hidden;
|
||||
.video-item {
|
||||
position: relative;
|
||||
}
|
||||
.user-video {
|
||||
width: 420rpx;
|
||||
height: 300rpx;
|
||||
border-radius: 12rpx;
|
||||
display: block;
|
||||
}
|
||||
.upload-mask {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
.upload-tip { color: #fff; font-size: 24rpx; }
|
||||
&.upload-error .upload-tip { color: #ffb3b3; }
|
||||
}
|
||||
}
|
||||
|
||||
/* 图片消息配文气泡 (图文一起发出时, 独立气泡显示在图片下方) */
|
||||
.image-caption-bubble {
|
||||
margin-top: 10rpx;
|
||||
|
||||
@@ -11,25 +11,32 @@
|
||||
<view class="tools-grid">
|
||||
<view class="tool-item" @click="selectImage">
|
||||
<view class="tool-icon">
|
||||
<text class="iconfont icon-image"></text>
|
||||
<text class="icondiy icon-system-image-line"></text>
|
||||
</view>
|
||||
<text class="tool-text">{{ $lang('ai-chat.index.image') }}</text>
|
||||
</view>
|
||||
<view class="tool-item" @click="selectVideo">
|
||||
<view class="tool-icon">
|
||||
<text class="icondiy icon-system-video"></text>
|
||||
</view>
|
||||
<!-- 第二个参数是 $lang 的默认值: 后端 bundle 尚未下发该 key 时也不会显示 raw key -->
|
||||
<text class="tool-text">{{ $lang('ai-chat.index.video', '视频/音频') }}</text>
|
||||
</view>
|
||||
<view class="tool-item" @click="selectFile">
|
||||
<view class="tool-icon">
|
||||
<text class="iconfont icon-file"></text>
|
||||
<text class="icondiy icon-system-file-2-line"></text>
|
||||
</view>
|
||||
<text class="tool-text">{{ $lang('ai-chat.index.file') }}</text>
|
||||
</view>
|
||||
<view class="tool-item" @click="startRecord">
|
||||
<view class="tool-item" @click="selectVoiceOrAudio">
|
||||
<view class="tool-icon">
|
||||
<text class="iconfont icon-voice"></text>
|
||||
<text class="icondiy icon-system-chat-voice-line"></text>
|
||||
</view>
|
||||
<text class="tool-text">{{ $lang('ai-chat.index.voice') }}</text>
|
||||
</view>
|
||||
<view class="tool-item" @click="selectLocation">
|
||||
<view class="tool-icon">
|
||||
<text class="iconfont icon-location"></text>
|
||||
<text class="icondiy icon-system-map-pin-line"></text>
|
||||
</view>
|
||||
<text class="tool-text">{{ $lang('ai-chat.index.location') }}</text>
|
||||
</view>
|
||||
@@ -49,7 +56,7 @@ import { uploadImage, uploadMedia } from '@/common/js/media-upload.js'
|
||||
* Events:
|
||||
* update:visible - 同步关闭状态
|
||||
* image-selected - 选择图片,参数:图片消息对象
|
||||
* file-selected - 选择文件,参数:文件消息对象
|
||||
* file-selected - 选择文件/视频/音频,参数:消息对象 (type: 'file' | 'video' | 'audio')
|
||||
* voice-start - 开始语音
|
||||
* location-selected - 选择位置,参数:位置消息对象
|
||||
*/
|
||||
@@ -153,6 +160,139 @@ export default {
|
||||
// #endif
|
||||
},
|
||||
|
||||
// 选择视频并上传 (H5 / 微信小程序 / App)
|
||||
selectVideo() {
|
||||
const that = this
|
||||
// #ifdef H5
|
||||
const videoInput = document.createElement('input')
|
||||
videoInput.type = 'file'
|
||||
videoInput.accept = 'video/*'
|
||||
videoInput.onchange = async (e) => {
|
||||
const file = e.target.files[0]
|
||||
if (!file) return
|
||||
that.hideToolsPanel()
|
||||
await that._uploadOneFile(file, 'video')
|
||||
}
|
||||
videoInput.click()
|
||||
// #endif
|
||||
|
||||
// #ifdef MP-WEIXIN
|
||||
uni.chooseVideo({
|
||||
sourceType: ['album', 'camera'],
|
||||
compressed: true,
|
||||
maxDuration: 60,
|
||||
success: async (res) => {
|
||||
that.hideToolsPanel()
|
||||
await that._uploadOneFile({
|
||||
path: res.tempFilePath,
|
||||
size: res.size || 0,
|
||||
duration: res.duration || 0,
|
||||
name: 'video_' + Date.now() + '.mp4',
|
||||
type: 'video/mp4'
|
||||
}, 'video')
|
||||
},
|
||||
fail: (err) => {
|
||||
if (err && err.errMsg && !err.errMsg.includes('cancel')) {
|
||||
uni.showToast({ title: that.$lang('ai-chat.index.chooseVideoFail'), icon: 'none' })
|
||||
}
|
||||
}
|
||||
})
|
||||
// #endif
|
||||
|
||||
// #ifdef APP-PLUS
|
||||
uni.chooseVideo({
|
||||
sourceType: ['album', 'camera'],
|
||||
compressed: true,
|
||||
maxDuration: 60,
|
||||
success: async (res) => {
|
||||
that.hideToolsPanel()
|
||||
await that._uploadOneFile({
|
||||
path: res.tempFilePath,
|
||||
size: res.size || 0,
|
||||
duration: res.duration || 0,
|
||||
name: 'video_' + Date.now() + '.mp4',
|
||||
type: 'video/mp4'
|
||||
}, 'video')
|
||||
}
|
||||
})
|
||||
// #endif
|
||||
},
|
||||
|
||||
// “语音/音频”: 提供 录制语音 / 上传音频 两种方式
|
||||
selectVoiceOrAudio() {
|
||||
uni.showActionSheet({
|
||||
itemList: [
|
||||
this.$lang('ai-chat.index.recordVoice'),
|
||||
this.$lang('ai-chat.index.uploadAudio')
|
||||
],
|
||||
success: (res) => {
|
||||
if (res.tapIndex === 0) {
|
||||
this.startRecord()
|
||||
} else if (res.tapIndex === 1) {
|
||||
this.selectAudio()
|
||||
}
|
||||
},
|
||||
fail: () => {}
|
||||
})
|
||||
},
|
||||
|
||||
// 选择音频文件并上传 (H5 / 微信小程序 / App)
|
||||
selectAudio() {
|
||||
const that = this
|
||||
// #ifdef H5
|
||||
const audioInput = document.createElement('input')
|
||||
audioInput.type = 'file'
|
||||
audioInput.accept = 'audio/*'
|
||||
audioInput.onchange = async (e) => {
|
||||
const file = e.target.files[0]
|
||||
if (!file) return
|
||||
that.hideToolsPanel()
|
||||
await that._uploadOneFile(file, 'audio')
|
||||
}
|
||||
audioInput.click()
|
||||
// #endif
|
||||
|
||||
// #ifdef MP-WEIXIN
|
||||
// 微信小程序没有专门的音频选择 API, 用 chooseMessageFile 选文件后按扩展名过滤
|
||||
const chooseFileApi = (typeof uni.chooseMessageFile === 'function')
|
||||
? uni.chooseMessageFile
|
||||
: (typeof wx !== 'undefined' ? wx.chooseMessageFile : null)
|
||||
if (!chooseFileApi) {
|
||||
uni.showToast({ title: that.$lang('ai-chat.index.chooseAudioFail'), icon: 'none' })
|
||||
return
|
||||
}
|
||||
chooseFileApi({
|
||||
count: 1,
|
||||
type: 'file',
|
||||
extension: ['mp3', 'wav', 'aac', 'm4a', 'flac', 'ogg', 'amr', 'wma', 'aiff'],
|
||||
success: async (res) => {
|
||||
const file = (res.tempFiles && res.tempFiles[0]) || null
|
||||
if (!file) return
|
||||
that.hideToolsPanel()
|
||||
await that._uploadOneFile(file, 'audio')
|
||||
},
|
||||
fail: (err) => {
|
||||
if (err && err.errMsg && !err.errMsg.includes('cancel')) {
|
||||
uni.showToast({ title: that.$lang('ai-chat.index.chooseAudioFail'), icon: 'none' })
|
||||
}
|
||||
}
|
||||
})
|
||||
// #endif
|
||||
|
||||
// #ifdef APP-PLUS
|
||||
uni.chooseFile({
|
||||
count: 1,
|
||||
extension: ['mp3', 'wav', 'aac', 'm4a', 'flac', 'ogg', 'amr'],
|
||||
success: async (res) => {
|
||||
const file = res.tempFiles[0]
|
||||
if (!file) return
|
||||
that.hideToolsPanel()
|
||||
await that._uploadOneFile(file, 'audio')
|
||||
}
|
||||
})
|
||||
// #endif
|
||||
},
|
||||
|
||||
async _uploadOneFile(file, kind) {
|
||||
// PR-FRONTEND-MEDIA-UPLOAD-FIX (2026-09-16): Blob URL 生命周期管理
|
||||
// 原代码 URL.createObjectURL 出的 blob URL 永不释放, 长期跑会泄漏
|
||||
@@ -177,6 +317,12 @@ export default {
|
||||
fileName: file.name || (file.path ? file.path.split('/').pop() : ''),
|
||||
fileSize: file.size || 0,
|
||||
fileType: file.type || '',
|
||||
// url 供音频/视频播放器使用; imageUrl 供图片/视频封面预览
|
||||
url: displayPath,
|
||||
duration: file.duration || 0,
|
||||
// 音频播放状态 (audio 气泡点击播放/暂停需要, 预先声明保证响应式)
|
||||
playing: false,
|
||||
currentTime: 0,
|
||||
imageUrl: displayPath,
|
||||
uploading: true,
|
||||
uploadError: null,
|
||||
@@ -193,7 +339,10 @@ export default {
|
||||
: await uploadMedia(inputArg, kind)
|
||||
this.$set(pending, 'uploading', false)
|
||||
this.$set(pending, 'uploaded', uploaded)
|
||||
if (uploaded.url) this.$set(pending, 'imageUrl', uploaded.url)
|
||||
if (uploaded.url) {
|
||||
this.$set(pending, 'imageUrl', uploaded.url)
|
||||
this.$set(pending, 'url', uploaded.url)
|
||||
}
|
||||
} catch (e) {
|
||||
this.$set(pending, 'uploading', false)
|
||||
this.$set(pending, 'uploadError', e.message || '上传失败')
|
||||
@@ -325,7 +474,8 @@ export default {
|
||||
margin-bottom: 16rpx;
|
||||
box-shadow: $shadow-sm;
|
||||
|
||||
.iconfont {
|
||||
.iconfont,
|
||||
.icondiy {
|
||||
font-size: 48rpx;
|
||||
color: $color-primary;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user