用的webman/database 模型
eventLoop使用 Workerman\Events\Fiber::class;
并發(fā)上傳,用insertGetId返回?cái)?shù)據(jù)庫(kù)id,會(huì)重復(fù),數(shù)據(jù)庫(kù)里面是正常自增字段
/**
* 上傳文件
* @param Request $request
* @return Response
*/
public function upload_file(Request $request): Response
{
try {
add_log('上傳文件');
$user = $request->token_user;
$file = $request->file('file');
$type = $request->post('type');
$位置 = $request->post('位置');
if ($file && $type && $file->isValid()) {
$ext = $file->getUploadExtension();
if (in_array($ext, ['php', 'html', 'sql', 'json'])) {
return response('文件類型錯(cuò)誤', 503);
}
if (in_array($type, ['頭像', '頭圖', '詳情圖'])) {
if (!in_array($ext, ['jpg', 'jpeg', 'png', 'gif'])) {
return response('非圖片格式', 503);
}
if ($file->getSize() > 1024 * 1024 * 10) {
return response('圖片大小不能超過(guò)10M', 503);
}
} else if ($type === '附件') {
if (!in_array($ext, ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'zip', 'rar', 'jpg', 'jpeg', 'png', 'gif'])) {
return response('附件格式錯(cuò)誤', 503);
}
if ($file->getSize() > 1024 * 1024 * 100) {
return response('附件大小不能超過(guò)100M', 503);
}
} else {
return response('參數(shù)錯(cuò)誤', 503);
}
$format = $file->getExtension();
$upload_name = $file->getUploadName();
if (empty($upload_name)) return response('文件名錯(cuò)誤,請(qǐng)修改文件名', 503);
$name = Tool::get_microtime();
$dir = public_path() . '/files/upload/' . date('Ym') . '/' . date('d') . '/';
if (!is_dir($dir)) mkdir($dir, 0777, true);
$file->move($dir . $name . '.' . $ext);
$file_url = 'files/upload/' . date('Ym') . '/' . date('d') . '/' . $name . '.' . $ext;
$path2 = public_path() . '/' . $file_url;
switch ($type) {
case '頭像':
Image::load($path2)
->optimize()
->fit(Fit::Stretch, 100, 100)
->quality(75)
->save();
break;
case '詳情圖':
Image::load($path2)
->optimize()
->fit(Fit::Max, 800)
->quality(75)
->save();
break;
case '頭圖':
Image::load($path2)
->optimize()
->fit(Fit::Max, 500)
->quality(75)
->save();
break;
default:
break;
}
$imageInfo = getimagesize($path2);
$fileSize = filesize($path2);
$sha1 = sha1_file($path2);
$file_id = UploadFile::insertGetId([
'類型' => $type,
'名稱' => $upload_name,
'網(wǎng)址' => $file_url,
'目錄' => $path2,
'位置' => $位置,
'文件大小' => $fileSize,
'文件格式' => $format,
'后綴' => $ext,
'錄入人' => $user['姓名'],
'sha1' => $sha1,
'width' => $imageInfo[0] ?? 0,
'height' => $imageInfo[1] ?? 0,
]);
return res_success([
'id' => $file_id,
'name' => $upload_name,
'url' => $file_url,
'size' => $fileSize,
'type' => $ext,
], '上傳成功');
}
return response('參數(shù)錯(cuò)誤', 503);
} catch (Throwable $e) {
write_log('exception', 'error', '上傳文件', $e);
return response('上傳失敗', 503);
}
}
并發(fā)同時(shí)請(qǐng)求就會(huì)
這里寫具體的系統(tǒng)環(huán)境相關(guān)信息
不會(huì)吧,你不用fiber 試試呢
這個(gè)我測(cè)試過(guò) 真沒(méi)發(fā)現(xiàn)還有這個(gè)問(wèn)題呢,我也壓力測(cè)試過(guò),沒(méi)出現(xiàn)重復(fù)的
數(shù)據(jù)庫(kù)配置
如果開啟協(xié)程 或者 你使用swon 那個(gè)就會(huì)自動(dòng)變成協(xié)程,本地可以卸載然后evenloop => '' windows更不會(huì)并發(fā)處理就一個(gè)進(jìn)程 你想用多進(jìn)程必須linux代理多端口 才行,我測(cè)試沒(méi)問(wèn)題
<!--suppress ExceptionCaughtLocallyJS -->
<template>
<div class="my-upload">
<!-- 上傳區(qū)域 -->
<div
class="upload-area"
:class="{
'is-dragover': isDragover,
'is-disabled': disabled,
'has-files': fileList && fileList.length > 0 && !multiple,
}"
@click="handleClick"
@drop="handleDrop"
@dragover.prevent="handleDragover"
@dragleave="handleDragleave"
>
<input
ref="fileInputRef"
type="file"
:accept="accept"
:multiple="multiple"
:disabled="disabled"
@change="handleFileChange"
style="display: none"
/>
<!-- 上傳圖標(biāo)和提示 -->
<div class="upload-content" v-if="!fileList || !fileList.length || multiple">
<div class="upload-icon">
<el-icon size="48"><Upload /></el-icon>
</div>
<div class="upload-text">
<p class="upload-title">{{ uploadText }}</p>
<p class="upload-subtitle">{{ uploadSubText }}</p>
</div>
</div>
<!-- 單文件預(yù)覽 -->
<div class="single-file-preview" v-else-if="!multiple && fileList && fileList.length === 1">
<div class="file-preview">
<img
v-if="
isImageFile(fileList[0]) && !imageLoadErrors.get(fileList[0].uid || fileList[0].name)
"
:src="getFilePreviewUrl(fileList[0])"
:alt="fileList[0].name"
class="preview-image"
@load="() => {}"
@error="() => handleImageError(fileList[0])"
/>
<div
v-else-if="
!isImageFile(fileList[0]) || imageLoadErrors.get(fileList[0].uid || fileList[0].name)
"
class="file-icon"
>
<el-icon size="48">
<component :is="getFileIcon(fileList[0])" />
</el-icon>
</div>
</div>
<div class="file-info">
<p class="file-name" :title="fileList[0].name">{{ fileList[0].name }}</p>
<p class="file-size">{{ formatFileSize(fileList[0].size) }}</p>
</div>
<div class="file-actions">
<el-button type="danger" size="small" :icon="Delete" circle @click.stop="removeFile(0)" />
</div>
</div>
</div>
<!-- 多文件列表 -->
<div class="file-list" v-if="multiple && fileList && fileList.length > 0">
<div class="file-item" v-for="(file, index) in fileList" :key="file.uid || index">
<div class="file-preview">
<img
v-if="isImageFile(file) && !imageLoadErrors.get(file.uid || file.name)"
:src="getFilePreviewUrl(file)"
:alt="file.name"
class="preview-thumbnail"
@load="() => {}"
@error="() => handleImageError(file)"
/>
<div
v-else-if="!isImageFile(file) || imageLoadErrors.get(file.uid || file.name)"
class="file-icon"
>
<el-icon>
<component :is="getFileIcon(file)" />
</el-icon>
</div>
</div>
<div class="file-info">
<p class="file-name" :title="file.name">{{ file.name }}</p>
<p class="file-size">{{ formatFileSize(file.size) }}</p>
<!-- 上傳進(jìn)度 -->
<div class="file-progress" v-if="file.status === 'uploading'">
<el-progress
:percentage="Math.max(0, Math.min(100, file.percentage || 0))"
:stroke-width="4"
:show-text="false"
/>
</div>
<!-- 狀態(tài)標(biāo)識(shí) -->
<div class="file-status" v-else>
<el-tag :type="getStatusType(file.status)" size="small">
{{ getStatusText(file.status) }}
</el-tag>
</div>
</div>
<div class="file-actions">
<el-button
v-if="file.status === 'ready'"
type="primary"
size="small"
:icon="Upload"
circle
:disabled="uploading"
@click="uploadFile(file)"
/>
<el-button type="danger" size="small" :icon="Delete" circle @click="removeFile(index)" />
</div>
</div>
</div>
<!-- 上傳控制按鈕 -->
<div class="upload-controls" v-if="showControls && fileList && fileList.length > 0">
<el-button
type="primary"
:loading="uploading || isChunkUploading"
:disabled="!hasReadyFiles"
@click="uploadAll"
>
{{ uploading || isChunkUploading ? '上傳中...' : '開始上傳' }}
</el-button>
<el-button v-if="!autoUpload" :disabled="uploading || isChunkUploading" @click="clearAll">
清空列表
</el-button>
<!-- 分片上傳取消按鈕 -->
<el-button
v-if="isChunkUploading && currentChunkSession"
type="danger"
size="small"
@click="cancelChunkUpload"
>
取消上傳
</el-button>
</div>
<!-- 分片上傳進(jìn)度顯示 -->
<div v-if="isChunkUploading && currentChunkSession" class="chunk-upload-progress mt-16">
<div class="progress-header">
<h4>大文件分片上傳進(jìn)度</h4>
<span class="progress-text"
>{{ Math.max(0, Math.min(100, chunkUploadProgress)).toFixed(1) }}%</span
>
</div>
<el-progress
:percentage="Math.max(0, Math.min(100, chunkUploadProgress))"
:stroke-width="8"
:text-inside="true"
:format="() => `${Math.max(0, Math.min(100, chunkUploadProgress)).toFixed(1)}%`"
/>
<div class="upload-stats mt-8">
<div class="stat-item">
<span class="stat-label">文件名稱:</span>
<span class="stat-value">{{ currentChunkSession.fileName }}</span>
</div>
<div class="stat-item">
<span class="stat-label">文件大?。?lt;/span>
<span class="stat-value">{{ formatFileSize(currentChunkSession.fileSize) }}</span>
</div>
<div class="stat-item">
<span class="stat-label">上傳速度:</span>
<span class="stat-value">{{ formatUploadSpeed(uploadSpeed) }}</span>
</div>
<div class="stat-item">
<span class="stat-label">剩余時(shí)間:</span>
<span class="stat-value">{{ formatRemainingTime(remainingTime) }}</span>
</div>
<div class="stat-item">
<span class="stat-label">狀態(tài):</span>
<span class="stat-value status" :class="currentChunkSession.status">
{{ getChunkUploadStatusText(currentChunkSession.status) }}
</span>
</div>
</div>
</div>
<!-- 已上傳的文件列表 -->
<div
v-if="showUploadedList && uploadedFileList && uploadedFileList.length > 0"
class="uploaded-files mt-16"
>
<h4>已上傳的文件</h4>
<div class="uploaded-file-list">
<div v-for="(file, index) in uploadedFileList" :key="file.id" class="uploaded-file-item">
<div class="file-preview">
<img
v-if="isUploadedImageFile(file) && !imageLoadErrors.get(`uploaded_${file.id}`)"
:src="getUploadedFilePreviewUrl(file)"
:alt="file.name"
class="preview-thumbnail"
@error="() => handleUploadedImageError(file)"
/>
<div
v-else-if="!isUploadedImageFile(file) || imageLoadErrors.get(`uploaded_${file.id}`)"
class="file-icon"
>
<el-icon size="24">
<component :is="getUploadedFileIcon(file)" />
</el-icon>
</div>
</div>
<div class="file-info">
<p
class="file-name clickable"
:title="file.name + ' (點(diǎn)擊下載)'"
@click="downloadUploadedFile(file)"
>
{{ file.name }}
</p>
<p class="file-size">{{ formatFileSize(file.size) }}</p>
</div>
<div class="file-actions">
<el-button
type="primary"
size="small"
:icon="Download"
circle
@click="downloadUploadedFile(file)"
title="下載"
/>
<el-button
type="danger"
size="small"
:icon="Delete"
circle
@click="removeUploadedFile(file, index)"
title="刪除"
/>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
Delete,
Document,
Download,
Files,
FolderOpened,
Picture,
Upload,
} from '@element-plus/icons-vue'
import { baseURL2, chunkUploadConfig, uploadsUrl, deleteUploadFileUrl } from '@/config'
// 文件狀態(tài)類型
type FileStatus = 'ready' | 'uploading' | 'success' | 'error'
// 上傳響應(yīng)接口
interface UploadResponse {
code?: number
msg?: string
data?: Record<string, unknown>
url?: string
[key: string]: unknown
}
// 文件對(duì)象接口
interface UploadFile {
uid?: string
name: string
size: number
type: string
raw: File
status: FileStatus
percentage?: number
url?: string
response?: UploadResponse
}
// 已上傳的文件接口
interface UploadedFile {
id: number | string
name: string
size: number
type: string // 文件擴(kuò)展名
url: string
}
// 分片上傳會(huì)話接口
interface ChunkUploadSession {
sessionId: string
fileId: number
fileName: string
fileSize: number
fileHash: string
chunkSize: number
totalChunks: number
uploadedChunks: number[]
status: 'ready' | 'uploading' | 'merging' | 'completed' | 'failed' | 'cancelled'
}
// 分片信息接口
interface ChunkInfo {
index: number
start: number
end: number
blob: Blob
hash?: string
status: 'pending' | 'uploading' | 'completed' | 'failed'
retryCount: number
}
// 組件屬性
interface Props {
accept?: string
multiple?: boolean
disabled?: boolean
maxSize?: number // MB
maxCount?: number
autoUpload?: boolean
showControls?: boolean
showUploadedList?: boolean // 是否顯示已上傳文件列表
uploadText?: string
uploadSubText?: string
action?: string // 上傳地址
headers?: Record<string, string> // 請(qǐng)求頭
data?: Record<string, unknown> // 上傳數(shù)據(jù)
beforeUpload?: (file: File) => boolean | Promise<boolean>
onSuccess?: (response: UploadResponse, file: UploadFile, fileList: UploadFile[]) => void
onError?: (error: Error, file: UploadFile, fileList: UploadFile[]) => void
onProgress?: (event: ProgressEvent, file: UploadFile, fileList: UploadFile[]) => void
onChange?: (file: UploadFile, fileList: UploadFile[]) => void
onRemove?: (file: UploadFile, fileList: UploadFile[]) => void
onUploadedFileRemove?: (file: UploadedFile) => void
}
const props = withDefaults(defineProps<Props>(), {
accept: '.pdf,.doc,.docx,.xls,.xlsx,.zip,.rar,.jpg,.jpeg,.png,.gif',
multiple: true,
disabled: false,
maxSize: 1000, // 默認(rèn)100MB,匹配PHP接口附件類型限制
maxCount: 10,
autoUpload: false,
showControls: true,
showUploadedList: true, // 默認(rèn)顯示已上傳文件列表
uploadText: '點(diǎn)擊或拖拽文件到此處上傳',
uploadSubText: '支持PDF、Office文檔、圖片、壓縮包,單個(gè)文件不超過(guò)100MB',
action: uploadsUrl, // 使用配置中的上傳地址作為默認(rèn)值
headers: () => ({
Authorization: 'Bearer ' + (localStorage.getItem('token') || ''),
'X-Requested-With': 'XMLHttpRequest',
}),
data: () => ({
type: '附件', // PHP接口要求的文件類型參數(shù)
}),
})
// 使用 defineModel 進(jìn)行雙向綁定
const uploadedFileList = defineModel<UploadedFile[]>({ default: () => [] })
// 組件內(nèi)部管理的文件列表
const fileList = ref<UploadFile[]>([])
// 事件定義
const emit = defineEmits<{
change: [file: UploadFile, fileList: UploadFile[]]
success: [response: UploadResponse, file: UploadFile, fileList: UploadFile[]]
error: [error: Error, file: UploadFile, fileList: UploadFile[]]
progress: [event: ProgressEvent, file: UploadFile, fileList: UploadFile[]]
remove: [file: UploadFile, fileList: UploadFile[]]
'uploaded-file-remove': [file: UploadedFile]
}>()
// 響應(yīng)式數(shù)據(jù)
const fileInputRef = ref<HTMLInputElement>()
const isDragover = ref(false)
const uploading = ref(false)
// 分片上傳相關(guān)狀態(tài)
const isChunkUploading = ref(false)
const chunkUploadProgress = ref(0)
const currentChunkSession = ref<ChunkUploadSession | null>(null)
const uploadSpeed = ref(0)
const remainingTime = ref(0)
// 存儲(chǔ)已創(chuàng)建的Blob URLs用于清理
const createdBlobUrls = ref<Set<string>>(new Set())
// 跟蹤圖片加載失敗的文件
const imageLoadErrors = ref<Map<string, boolean>>(new Map())
// 處理圖片加載失敗
const handleImageError = (file: UploadFile) => {
const key = file.uid || file.name
imageLoadErrors.value.set(key, true)
}
// 處理已上傳文件的圖片加載失敗
const handleUploadedImageError = (file: UploadedFile) => {
const key = `uploaded_${file.id}`
imageLoadErrors.value.set(key, true)
}
// 確保uploadedFileList始終是數(shù)組
watch(
uploadedFileList,
(newVal) => {
if (!Array.isArray(newVal)) {
uploadedFileList.value = []
}
},
{ immediate: true },
)
// 計(jì)算屬性
const hasReadyFiles = computed(() => {
if (!fileList.value || !Array.isArray(fileList.value)) {
return false
}
return fileList.value.some((file) => file && file.status === 'ready')
})
// 判斷是否為圖片文件
const isImageFile = (file: UploadFile): boolean => {
// 如果有 response.data.type,優(yōu)先使用后端返回的擴(kuò)展名
const responseType = file.response?.data?.type
if (responseType) {
const ext = String(responseType).toLowerCase()
// 使用后端返回的文件類型判斷
return ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp'].includes(ext)
}
// 對(duì)于未上傳的文件,使用MIME類型和文件名雙重判斷
const mimeType = file.type.toLowerCase()
const fileName = file.name.toLowerCase()
// MIME類型判斷
const isMimeImage = mimeType.startsWith('image/')
// 文件擴(kuò)展名判斷
const imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp']
const isExtImage = imageExtensions.some((ext) => fileName.endsWith('.' + ext))
// 圖片文件判斷完成
return isMimeImage || isExtImage
}
// 判斷已上傳文件是否為圖片
const isUploadedImageFile = (file: UploadedFile): boolean => {
// 防止空值錯(cuò)誤
if (!file || !file.type) return false
const ext = file.type.toLowerCase()
return ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp'].includes(ext)
}
// 獲取已上傳文件的圖標(biāo)
const getUploadedFileIcon = (file: UploadedFile) => {
// 防止空值錯(cuò)誤
if (!file || !file.type) return Document
const ext = file.type.toLowerCase()
// 圖片類型
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp'].includes(ext)) {
return Picture
}
// 壓縮包類型
if (['zip', 'rar', '7z', 'tar', 'gz'].includes(ext)) {
return FolderOpened
}
// PDF文檔
if (ext === 'pdf') {
return Files
}
// 其他都是文檔類型
return Document
}
// 獲取已上傳文件的預(yù)覽URL
const getUploadedFilePreviewUrl = (file: UploadedFile): string => {
// 防止空值錯(cuò)誤
if (!file || !file.url) return ''
return baseURL2 + '/' + file.url.replace(/^\/+/g, '')
}
// 下載已上傳的文件
const downloadUploadedFile = (file: UploadedFile) => {
// 防止空值錯(cuò)誤
if (!file || !file.url) {
ElMessage.error('文件下載地址不存在')
return
}
const downloadUrl = baseURL2 + '/' + file.url.replace(/^\/+/g, '')
const link = document.createElement('a')
link.href = downloadUrl
link.download = file.name || 'download'
link.target = '_blank'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
ElMessage.success(`正在下載: ${file.name || '文件'}`)
}
// 生成唯一ID
const generateUID = (): string => {
return (
Date.now().toString(36) +
Math.random().toString(36).substring(2) +
Math.random().toString(36).substring(2)
)
}
// 計(jì)算文件SHA1哈希
const calculateFileHash = async (file: File): Promise<string> => {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = async () => {
try {
const arrayBuffer = reader.result as ArrayBuffer
const hashBuffer = await crypto.subtle.digest('SHA-1', arrayBuffer)
const hashArray = Array.from(new Uint8Array(hashBuffer))
const hashHex = hashArray.map((b) => b.toString(16).padStart(2, '0')).join('')
resolve(hashHex)
} catch (_error) {
reject(_error)
}
}
reader.onerror = reject
reader.readAsArrayBuffer(file)
})
}
// 判斷是否使用分片上傳
const shouldUseChunkUpload = (file: File): boolean => {
return file.size > chunkUploadConfig.largeFileThreshold
}
// 切分文件
const createChunks = (file: File): ChunkInfo[] => {
const chunks: ChunkInfo[] = []
const chunkSize = chunkUploadConfig.chunkSize
const totalChunks = Math.ceil(file.size / chunkSize)
for (let i = 0; i < totalChunks; i++) {
const start = i * chunkSize
const end = Math.min(start + chunkSize, file.size)
const blob = file.slice(start, end)
chunks.push({
index: i,
start,
end,
blob,
status: 'pending',
retryCount: 0,
})
}
return chunks
}
// 格式化上傳速度
const formatUploadSpeed = (bytesPerSecond: number): string => {
if (bytesPerSecond === 0) return '0 B/s'
const k = 1024
const sizes = ['B/s', 'KB/s', 'MB/s', 'GB/s']
const i = Math.floor(Math.log(bytesPerSecond) / Math.log(k))
return parseFloat((bytesPerSecond / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
// 格式化剩余時(shí)間
const formatRemainingTime = (seconds: number): string => {
if (seconds === 0 || !isFinite(seconds)) return '未知'
const hours = Math.floor(seconds / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
const secs = Math.floor(seconds % 60)
if (hours > 0) {
return `${hours}小時(shí)${minutes}分鐘`
} else if (minutes > 0) {
return `${minutes}分鐘${secs}秒`
} else {
return `${secs}秒`
}
}
// 檢查斷線續(xù)傳
const checkResumeUpload = async (fileHash: string): Promise<ChunkUploadSession | null> => {
try {
const response = await fetch(chunkUploadConfig.resumeUploadCheckUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...props.headers,
},
body: JSON.stringify({ fileHash }),
})
if (!response.ok) {
throw new Error(`HTTP錯(cuò)誤: ${response.status} ${response.statusText}`)
}
const result = await response.json()
// 斷線續(xù)傳檢查結(jié)果
if (result.code === 200 && result.data.resumable) {
return {
sessionId: result.data.sessionId,
fileId: result.data.fileId,
fileName: '',
fileSize: 0,
fileHash,
chunkSize: result.data.chunkSize,
totalChunks: result.data.totalChunks,
uploadedChunks: result.data.uploadedChunks,
status: 'uploading',
}
}
return null
} catch {
// 檢查斷線續(xù)傳失敗
return null
}
}
// 初始化分片上傳會(huì)話
const initChunkUpload = async (file: File): Promise<ChunkUploadSession | null> => {
try {
// 開始初始化分片上傳
// 計(jì)算文件哈希
const fileHash = await calculateFileHash(file)
// 文件哈希計(jì)算完成
// 檢查斷線續(xù)傳
const resumeSession = await checkResumeUpload(fileHash)
if (resumeSession) {
// 檢測(cè)到斷線續(xù)傳
resumeSession.fileName = file.name
resumeSession.fileSize = file.size
return resumeSession
}
// 初始化新的上傳會(huì)話
const totalChunks = Math.ceil(file.size / chunkUploadConfig.chunkSize)
const requestData = {
fileName: file.name,
fileSize: file.size,
fileHash,
fileType: props.data.type || '附件',
chunkSize: chunkUploadConfig.chunkSize,
totalChunks,
位置: props.data.位置 || '',
}
// 初始化請(qǐng)求數(shù)據(jù)準(zhǔn)備完成
const response = await fetch(chunkUploadConfig.initChunkUploadUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...props.headers,
},
body: JSON.stringify(requestData),
})
if (!response.ok) {
throw new Error(`HTTP錯(cuò)誤: ${response.status} ${response.statusText}`)
}
const result = await response.json()
// 初始化響應(yīng)收到
if (result.code === 200) {
return {
sessionId: result.data.sessionId,
fileId: result.data.fileId,
fileName: file.name,
fileSize: file.size,
fileHash,
chunkSize: result.data.chunkSize,
totalChunks: result.data.totalChunks,
uploadedChunks: result.data.uploadedChunks || [],
status: 'ready',
}
} else {
throw new Error(result.message || '初始化失敗')
}
} catch {
// 初始化分片上傳失敗
return null
}
}
// 上傳單個(gè)分片
const uploadSingleChunk = async (
chunk: ChunkInfo,
session: ChunkUploadSession,
): Promise<boolean> => {
try {
const formData = new FormData()
formData.append('sessionId', session.sessionId)
formData.append('chunkIndex', chunk.index.toString())
formData.append('chunk', chunk.blob)
// 可選:計(jì)算分片哈希
if (chunk.hash) {
formData.append('chunkHash', chunk.hash)
}
// 上傳分片
const response = await fetch(chunkUploadConfig.uploadChunkUrl, {
method: 'POST',
headers: props.headers,
body: formData,
})
if (!response.ok) {
throw new Error(`HTTP錯(cuò)誤: ${response.status} ${response.statusText}`)
}
const result = await response.json()
if (result.code === 200) {
chunk.status = 'completed'
// 分片上傳成功
return true
} else {
throw new Error(result.message || '分片上傳失敗')
}
} catch {
// 分片上傳失敗
chunk.status = 'failed'
chunk.retryCount++
return false
}
}
// 合并分片
const mergeChunks = async (session: ChunkUploadSession): Promise<UploadedFile | null> => {
try {
// 開始合并分片
const response = await fetch(chunkUploadConfig.mergeChunksUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...props.headers,
},
body: JSON.stringify({ sessionId: session.sessionId }),
})
if (!response.ok) {
throw new Error(`HTTP錯(cuò)誤: ${response.status} ${response.statusText}`)
}
const result = await response.json()
// 合并響應(yīng)收到
if (result.code === 200) {
return {
id: result.data.id,
name: result.data.name,
size: result.data.size,
type: result.data.type,
url: result.data.url,
}
} else {
throw new Error(result.message || '合并失敗')
}
} catch {
// 合并分片失敗
return null
}
}
// 分片上傳主函數(shù)
const performChunkUpload = async (file: File): Promise<void> => {
try {
isChunkUploading.value = true
chunkUploadProgress.value = 0
// 初始化上傳會(huì)話
const session = await initChunkUpload(file)
if (!session) {
throw new Error('初始化分片上傳失敗')
}
currentChunkSession.value = session
session.status = 'uploading'
// 創(chuàng)建分片
const chunks = createChunks(file)
// 文件切分完成
// 標(biāo)記已上傳的分片
for (const uploadedIndex of session.uploadedChunks) {
if (chunks[uploadedIndex]) {
chunks[uploadedIndex].status = 'completed'
}
}
// 統(tǒng)計(jì)需要上傳的分片
const pendingChunks = chunks.filter((chunk) => chunk.status === 'pending')
// 需要上傳分片統(tǒng)計(jì)
if (pendingChunks.length === 0) {
// 所有分片都已上傳,直接合并
// 所有分片已上傳,開始合并
const uploadedFile = await mergeChunks(session)
if (uploadedFile) {
uploadedFileList.value.push(uploadedFile)
ElMessage.success(`文件 "${file.name}" 上傳成功`)
} else {
throw new Error('文件合并失敗')
}
return
}
// 并發(fā)上傳分片
const startTime = Date.now()
const uploadedBytes = session.uploadedChunks.length * session.chunkSize
await uploadChunksWithConcurrency(pendingChunks, session, (progress) => {
const currentTime = Date.now()
const elapsedTime = (currentTime - startTime) / 1000
const currentUploadedBytes = uploadedBytes + progress.completed * session.chunkSize
// 計(jì)算上傳速度
if (elapsedTime > 0) {
uploadSpeed.value = currentUploadedBytes / elapsedTime
const remainingBytes = file.size - currentUploadedBytes
// 避免除零錯(cuò)誤
if (uploadSpeed.value > 0) {
remainingTime.value = remainingBytes / uploadSpeed.value
}
}
// 更新進(jìn)度(確保不超過(guò)100%)
chunkUploadProgress.value = Math.min((currentUploadedBytes / file.size) * 100, 100)
// 上傳進(jìn)度更新
})
// 合并分片
// 所有分片上傳完成,開始合并
session.status = 'merging'
const uploadedFile = await mergeChunks(session)
if (uploadedFile) {
uploadedFileList.value.push(uploadedFile)
session.status = 'completed'
ElMessage.success(`文件 "${file.name}" 上傳成功`)
// 延遲移除文件
setTimeout(() => {
const fileIndex = fileList.value.findIndex((f) => f.name === file.name)
if (fileIndex !== -1) {
fileList.value.splice(fileIndex, 1)
}
}, 1500)
} else {
throw new Error('文件合并失敗')
}
} catch (error) {
// 分片上傳失敗
ElMessage.error(`分片上傳失敗: ${error instanceof Error ? error.message : String(error)}`)
if (currentChunkSession.value) {
currentChunkSession.value.status = 'failed'
}
} finally {
isChunkUploading.value = false
chunkUploadProgress.value = 0
uploadSpeed.value = 0
remainingTime.value = 0
currentChunkSession.value = null
}
}
// 并發(fā)上傳分片
const uploadChunksWithConcurrency = async (
chunks: ChunkInfo[],
session: ChunkUploadSession,
onProgress: (progress: { completed: number; total: number }) => void,
): Promise<void> => {
const maxConcurrent = chunkUploadConfig.maxConcurrent
const semaphore = new Array(maxConcurrent).fill(null)
const totalPendingChunks = chunks.length // 保存初始待上傳分片數(shù)
let completed = 0
const uploadNext = async (): Promise<void> => {
while (chunks.length > 0) {
const chunk = chunks.shift()
if (!chunk) break
chunk.status = 'uploading'
// 重試邏輯
let success = false
for (let retry = 0; retry <= chunkUploadConfig.retryLimit; retry++) {
if (retry > 0) {
// 分片重試
await new Promise((resolve) => setTimeout(resolve, 1000 * retry))
}
success = await uploadSingleChunk(chunk, session)
if (success) break
}
if (!success) {
throw new Error(`分片 ${chunk.index} 上傳失敗,已重試 ${chunkUploadConfig.retryLimit} 次`)
}
completed++
onProgress({ completed, total: totalPendingChunks })
}
}
// 啟動(dòng)并發(fā)上傳
await Promise.all(semaphore.map(() => uploadNext()))
}
// 取消分片上傳
const cancelChunkUpload = async (): Promise<void> => {
if (!currentChunkSession.value) return
try {
const response = await fetch(chunkUploadConfig.cancelUploadUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...props.headers,
},
body: JSON.stringify({ sessionId: currentChunkSession.value.sessionId }),
})
if (!response.ok) {
throw new Error(`HTTP錯(cuò)誤: ${response.status} ${response.statusText}`)
}
const result = await response.json()
if (result.code === 200) {
// 上傳已取消
ElMessage.info('上傳已取消')
} else {
// 取消上傳響應(yīng)異常
}
} catch {
// 取消上傳失敗
} finally {
isChunkUploading.value = false
currentChunkSession.value = null
chunkUploadProgress.value = 0
}
}
// 移除已上傳的文件
const removeUploadedFile = async (file: UploadedFile, index: number) => {
if (
!uploadedFileList.value ||
!Array.isArray(uploadedFileList.value) ||
index < 0 ||
index >= uploadedFileList.value.length
) {
return
}
// 二次確認(rèn)刪除
try {
await ElMessageBox.confirm(`確定要?jiǎng)h除已上傳的文件 "${file.name}" 嗎?`, '刪除確認(rèn)', {
confirmButtonText: '確定',
cancelButtonText: '取消',
type: 'warning',
})
// 調(diào)用刪除接口
try {
const response = await fetch(deleteUploadFileUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...props.headers,
},
body: JSON.stringify({
fileId: file.id,
}),
})
if (!response.ok) {
throw new Error(`HTTP錯(cuò)誤: ${response.status} ${response.statusText}`)
}
const result = await response.json()
if (result.code !== 200) {
throw new Error(result.msg || '刪除失敗')
}
// 接口調(diào)用成功,從列表中移除文件
uploadedFileList.value.splice(index, 1)
emit('uploaded-file-remove', file)
props.onUploadedFileRemove?.(file)
ElMessage.success('文件刪除成功')
} catch (error) {
console.error('刪除文件失敗:', error)
ElMessage.error(error instanceof Error ? error.message : '刪除文件失敗,請(qǐng)稍后重試')
}
} catch {
// 用戶取消刪除,不做任何操作
}
}
const getFileIcon = (file: UploadFile) => {
// 如果有 response.data.type,優(yōu)先使用后端返回的擴(kuò)展名
const responseType = file.response?.data?.type
const ext = (responseType ? String(responseType) : file.type.split('/')[1] || '').toLowerCase()
// 圖片類型
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp'].includes(ext)) {
return Picture
}
// 壓縮包類型
if (['zip', 'rar', '7z', 'tar', 'gz'].includes(ext)) {
return FolderOpened
}
// PDF文檔
if (ext === 'pdf') {
return Files
}
// 其他都是文檔類型
return Document
}
// 獲取文件預(yù)覽URL
const getFilePreviewUrl = (file: UploadFile): string => {
if (file.url) {
// 對(duì)于已上傳的文件,使用baseURL2 + 相對(duì)路徑構(gòu)成完整的預(yù)覽地址
// 去除url開頭的斜杠避免重復(fù)
return baseURL2 + '/' + file.url.replace(/^\/+/g, '')
}
// 對(duì)于未上傳的文件,使用Blob URL進(jìn)行預(yù)覽
try {
const blobUrl = URL.createObjectURL(file.raw)
// 記錄已創(chuàng)建的Blob URL用于后續(xù)清理
createdBlobUrls.value.add(blobUrl)
return blobUrl
} catch (error) {
console.error('創(chuàng)建預(yù)覽URL失敗:', error)
return ''
}
}
// 格式化文件大小
const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
// 獲取狀態(tài)類型
const getStatusType = (status: FileStatus) => {
const statusMap = {
ready: 'info' as const,
uploading: 'warning' as const,
success: 'success' as const,
error: 'danger' as const,
}
return statusMap[status] || ('info' as const)
}
// 獲取狀態(tài)文本
const getStatusText = (status: FileStatus): string => {
const statusMap = {
ready: '等待上傳',
uploading: '上傳中',
success: '上傳成功',
error: '上傳失敗',
}
return statusMap[status] || '未知狀態(tài)'
}
// 驗(yàn)證文件
const validateFile = (file: File): boolean => {
// 開始驗(yàn)證文件
// 檢查文件大小
if (file.size > props.maxSize * 1024 * 1024) {
ElMessage.error(`文件大小不能超過(guò) ${props.maxSize}MB`)
return false
}
// 檢查文件數(shù)量
const currentFileList = fileList.value || []
if (!props.multiple && currentFileList.length >= 1) {
ElMessage.error('只能上傳一個(gè)文件')
return false
}
if (props.multiple && currentFileList.length >= props.maxCount) {
ElMessage.error(`最多只能上傳 ${props.maxCount} 個(gè)文件`)
return false
}
// 獲取文件擴(kuò)展名進(jìn)行驗(yàn)證
const fileName = file.name.toLowerCase()
const allowedExtensions = [
'pdf',
'doc',
'docx',
'xls',
'xlsx',
'zip',
'rar',
'jpg',
'jpeg',
'png',
'gif',
]
const hasValidExtension = allowedExtensions.some((ext) => fileName.endsWith('.' + ext))
// 如果擴(kuò)展名驗(yàn)證失敗,再嘗試MIME類型驗(yàn)證
if (!hasValidExtension) {
const allowedTypes = [
'application/pdf', // PDF
'application/msword', // DOC
'application/vnd.openxmlformats-officedocument.wordprocessingml.document', // DOCX
'application/vnd.ms-excel', // XLS
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', // XLSX
'application/zip', // ZIP
'application/x-rar-compressed', // RAR
'application/x-rar', // RAR alternative
'image/jpeg', // JPG/JPEG
'image/png', // PNG
'image/gif', // GIF
]
if (!allowedTypes.includes(file.type)) {
// 文件類型驗(yàn)證失敗
ElMessage.error('附件格式錯(cuò)誤,請(qǐng)上傳PDF、DOC、DOCX、XLS、XLSX、ZIP、RAR或圖片文件')
return false
}
}
// 文件驗(yàn)證通過(guò)
return true
}
// 添加文件到列表
const addFile = (file: File): UploadFile | null => {
// 準(zhǔn)備添加文件
if (!validateFile(file)) {
// 文件驗(yàn)證失敗,不添加到列表
return null
}
const uploadFile: UploadFile = {
uid: generateUID(),
name: file.name,
size: file.size,
type: file.type,
raw: file,
status: 'ready',
percentage: 0,
}
// 創(chuàng)建上傳文件對(duì)象
if (!props.multiple) {
fileList.value = [uploadFile]
// 單文件模式,替換文件列表
} else {
if (!fileList.value) {
fileList.value = []
}
fileList.value.push(uploadFile)
// 多文件模式,添加到文件列表
}
emit('change', uploadFile, fileList.value)
// 選擇上傳方式:分片或普通上傳
if (shouldUseChunkUpload(file)) {
// 使用分片上傳
if (props.autoUpload) {
nextTick(() => {
performChunkUpload(file)
})
}
} else {
// 使用普通上傳
if (props.autoUpload) {
// 自動(dòng)上傳模式,開始上傳文件
nextTick(() => {
if (uploadFile && uploadFile.uid) {
uploadSingleFile(uploadFile)
}
})
} else {
// 手動(dòng)上傳模式,文件已添加到列表等待用戶操作
}
}
return uploadFile
}
// 點(diǎn)擊上傳區(qū)域
const handleClick = () => {
if (props.disabled) return
if (!props.multiple && fileList.value.length > 0) return
fileInputRef.value?.click()
}
// 文件輸入改變
const handleFileChange = (event: Event) => {
const target = event.target as HTMLInputElement
const files = target.files
if (files) {
Array.from(files).forEach((file) => {
addFile(file)
})
}
// 清空input值,允許重復(fù)選擇同一文件
target.value = ''
}
// 拖拽相關(guān)事件
const handleDragover = (event: DragEvent) => {
if (props.disabled) return
event.preventDefault()
isDragover.value = true
}
const handleDragleave = () => {
isDragover.value = false
}
const handleDrop = (event: DragEvent) => {
if (props.disabled) return
event.preventDefault()
isDragover.value = false
const files = event.dataTransfer?.files
if (files) {
Array.from(files).forEach((file) => {
addFile(file)
})
}
}
// 上傳單個(gè)文件
const uploadSingleFile = async (file: UploadFile) => {
// 防止重復(fù)上傳:檢查文件狀態(tài)
if (file.status === 'uploading' || file.status === 'success') {
// 文件正在上傳中或已上傳成功,跳過(guò)重復(fù)上傳
return
}
if (props.beforeUpload) {
try {
const result = await props.beforeUpload(file.raw)
if (!result) return
} catch {
// beforeUpload error
return
}
}
const formData = new FormData()
formData.append('file', file.raw)
// 添加額外數(shù)據(jù)
Object.keys(props.data).forEach((key) => {
const value = props.data[key]
if (value !== undefined && value !== null) {
formData.append(key, String(value))
}
})
file.status = 'uploading'
file.percentage = 0
try {
const xhr = new XMLHttpRequest()
// 上傳進(jìn)度
xhr.upload.onprogress = (event) => {
if (event.lengthComputable) {
// 確保百分比在有效范圍內(nèi)
file.percentage = Math.max(0, Math.min(100, Math.round((event.loaded * 100) / event.total)))
emit('progress', event, file, fileList.value)
props.onProgress?.(event, file, fileList.value)
}
}
// 上傳完成
xhr.onload = () => {
if (xhr.status === 200) {
try {
const response = JSON.parse(xhr.responseText)
// 上傳響應(yīng)數(shù)據(jù)
// 檢查響應(yīng)的成功狀態(tài)
if (response.code === 200 || response.code === '200') {
file.status = 'success'
file.percentage = 100
file.response = response
emit('success', response, file, fileList.value)
props.onSuccess?.(response, file, fileList.value)
// 顯示成功提示
ElMessage.success(response.msg || `文件 "${file.name}" 上傳成功`)
// 如果有返回?cái)?shù)據(jù),自動(dòng)添加到已上傳列表
if (response.data && response.data.id) {
const uploadedFile: UploadedFile = {
id: response.data.id,
name: response.data.name || file.name,
size: response.data.size || file.size,
type: response.data.type || file.type.split('/')[1] || '',
url: response.data.url || '',
}
// 防止重復(fù)添加
const exists = uploadedFileList.value.some((f) => f.id === uploadedFile.id)
if (!exists) {
uploadedFileList.value.push(uploadedFile)
// 添加已上傳文件
}
}
// 上傳成功后延遲一下移除文件,讓用戶能看到成功狀態(tài)
setTimeout(() => {
if (fileList.value && Array.isArray(fileList.value)) {
const fileIndex = fileList.value.findIndex((f) => f.uid === file.uid)
if (fileIndex !== -1) {
fileList.value.splice(fileIndex, 1)
}
}
}, 1500) // 1.5秒后移除
} else {
// 后端返回了錯(cuò)誤狀態(tài)
handleUploadError(new Error(response.msg || '上傳失敗'), file)
}
} catch {
// 解析上傳響應(yīng)失敗
handleUploadError(new Error('解析響應(yīng)失敗:' + xhr.responseText), file)
}
} else {
handleUploadError(new Error(`上傳失敗,狀態(tài)碼:${xhr.status}`), file)
}
}
// 上傳錯(cuò)誤
xhr.onerror = () => {
handleUploadError(new Error('網(wǎng)絡(luò)錯(cuò)誤'), file)
}
// 必須先調(diào)用open()方法,然后再設(shè)置請(qǐng)求頭
xhr.open('POST', props.action, true)
// 設(shè)置請(qǐng)求頭
Object.keys(props.headers).forEach((key) => {
// 避免設(shè)置無(wú)效的請(qǐng)求頭
if (key && props.headers[key]) {
xhr.setRequestHeader(key, props.headers[key])
}
})
xhr.send(formData)
} catch (error) {
handleUploadError(error as Error, file)
}
}
// 處理上傳錯(cuò)誤
const handleUploadError = (error: Error, file: UploadFile) => {
file.status = 'error'
file.percentage = 0
emit('error', error, file, fileList.value)
props.onError?.(error, file, fileList.value)
ElMessage.error(`文件 ${file.name} 上傳失?。?{error.message}`)
}
// 上傳指定文件
const uploadFile = (file: UploadFile) => {
// 防止重復(fù)上傳:檢查文件狀態(tài)
if (file.status === 'uploading' || file.status === 'success') {
console.warn('文件正在上傳中或已上傳成功,跳過(guò)重復(fù)上傳:', file.name)
return
}
// 選擇上傳方式:分片或普通上傳
if (shouldUseChunkUpload(file.raw)) {
// 使用分片上傳
performChunkUpload(file.raw)
} else {
// 使用普通上傳
uploadSingleFile(file)
}
}
// 上傳所有文件
const uploadAll = () => {
if (!fileList.value || !Array.isArray(fileList.value)) {
ElMessage.warning('沒(méi)有可上傳的文件')
return
}
const readyFiles = fileList.value.filter((file) => file.status === 'ready')
if (readyFiles.length === 0) {
ElMessage.warning('沒(méi)有可上傳的文件')
return
}
// 防止重復(fù)上傳:檢查是否已經(jīng)在上傳中
if (uploading.value || isChunkUploading.value) {
console.warn('文件正在上傳中,跳過(guò)重復(fù)上傳')
return
}
uploading.value = true
// 分類文件:分片上傳和普通上傳
const chunkFiles: File[] = []
const normalFiles: UploadFile[] = []
readyFiles.forEach((file) => {
if (shouldUseChunkUpload(file.raw)) {
chunkFiles.push(file.raw)
} else {
normalFiles.push(file)
}
})
console.log(
`分類結(jié)果: ${chunkFiles.length} 個(gè)大文件使用分片上傳,${normalFiles.length} 個(gè)小文件使用普通上傳`,
)
// 并發(fā)執(zhí)行上傳
const uploadPromises: Promise<void>[] = []
// 添加普通上傳任務(wù)
normalFiles.forEach((file) => {
uploadPromises.push(uploadSingleFile(file))
})
// 添加分片上傳任務(wù)(一次只上傳一個(gè)大文件)
const uploadChunkFiles = async () => {
for (const file of chunkFiles) {
await performChunkUpload(file)
}
}
if (chunkFiles.length > 0) {
uploadPromises.push(uploadChunkFiles())
}
Promise.all(uploadPromises).finally(() => {
uploading.value = false
// 批量上傳完成后,延遲一下清空所有成功的文件
setTimeout(() => {
if (fileList.value && Array.isArray(fileList.value)) {
const successFiles = fileList.value.filter((file) => file.status === 'success')
if (successFiles.length > 0) {
// 移除所有成功的文件
fileList.value = fileList.value.filter((file) => file.status !== 'success')
}
}
}, 2000) // 2秒后清空成功的文件
})
}
// 移除文件時(shí)清理對(duì)應(yīng)的Blob URL
const removeFile = (index: number) => {
if (
!fileList.value ||
!Array.isArray(fileList.value) ||
index < 0 ||
index >= fileList.value.length
) {
return
}
const file = fileList.value[index]
// 二次確認(rèn)刪除
ElMessageBox.confirm(`確定要?jiǎng)h除文件 "${file.name}" 嗎?`, '刪除確認(rèn)', {
confirmButtonText: '確定',
cancelButtonText: '取消',
type: 'warning',
})
.then(() => {
// 如果文件有Blob URL,清理它
const previewUrl = getFilePreviewUrl(file)
if (previewUrl.startsWith('blob:') && createdBlobUrls.value.has(previewUrl)) {
try {
URL.revokeObjectURL(previewUrl)
createdBlobUrls.value.delete(previewUrl)
} catch (error) {
console.warn('清理文件預(yù)覽URL失敗:', error)
}
}
// 清理圖片加載錯(cuò)誤狀態(tài)
const key = file.uid || file.name
imageLoadErrors.value.delete(key)
fileList.value.splice(index, 1)
emit('remove', file, fileList.value)
props.onRemove?.(file, fileList.value)
ElMessage.success('文件刪除成功')
})
.catch(() => {
// 用戶取消刪除,不做任何操作
})
}
// 獲取分片上傳狀態(tài)文本
const getChunkUploadStatusText = (status: string): string => {
const statusMap: Record<string, string> = {
ready: '準(zhǔn)備中',
uploading: '上傳中',
merging: '合并中',
completed: '已完成',
failed: '上傳失敗',
cancelled: '已取消',
}
return statusMap[status] || '未知狀態(tài)'
}
// 清空所有文件
const clearAll = () => {
// 清理所有Blob URLs
if (fileList.value && Array.isArray(fileList.value)) {
fileList.value.forEach((file) => {
if (file && file.url && file.url.startsWith('blob:')) {
try {
URL.revokeObjectURL(file.url)
} catch (error) {
console.warn('清理Blob URL失敗:', error)
}
}
})
}
// 清空?qǐng)D片加載錯(cuò)誤狀態(tài)
imageLoadErrors.value.clear()
fileList.value = []
}
// 清空成功的文件
const clearSuccessFiles = () => {
if (fileList.value && Array.isArray(fileList.value)) {
// 先清理成功文件的Blob URLs
const successFiles = fileList.value.filter((file) => file && file.status === 'success')
successFiles.forEach((file) => {
if (file.url && file.url.startsWith('blob:')) {
try {
URL.revokeObjectURL(file.url)
} catch (error) {
console.warn('清理Blob URL失敗:', error)
}
}
})
// 然后過(guò)濾掉成功的文件
fileList.value = fileList.value.filter((file) => file && file.status !== 'success')
}
}
// 組件銷毀時(shí)的清理邏輯
onBeforeUnmount(async () => {
// MyUpload組件即將銷毀,開始清理資源...
// 如果有正在進(jìn)行的分片上傳,先取消它
if (isChunkUploading.value && currentChunkSession.value) {
// 檢測(cè)到正在進(jìn)行的分片上傳,正在取消...
try {
await cancelChunkUpload()
// 分片上傳已成功取消
} catch (error) {
console.error('取消分片上傳時(shí)出錯(cuò):', error)
}
}
// 清理所有創(chuàng)建的Blob URLs
createdBlobUrls.value.forEach((blobUrl) => {
try {
URL.revokeObjectURL(blobUrl)
} catch (error) {
console.warn('清理Blob URL失敗:', error)
}
})
createdBlobUrls.value.clear()
// 清理文件預(yù)覽的Blob URLs(兼容舊的清理方式)
if (fileList.value && Array.isArray(fileList.value)) {
fileList.value.forEach((file) => {
if (file.url && file.url.startsWith('blob:')) {
try {
URL.revokeObjectURL(file.url)
// 已清理Blob URL
} catch (error) {
console.warn('清理Blob URL失敗:', error)
}
}
})
}
// 重置所有狀態(tài)
isChunkUploading.value = false
chunkUploadProgress.value = 0
currentChunkSession.value = null
uploadSpeed.value = 0
remainingTime.value = 0
uploading.value = false
// MyUpload組件資源清理完成
})
// 暴露方法
defineExpose({
uploadAll,
clearAll,
clearSuccessFiles,
addFile,
cancelChunkUpload,
performChunkUpload,
hasReadyFiles,
fileList,
})
</script>
<style scoped lang="scss">
@import '@/assets/styles/variables.scss';
.my-upload {
width: 100%;
.upload-area {
position: relative;
border: 2px dashed $border-color-base;
border-radius: 8px;
background-color: $color-white;
transition: all 0.3s ease;
cursor: pointer;
overflow: hidden;
&:hover {
border-color: $primary-color;
background-color: rgba($primary-color, 0.02);
}
&.is-dragover {
border-color: $primary-color;
background-color: rgba($primary-color, 0.05);
transform: scale(1.02);
}
&.is-disabled {
cursor: not-allowed;
opacity: 0.6;
&:hover {
border-color: $border-color-base;
background-color: $color-white;
}
}
&.has-files {
cursor: default;
&:hover {
border-color: $border-color-base;
background-color: $color-white;
}
}
.upload-content {
padding: 40px 20px;
text-align: center;
.upload-icon {
color: $text-secondary;
margin-bottom: 16px;
transition: color 0.3s ease;
}
.upload-text {
.upload-title {
margin: 0 0 8px 0;
font-size: 16px;
font-weight: 500;
color: $text-primary;
}
.upload-subtitle {
margin: 0;
font-size: 14px;
color: $text-secondary;
}
}
}
.single-file-preview {
display: flex;
align-items: center;
padding: 20px;
gap: 16px;
.file-preview {
flex-shrink: 0;
width: 80px;
height: 80px;
border-radius: 6px;
overflow: hidden;
background-color: $background-color-base;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid $border-color-light;
.preview-image {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
border-radius: 4px;
&[src=''],
&:not([src]) {
display: none;
}
}
.file-icon {
color: $text-secondary;
}
}
.file-info {
flex: 1;
min-width: 0;
.file-name {
margin: 0 0 8px 0;
font-size: 16px;
font-weight: 500;
color: $text-primary;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.file-size {
margin: 0;
font-size: 14px;
color: $text-secondary;
}
}
.file-actions {
flex-shrink: 0;
}
}
}
.file-list {
margin-top: 16px;
border: 1px solid $border-color-light;
border-radius: 6px;
overflow: hidden;
.file-item {
display: flex;
align-items: center;
padding: 16px;
gap: 16px;
border-bottom: 1px solid $border-color-light;
background-color: $color-white;
transition: background-color 0.3s ease;
&:last-child {
border-bottom: none;
}
&:hover {
background-color: $background-color-base;
}
.file-preview {
flex-shrink: 0;
width: 48px;
height: 48px;
border-radius: 4px;
overflow: hidden;
background-color: $background-color-base;
display: flex;
align-items: center;
justify-content: center;
.preview-thumbnail {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
border-radius: 4px;
&[src=''],
&:not([src]) {
display: none;
}
}
.file-icon {
color: $text-secondary;
font-size: 24px;
}
}
.file-info {
flex: 1;
min-width: 0;
.file-name {
margin: 0 0 4px 0;
font-size: 14px;
font-weight: 500;
color: $text-primary;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.file-size {
margin: 0 0 8px 0;
font-size: 12px;
color: $text-secondary;
}
.file-progress {
width: 200px;
}
.file-status {
margin-top: 4px;
}
}
.file-actions {
flex-shrink: 0;
display: flex;
gap: 8px;
}
}
}
.upload-controls {
margin-top: 16px;
text-align: center;
.el-button {
margin: 0 8px;
}
}
// 分片上傳進(jìn)度樣式
.chunk-upload-progress {
background: $color-white;
border: 1px solid $border-color-light;
border-radius: 8px;
padding: 16px;
margin-top: 16px;
.progress-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
h4 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: $text-primary;
&::before {
content: '??';
margin-right: 8px;
}
}
.progress-text {
font-size: 18px;
font-weight: 600;
color: $primary-color;
}
}
.upload-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 8px;
margin-top: 12px;
.stat-item {
display: flex;
align-items: center;
.stat-label {
font-size: 13px;
color: $text-secondary;
margin-right: 8px;
min-width: 70px;
}
.stat-value {
font-size: 13px;
color: $text-primary;
font-weight: 500;
&.status {
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
&.ready {
background: #e1f3ff;
color: #0066cc;
}
&.uploading {
background: #fff7e6;
color: #d46b08;
}
&.merging {
background: #f6ffed;
color: #389e0d;
}
&.completed {
background: #f6ffed;
color: #52c41a;
}
&.failed {
background: #fff2f0;
color: #a8071a;
}
&.cancelled {
background: #f5f5f5;
color: #666;
}
}
}
}
}
}
.uploaded-files {
margin-top: 20px;
h4 {
margin: 0 0 12px 0;
font-size: 16px;
font-weight: 600;
color: $text-primary;
&::before {
content: '';
display: inline-block;
width: 4px;
height: 16px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
margin-right: 8px;
border-radius: 2px;
}
}
}
.uploaded-file-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.uploaded-file-item {
display: flex;
align-items: center;
padding: 12px 16px;
background: $color-white;
border: 1px solid $border-color-light;
border-radius: 8px;
transition: all 0.3s ease;
gap: 12px;
&:hover {
background: $background-color-base;
border-color: $primary-color;
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba($primary-color, 0.15);
}
.file-preview {
flex-shrink: 0;
width: 48px;
height: 48px;
border-radius: 4px;
overflow: hidden;
background-color: $background-color-base;
display: flex;
align-items: center;
justify-content: center;
.preview-thumbnail {
width: 100%;
height: 100%;
object-fit: cover;
}
.file-icon {
color: $primary-color;
}
}
.file-info {
flex: 1;
min-width: 0;
.file-name {
margin: 0 0 4px 0;
font-size: 14px;
font-weight: 500;
color: $text-primary;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
&.clickable {
cursor: pointer;
color: $primary-color;
transition: all 0.3s ease;
&:hover {
color: darken($primary-color, 10%);
text-decoration: underline;
}
&:active {
color: darken($primary-color, 20%);
}
}
}
.file-size {
margin: 0;
font-size: 12px;
color: $text-secondary;
}
}
.file-actions {
display: flex;
gap: 6px;
flex-shrink: 0;
}
}
}
// 響應(yīng)式設(shè)計(jì)
@media (max-width: 768px) {
.my-upload {
.upload-area {
.upload-content {
padding: 30px 16px;
}
.single-file-preview {
padding: 16px;
.file-preview {
width: 60px;
height: 60px;
}
}
}
.file-list {
.file-item {
padding: 12px;
.file-info {
.file-progress {
width: 150px;
}
}
.file-actions {
flex-direction: column;
gap: 4px;
}
}
}
}
}
</style>
這是上傳組件MyUpload.vue
<MyUpload
ref="uploadRef"
v-model="form.附件網(wǎng)址"
:data="{
type: '附件',
位置: '車輛信息',
}"
/>
上傳組件使用
linux上并發(fā)設(shè)置都沒(méi)問(wèn)題建議你看下mysql配置
或者在每個(gè)請(qǐng)求結(jié)束也就是response上吧lastId寫入log文件 也有可能是opcache緩存的問(wèn)題