feat: 添加拖拽上传图片/视频功能
- 支持拖拽图片到窗口自动上传到输入栏 - 支持拖拽视频到窗口自动上传到输入栏 - 支持拖拽其他文件触发普通上传 - 多主题适配:经典色(橙色)、浅色(黑色)、深色(白色) - 修复浅色/经典色主题下提示文字看不清的问题
This commit is contained in:
parent
7dece2e722
commit
2397e6ec1c
@ -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 -->
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
@ -144,6 +144,15 @@ export function dataState() {
|
||||
// 新手教程首次引导弹窗
|
||||
tutorialPromptVisible: false,
|
||||
tutorialPromptLoading: false,
|
||||
tutorialPromptUsername: ''
|
||||
tutorialPromptUsername: '',
|
||||
|
||||
// 拖拽上传状态
|
||||
dragOverActive: false,
|
||||
|
||||
// 拖拽事件绑定函数(用于正确移除监听)
|
||||
_boundDragEnter: null,
|
||||
_boundDragOver: null,
|
||||
_boundDragLeave: null,
|
||||
_boundDrop: null
|
||||
};
|
||||
}
|
||||
|
||||
123
static/src/styles/components/drag-upload.scss
Normal file
123
static/src/styles/components/drag-upload.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -11,4 +11,5 @@
|
||||
@use './components/chat/virtual-monitor';
|
||||
@use './components/input/composer';
|
||||
@use './components/overlays/overlays';
|
||||
@use './components/drag-upload';
|
||||
@use './utilities/responsive';
|
||||
|
||||
Loading…
Reference in New Issue
Block a user