UploadFiles.vue 10 KB

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