主页面代码:
<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,
);
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,
);
}
} else {
let errorMsg = `上传失败: ${xhr.statusText}`;
try {
const errorResponse = JSON.parse(xhr.responseText);
if (errorResponse.message) {
errorMsg = errorResponse.message;
}
} catch {}
handleUploadErrorForFile(
new Error(errorMsg),
file,
);
}
});
// 监听上传错误
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: '文件验证通过'
}
}