feat: 添加拖拽上传图片/视频功能

- 支持拖拽图片到窗口自动上传到输入栏
- 支持拖拽视频到窗口自动上传到输入栏
- 支持拖拽其他文件触发普通上传
- 多主题适配:经典色(橙色)、浅色(黑色)、深色(白色)
- 修复浅色/经典色主题下提示文字看不清的问题
This commit is contained in:
JOJO 2026-04-12 15:55:00 +08:00
parent 7dece2e722
commit 2397e6ec1c
6 changed files with 308 additions and 1 deletions

View File

@ -1,5 +1,22 @@
<template>
<AppShell :download-file="downloadFile" :download-folder="downloadFolder">
<!-- 拖拽上传提示层 -->
<transition name="drag-overlay-fade">
<div v-if="dragOverActive" class="drag-upload-overlay">
<div class="drag-upload-content">
<div class="drag-upload-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="17 8 12 3 7 8"/>
<line x1="12" y1="3" x2="12" y2="15"/>
</svg>
</div>
<div class="drag-upload-text">松开鼠标上传图片</div>
<div class="drag-upload-hint">支持拖拽图片视频文件到此处</div>
</div>
</div>
</transition>
<div v-if="!isConnected && messages.length === 0" class="app-loading-state">
<div class="loading-animation" aria-hidden="true">
<!-- From Uiverse.io by Nawsome -->

View File

@ -85,6 +85,9 @@ export async function mounted() {
this.resourceStartContainerStatsPolling();
this.resourceStartProjectStoragePolling();
this.resourceStartUsageQuotaPolling();
// 设置拖拽上传
this.setupDragAndDrop();
}
export function beforeUnmount() {
@ -112,6 +115,9 @@ export function beforeUnmount() {
this.resourceStopUsageQuotaPolling();
this.stopConnectionHeartbeat();
teardownShowImageObserver();
// 移除拖拽上传监听
this.teardownDragAndDrop();
if (this.titleTypingTimer) {
clearInterval(this.titleTypingTimer);
this.titleTypingTimer = null;

View File

@ -404,5 +404,156 @@ export const uploadMethods = {
return;
}
this.triggerFileUpload();
},
// 拖拽上传相关方法
handleDragEnter(event: DragEvent) {
event.preventDefault();
event.stopPropagation();
// 检查是否包含文件
if (event.dataTransfer?.types?.includes('Files')) {
this.dragOverActive = true;
}
},
handleDragOver(event: DragEvent) {
event.preventDefault();
event.stopPropagation();
},
handleDragLeave(event: DragEvent) {
event.preventDefault();
event.stopPropagation();
// 只有当真正离开窗口时才关闭
const rect = document.body.getBoundingClientRect();
const x = event.clientX;
const y = event.clientY;
if (x <= rect.left || x >= rect.right || y <= rect.top || y >= rect.bottom) {
this.dragOverActive = false;
}
},
handleDrop(event: DragEvent) {
event.preventDefault();
event.stopPropagation();
this.dragOverActive = false;
if (!this.isConnected) {
this.uiPushToast({
title: '未连接',
message: '请等待服务器连接后再上传',
type: 'warning'
});
return;
}
const policyStore = usePolicyStore();
if (policyStore.uiBlocks?.block_upload) {
this.uiPushToast({
title: '上传被禁用',
message: '已被管理员禁用上传功能',
type: 'warning'
});
return;
}
const files = event.dataTransfer?.files;
if (!files || files.length === 0) {
return;
}
// 处理拖拽的文件
this.processDroppedFiles(Array.from(files));
},
async processDroppedFiles(files: File[]) {
if (!files.length) return;
// 分离图片和非图片文件
const imageFiles = files.filter(file => this.isImageFile(file));
const videoFiles = files.filter(file => this.isVideoFile(file));
const otherFiles = files.filter(file => !this.isImageFile(file) && !this.isVideoFile(file));
// 优先处理图片,其次视频
if (imageFiles.length > 0) {
await this.handleDroppedImages(imageFiles);
} else if (videoFiles.length > 0) {
await this.handleDroppedVideo(videoFiles[0]); // 视频只取第一个
}
// 处理其他文件(普通文件上传)
if (otherFiles.length > 0) {
this.uploadHandleSelected(otherFiles);
}
},
async handleDroppedImages(files: File[]) {
const modelStore = useModelStore();
const currentModel = modelStore.models.find((m) => m.key === this.currentModelKey);
if (!currentModel?.supportsImage) {
this.uiPushToast({
title: '当前模型不支持图片',
message: '请选择支持图片输入的模型后再发送图片',
type: 'error'
});
return;
}
// 只上传并添加到输入栏,不自动发送
await this.handleLocalImageFiles(files);
},
async handleDroppedVideo(file: File) {
const modelStore = useModelStore();
const currentModel = modelStore.models.find((m) => m.key === this.currentModelKey);
if (!currentModel?.supportsVideo) {
this.uiPushToast({
title: '当前模型不支持视频',
message: '请切换到支持视频输入的模型后再发送视频',
type: 'error'
});
return;
}
// 只上传并添加到输入栏,不自动发送
await this.handleLocalVideoFiles([file]);
},
_boundDragEnter: null as ((e: DragEvent) => void) | null,
_boundDragOver: null as ((e: DragEvent) => void) | null,
_boundDragLeave: null as ((e: DragEvent) => void) | null,
_boundDrop: null as ((e: DragEvent) => void) | null,
setupDragAndDrop() {
// 存储绑定后的函数以便后续移除
this._boundDragEnter = this.handleDragEnter.bind(this);
this._boundDragOver = this.handleDragOver.bind(this);
this._boundDragLeave = this.handleDragLeave.bind(this);
this._boundDrop = this.handleDrop.bind(this);
// 使用 document.body 作为拖拽监听目标
document.body.addEventListener('dragenter', this._boundDragEnter);
document.body.addEventListener('dragover', this._boundDragOver);
document.body.addEventListener('dragleave', this._boundDragLeave);
document.body.addEventListener('drop', this._boundDrop);
},
teardownDragAndDrop() {
if (this._boundDragEnter) {
document.body.removeEventListener('dragenter', this._boundDragEnter);
}
if (this._boundDragOver) {
document.body.removeEventListener('dragover', this._boundDragOver);
}
if (this._boundDragLeave) {
document.body.removeEventListener('dragleave', this._boundDragLeave);
}
if (this._boundDrop) {
document.body.removeEventListener('drop', this._boundDrop);
}
this._boundDragEnter = null;
this._boundDragOver = null;
this._boundDragLeave = null;
this._boundDrop = null;
}
};

View File

@ -144,6 +144,15 @@ export function dataState() {
// 新手教程首次引导弹窗
tutorialPromptVisible: false,
tutorialPromptLoading: false,
tutorialPromptUsername: ''
tutorialPromptUsername: '',
// 拖拽上传状态
dragOverActive: false,
// 拖拽事件绑定函数(用于正确移除监听)
_boundDragEnter: null,
_boundDragOver: null,
_boundDragLeave: null,
_boundDrop: null
};
}

View File

@ -0,0 +1,123 @@
// 拖拽上传覆盖层样式 - 多主题适配
// 经典色: 橙色 accent | 浅色: 黑色 | 深色: 白色
.drag-upload-overlay {
position: fixed;
inset: 0;
background: var(--theme-overlay-scrim);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
pointer-events: none;
}
.drag-upload-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
padding: 48px 64px;
border: 2px dashed var(--claude-accent);
border-radius: 16px;
background: var(--claude-highlight);
animation: drag-pulse 1.5s ease-in-out infinite;
}
.drag-upload-icon {
width: 64px;
height: 64px;
color: var(--claude-accent);
svg {
width: 100%;
height: 100%;
}
}
.drag-upload-text {
font-size: 20px;
font-weight: 600;
color: var(--claude-text);
}
.drag-upload-hint {
font-size: 14px;
color: var(--claude-text-secondary);
}
// 动画 - 使用主题色的脉冲效果
@keyframes drag-pulse {
0%, 100% {
box-shadow: 0 0 0 0 var(--claude-highlight);
}
50% {
box-shadow: 0 0 0 12px transparent;
}
}
// 过渡动画
.drag-overlay-fade-enter-active,
.drag-overlay-fade-leave-active {
transition: opacity 0.2s ease;
}
.drag-overlay-fade-enter-from,
.drag-overlay-fade-leave-to {
opacity: 0;
}
// 浅色主题(light) - 使用黑色强调
:root[data-theme='light'] {
.drag-upload-content {
border-color: var(--claude-text);
background: var(--claude-highlight);
}
.drag-upload-icon {
color: var(--claude-text);
}
.drag-upload-hint {
color: var(--claude-text);
}
@keyframes drag-pulse {
0%, 100% {
box-shadow: 0 0 0 0 rgba(0, 0, 0, 0.1);
}
50% {
box-shadow: 0 0 0 12px transparent;
}
}
}
// 经典色主题(claude) - 提示文字改为黑色
:root[data-theme='claude'],
:root:not([data-theme]) {
.drag-upload-hint {
color: var(--claude-text);
}
}
// 深色主题(dark) - 使用白色强调
:root[data-theme='dark'] {
.drag-upload-content {
border-color: var(--claude-text);
background: var(--claude-highlight);
}
.drag-upload-icon {
color: var(--claude-text);
}
@keyframes drag-pulse {
0%, 100% {
box-shadow: 0 0 0 0 rgba(255, 255, 255, 0.1);
}
50% {
box-shadow: 0 0 0 12px transparent;
}
}
}

View File

@ -11,4 +11,5 @@
@use './components/chat/virtual-monitor';
@use './components/input/composer';
@use './components/overlays/overlays';
@use './components/drag-upload';
@use './utilities/responsive';