eagleye

Quasar框架文件上传组件全解析:QFile与QUploader企业级实践指南

Quasar框架文件上传组件全解析:QFile与QUploader企业级实践指南

目录

组件核心差异对比

基础能力对比表

评估维度

QFile组件

QUploader组件

核心定位

轻量级文件选择器

全功能上传解决方案

上传机制

需手动实现上传逻辑

内置XHR上传能力

UI组件

基础按钮+文件名显示

拖拽区域+文件列表+进度指示

拦截器支持

✅ 通过Axios支持

❌ 原生XHR不支持

配置复杂度

中等(需手动配置Axios)

低(内置完整功能)

自定义程度

高(完全控制上传逻辑)

中(组件内置逻辑)

工作原理架构图

┌─────────────── QFile工作流 ───────────────┐

│ │

│ 选择文件 → 获取File对象 → 构建FormData → │

│ │

│ Axios发送请求 → 处理响应 │

│ │

│ (拦截器自动处理认证、错误、进度监控) │

│ │

└───────────────────────────────────────────┘

┌─────────────── QUploader工作流 ─────────────┐

│ │

│ 选择/拖拽文件 → 组件内置XHR → 直接上传 │

│ │

│ (需手动配置认证、独立处理错误和进度) │

│ │

└────────────────────────────────────────────┘

QUploaderAxios关系深度解析

核心结论

QUploader确实绕过了Axios及其拦截器,因为它使用原生XMLHttpRequest实现上传功能,而非基于Axios封装。这导致:

1. 拦截器失效Axios的请求/响应拦截器不会作用于QUploader的上传请求

2. 认证需手动处理:需通过工厂函数手动添加Authorization头

3. 独立错误处理:需通过组件@error事件单独处理上传错误

4. 状态管理差异QUploader内置上传状态管理,与Axios无关

技术原理对比

特性

QUploader实现

Axios实现

底层技术

原生XMLHttpRequest

XMLHttpRequest/Fetch API封装

拦截器支持

❌ 不支持

✅ 请求/响应拦截器

认证处理

工厂函数手动添加header

请求拦截器自动注入token

错误处理

@error事件回调

try/catch或响应拦截器

进度监控

内置@upload-progress事件

onUploadProgress配置项

取消请求

abort()方法

CancelToken或AbortController

QUploader添加认证头示例

<template>

<q-uploader

:factory="createUploadConfig"

label="带认证的文件上传"

/>

</template>

<script setup>

import { useAuthStore } from 'stores/auth';

const authStore = useAuthStore();

// 工厂函数手动添加认证头

const createUploadConfig = () => {

return {

url: 'https://api.example.com/upload',

method: 'POST',

headers: [

{ name: 'Authorization', value: `Bearer ${authStore.token}` },

{ name: 'X-App-Version', value: '2.3.1' }

]

};

};

</script>

QFile+Axios多图上传完整实现

企业级完整代码实现

<template>

<div class="upload-container">

<h2>多图上传控制台</h2>

<!-- 文件选择区域 -->

<div class="upload-area">

<q-file

v-model="selectedFiles"

label="选择多张图片"

multiple

accept=".jpg, .jpeg, .png, .webp"

@update:model-value="handleFileSelection"

class="file-input"

>

<template v-slot:prepend>

<q-icon name="cloud_upload" size="2rem" color="primary" />

</template>

</q-file>

<p class="upload-hint">或拖放图片到此处(最多10张)</p>

<p class="file-format-hint">支持格式: JPG, PNG, WebP</p>

</div>

<!-- 预览区域 -->

<div class="preview-section">

<h3>图片预览 ({{ previewImages.length }})</h3>

<div class="preview-grid">

<div v-for="(image, index) in previewImages" :key="index" class="preview-item">

<img :src="image.url" alt="预览图" class="preview-img">

<div class="preview-overlay">

<button class="remove-btn" @click="removeImage(index)">

<q-icon name="close" size="1.2rem" />

</button>

<div class="file-info">

<span class="file-name">{{ image.name }}</span>

<span class="file-size">{{ formatSize(image.size) }}</span>

</div>

</div>

</div>

</div>

</div>

<!-- 控制按钮 -->

<div class="control-buttons">

<q-btn

label="开始上传"

color="primary"

@click="uploadAllImages"

:disabled="isUploading || previewImages.length === 0"

:loading="isUploading"

class="upload-btn"

/>

<q-btn

label="清空列表"

color="negative"

@click="clearAllImages"

:disabled="isUploading"

class="clear-btn"

/>

</div>

<!-- 上传状态 -->

<div v-if="isUploading" class="upload-status">

<h3>上传进度</h3>

<q-linear-progress :value="uploadProgress" stripe color="primary" />

<div class="progress-details">

<span>进度: {{ Math.round(uploadProgress * 100) }}%</span>

<span>已上传: {{ uploadedCount }}/{{ totalCount }}</span>

</div>

</div>

<!-- 上传结果 -->

<div v-if="uploadResults.length > 0" class="upload-results">

<h3>上传结果</h3>

<q-list bordered>

<q-item v-for="(result, index) in uploadResults" :key="index" :color="result.success ? 'positive' : 'negative'">

<q-item-section avatar>

<q-icon :name="result.success ? 'check_circle' : 'error'" />

</q-item-section>

<q-item-section>

<q-item-label>{{ result.name }}</q-item-label>

<q-item-label caption>{{ result.message }}</q-item-label>

</q-item-section>

</q-item>

</q-list>

</div>

</div>

</template>

<script setup>

import { ref, computed } from 'vue';

import axios from 'axios';

import { useQuasar } from 'quasar';

const $q = useQuasar();

// 状态管理

const selectedFiles = ref([]);

const previewImages = ref([]);

const uploadResults = ref([]);

const isUploading = ref(false);

const uploadProgress = ref(0);

const uploadedCount = ref(0);

const totalCount = ref(0);

// 格式化文件大小

const formatSize = (bytes) => {

if (bytes === 0) return '0 Bytes';

const k = 1024;

const sizes = ['Bytes', 'KB', 'MB', 'GB'];

const i = Math.floor(Math.log(bytes) / Math.log(k));

return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];

};

// 处理文件选择

const handleFileSelection = (files) => {

if (!files || files.length === 0) return;

// 限制最多10张图片

const maxFiles = 10;

const newFiles = Array.from(files);

const filesToAdd = newFiles.slice(0, maxFiles - previewImages.value.length);

filesToAdd.forEach(file => {

// 验证文件类型

if (!file.type.match('image.*')) {

$q.notify({

type: 'negative',

message: `文件 ${file.name} 不是图片类型`

});

return;

}

// 验证文件大小(最大5MB)

if (file.size > 5 * 1024 * 1024) {

$q.notify({

type: 'negative',

message: `文件 ${file.name} 超过5MB限制`

});

return;

}

// 创建预览

const reader = new FileReader();

reader.onload = (e) => {

previewImages.value.push({

file,

url: e.target.result,

name: file.name,

size: file.size

});

};

reader.readAsDataURL(file);

});

};

// 移除图片

const removeImage = (index) => {

previewImages.value.splice(index, 1);

};

// 清空所有图片

const clearAllImages = () => {

previewImages.value = [];

uploadResults.value = [];

};

// 上传所有图片

const uploadAllImages = async () => {

// 重置状态

isUploading.value = true;

uploadProgress.value = 0;

uploadedCount.value = 0;

totalCount.value = previewImages.value.length;

uploadResults.value = [];

// 创建FormData

const formData = new FormData();

// 添加图片文件

previewImages.value.forEach((item, index) => {

formData.append(`images[${index}]`, item.file, item.name);

});

// 添加额外元数据

formData.append('uploadTimestamp', new Date().toISOString());

formData.append('uploadSource', 'admin-panel');

try {

// 发送上传请求

const response = await axios.post('https://api.example.com/upload/batch', formData, {

headers: {

'Content-Type': 'multipart/form-data'

// Axios拦截器会自动添加Authorization头

},

onUploadProgress: (progressEvent) => {

// 更新上传进度

uploadProgress.value = progressEvent.loaded / progressEvent.total;

}

});

// 处理成功响应

if (response.data.success) {

response.data.results.forEach(result => {

uploadResults.value.push({

name: result.fileName,

success: true,

message: `上传成功,文件ID: ${result.fileId}`

});

});

$q.notify({

type: 'positive',

message: `成功上传 ${previewImages.value.length} 张图片`

});

}

} catch (error) {

// 处理错误

uploadResults.value.push({

name: '上传失败',

success: false,

message: error.response?.data?.message || error.message

});

$q.notify({

type: 'negative',

message: '上传失败,请重试'

});

} finally {

isUploading.value = false;

}

};

</script>

<style scoped>

.upload-container {

max-width: 1000px;

margin: 0 auto;

padding: 20px;

}

.upload-area {

 

border-radius: 12px;

padding: 30px;

margin-bottom: 30px;

border: 2px dashed #dee2e6;

}

.preview-section {

margin-bottom: 30px;

}

.preview-grid {

display: grid;

grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));

gap: 15px;

margin-top: 15px;

}

.preview-item {

position: relative;

border-radius: 8px;

overflow: hidden;

box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);

aspect-ratio: 1/1;

}

.preview-img {

width: 100%;

height: 100%;

object-fit: cover;

}

.preview-overlay {

position: absolute;

bottom: 0;

left: 0;

right: 0;

background: linear-gradient(transparent, rgba(0, 0, 0, 0.7));

color: white;

padding: 10px;

}

.file-name {

font-weight: bold;

font-size: 0.9rem;

white-space: nowrap;

overflow: hidden;

text-overflow: ellipsis;

}

.file-size {

font-size: 0.8rem;

opacity: 0.9;

}

.remove-btn {

position: absolute;

top: 5px;

right: 5px;

 

border-radius: 50%;

width: 24px;

height: 24px;

display: flex;

align-items: center;

justify-content: center;

cursor: pointer;

transition: background-color 0.2s;

}

.remove-btn:hover {

 

}

.control-buttons {

display: flex;

gap: 15px;

margin-bottom: 30px;

}

.upload-btn, .clear-btn {

flex: 1;

}

.upload-status {

margin-bottom: 30px;

padding: 20px;

 

border-radius: 8px;

}

.progress-details {

display: flex;

justify-content: space-between;

margin-top: 8px;

font-size: 0.9rem;

}

.upload-results {

margin-top: 20px;

}

</style>

企业级优化方案

1. 分片上传实现

// 分片上传核心代码

const CHUNK_SIZE = 5 * 1024 * 1024; // 5MB分片

async function uploadLargeFile(file) {

// 1. 初始化上传

const { fileId } = await axios.post('/api/upload/init', {

fileName: file.name,

fileSize: file.size,

mimeType: file.type,

chunkSize: CHUNK_SIZE

});

// 2. 计算总分片数

const totalChunks = Math.ceil(file.size / CHUNK_SIZE);

const uploadPromises = [];

// 3. 生成所有分片上传任务

for (let i = 0; i < totalChunks; i++) {

const start = i * CHUNK_SIZE;

const end = Math.min(start + CHUNK_SIZE, file.size);

const chunk = file.slice(start, end);

// 创建FormData

const formData = new FormData();

formData.append('fileId', fileId);

formData.append('chunkIndex', i);

formData.append('chunk', chunk);

// 添加到上传队列

uploadPromises.push(

axios.post('/api/upload/chunk', formData, {

onUploadProgress: (e) => {

// 计算该片进度

const chunkProgress = e.loaded / e.total;

// 更新整体进度

updateOverallProgress(i, chunkProgress, totalChunks);

}

})

);

}

// 4. 并发上传所有分片(控制并发数)

const results = await Promise.allSettled(uploadPromises);

// 5. 验证所有分片上传成功

const allSuccess = results.every(r => r.status === 'fulfilled');

if (allSuccess) {

// 6. 通知服务器合并分片

return axios.post('/api/upload/complete', { fileId });

} else {

throw new Error('部分分片上传失败');

}

}

 

posted on 2025-08-19 17:58  GoGrid  阅读(11)  评论(0)    收藏  举报

导航