vue3+element-plus 文件上传

主页面代码:

<template>

<div class="file-upload-container">

<el-alert

class="upload-tip-alert"

type="warning"

show-icon

:closable="false"

title="温馨提示:文件上传之前,请务必先在左侧完成「文件设置」(选择文件类别)。"

/>

<div class="upload-layout">

<div class="settings-panel">

<div class="settings-card">

<div class="settings-header">

<span class="settings-title">文件设置</span>

</div>

<div class="form-item">

<div class="form-label">

<span class="required">*</span>

文件类别

<span class="form-hint">(必填, 单选)</span>

</div>

<el-select

ref="categorySelectRef"

v-model="fileType"

placeholder="请选择或添加新类别"

popper-class="file-setting-select"

class="full-select"

>

<el-option

v-for="(item, index) in fileOptions"

:key="index"

:label="item"

:value="item"

/>

<template #empty>

<span></span>

</template>

<template #footer>

<div

class="select-add-action"

@click.stop="openAddDialog('category')"

>

<i class="icon iconfont icon-xinzeng select-icon"></i>添加新类别

</div>

</template>

</el-select>

</div>

<div class="form-item">

<div class="form-label">

添加标签

<span class="form-hint">(非必填, 单选)</span>

</div>

<el-select

ref="tagSelectRef"

v-model="fileTag"

placeholder="请选择或添加新标签"

clearable

filterable

popper-class="file-setting-select"

class="full-select"

>

<el-option

v-for="(item, index) in tagOptions"

:key="index"

:label="item"

:value="item"

/>

<template #empty>

<span></span>

</template>

<template #footer>

<div

class="select-add-action"

@click.stop="openAddDialog('tag')"

>

<i class="icon iconfont icon-xinzeng select-icon"></i>添加新标签

</div>

</template>

</el-select>

</div>

</div>

</div>

<div

class="upload-panel"

:class="{ 'is-disabled': !canUpload }"

>

<el-upload

class="upload-dragger"

drag

:action="uploadUrl"

multiple

:show-file-list="false"

:before-upload="beforeUpload"

:accept="acceptedExtensions"

:disabled="!canUpload || (isUploading && currentUploadFiles.length > 0)"

>

<!-- 上传进度显示区域 -->

<div class="upload-progress-area" v-if="showUploadProgress">

<div class="progress-title">

<span class="is-loading"

>正在上传 ({{ uploadingCount }}/{{

currentUploadFiles.length

}})</span

>

<span

class="cancel-single"

@click.stop="showBatchCancelConfirm"

v-if="uploadingCount > 0"

>取消上传</span>

</div>

<div class="file-progress-list">

<div

class="file-progress-item"

v-for="file in currentUploadFiles"

:key="file.id"

>

<div class="file-progress-info">

<div class="file-name">

<span class="icon iconfont icon-wenjian"></span>

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

</div>

<div class="progress-status">

<span v-if="file.status === 'success'" class="upload-text"

>100%</span

>

<span v-else-if="file.status === 'exception'" class="error-text"

>上传失败</span

>

<span class="upload-text" v-else>{{ file.percentage }}%</span>

</div>

</div>

<div class="progress-bar-container">

<el-progress

:percentage="file.percentage"

:status="getProgressStatus(file.status)"

:color="customColor"

:stroke-width="6"

:show-text="false"

/>

</div>

</div>

</div>

</div>

<!-- 上传按钮区域 -->

<div class="upload-box" v-else>

<div class="upload-btn">

<span class="icon iconfont icon-shangchuanwenjian"></span>

<span>上传文件</span>

</div>

<div class="el-upload-text">将文件拖到此处,或点击上传文件</div>

<div class="text-tip">{{ uploadTypeTip }}</div>

</div>

</el-upload>

</div>

</div>

<!-- 文件列表 -->

<div v-if="fileList && fileList.length > 0" class="file-list-section">

<div class="top-content">

<TitleCard

:title="`已上传文件 (${total})`"

class="title-card"

></TitleCard>

<el-button

class="btn delete-btn"

@click="deleteSelected"

:disabled="selectedFiles && selectedFiles.length === 0"

>

批量删除

</el-button>

</div>

<TableList

:tableData="fileList"

border

stripe

:maxHeight="470"

hasSelection

row-key="id"

paginationShow

:currentPage="currentPage"

:pageSize="pageSize"

:total="total"

:page-sizes="10, 20, 50, 100"

:layout="'total, sizes, prev, pager, next, jumper'"

:background="true"

@changeCurrentPage="handlePageChange"

@changePageSize="handleSizeChange"

@handleSelectionChange="handleSelectionChange"

>

<el-table-column

prop="categroy"

header-align="center"

align="center"

label="文件类别"

width="100"

/>

<el-table-column prop="file_tag" min-width="180" align="center" header-align="center" label="标签" />

<el-table-column prop="name" min-width="350" header-align="center" label="文件名" />

<el-table-column

prop="type"

header-align="center"

align="center"

label="文件格式"

width="100"

/>

<el-table-column

prop="uploadTime"

header-align="center"

align="center"

label="上传时间"

width="180"

>

<template #default="{ row }">

{{ row.uploadTime.replace("T", " ").split(".")0 }}

</template>

</el-table-column>

<el-table-column

prop="operate"

header-align="center"

align="center"

label="操作人"

width="100"

/>

<el-table-column

prop="status"

header-align="center"

align="center"

label="状态"

width="90"

>

<template #default="{ row }">

<div

:class="

row.status === 'processing'

? 'processing-text'

: row.status === 'danger'

? 'error-text'

: ''

"

>

{{ row.status === "success" ? "上传成功" : "上传失败" }}

</div>

</template>

</el-table-column>

<el-table-column

label="操作"

header-align="center"

align="center"

width="80"

fixed="right"

>

<template #default="{ row }">

<el-button type="danger" text @click="deleteSingleFile(row)">

删除

</el-button>

</template>

</el-table-column>

</TableList>

</div>

<el-dialog

v-model="addDialogVisible"

:title="addDialogTitle"

width="420px"

align-center

class="add-option-dialog"

@opened="focusAddInput"

@closed="resetAddDialog"

>

<el-input

ref="addInputRef"

v-model="addDialogName"

:placeholder="addDialogPlaceholder"

maxlength="20"

clearable

@keyup.enter="confirmAddOption"

/>

<template #footer>

<el-button @click="addDialogVisible = false">取消</el-button>

<el-button

type="primary"

:disabled="!addDialogName.trim()"

@click="confirmAddOption"

>

确定

</el-button>

</template>

</el-dialog>

</div>

</template>

<script lang="ts" setup>

import { ElMessage, ElMessageBox } from "element-plus";

import type { UploadRawFile } from "element-plus";

import { config } from "../../config/index.js";

import TableList from "../../components/TableList.vue";

import { deleteFileApi, deleteFiles, validateFile, getFileTags, createFileTag } from "../../api/upload.ts";

// 类型定义

interface FileInfo {

id: string;

categroy: string;

file_tag: string;

name: string;

size: number;

type: string;

uploadTime: Date;

operate: string | null;

status: "success" | "processing" | "error";

url?: string;

path?: string;

}

interface UploadProgressItem {

id: number;

name: string;

percentage: number;

status?: "success" | "exception" | "canceled" | "paused" | "";

isCanceled?: boolean; // 标记是否已取消

file?: UploadRawFile; // 存储原始文件对象

}

// 响应式数据

const fileList = ref<any>(\[\]);

const currentUploadFiles = ref<UploadProgressItem\[\]>(\[\]);

const selectedFiles = ref<FileInfo\[\]>(\[\]);

const fileType = ref("");

const fileTag = ref("");

const fileOptions = ref<string\[\]>(\[\]);

const tagOptions = ref<string\[\]>(\[\]);

const isUploading = ref(false); // 控制文件上传状态

const customColor = ref("#3E7BED");

const categorySelectRef = ref();

const tagSelectRef = ref();

const addInputRef = ref();

const addDialogVisible = ref(false);

const addDialogType = ref<"category" | "tag">("category");

const addDialogName = ref("");

const addDialogTitle = computed(() =>

addDialogType.value === "category" ? "添加新类别" : "添加新标签"

);

const addDialogPlaceholder = computed(() =>

addDialogType.value === "category"

? "请输入新类别名称"

: "请输入新标签名称"

);

// 分页数据

const currentPage = ref(1);

const pageSize = ref(10);

const total = ref(0);

// 存储所有活跃的上传请求

const currentUploadRequests = ref<Map<number, XMLHttpRequest>>(new Map());

// 计算属性

const uploadUrl = computed(() => `${config.apiBaseUrl}/api/upload`);

const allowedExtensions = computed(() => {

const extensions: any = config.uploadAllowedExtensions || ".pdf";

return Array.isArray(extensions) ? extensions : extensions.split(",");

});

const acceptedExtensions = computed(() => allowedExtensions.value.join(","));

const canUpload = computed(() => !!fileType.value);

const uploadTypeTip = computed(() => {

const seen = new Set<string>();

const labels: string\[\] = \[\];

allowedExtensions.value.forEach((ext: string) => {

const raw = String(ext).trim().replace(/^\./, "").toUpperCase();

if (!raw) return;

const label = raw;

if (seen.has(label)) return;

seen.add(label);

labels.push(label);

});

const types = labels.length ? labels.join("、") : "PDF";

return `*支持多文件上传,支持 ${types} 类型文件`;

});

// 打开添加类别或标签的弹窗

const openAddDialog = (type: "category" | "tag"): void => {

const selectRef =

type === "category" ? categorySelectRef.value : tagSelectRef.value;

selectRef?.blur();

addDialogType.value = type;

addDialogName.value = "";

addDialogVisible.value = true;

};

// 输入框聚焦时获取焦点

const focusAddInput = (): void => {

nextTick(() => {

addInputRef.value?.focus?.();

});

};

// 重置添加类别或标签的弹窗

const resetAddDialog = (): void => {

addDialogName.value = "";

};

// 确认添加类别或标签

const confirmAddOption = async (): Promise<void> => {

const name = addDialogName.value.trim();

if (!name) return;

// category -> tag_type=0,tag -> tag_type=1

const tagType = addDialogType.value === "category" ? 0 : 1;

try {

const res: any = await createFileTag(name, tagType);

if (res.success) {

// 新增成功后重新加载对应列表,并选中新增项

if (addDialogType.value === "category") {

await loadFileCategories();

fileType.value = name;

} else {

await loadFileTags();

fileTag.value = name;

}

addDialogVisible.value = false;

ElMessage.success(addDialogType.value === "category" ? "类别添加成功" : "标签添加成功");

} else {

ElMessage.error(res.message || "添加失败");

}

} catch (error: any) {

}

};

// 是否显示上传进度

const showUploadProgress = computed(() => {

return isUploading.value && currentUploadFiles.value.length > 0;

});

// 获取正在上传的文件数量

const uploadingCount = computed(() => {

return currentUploadFiles.value.filter(

(file) =>

file.status !== "success" &&

file.status !== "exception" &&

file.status !== "canceled"

).length;

});

// 获取进度条状态

const getProgressStatus = (status: any): "success" | "exception" | "" => {

if (status === "success") return "success";

if (status === "exception" || status === "canceled") return "exception";

return "";

};

// 文件上传前的验证

const beforeUpload = (file: UploadRawFile): boolean => {

if (!fileType.value) {

return false;

}

// 使用API中的验证函数

const validation: any = validateFile(file);

if (!validation.valid) {

ElMessage.error(validation.message);

return false;

}

// 创建上传进度项

const progressItem: UploadProgressItem = {

id: Date.now() + Math.random(),

name: file.name,

percentage: 0,

status: "",

isCanceled: false,

file: file,

};

// 添加到当前上传文件列表

currentUploadFiles.value.push(progressItem);

isUploading.value = true;

// 使用自定义上传

handleCustomUpload(file, progressItem);

return false; // 阻止默认上传

};

// 自定义上传处理

const handleCustomUpload = async (

file: UploadRawFile,

progressItem: UploadProgressItem

): Promise<void> => {

// 在开始上传前再次检查是否已被取消

if (progressItem.isCanceled) {

return;

}

try {

const formData = new FormData();

formData.append("file", file);

formData.append("category", fileType.value);

if (fileTag.value) {

formData.append("file_tag", fileTag.value);

}

const xhr = new XMLHttpRequest();

// 存储XHR引用

currentUploadRequests.value.set(progressItem.id, xhr);

// 监听上传进度

xhr.upload.addEventListener("progress", (progressEvent) => {

// 检查是否已取消

const currentFile = currentUploadFiles.value.find(

(f) => f.id === progressItem.id

);

if (currentFile?.isCanceled) {

xhr.abort();

currentUploadRequests.value.delete(progressItem.id);

return;

}

if (progressEvent.lengthComputable) {

const percentage = Math.round(

(progressEvent.loaded * 100) / progressEvent.total

);

const displayPercentage = Math.min(percentage, 99);

// 更新对应文件的进度

const index = currentUploadFiles.value.findIndex(

(f) => f.id === progressItem.id

);

if (index !== -1) {

currentUploadFiles.valueindex = {

...currentUploadFiles.valueindex,

percentage: displayPercentage,

};

}

}

});

// 监听上传完成

xhr.addEventListener("load", () => {

currentUploadRequests.value.delete(progressItem.id);

const currentFile = currentUploadFiles.value.find(

(f) => f.id === progressItem.id

);

// 如果文件已被取消,不处理响应

if (currentFile?.isCanceled) {

// 尝试删除可能已上传的文件

if (xhr.status === 200) {

try {

const response = JSON.parse(xhr.responseText);

if (response.data && response.data.id) {

deleteFileApi(response.data.id);

}

} catch (error) {

console.error('处理取消文件响应失败:', error);

}

}

return;

}

if (xhr.status === 200) {

try {

const response = JSON.parse(xhr.responseText);

if (response.success === false) {

handleUploadErrorForFile(

new Error(response.message || "上传失败"),

file,

progressItem.id

);

return;

}

const index = currentUploadFiles.value.findIndex(

(f) => f.id === progressItem.id

);

if (index !== -1) {

currentUploadFiles.valueindex = {

...currentUploadFiles.valueindex,

percentage: 100,

status: "success",

};

setTimeout(() => {

handleUploadComplete(file, response, progressItem.id);

}, 500);

}

} catch (error) {

handleUploadErrorForFile(

new Error("响应解析失败"),

file,

progressItem.id

);

}

} else {

let errorMsg = `上传失败: ${xhr.statusText}`;

try {

const errorResponse = JSON.parse(xhr.responseText);

if (errorResponse.message) {

errorMsg = errorResponse.message;

}

} catch {}

handleUploadErrorForFile(

new Error(errorMsg),

file,

progressItem.id

);

}

});

// 监听上传错误

xhr.addEventListener("error", () => {

currentUploadRequests.value.delete(progressItem.id);

const currentFile = currentUploadFiles.value.find(

(f) => f.id === progressItem.id

);

// 只有未取消的文件才报错

if (!currentFile?.isCanceled) {

handleUploadErrorForFile(new Error("网络错误"), file, progressItem.id);

}

});

// 发送请求

xhr.open("POST", uploadUrl.value);

xhr.timeout = 60000;

xhr.send(formData);

} catch (error: any) {

currentUploadRequests.value.delete(progressItem.id);

const currentFile = currentUploadFiles.value.find(

(f) => f.id === progressItem.id

);

// 只有未取消的文件才报错

if (!currentFile?.isCanceled) {

handleUploadErrorForFile(error, file, progressItem.id);

}

}

};

// 单个文件上传错误处理

const handleUploadErrorForFile = (

error: Error,

file: UploadRawFile,

fileId: number

): void => {

const index = currentUploadFiles.value.findIndex((f) => f.id === fileId);

if (index !== -1) {

const currentFile = currentUploadFiles.valueindex;

if (!currentFile.isCanceled) {

currentUploadFiles.valueindex = {

...currentFile,

percentage: 100,

status: "exception",

};

ElMessage.error(`文件上传失败: ${error.message}`);

}

}

checkAndResetUploadState();

};

// 检查并重置上传状态

const checkAndResetUploadState = (): void => {

// 检查是否还有未取消的活跃上传

const hasActiveUploads = currentUploadFiles.value.some(

file =>

!file.isCanceled &&

file.status !== "success" &&

file.status !== "exception"

);

if (!hasActiveUploads) {

// 延迟重置,确保所有回调都处理完成

setTimeout(() => {

resetUploadState();

}, 1000);

}

};

// 重置上传状态 - 确保完全清理

const resetUploadState = (): void => {

// 先中止所有可能的请求

currentUploadRequests.value.forEach((xhr, fileId) => {

xhr.abort();

});

currentUploadRequests.value.clear();

currentUploadFiles.value = \[\];

isUploading.value = false;

};

// 上传完成处理

const handleUploadComplete = async (

file: UploadRawFile,

result: any,

fileId: number

): Promise<void> => {

// 首先检查文件是否已被取消

const currentFile = currentUploadFiles.value.find((f) => f.id === fileId);

if (currentFile?.isCanceled) {

// 如果文件已被取消,删除已上传的文件(如果有)

if (result.data && result.data.id) {

try {

await deleteFileApi(result.data.id);

} catch (error) {

console.error('删除已取消文件失败:', error);

}

}

return;

}

// 只有确认未取消的情况下才添加到列表

const newFile: FileInfo = {

id: result.data?.id,

categroy: fileType.value,

file_tag: fileTag.value,

name: result.data?.name,

size: result.data?.size,

type: result.data?.type,

uploadTime: result.data?.uploadTime,

operate: localStorage.getItem("username"),

status: result.success ? "success" : "error",

url: result.data?.url,

path: result.data?.path,

};

// 从缓存获取现有文件,添加新文件到开头

const allFiles = JSON.parse(sessionStorage.getItem("fileList") || "\[\]");

const updatedFiles = newFile, ...allFiles;

// 更新缓存

updateSessionStorage(updatedFiles);

// 从当前上传列表中移除已完成文件

currentUploadFiles.value = currentUploadFiles.value.filter(

(f) => f.id !== fileId

);

// 从请求映射中移除

currentUploadRequests.value.delete(fileId);

// 立即更新文件列表

loadFileList();

// 检查是否所有文件都上传完成

checkAndResetUploadState();

};

// 批量取消确认方法

const showBatchCancelConfirm = async (): Promise<void> => {

// 获取所有未完成的文件

const pendingFiles = currentUploadFiles.value.filter(

file =>

file.status !== "success" &&

file.status !== "exception" &&

file.status !== "canceled"

);

if (pendingFiles.length === 0) {

ElMessage.info("没有正在上传的文件");

return;

}

// 暂停所有上传(中止请求但保留状态)

pauseAllUploads();

try {

await ElMessageBox.confirm(

`已暂停 ${pendingFiles.length} 个文件的上传,确定要取消上传吗?`,

"确认取消上传",

{

confirmButtonText: "确定取消",

cancelButtonText: "继续上传",

confirmButtonClass: "confirm-btn",

type: "warning",

// 添加关闭回调

beforeClose: (action, instance, done) => {

if (action === 'close' || action === 'cancel') {

// 关闭按钮或取消按钮都视为继续上传

instance.confirmButtonLoading = false;

instance.cancelButtonLoading = false;

done();

// 这里不能直接调用resume,需要在catch中处理

throw new Error('continue_upload'); // 抛出特定错误

} else if (action === 'confirm') {

instance.confirmButtonLoading = true;

instance.cancelButtonLoading = true;

// 确认取消

done();

}

}

}

);

// 点击"确定取消" - 彻底取消上传

confirmBatchUploadCancel();

} catch (error: any) {

// 点击"继续上传"或关闭按钮

if (error.message === 'continue_upload') {

resumeAllUploads();

} else {

// 其他错误也视为继续上传

resumeAllUploads();

}

}

};

// 暂停所有上传(中止请求但保留文件信息)

const pauseAllUploads = (): void => {

// 中止所有活跃的上传请求

currentUploadRequests.value.forEach((xhr, fileId) => {

xhr.abort();

});

currentUploadRequests.value.clear();

// 标记所有未完成文件为暂停状态(不改变isCanceled状态)

currentUploadFiles.value.forEach((file, index) => {

if (file.status !== "success" && file.status !== "exception" && file.status !== "canceled") {

currentUploadFiles.valueindex = {

...file,

status: "paused", // 添加暂停状态

};

}

});

};

// 恢复所有上传

const resumeAllUploads = (): void => {

const pausedFiles = currentUploadFiles.value.filter(

file => file.status === "paused"

);

if (pausedFiles.length === 0) return;

// 重新开始所有暂停的上传

pausedFiles.forEach(file => {

if (file.file) {

const index = currentUploadFiles.value.findIndex(f => f.id === file.id);

if (index !== -1) {

// 重置状态为上传中

currentUploadFiles.valueindex = {

...currentUploadFiles.valueindex,

status: "",

isCanceled: false,

};

// 重新开始上传

handleCustomUpload(file.file!, currentUploadFiles.valueindex);

}

}

});

};

// 确认取消上传(彻底取消)

const confirmBatchUploadCancel = (): void => {

// 获取所有未完成的文件

const pendingFiles = currentUploadFiles.value.filter(

file =>

file.status !== "success" &&

file.status !== "exception"

);

// 标记所有未完成文件为已取消

pendingFiles.forEach(file => {

const index = currentUploadFiles.value.findIndex(f => f.id === file.id);

if (index !== -1) {

currentUploadFiles.valueindex = {

...currentUploadFiles.valueindex,

status: "canceled",

percentage: 0,

isCanceled: true,

};

}

});

ElMessage.warning(`已取消 ${pendingFiles.length} 个文件的上传`);

// 重置上传状态

resetUploadState();

loadFileList();

};

// 选择变化处理

const handleSelectionChange = (selection: FileInfo\[\]): void => {

selectedFiles.value = selection;

};

// 批量删除选中文件

const deleteSelected = async (): Promise<void> => {

try {

await ElMessageBox.confirm(

`确定要删除选中的 ${selectedFiles.value.length} 个文件吗?`,

"确认删除",

{

confirmButtonText: "确定",

cancelButtonText: "取消",

type: "warning",

}

);

const fileIds = selectedFiles.value.map((file) => file.id);

// 调用批量删除API

const results: any = await deleteFiles(fileIds);

// 从缓存中获取所有文件,过滤掉要删除的文件

const allFiles = JSON.parse(sessionStorage.getItem("fileList") || "\[\]");

const updatedFiles = allFiles.filter(

(file: FileInfo) => !fileIds.includes(file.id)

);

// 更新缓存

updateSessionStorage(updatedFiles);

// 批量删除后总是跳转到第一页

currentPage.value = 1;

loadFileList();

selectedFiles.value = \[\];

ElMessage.success(`已删除 ${fileIds.length} 个文件`);

} catch (error) {

// 用户取消操作

console.log("用户取消操作", error);

}

};

// 删除文件

const deleteSingleFile = async (file: FileInfo): Promise<void> => {

try {

await ElMessageBox.confirm(

`确定要删除文件 "${file.name}" 吗?`,

"确认删除",

{

confirmButtonText: "确定",

cancelButtonText: "取消",

type: "warning",

}

);

// 调用删除API

const results: any = await deleteFileApi(file.id);

if (results.success) {

// 从缓存中获取所有文件,过滤掉要删除的文件

const allFiles = JSON.parse(sessionStorage.getItem("fileList") || "\[\]");

const updatedFiles = allFiles.filter(

(item: FileInfo) => item.id !== file.id

);

// 更新缓存

updateSessionStorage(updatedFiles);

currentPage.value = 1;

loadFileList();

ElMessage.success(`删除成功`);

} else {

ElMessage.error(results.message);

}

} catch (error: any) {

if (error.message !== "cancel") {

console.error(`删除文件失败: ${error.response.data.message}`);

}

}

};

// 在文件上传和删除操作中,确保正确更新缓存

const updateSessionStorage = (files: FileInfo\[\]) => {

sessionStorage.setItem("fileList", JSON.stringify(files));

};

// 加载文件类别列表(tag_type=0:返回全部类别)

const loadFileCategories = async (): Promise<void> => {

try {

const res: any = await getFileTags(0);

if (res.success) {

fileOptions.value = (res.data || \[\]).map((item: any) => item.tag_name);

}

} catch (error) {

console.error("加载文件类别失败:", error);

}

};

// 加载文件标签列表(tag_type=1:返回默认+当前用户自创标签)

const loadFileTags = async (): Promise<void> => {

try {

const res: any = await getFileTags(1);

if (res.success) {

tagOptions.value = (res.data || \[\]).map((item: any) => item.tag_name);

}

} catch (error) {

console.error("加载文件标签失败:", error);

}

};

// 加载文件列表

const loadFileList = () => {

try {

// 从缓存获取所有数据

const allFiles = JSON.parse(sessionStorage.getItem("fileList") || "\[\]");

// 计算分页数据

const startIndex = (currentPage.value - 1) * pageSize.value;

const endIndex = startIndex + pageSize.value;

// 获取当前页的数据

fileList.value = allFiles.slice(startIndex, endIndex);

total.value = allFiles.length;

} catch (error) {

console.error("加载文件列表失败:", error);

fileList.value = \[\];

total.value = 0;

}

};

// 处理页码变化

const handlePageChange = (page: number): void => {

currentPage.value = page;

loadFileList();

};

// 处理分页页数变化

const handleSizeChange = (size: number): void => {

pageSize.value = size;

currentPage.value = 1;

loadFileList();

};

onMounted(() => {

nextTick(() => {

loadFileList();

loadFileCategories();

loadFileTags();

});

});

</script>

<style lang="scss" scoped>

.file-upload-container {

padding: 0 20px;

.page-header {

.title-card {

margin-top: 16px;

}

}

.upload-tip-alert {

margin-top: 16px;

:deep(.el-alert__title) {

font-size: 13px;

font-weight: 400;

}

}

.upload-layout {

display: flex;

align-items: stretch;

width: 100%;

gap: 16px;

margin-top: 16px;

}

.settings-panel {

flex: 0 0 280px;

width: 280px;

display: flex;

}

.settings-card {

flex: 1;

width: 100%;

min-height: 260px;

box-sizing: border-box;

background: #fff;

border: 1px solid #e9eaf1;

border-radius: 10px;

padding: 16px;

}

.settings-header {

display: flex;

align-items: center;

margin-bottom: 18px;

}

.settings-title {

font-size: 16px;

color: $font-size-primary;

}

.form-item {

margin-bottom: 16px;

&:last-child {

margin-bottom: 0;

}

}

.form-label {

font-size: 14px;

color: $font-size-primary;

margin-bottom: 8px;

.required {

color: #f56c6c;

margin-right: 2px;

}

.form-hint {

color: $font-size-third;

font-size: 12px;

}

}

.full-select {

width: 100%;

}

.upload-panel {

flex: 1 1 auto;

min-width: 0;

width: 100%;

:deep(.el-upload) {

flex: 1;

width: 100% !important;

max-width: none;

height: 100%;

display: flex !important;

flex-direction: column;

align-items: stretch !important;

justify-content: stretch;

}

:deep(.el-upload-dragger) {

display: flex;

flex: 1;

flex-direction: column;

justify-content: center;

align-items: center;

align-self: stretch;

width: 100% !important;

max-width: none !important;

height: 100% !important;

min-height: 260px;

box-sizing: border-box;

border-radius: 10px;

background: #f8fafc;

border: 2px dotted #e9eaf1;

padding: 20px;

}

:deep(.el-upload.is-disabled) {

cursor: not-allowed;

}

:deep(.el-upload.is-disabled .el-upload-dragger) {

cursor: not-allowed;

}

&.is-disabled {

cursor: not-allowed;

:deep(.el-upload) {

pointer-events: none;

cursor: not-allowed;

}

:deep(.el-upload-dragger),

:deep(.el-upload.is-disabled .el-upload-dragger) {

background: #f5f5f5 !important;

opacity: 0.7;

}

.upload-box {

.upload-btn {

border-color: #d9d9d9;

color: #999999;

}

.el-upload-text {

color: #333333;

}

.text-tip {

color: #999999;

}

}

}

}

.upload-dragger {

.upload-progress-area {

width: 50%;

.progress-title {

font-size: 14px;

display: flex;

align-items: center;

justify-content: space-between;

position: relative;

margin-bottom: 5px;

padding: 0 20px;

.is-loading {

color: $bg-color;

}

.cancel-single {

margin-left: 8px;

color: $delete-color;

font-size: 12px;

cursor: pointer;

}

.icon-guanbi {

position: absolute;

right: 0;

bottom: 0;

color: #d1d6e0;

cursor: pointer;

&:hover {

color: $delete-color;

}

}

}

.file-progress-list {

max-height: 300px;

overflow-y: auto;

padding: 0 20px;

.file-progress-item {

background: #f8f9fa;

border-radius: 6px;

padding: 8px 12px;

margin-bottom: 8px;

.file-progress-info {

display: flex;

justify-content: space-between;

align-items: center;

margin-bottom: 5px;

.file-name {

font-size: 14px;

color: $font-size-second;

display: flex;

align-items: center;

flex: 1; // 占据剩余空间

min-width: 0; // 允许flex item收缩

margin-right: 10px; // 添加右边距

.icon-wenjian {

margin-right: 7px;

flex-shrink: 0; // 图标不收缩

margin-top: 1px; // 微调对齐

}

.fileName {

display: -webkit-box;

-webkit-box-orient: vertical;

-webkit-line-clamp: 2;

overflow: hidden;

text-overflow: ellipsis;

word-break: break-all; // 允许在任意字符间断行

word-wrap: break-word; // 长单词换行

overflow-wrap: break-word; // 现代浏览器的换行

white-space: normal; // 允许换行

line-height: 1.4; // 合适的行高

text-align: left;

width: 100%; // 确保占据整个可用宽度

}

}

.progress-status {

font-size: 12px;

flex-shrink: 0; // 状态区域不收缩

display: flex;

align-items: center;

white-space: nowrap; // 状态文本不换行

.error-text {

color: $delete-color;

}

.upload-text {

color: $bg-color;

}

}

}

.progress-bar-container {

width: 100%;

:deep(.el-progress-bar) {

padding-right: 0;

.el-progress-bar__outer,

.el-progress-bar__inner {

border-radius: 3px;

transition: all 0.3s ease;

}

}

}

}

}

}

.upload-box {

display: flex;

flex-direction: column;

align-items: center;

.upload-btn {

width: 130px;

height: 40px;

border-radius: 20px;

border: 1px solid $bg-color;

color: $bg-color;

font-size: 16px;

display: flex;

align-items: center;

justify-content: center;

&:hover {

background: #f4f7fc;

}

.icon-shangchuanwenjian {

margin-right: 6px;

font-size: 18px;

}

}

.el-upload-text {

font-size: 14px;

color: $font-size-primary;

margin-top: 15px;

}

.text-tip {

max-width: 540px;

font-size: 12px;

color: $font-size-third;

margin-top: 5px;

line-height: 1.6;

text-align: center;

}

}

}

.file-list-section {

margin-top: 15px;

.top-content {

display: flex;

align-items: center;

justify-content: space-between;

margin-bottom: 13px;

.delete-btn {

border: 1px solid $bg-color;

color: $bg-color;

}

.el-button.is-disabled,

.el-button.is-disabled:hover {

background: #ffffff !important;

}

.btn {

font-size: 14px;

width: 100px;

height: 34px;

border-radius: 4px;

}

}

.processing-text {

color: $color-success;

}

.error-text {

color: $delete-color;

}

}

}

</style>

<style lang="scss">

.file-setting-select {

.select-add-action {

padding: 0 8px;

color: $font-size-primary;

font-size: 13px;

line-height: 22px;

cursor: pointer;

&:hover {

color: $color-primary;

}

.select-icon {

font-size: 12px;

margin-right: 3px;

}

}

.el-select-dropdown__empty {

display: none;

padding: 0;

}

}

.add-option-dialog {

border-radius: 8px;

.el-dialog__header {

padding-bottom: 8px;

}

.el-dialog__title {

font-size: 16px;

}

.el-dialog__body {

padding: 20px 0;

}

.el-dialog__footer {

padding-top: 12px;

}

}

</style>

TableList组件代码:

<template>

<div>

<el-table

ref="dataTable"

:data="tableData"

:row-key="rowKey || undefined"

:header-cell-style="headerCellStyle"

header-row-class-name="my-table-header"

cell-class-name="my-td-cell"

:row-style="rowStyle"

@sort-change="tableSort"

@selection-change="handleSelection"

@row-dblclick="handleRowClick"

row-class-name="clickable-row"

class="data-table"

border

:max-height="maxHeight"

:show-overflow-tooltip="showOverflowTooltip"

:tooltip-options="{

appendTo: 'body',

placement: 'top',

popperClass: 'table-with-popper',

}"

stripe

>

<el-table-column

v-if="hasSelection"

type="selection"

header-align="center"

align="center"

width="40"

:resizable="false"

:reserve-selection="true"

/>

<el-table-column

v-if="indexShow"

label="序号"

width="50"

header-align="center"

align="center"

:resizable="false"

>

<template #default="scope">

<span>{{ (currentPage - 1) * pageSize + scope.$index + 1 }}</span>

</template>

</el-table-column>

<slot />

</el-table>

<!-- 分页组件 -->

<Paging

v-if="paginationShow"

:currentPage="currentPage"

:pageSize="pageSize"

layout="total, sizes, prev, pager, next, jumper"

:pageSizes="pageSizes"

:total="total"

:background="true"

@handleCurrentChange="changeCurrentPage"

@handleSizeChange="changePageSize"

class="pagination"

/>

</div>

</template>

<script setup lang="ts">

import Paging from "./Paging.vue";

const props = defineProps({

showOverflowTooltip: {

// 表格是否展示浮框

type: Boolean,

default: true,

},

paginationShow: {

// 是否显示分页

type: Boolean,

default: false,

},

currentPage: {

// 当前页

type: Number,

default: 1,

},

pageSize: {

// 一页展示条数

type: Number,

default: 10,

},

// 分页器每页大小选项

pageSizes: {

type: Array as () => number\[\],

default: () => 10, 20, 50, 100,

},

total: {

// 总条数

type: Number,

default: 0,

},

// 表格高度

maxHeight: {

type: Number,

default: 400,

},

// 表格行高

rowHeight: {

type: Number,

default: 36, // 默认行高

},

// 表内容数据

tableData: {

type: Array as () => any\[\],

default: () => \[\],

},

//是否显示列表序号

indexShow: {

type: Boolean,

default: false,

},

//是否显示多选框

hasSelection: {

type: Boolean,

default: false,

},

// 行主键字段名,传值后 el-table 内部使用 Map 跟踪选中态

rowKey: {

type: String,

default: "",

},

});

const emit = defineEmits([

"tableSort",

"handleSelectionChange",

"changeCurrentPage",

"changePageSize",

"handleRowClick",

]);

const myTable = ref();

// 设置表头样式

const headerCellStyle = () => {

return {

height: "36px",

background: "#F9FAFC",

color: "#333333",

fontSize: "14px",

padding: "1px 0",

fontWeight: "normal",

};

};

// 动态设置行高

const rowStyle = () => {

return {

height: `${props.rowHeight}px`,

lineHeight: `${props.rowHeight}px`,

};

};

// 表格排序

const tableSort = ({

column,

prop,

order,

}: {

column: any;

prop: string;

order: string;

}) => {

emit("tableSort", order ? prop : "", order);

myTable.value && myTable.value.setScrollTop(0);

};

const handleSelection = (data: any) => {

emit("handleSelectionChange", data);

};

// 点击行

const handleRowClick = (row: any) => {

emit("handleRowClick", row);

};

// 点击改变当前页重新渲染表格数据

const changeCurrentPage = (current: number) => {

emit("changeCurrentPage", current);

};

//size改变

const changePageSize = (size: number) => {

emit("changePageSize", size);

};

//清空选中

const clearSelection = () => {

if (myTable.value && typeof myTable.value.clearSelection === 'function') {

myTable.value.clearSelection();

}

};

// 完全清空选中状态(包括 reserve-selection 内部保留的 rowKey Set)

// 用于批量删除等需要彻底重置选中状态的场景

const clearSelectionCompletely = () => {

if (!myTable.value) return;

// 1. 清空 el-table 当前 selection

if (typeof myTable.value.clearSelection === 'function') {

myTable.value.clearSelection();

}

// 2. 清空 reserve-selection 内部 rowKey Set

// element-plus 中 reserve-selection 维护 _maintainSelection 数组

if (Array.isArray(myTable.value._maintainSelection)) {

myTable.value._maintainSelection.length = 0;

}

// 3. 兼容其他可能的内部属性名

if (Array.isArray(myTable.value.maintainSelection)) {

myTable.value.maintainSelection.length = 0;

}

// 4. 强制触发 selection 更新

if (typeof myTable.value.setSelection === 'function') {

myTable.value.setSelection(\[\], false);

}

};

// 清空排序

const clearTableSort = () => {

myTable.value && myTable.value.clearSort();

};

// 获取所有选中的行(包括保留的跨页选择,配合 reserve-selection 使用)

const getSelectionRows = () => {

if (!myTable.value) return \[\];

if (typeof myTable.value.getSelectionRows === 'function') {

return myTable.value.getSelectionRows();

}

return \[\];

};

defineExpose({

// 暴露子组件方法给父组件调用

clearSelection,

clearSelectionCompletely,

clearTableSort,

getSelectionRows,

});

</script>

<style lang="scss" scoped>

.table-content {

white-space: nowrap; /* 强制文本在一行显示 */

overflow: hidden; /* 隐藏超出容器的内容 */

text-overflow: ellipsis; /* 超出部分显示省略号 */

}

:deep(.my-table-header .cell) {

font-size: 14px;

padding: 6px 4px;

font-weight: 600;

}

</style>

config文件中的变量定义:

uploadAllowedExtensions: ('.pdf,.png,.jpg,.jpeg,.jp2,.webp,.gif,.bmp,.doc,.docx,.ppt,.pptx,.xls,.xlsx')

.split(',')

.map((ext: string) => ext.trim())

.filter(Boolean)

validateFile文件校验方法:

/**

* 验证文件格式

* @param {File} file - 文件对象

* @returns {ValidationResult} 验证结果

*/

export function validateFile(file: File){

// 100 * 1024 * 1024,表示100MB

const maxSize = config.uploadMaxSize || 104857600 // 100MB

const allowedExtensions: any = config.uploadAllowedExtensions || '.pdf'

// 检查文件大小

if (file.size > maxSize) {

const maxSizeMB = (maxSize / (1024 * 1024))

return {

valid: false,

message: `文件大小不能超过 ${maxSizeMB}MB`

}

}

// 检查文件扩展名

const fileExtension = '.' + file.name.split('.').pop()?.toLowerCase() || ''

const extensions = Array.isArray(allowedExtensions) ? allowedExtensions : allowedExtensions.split(',')

if (!extensions.includes(fileExtension)) {

return {

valid: false,

message: `不支持的文件格式,请上传 ${extensions.join(', ')} 格式的文件`

}

}

return {

valid: true,

message: '文件验证通过'

}

}

相关推荐
SEO_juper2 小时前
Chrome 扩展一键查看当前页面 SEO 信息:不用切页面,点一下全出来
前端·javascript·chrome
echome8882 小时前
CSS 布局:Flex 和 Grid 怎么选,附常见坑
前端·css·html
计算机魔术师2 小时前
科技巨头砸7400亿押注AI,为什么苹果成了唯一赢家?
前端
QianhangQianping2 小时前
撤销重做不是「存快照」:一个可视化编辑器的Operation 设计
java·前端·数据库·typescript·编辑器·前端架构·撤销重做
Nymph_Zhu3 小时前
pdf转换markdown预览及内容互相点击定位
前端·pdf·vue3·markdown·element-plus
计算机魔术师3 小时前
Google 一天蒸发5000亿,Reddit 靠 AI 训练喂得饱自己却饿死
前端
小大宇3 小时前
Vue知识点-ElementPlus-05 输入框
前端·javascript·vue.js
l1m0_4 小时前
AI设计工具有哪些?Paico、Pixso AI、墨刀AI、Lovable、Codex怎么选
前端·人工智能·ai·设计·墨刀·codex·pixso
web打印社区4 小时前
Lodop在Edge和双核浏览器-先分清内核
前端·javascript·edge·pdf·ecmascript·html5