feat: 优化图片/视频上传和显示功能

- 支持从本地一次选择多个文件上传
- 图片选择器上传完成后自动关闭
- 输入框和用户消息块显示缩略图(60x60px)而非文件名
- 输入框缩略图悬停时显示删除按钮(右上角 × 符号)
- 统一图片/视频预览样式,提升用户体验

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
JOJO 2026-03-13 11:40:54 +08:00
parent cd3f07bcc8
commit bd863f6d38
4 changed files with 83 additions and 19 deletions

View File

@ -125,6 +125,8 @@ export const uploadMethods = {
this.inputAddSelectedImage(item.path);
this.upsertImageEntry(item.path, item.filename);
});
// 上传完成后自动关闭选择窗口
this.closeImagePicker();
},
async handleLocalVideoFiles(files) {

View File

@ -10,10 +10,14 @@
<div class="message-text user-bubble-text">
<div v-if="msg.content" class="bubble-text">{{ msg.content }}</div>
<div v-if="msg.images && msg.images.length" class="image-inline-row">
<span class="image-name" v-for="img in msg.images" :key="img">{{ formatImageName(img) }}</span>
<div class="image-thumbnail-wrapper" v-for="img in msg.images" :key="img">
<img :src="getPreviewUrl(img)" :alt="formatImageName(img)" class="image-thumbnail" />
</div>
</div>
<div v-if="msg.videos && msg.videos.length" class="image-inline-row video-inline-row">
<span class="image-name" v-for="video in msg.videos" :key="video">{{ formatImageName(video) }}</span>
<div class="image-thumbnail-wrapper" v-for="video in msg.videos" :key="video">
<img :src="getPreviewUrl(video)" :alt="formatImageName(video)" class="image-thumbnail" />
</div>
</div>
</div>
</div>
@ -430,6 +434,11 @@ function formatImageName(path: string): string {
return parts[parts.length - 1] || path;
}
function getPreviewUrl(path: string): string {
if (!path) return '';
return `/api/gui/files/download?path=${encodeURIComponent(path)}`;
}
const isStackable = (action: any) => action && (action.type === 'thinking' || action.type === 'tool');
const isEmptyTextAction = (action: any) => {
if (!action || action.type !== 'text') {

View File

@ -10,19 +10,19 @@
'has-text': (inputMessage || '').trim().length > 0
}"
>
<input type="file" ref="fileUploadInput" class="file-input-hidden" @change="onFileChange" />
<input type="file" ref="fileUploadInput" class="file-input-hidden" multiple @change="onFileChange" />
<div class="input-stack">
<div v-if="selectedImages && selectedImages.length" class="image-inline-row">
<span class="image-name" v-for="img in selectedImages" :key="img">
{{ formatImageName(img) }}
<button type="button" class="image-remove-btn" @click.stop="$emit('remove-image', img)">×</button>
</span>
<div class="image-thumbnail-wrapper" v-for="img in selectedImages" :key="img">
<img :src="getPreviewUrl(img)" :alt="formatImageName(img)" class="image-thumbnail" />
<button type="button" class="image-remove-btn-hover" @click.stop="$emit('remove-image', img)">×</button>
</div>
</div>
<div v-if="selectedVideos && selectedVideos.length" class="image-inline-row video-inline-row">
<span class="image-name" v-for="video in selectedVideos" :key="video">
{{ formatImageName(video) }}
<button type="button" class="image-remove-btn" @click.stop="$emit('remove-video', video)">×</button>
</span>
<div class="image-thumbnail-wrapper" v-for="video in selectedVideos" :key="video">
<img :src="getPreviewUrl(video)" :alt="formatImageName(video)" class="image-thumbnail" />
<button type="button" class="image-remove-btn-hover" @click.stop="$emit('remove-video', video)">×</button>
</div>
</div>
<div class="input-row">
<button
@ -190,6 +190,11 @@ const formatImageName = (path: string): string => {
return parts[parts.length - 1] || path;
};
const getPreviewUrl = (path: string): string => {
if (!path) return '';
return `/api/gui/files/download?path=${encodeURIComponent(path)}`;
};
const applyLineMetrics = (lines: number, multiline: boolean) => {
inputStore.setInputLineCount(lines);
inputStore.setInputMultiline(multiline);
@ -269,11 +274,48 @@ onMounted(() => {
flex-wrap: wrap;
gap: 8px;
padding: 4px 10px 2px;
font-size: 12px;
color: var(--text-secondary, #7f8792);
line-height: 1.4;
}
.image-name {
white-space: nowrap;
.image-thumbnail-wrapper {
position: relative;
width: 60px;
height: 60px;
border-radius: 6px;
overflow: hidden;
cursor: pointer;
border: 1px solid var(--border-color, #2a2f3a);
}
.image-thumbnail {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.image-remove-btn-hover {
position: absolute;
top: 0;
right: 0;
background: none;
color: #fff;
border: none;
font-size: 20px;
font-weight: bold;
line-height: 1;
cursor: pointer;
display: none;
padding: 2px 4px;
text-shadow: 0 0 3px rgba(0, 0, 0, 0.8);
transition: color 0.15s ease;
}
.image-thumbnail-wrapper:hover .image-remove-btn-hover {
display: block;
}
.image-remove-btn-hover:hover {
color: #ef4444;
}
</style>

View File

@ -401,14 +401,25 @@
display: flex;
flex-wrap: wrap;
gap: 10px;
font-size: 12px;
color: var(--claude-text-secondary);
line-height: 1.4;
padding-bottom: 2px;
}
.user-message .message-text.user-bubble-text .image-name {
white-space: nowrap;
.user-message .message-text.user-bubble-text .image-thumbnail-wrapper {
position: relative;
width: 60px;
height: 60px;
border-radius: 6px;
overflow: hidden;
cursor: pointer;
border: 1px solid var(--border-color, #2a2f3a);
}
.user-message .message-text.user-bubble-text .image-thumbnail {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.assistant-generating-block {