UploadFiles.vue 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385
  1. <template>
  2. <div class="upload-wrapper">
  3. <!-- 上传按钮 -->
  4. <div class="upload-button-container">
  5. <label for="fileInput" class="upload-button" :class="{ disabled: isUploadDisabled }">
  6. <el-icon><UploadFilled /></el-icon>
  7. <span>{{ label }}</span>
  8. </label>
  9. <span class="upload-button-support">支持pdf、word、excel文件</span>
  10. </div>
  11. <input
  12. type="file"
  13. id="fileInput"
  14. multiple
  15. accept=".pdf,.docx,.xls,.xlsx,.ppt,.pptx"
  16. @change="handleFileSelect"
  17. :disabled="isUploadDisabled"
  18. />
  19. <!-- 总体进度条 -->
  20. <div class="progress-container" v-show="showProgress">
  21. <div class="progress-info">
  22. <span>正在上传文件...</span>
  23. <span>{{ progressPercent }}%</span>
  24. </div>
  25. <div class="progress-bar">
  26. <div
  27. class="progress-bar-fill"
  28. :class="{ completed: isCompleted }"
  29. :style="{ width: `${progressPercent}%` }"
  30. ></div>
  31. </div>
  32. </div>
  33. <!-- 文件列表 -->
  34. <div class="file-list" v-if="fileList.length > 0">
  35. <div v-for="file in fileList" :key="file.fileId" class="file-item">
  36. <div class="file-info">
  37. <img :src="FILE_TYPE_ICON[file.fileType as keyof typeof FILE_TYPE_ICON]" />
  38. <span class="file-name">{{ file.fileName }}</span>
  39. </div>
  40. <el-icon class="delete-button" @click="removeFile(file.fileId)"><Delete /></el-icon>
  41. </div>
  42. </div>
  43. </div>
  44. </template>
  45. <script lang="ts" setup>
  46. import { ref, computed, nextTick, watch } from 'vue';
  47. import { UploadFilled, Delete } from '@element-plus/icons-vue';
  48. import { ElMessage } from 'element-plus';
  49. import { msgConfirm } from '@/utils/element-plus/messageBox';
  50. import { FILE_TYPE_ICON } from '../constant';
  51. import type { FileItem } from '../types';
  52. const props = defineProps<{
  53. label: string;
  54. fileList?: FileItem[];
  55. maxSize?: number;
  56. maxCount?: number;
  57. }>();
  58. // 常量定义
  59. const MAX_SIZE = computed(() => (props.maxSize || 5) * 1024 * 1024); // 默认5MB
  60. const MAX_COUNT = computed(() => props.maxCount || 9); // 默认最多9个文件
  61. // 响应式状态
  62. const fileList = ref<FileItem[]>([]);
  63. const tempFiles = ref<File[]>([]);
  64. const showProgress = ref(false);
  65. const isCompleted = ref(false);
  66. const progress = ref(0);
  67. // 计算属性
  68. const progressPercent = computed(() => {
  69. return Math.round(progress.value);
  70. });
  71. const isUploadDisabled = computed(() => {
  72. return fileList.value.length >= MAX_COUNT.value;
  73. });
  74. // 检查文件是否已存在
  75. const isFileAlreadyUploaded = (newFile: File): boolean => {
  76. return fileList.value.some((item) => {
  77. return item.file?.name === newFile.name && item.file.size === newFile.size && item.file?.type === newFile.type;
  78. });
  79. };
  80. // 获取文件类型
  81. const getFileType = (file: File): string => {
  82. const fileType = file.type;
  83. // 转换为系统中已有的四种类型
  84. if (fileType.includes('pdf')) return 'pdf';
  85. if (fileType.includes('word') || fileType.includes('msword') || fileType.includes('wordprocessingml'))
  86. return 'word';
  87. if (fileType.includes('excel') || fileType.includes('spreadsheetml') || fileType.includes('ms-excel'))
  88. return 'excel';
  89. if (fileType.includes('powerpoint') || fileType.includes('presentationml') || fileType.includes('ms-powerpoint'))
  90. return 'ppt';
  91. // 默认返回
  92. return 'pdf';
  93. };
  94. // 方法
  95. const handleFileSelect = (event: Event) => {
  96. const input = event.target as HTMLInputElement;
  97. if (!input.files || input.files.length === 0) return;
  98. const files = Array.from(input.files);
  99. let validFiles = 0;
  100. tempFiles.value = [];
  101. // 计算可以上传的文件数量
  102. const remainingSlots = MAX_COUNT.value - fileList.value.length;
  103. // 检查是否超出最大文件数量限制
  104. if (files.length > remainingSlots) {
  105. ElMessage.warning(`最多只能上传${MAX_COUNT.value}个文件`);
  106. }
  107. // 只处理允许数量的文件
  108. const filesToProcess = files.slice(0, remainingSlots);
  109. filesToProcess.forEach((file) => {
  110. if (!isValidFileType(file)) {
  111. ElMessage.error(`文件${file.name}格式不正确,请上传pdf、word、excel、ppt文件`);
  112. return;
  113. }
  114. if (file.size > MAX_SIZE.value) {
  115. ElMessage.error(`文件${file.name} 大小超过${props.maxSize || 5}MB限制`);
  116. return;
  117. }
  118. // 检查是否已经上传过相同的文件
  119. if (isFileAlreadyUploaded(file)) {
  120. ElMessage.warning(`文件${file.name} 已经上传过了`);
  121. return;
  122. }
  123. // 保存到临时文件数组中,而不是立即添加到UI
  124. tempFiles.value.push(file);
  125. validFiles++;
  126. });
  127. progress.value = 0;
  128. // 显示进度条
  129. if (validFiles > 0) {
  130. showProgress.value = true;
  131. isCompleted.value = false;
  132. // 确保DOM更新后再开始动画
  133. nextTick(() => {
  134. simulateFileUpload();
  135. });
  136. }
  137. // 清空input以便再次选择相同文件
  138. input.value = '';
  139. };
  140. const isValidFileType = (file: File): boolean => {
  141. const allowedTypes = [
  142. 'application/pdf',
  143. 'application/msword',
  144. 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
  145. 'application/vnd.ms-excel',
  146. 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
  147. 'application/vnd.ms-powerpoint',
  148. 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
  149. ];
  150. return allowedTypes.includes(file.type);
  151. };
  152. const addFileToUI = (file: File) => {
  153. const fileId = Date.now() + Math.floor(Math.random() * 1000);
  154. const fileType = getFileType(file);
  155. const fileSize = file.size;
  156. const fileName = file.name;
  157. fileList.value.unshift({
  158. fileId: fileId,
  159. file: file,
  160. fileType: fileType,
  161. fileName: fileName,
  162. fileSize: `${Math.round(fileSize / 1024)}KB`,
  163. });
  164. };
  165. const removeFile = (id: number) => {
  166. const index = fileList.value.findIndex((item) => item.fileId === id);
  167. if (index !== -1) {
  168. fileList.value.splice(index, 1);
  169. }
  170. ElMessage.success('文件删除成功');
  171. };
  172. const removeAllFiles = () => {
  173. fileList.value = [];
  174. };
  175. const simulateFileUpload = () => {
  176. // 设置初始进度为5%,让用户立即看到进度
  177. progress.value = 5;
  178. // 使用更快的速度模拟上传进度
  179. let targetProgress = 5;
  180. const totalDuration = 500; // 总上传时间,毫秒
  181. const interval = 200; // 更新间隔
  182. const steps = totalDuration / interval;
  183. const increment = 95 / steps; // 每步增加的百分比,从5%到100%
  184. const uploadInterval = setInterval(() => {
  185. targetProgress += increment;
  186. if (targetProgress >= 100) {
  187. progress.value = 100;
  188. clearInterval(uploadInterval);
  189. // 上传完成后再添加文件到UI
  190. setTimeout(() => {
  191. tempFiles.value.forEach((file) => {
  192. addFileToUI(file);
  193. });
  194. isCompleted.value = true;
  195. ElMessage.success('文件上传成功');
  196. emit('uploadSuccess', fileList.value);
  197. setTimeout(() => {
  198. showProgress.value = false;
  199. }, 500);
  200. }, 200);
  201. } else {
  202. progress.value = targetProgress;
  203. }
  204. }, interval);
  205. };
  206. const emit = defineEmits(['uploadSuccess']);
  207. defineExpose({
  208. removeAllFiles,
  209. });
  210. watch(
  211. () => props.fileList,
  212. (newVal) => {
  213. fileList.value = newVal || [];
  214. },
  215. {
  216. immediate: true,
  217. },
  218. );
  219. </script>
  220. <style lang="scss" scoped>
  221. .upload-wrapper {
  222. display: flex;
  223. flex-direction: column;
  224. width: 100%;
  225. }
  226. .upload-button-container {
  227. display: flex;
  228. align-items: center;
  229. gap: 20px;
  230. }
  231. .upload-button-support {
  232. font-size: 12px;
  233. color: rgba($text-color, 0.65);
  234. }
  235. .upload-button {
  236. @include flex-center;
  237. gap: 5px;
  238. width: 100px;
  239. border: 1px solid rgba($text-color, 0.15);
  240. color: $primary-color;
  241. font-size: 14px;
  242. border-radius: 2px;
  243. text-align: center;
  244. cursor: pointer;
  245. transition: all 0.3s ease-in-out;
  246. &:hover {
  247. background-color: $primary-color;
  248. color: $white-color;
  249. }
  250. &.disabled {
  251. background-color: #f5f5f5;
  252. color: #aaa;
  253. cursor: not-allowed;
  254. &:hover {
  255. background-color: #f5f5f5;
  256. color: #aaa;
  257. }
  258. }
  259. }
  260. #fileInput {
  261. display: none;
  262. }
  263. .progress-container {
  264. margin-top: 16px;
  265. }
  266. .progress-info {
  267. display: flex;
  268. justify-content: space-between;
  269. font-size: 14px;
  270. color: #666;
  271. }
  272. .progress-bar {
  273. width: 100%;
  274. height: 8px;
  275. background-color: #e5e7eb;
  276. border-radius: 9999px;
  277. overflow: hidden;
  278. }
  279. .progress-bar-fill {
  280. height: 100%;
  281. background-color: $primary-color;
  282. border-radius: 9999px;
  283. transition: width 0.3s ease;
  284. &.completed {
  285. background-color: #10b981;
  286. }
  287. }
  288. .file-list {
  289. margin-top: 16px;
  290. display: flex;
  291. flex-wrap: wrap;
  292. gap: 8px;
  293. }
  294. .file-item {
  295. @include flex-center;
  296. justify-content: space-between;
  297. gap: 10px;
  298. height: 32px;
  299. border: 1px solid #e5e7eb;
  300. border-radius: 6px;
  301. padding: 12px;
  302. transition: all 0.2s ease;
  303. &:hover {
  304. background-color: #f8fafc;
  305. }
  306. }
  307. .file-info {
  308. display: flex;
  309. align-items: center;
  310. gap: 8px;
  311. img {
  312. width: 10px;
  313. }
  314. }
  315. .file-name {
  316. flex: 1;
  317. overflow: hidden;
  318. text-overflow: ellipsis;
  319. white-space: nowrap;
  320. font-size: 14px;
  321. color: rgba($text-color, 0.65);
  322. }
  323. .delete-button {
  324. color: #ef4444;
  325. background: none;
  326. border: none;
  327. cursor: pointer;
  328. font-size: 12px;
  329. }
  330. </style>