chore:上传图片可以支持3张,正常显示

This commit is contained in:
2026-09-17 09:48:49 +08:00
parent fffb5dae18
commit 3b4051c775
4 changed files with 290 additions and 23 deletions

View File

@@ -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 ======

View File

@@ -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 ======

View File

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

View File

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