diff --git a/lang/en-us/ai-chat/index.js b/lang/en-us/ai-chat/index.js index 2ac9917..62b76c3 100644 --- a/lang/en-us/ai-chat/index.js +++ b/lang/en-us/ai-chat/index.js @@ -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 ====== diff --git a/lang/zh-cn/ai-chat/index.js b/lang/zh-cn/ai-chat/index.js index 63e3119..b547740 100644 --- a/lang/zh-cn/ai-chat/index.js +++ b/lang/zh-cn/ai-chat/index.js @@ -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 ====== diff --git a/pages_tool/ai-chat/ai-chat-message.vue b/pages_tool/ai-chat/ai-chat-message.vue index 1b4e0af..03deab5 100644 --- a/pages_tool/ai-chat/ai-chat-message.vue +++ b/pages_tool/ai-chat/ai-chat-message.vue @@ -54,11 +54,21 @@ - - - - 上传中... - {{ message.uploadError }} + + + + + 上传中... + {{ message.uploadError }} + @@ -66,9 +76,8 @@ + tools-panel _uploadOneFile 选 kind='file'/'video'/'audio' 时, + m.type + m.fileName/size + m.imageUrl=本地预览(若有) / m.uploaded.url 上传后 URL --> @@ -80,6 +89,31 @@ {{ message.uploadError }} + + + + + 上传中... + {{ message.uploadError }} + + + + + + + + {{ message.fileName || $lang('ai-chat.index.audioMessage') }} + {{ formatFileSize(message.fileSize) }} + + 上传中... + {{ message.uploadError }} + + {{ message.content }} @@ -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; diff --git a/pages_tool/ai-chat/components/ai-chat-tools-panel.vue b/pages_tool/ai-chat/components/ai-chat-tools-panel.vue index 7ddd588..3154f9c 100644 --- a/pages_tool/ai-chat/components/ai-chat-tools-panel.vue +++ b/pages_tool/ai-chat/components/ai-chat-tools-panel.vue @@ -11,25 +11,32 @@ - + {{ $lang('ai-chat.index.image') }} + + + + + + {{ $lang('ai-chat.index.video', '视频/音频') }} + - + {{ $lang('ai-chat.index.file') }} - + - + {{ $lang('ai-chat.index.voice') }} - + {{ $lang('ai-chat.index.location') }} @@ -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; }