This commit is contained in:
2026-09-16 16:38:34 +08:00
parent 171190c1cd
commit 78973c99a3
5 changed files with 197 additions and 57 deletions

View File

@@ -53,10 +53,16 @@
<!-- 用户消息 (WorkBuddy 风格: 右对齐 + 浅灰气泡 + 头像在右) -->
<view v-if="message.role === 'user'" class="user-message">
<view class="message-content">
<view v-if="message.type === 'image'" 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>
<view v-if="message.type === 'image'">
<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>
</view>
<!-- 图片配文: 图文一起发出时, 文字显示在图片下方 -->
<view v-if="message.content" class="message-bubble image-caption-bubble">
<text class="message-text" selectable>{{ message.content }}</text>
</view>
</view>
<!-- PR-FRONTEND-MEDIA-UPLOAD (2026-09-16): 用户文件气泡
tools-panel _uploadOneFile 选 kind='file' (audio/video 当前未走
@@ -315,6 +321,13 @@
<!-- 输入区域 (新版: 顶部胶囊形输入条 + 底部 5 个图标) -->
<view class="input-area" :style="inputAreaStyle">
<view class="input-card">
<!-- 待发送图片附件预览: 挂在输入条内部顶端(文字行上方), 出现时输入框自动向上撑高, 删除后恢复原高度 -->
<view v-if="pendingImage" class="pending-attachment">
<image :src="pendingImage.uploaded ? pendingImage.uploaded.url : pendingImage.tempPath" mode="aspectFill" class="pending-image" />
<view v-if="pendingImage.uploading" class="pending-mask"><text>上传中</text></view>
<view v-else-if="pendingImage.uploadError" class="pending-mask error"><text>失败</text></view>
<view class="pending-remove" @tap="removePendingImage">×</view>
</view>
<!-- 顶部输入行: 语音图标 + 输入区 + 加号 + 发送 -->
<view class="input-row-top">
<!-- 无文字时左侧显示语音图标,点击才打开语音面板 -->
@@ -353,8 +366,8 @@
<text class="plus-icon">+</text>
</button>
<!-- 有文字时显示发送按钮 -->
<button v-if="hasInputText" class="top-send-btn" @click="sendMessage">
<!-- 有文字或图片附件上传完成时显示发送按钮 -->
<button v-if="hasInputText || canSendPendingImage" class="top-send-btn" @click="sendMessage">
<!-- H5/小程序都兼容:旋转箭头模拟向上的纸飞机 -->
<text class="send-plane-text">➤</text>
</button>
@@ -390,7 +403,7 @@
<!-- 更多工具面板 -->
<ai-chat-tools-panel
:visible.sync="showToolsPanel"
@image-selected="onImageSelected"
@image-picked="onImagePicked"
@file-selected="onFileSelected"
@voice-start="showVoicePanel"
@location-selected="onLocationSelected"
@@ -454,6 +467,7 @@
</template>
<script>
import aiService, { CONVERSATION_KEY } from '@/common/js/ai-service.js'
import { uploadImage } from '@/common/js/media-upload.js'
import IMAGE_CONFIG from '@/common/js/image-config.js'
import { isClipboardPrivacyError } from '@/common/js/uniapp.utils.js'
import AiChatSearch from './components/ai-chat-search.vue'
@@ -519,6 +533,8 @@ export default {
return {
messages: [],
inputText: '',
// 输入框待发送图片附件 (选图后先预览并后台上传, 发送时随文字一起发出)
pendingImage: null,
scrollTop: 0,
scrollIntoViewId: null,
hasMoreHistory: false,
@@ -609,6 +625,10 @@ export default {
const hasEmoji = this.containsEmoji(this.inputText)
return hasContent || hasEmoji
},
// 待发送图片已上传完成, 可发送
canSendPendingImage() {
return !!(this.pendingImage && !this.pendingImage.uploading && !this.pendingImage.uploadError && this.pendingImage.uploaded)
},
hasInputText() {
return this.inputText && this.inputText.trim() !== ''
}
@@ -735,9 +755,27 @@ export default {
this.$emit('nickname-changed', newNickname)
},
onImageSelected(imageMessage) {
this.messages.push(imageMessage)
this.$emit('image-selected', imageMessage)
// 选图后: 不直接进聊天区, 作为输入框上方附件预览并立即开始上传
onImagePicked(paths) {
const list = Array.isArray(paths) ? paths : (paths ? [paths] : [])
if (!list.length) return
const path = list[0]
this.pendingImage = { tempPath: path, uploading: true, uploaded: null, uploadError: null }
uploadImage(path).then((uploaded) => {
if (!this.pendingImage) return
this.$set(this.pendingImage, 'uploading', false)
this.$set(this.pendingImage, 'uploaded', uploaded)
}).catch((e) => {
if (!this.pendingImage) return
this.$set(this.pendingImage, 'uploading', false)
this.$set(this.pendingImage, 'uploadError', (e && e.message) || '上传失败')
uni.showToast({ title: '图片上传失败: ' + ((e && e.message) || ''), icon: 'none' })
})
},
// 移除待发送图片附件
removePendingImage() {
this.pendingImage = null
},
onFileSelected(fileMessage) {
@@ -983,11 +1021,24 @@ async loadChatHistoryIfExist() {
// 发送消息
async sendMessage() {
// 确保有内容或表情
// 确保有内容、表情或待发送图片
const hasContent = this.inputText && this.inputText.trim() !== ''
const hasEmoji = this.containsEmoji(this.inputText)
const pending = this.pendingImage
if (!hasContent && !hasEmoji) return
// 附件图片必须上传完成才能发送
if (pending) {
if (pending.uploading) {
uni.showToast({ title: '图片上传中,请稍候', icon: 'none' })
return
}
if (pending.uploadError || !pending.uploaded) {
uni.showToast({ title: '图片上传失败,请删除后重试', icon: 'none' })
return
}
}
if (!hasContent && !hasEmoji && !pending) return
// 收集已上传、尚未随消息发送的附件 (用户图片/文件消息)
const readyAttachments = this.messages
@@ -1006,22 +1057,44 @@ async loadChatHistoryIfExist() {
}
})
// 输入框待发送图片: 图文一起发出
if (pending && pending.uploaded) {
readyAttachments.push({
type: pending.uploaded.kind || 'image',
url: pending.uploaded.url,
mime: pending.uploaded.mime,
name: pending.uploaded.name,
size: pending.uploaded.size
})
}
// 记录用户发出消息的时间戳, AI 回复完成时算 elapsed
this._aiRequestStart = Date.now()
const queryText = this.inputText && this.inputText.trim()
const queryText = hasContent
? this.inputText.trim()
: (readyAttachments.length ? '[附件]' : '')
const userMessage = {
id: ++this.messageId,
role: 'user',
type: 'text',
content: queryText,
timestamp: Date.now()
}
// 带图片时消息类型为 image, 图片 + 配文一起显示; 纯文字时仍为 text
const userMessage = pending
? {
id: ++this.messageId,
role: 'user',
type: 'image',
imageUrl: pending.uploaded.url || pending.tempPath,
content: hasContent ? this.inputText.trim() : '',
timestamp: Date.now()
}
: {
id: ++this.messageId,
role: 'user',
type: 'text',
content: queryText,
timestamp: Date.now()
}
this.shouldScrollToBottom = true
this.messages.push(userMessage)
this.inputText = ''
this.pendingImage = null
// 显示AI正在处理 - 带状态轮换提示
const loadingMessage = {
@@ -1046,7 +1119,8 @@ async loadChatHistoryIfExist() {
try {
// 直接发送消息(跳过健康检查,避免健康检查接口格式不匹配导致误判)
// 如果服务不可用,实际发送时会抛出异常,由 catch 块处理
const enhancedMessage = this.enhanceEmojiMessage(userMessage.content)
// 纯图片无文字时, 给 AI 的 query 用占位文案, 避免空 query
const enhancedMessage = this.enhanceEmojiMessage(userMessage.content || (readyAttachments.length ? '[图片]' : ''))
if (this.enableStreaming) {
await this.sendStreamMessage(enhancedMessage, readyAttachments)
} else {
@@ -4578,4 +4652,54 @@ _handleStreamError(msg) {
&.upload-error .upload-tip { color: #ffb3b3; }
}
}
/* 图片消息配文气泡 (图文一起发出时, 显示在图片下方) */
.image-caption-bubble {
margin-top: 10rpx;
background: #f5f5f5;
border-radius: 20rpx;
padding: 14rpx 22rpx;
max-width: 460rpx;
.message-text {
font-size: 28rpx;
color: #111827;
line-height: 1.5;
word-break: break-all;
}
}
/* 输入框内待发送图片缩略图: 位于文字输入行上方, 出现时输入框自动向上撑高 */
.pending-attachment {
position: relative;
display: inline-flex;
align-self: flex-start;
margin: 6rpx 14rpx 10rpx;
width: 120rpx;
height: 120rpx;
border-radius: 14rpx;
overflow: hidden;
flex-shrink: 0;
.pending-image { width: 100%; height: 100%; display: block; }
.pending-mask {
position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0, 0, 0, 0.45);
display: flex;
align-items: center;
justify-content: center;
text { color: #fff; font-size: 20rpx; }
&.error text { color: #ffb3b3; }
}
.pending-remove {
position: absolute;
top: 0; right: 0;
width: 36rpx; height: 36rpx;
line-height: 32rpx;
text-align: center;
background: rgba(0, 0, 0, 0.55);
color: #fff;
font-size: 26rpx;
border-radius: 0 0 0 14rpx;
}
}
</style>

View File

@@ -80,32 +80,11 @@ export default {
count: 9,
sizeType: ['compressed'],
sourceType: ['album', 'camera'],
success: async (res) => {
success: (res) => {
that.hideToolsPanel()
for (const p of res.tempFilePaths) {
const pending = {
id: ++that.messageId,
role: 'user',
type: 'image',
content: '',
imageUrl: p,
uploading: true,
uploadError: null,
uploaded: null,
timestamp: Date.now()
}
that.$emit('image-selected', pending)
try {
const uploaded = await uploadImage(p)
that.$set(pending, 'uploading', false)
that.$set(pending, 'uploaded', uploaded)
that.$set(pending, 'imageUrl', uploaded.url)
} catch (e) {
that.$set(pending, 'uploading', false)
that.$set(pending, 'uploadError', e.message || '上传失败')
uni.showToast({ title: '图片上传失败: ' + (e.message || ''), icon: 'none' })
}
}
// 新交互: 选图后不直接进聊天区, 交给父组件作为输入框附件预览,
// 用户可继续输入文字, 点发送时图文一起发出
that.$emit('image-picked', res.tempFilePaths || [])
},
fail: (err) => {
if (err.errMsg && !err.errMsg.includes('cancel')) {