index.vue 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706
  1. <template>
  2. <view class="file-upload-container">
  3. <!-- 上传按钮 -->
  4. <view v-if="!disabled && fileList.length < limit" class="upload-section">
  5. <u-button
  6. type="primary"
  7. size="default"
  8. @click="chooseFile"
  9. :loading="uploading"
  10. icon="plus"
  11. class="upload-btn"
  12. >
  13. 选择文件
  14. </u-button>
  15. </view>
  16. <!-- 上传提示 -->
  17. <view v-if="showTip && !disabled" class="upload-tip">
  18. <text class="tip-text">
  19. 请上传
  20. <text v-if="fileSize" class="tip-highlight">大小不超过 {{ fileSize }}MB</text>
  21. <text v-if="fileType.length > 0" class="tip-highlight">
  22. 格式为 {{ fileType.join('/') }}
  23. </text>
  24. 的文件
  25. </text>
  26. </view>
  27. <!-- 文件列表 -->
  28. <view v-if="fileList.length > 0" class="file-list">
  29. <view v-for="(file, index) in fileList" :key="file.uid || index" class="file-item">
  30. <view class="file-info">
  31. <u-icon name="file-text" size="32" color="#909399" />
  32. <view class="file-detail">
  33. <text class="file-name">{{ getFileName(file.name) }}</text>
  34. <text class="file-size">{{ formatFileSize(file.size) }}</text>
  35. </view>
  36. </view>
  37. <view class="file-actions">
  38. <u-button
  39. type="primary"
  40. size="mini"
  41. plain
  42. @click="handleDownload(file)"
  43. :disabled="!file.ossId"
  44. class="action-btn"
  45. >
  46. 下载
  47. </u-button>
  48. <u-button
  49. v-if="!disabled"
  50. type="error"
  51. size="mini"
  52. plain
  53. @click="handleDelete(index)"
  54. class="action-btn"
  55. >
  56. 删除
  57. </u-button>
  58. </view>
  59. </view>
  60. </view>
  61. <!-- 上传进度 -->
  62. <u-modal
  63. v-model="showProgress"
  64. title="文件上传中"
  65. :show-cancel-button="false"
  66. :show-confirm-button="false"
  67. width="70%"
  68. z-index="10075"
  69. >
  70. <view class="upload-progress">
  71. <u-line-progress
  72. :percent="uploadPercent"
  73. :show-percent="true"
  74. height="12"
  75. active-color="#2979ff"
  76. />
  77. <text class="progress-text">{{ uploadPercent }}%</text>
  78. </view>
  79. </u-modal>
  80. <!-- 删除确认弹窗 -->
  81. <u-modal
  82. :show="showDeleteConfirm"
  83. title="提示"
  84. content="确定要删除这个文件吗?"
  85. show-cancel-button
  86. show-confirm-button
  87. confirm-text="确定"
  88. cancel-text="取消"
  89. @confirm="confirmDelete"
  90. @cancel="cancelDelete"
  91. z-index="10080"
  92. ></u-modal>
  93. <!-- 清空文件确认弹窗 -->
  94. <u-modal
  95. :showl="showClearConfirm"
  96. title="提示"
  97. content="确定要清空所有文件吗?"
  98. show-cancel-button
  99. show-confirm-button
  100. confirm-text="确定"
  101. cancel-text="取消"
  102. @confirm="confirmClear"
  103. @cancel="cancelClear"
  104. z-index="10080"
  105. ></u-modal>
  106. </view>
  107. </template>
  108. <script>
  109. import { uploadFile, downloadFile } from '@/common/request.js';
  110. import { REQUEST_CONFIG } from '@/common/config.js';
  111. export default {
  112. name: 'FileUpload',
  113. props: {
  114. value: {
  115. type: [String, Object, Array],
  116. default: () => [],
  117. },
  118. // 数量限制
  119. limit: {
  120. type: Number,
  121. default: 5,
  122. },
  123. // 大小限制(MB)
  124. fileSize: {
  125. type: Number,
  126. default: 5,
  127. },
  128. // 文件类型限制
  129. fileType: {
  130. type: Array,
  131. default: () => ['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt', 'pdf'],
  132. },
  133. // 是否显示提示
  134. isShowTip: {
  135. type: Boolean,
  136. default: true,
  137. },
  138. // 禁用组件
  139. disabled: {
  140. type: Boolean,
  141. default: false,
  142. },
  143. },
  144. emits: ['input', 'submit', 'cancel'],
  145. data() {
  146. return {
  147. fileList: [],
  148. uploading: false,
  149. showProgress: false,
  150. uploadPercent: 0,
  151. showDeleteConfirm: false,
  152. deleteIndex: -1,
  153. showClearConfirm: false,
  154. };
  155. },
  156. computed: {
  157. showTip() {
  158. return this.isShowTip && (this.fileType.length > 0 || this.fileSize);
  159. },
  160. },
  161. watch: {
  162. value: {
  163. handler: 'initFileList',
  164. immediate: true,
  165. deep: true,
  166. },
  167. },
  168. methods: {
  169. // 初始化文件列表
  170. async initFileList(val) {
  171. if (val) {
  172. let list = [];
  173. if (Array.isArray(val)) {
  174. list = val;
  175. } else if (typeof val === 'string' && val.trim()) {
  176. // 如果是字符串,调用API获取文件详情
  177. try {
  178. const ids = val.split(',').filter((id) => id.trim());
  179. const response = await this.request({
  180. url: `/resource/oss/listByIds/${encodeURIComponent(ids.join(','))}`,
  181. method: 'GET',
  182. });
  183. if (response.code === 200) {
  184. list = response.data.map((oss) => ({
  185. name: oss.originalName,
  186. url: oss.url,
  187. ossId: oss.ossId,
  188. size: oss.size,
  189. }));
  190. }
  191. } catch (error) {
  192. console.error('获取文件详情失败:', error);
  193. }
  194. }
  195. this.fileList = list.map((item, index) => ({
  196. ...item,
  197. uid: item.uid || Date.now() + index,
  198. }));
  199. } else {
  200. this.fileList = [];
  201. }
  202. },
  203. // 选择文件
  204. chooseFile() {
  205. // 检查文件数量限制
  206. if (this.fileList.length >= this.limit) {
  207. uni.showToast({
  208. title: `最多只能上传${this.limit}个文件`,
  209. icon: 'none',
  210. });
  211. return;
  212. }
  213. uni.chooseFile({
  214. count: this.limit - this.fileList.length,
  215. type: 'file',
  216. success: (res) => {
  217. console.log('选择文件成功:', res);
  218. // 验证文件
  219. const validFiles = [];
  220. res.tempFiles.forEach((file) => {
  221. if (this.validateFile(file)) {
  222. validFiles.push(file);
  223. }
  224. });
  225. if (validFiles.length > 0) {
  226. this.uploadFiles(validFiles);
  227. }
  228. },
  229. fail: (error) => {
  230. console.error('选择文件失败:', error);
  231. uni.showToast({
  232. title: '选择文件失败',
  233. icon: 'none',
  234. });
  235. },
  236. });
  237. },
  238. // 验证文件
  239. validateFile(file) {
  240. // 检查文件类型
  241. if (this.fileType.length > 0) {
  242. const fileName = file.name || '';
  243. const fileExt = fileName.split('.').pop()?.toLowerCase();
  244. if (!fileExt || !this.fileType.includes(fileExt)) {
  245. uni.showToast({
  246. title: `文件格式不正确,请上传${this.fileType.join('/')}格式文件`,
  247. icon: 'none',
  248. });
  249. return false;
  250. }
  251. }
  252. // 检查文件名
  253. if (file.name && file.name.includes(',')) {
  254. uni.showToast({
  255. title: '文件名不能包含英文逗号',
  256. icon: 'none',
  257. });
  258. return false;
  259. }
  260. // 检查文件大小
  261. if (this.fileSize && file.size) {
  262. const fileSizeMB = file.size / 1024 / 1024;
  263. if (fileSizeMB > this.fileSize) {
  264. uni.showToast({
  265. title: `文件大小不能超过${this.fileSize}MB`,
  266. icon: 'none',
  267. });
  268. return false;
  269. }
  270. }
  271. return true;
  272. },
  273. // 上传文件
  274. async uploadFiles(files) {
  275. this.uploading = true;
  276. this.showProgress = true;
  277. this.uploadPercent = 0;
  278. try {
  279. const uploadPromises = files.map((file) => this.uploadSingleFile(file));
  280. const results = await Promise.all(uploadPromises);
  281. // 处理上传结果
  282. const successFiles = results.filter((result) => result.success);
  283. if (successFiles.length > 0) {
  284. this.fileList.push(
  285. ...successFiles.map((result) => ({
  286. name: result.data.fileName,
  287. url: result.data.url,
  288. ossId: result.data.ossId,
  289. size: result.originalSize,
  290. uid: Date.now() + Math.random(),
  291. })),
  292. );
  293. this.emitChange();
  294. uni.showToast({
  295. title: `成功上传${successFiles.length}个文件`,
  296. icon: 'success',
  297. });
  298. }
  299. const failedCount = files.length - successFiles.length;
  300. if (failedCount > 0) {
  301. uni.showToast({
  302. title: `${failedCount}个文件上传失败`,
  303. icon: 'none',
  304. });
  305. }
  306. } catch (error) {
  307. console.error('文件上传失败:', error);
  308. uni.showToast({
  309. title: '文件上传失败',
  310. icon: 'none',
  311. });
  312. } finally {
  313. this.uploading = false;
  314. this.showProgress = false;
  315. this.uploadPercent = 0;
  316. }
  317. },
  318. // 上传单个文件
  319. uploadSingleFile(file) {
  320. return new Promise((resolve) => {
  321. const uploadTask = uni.uploadFile({
  322. url: `${REQUEST_CONFIG.serverUrl}/resource/oss/upload`,
  323. filePath: file.path,
  324. name: 'file',
  325. header: {
  326. authorization: uni.getStorageSync('access_token')
  327. ? `Bearer ${uni.getStorageSync('access_token')}`
  328. : '',
  329. clientid:
  330. uni.getStorageSync('clientid') || 'e5cd7e4891bf95d1d19206ce24a7b32e',
  331. },
  332. success: (res) => {
  333. try {
  334. const data = JSON.parse(res.data);
  335. if (data.code === 200) {
  336. resolve({
  337. success: true,
  338. data: data.data,
  339. originalSize: file.size,
  340. });
  341. } else {
  342. console.error('上传失败:', data.msg);
  343. resolve({ success: false, error: data.msg });
  344. }
  345. } catch (error) {
  346. console.error('解析上传响应失败:', error);
  347. resolve({ success: false, error: '解析响应失败' });
  348. }
  349. },
  350. fail: (error) => {
  351. console.error('上传请求失败:', error);
  352. resolve({ success: false, error: error.errMsg || '上传失败' });
  353. },
  354. });
  355. // 监听上传进度
  356. uploadTask.onProgressUpdate((res) => {
  357. this.uploadPercent = res.progress;
  358. });
  359. });
  360. },
  361. // 下载文件
  362. async handleDownload(file) {
  363. if (!file.ossId) {
  364. uni.showToast({
  365. title: '无法获取文件下载地址',
  366. icon: 'none',
  367. });
  368. return;
  369. }
  370. try {
  371. uni.showLoading({
  372. title: '正在下载文件...',
  373. mask: true,
  374. });
  375. const downloadUrl = `/resource/oss/download/${file.ossId}`;
  376. const response = await downloadFile(downloadUrl, file.name);
  377. // const response = await downloadFile(downloadUrl, image.name);
  378. if (response.statusCode === 200) {
  379. uni.hideLoading();
  380. uni.saveFile({
  381. tempFilePath: response.tempFilePath,
  382. success: function (res) {
  383. uni.showToast({
  384. title: '下载成功',
  385. icon: 'success',
  386. });
  387. },
  388. fail: function () {
  389. uni.showToast({
  390. title: '保存失败',
  391. icon: 'none',
  392. });
  393. },
  394. });
  395. }
  396. // 在移动端,文件会自动保存到本地
  397. uni.hideLoading();
  398. uni.showToast({
  399. title: '文件下载完成',
  400. icon: 'success',
  401. });
  402. } catch (error) {
  403. console.error('下载文件失败:', error);
  404. uni.hideLoading();
  405. uni.showToast({
  406. title: '下载文件失败',
  407. icon: 'none',
  408. });
  409. }
  410. },
  411. // 删除文件
  412. handleDelete(index) {
  413. console.log('handleDelete', index);
  414. this.deleteIndex = index;
  415. this.showDeleteConfirm = true;
  416. },
  417. // 确认删除
  418. async confirmDelete() {
  419. try {
  420. const file = this.fileList[this.deleteIndex];
  421. // 如果有ossId,调用删除API
  422. if (file.ossId) {
  423. try {
  424. await this.request({
  425. url: `/resource/oss/${file.ossId}`,
  426. method: 'DELETE',
  427. });
  428. } catch (error) {
  429. console.error('删除文件失败:', error);
  430. }
  431. }
  432. this.fileList.splice(this.deleteIndex, 1);
  433. this.emitChange();
  434. uni.showToast({
  435. title: '删除成功',
  436. icon: 'success',
  437. });
  438. } catch (error) {
  439. console.error('删除文件失败:', error);
  440. uni.showToast({
  441. title: '删除失败',
  442. icon: 'none',
  443. });
  444. } finally {
  445. this.showDeleteConfirm = false;
  446. this.deleteIndex = -1;
  447. }
  448. },
  449. // 取消删除
  450. cancelDelete() {
  451. this.showDeleteConfirm = false;
  452. this.deleteIndex = -1;
  453. },
  454. // 获取文件名
  455. getFileName(name) {
  456. if (!name) return '未知文件';
  457. if (name.lastIndexOf('/') > -1) {
  458. return name.slice(name.lastIndexOf('/') + 1);
  459. }
  460. return name;
  461. },
  462. // 格式化文件大小
  463. formatFileSize(size) {
  464. if (!size) return '';
  465. if (size < 1024) {
  466. return size + 'B';
  467. } else if (size < 1024 * 1024) {
  468. return (size / 1024).toFixed(1) + 'KB';
  469. } else {
  470. return (size / 1024 / 1024).toFixed(1) + 'MB';
  471. }
  472. },
  473. // 发出变更事件
  474. emitChange() {
  475. const value = this.listToString(this.fileList);
  476. console.log('longwu---emitChange', value);
  477. this.$emit('input', value);
  478. },
  479. // 转换为字符串
  480. listToString(list, separator = ',') {
  481. return list
  482. .filter((item) => item.ossId)
  483. .map((item) => item.ossId)
  484. .join(separator);
  485. },
  486. // 显示确认对话框
  487. showConfirm(content) {
  488. return new Promise((resolve) => {
  489. uni.showModal({
  490. title: '提示',
  491. content,
  492. success: resolve,
  493. });
  494. });
  495. },
  496. // 提交文件
  497. handleSubmit() {
  498. if (this.fileList.length === 0) {
  499. uni.showToast({
  500. title: '请先选择文件',
  501. icon: 'none',
  502. });
  503. return;
  504. }
  505. // 发出提交事件,传递文件列表
  506. this.$emit('submit', {
  507. fileList: this.fileList,
  508. ossIds: this.listToString(this.fileList),
  509. });
  510. },
  511. // 取消操作
  512. handleCancel() {
  513. if (this.fileList.length > 0) {
  514. this.showClearConfirm = true;
  515. } else {
  516. this.$emit('cancel');
  517. }
  518. },
  519. // 确认清空文件
  520. confirmClear() {
  521. try {
  522. this.fileList = [];
  523. this.emitChange();
  524. this.$emit('cancel');
  525. uni.showToast({
  526. title: '已清空文件列表',
  527. icon: 'success',
  528. });
  529. } catch (error) {
  530. console.error('清空文件失败:', error);
  531. uni.showToast({
  532. title: '清空文件失败',
  533. icon: 'none',
  534. });
  535. } finally {
  536. this.showClearConfirm = false;
  537. }
  538. },
  539. // 取消清空文件
  540. cancelClear() {
  541. this.showClearConfirm = false;
  542. },
  543. // 请求方法
  544. async request(options) {
  545. const { request } = await import('@/common/request.js');
  546. return request(options);
  547. },
  548. },
  549. };
  550. </script>
  551. <style scoped>
  552. .file-upload-container {
  553. width: 90%;
  554. text-align: left;
  555. }
  556. .upload-section {
  557. margin-bottom: 20rpx;
  558. display: flex;
  559. justify-content: flex-start;
  560. align-items: flex-start;
  561. text-align: left;
  562. }
  563. .upload-btn {
  564. width: 160rpx;
  565. height: 64rpx;
  566. font-size: 26rpx;
  567. margin-left: 0;
  568. margin-right: auto;
  569. }
  570. .upload-tip {
  571. margin-bottom: 20rpx;
  572. padding: 16rpx;
  573. background: #f5f7fa;
  574. border-radius: 8rpx;
  575. }
  576. .tip-text {
  577. font-size: 24rpx;
  578. color: #606266;
  579. }
  580. .tip-highlight {
  581. color: #f56c6c;
  582. font-weight: 500;
  583. }
  584. .file-list {
  585. border: 1rpx solid #dcdfe6;
  586. border-radius: 8rpx;
  587. overflow: hidden;
  588. max-height: 400rpx;
  589. overflow-y: auto;
  590. }
  591. .file-item {
  592. display: flex;
  593. align-items: center;
  594. justify-content: space-between;
  595. padding: 24rpx;
  596. border-bottom: 1rpx solid #ebeef5;
  597. }
  598. .file-item:last-child {
  599. border-bottom: none;
  600. }
  601. .file-info {
  602. display: flex;
  603. align-items: center;
  604. flex: 1;
  605. margin-right: 20rpx;
  606. }
  607. .file-detail {
  608. margin-left: 16rpx;
  609. flex: 1;
  610. }
  611. .file-name {
  612. display: block;
  613. font-size: 28rpx;
  614. color: #303133;
  615. margin-bottom: 8rpx;
  616. word-break: break-all;
  617. }
  618. .file-size {
  619. font-size: 24rpx;
  620. color: #909399;
  621. }
  622. .file-actions {
  623. display: flex;
  624. gap: 16rpx;
  625. }
  626. .action-btn {
  627. height: 60rpx;
  628. padding: 0 24rpx;
  629. font-size: 24rpx;
  630. }
  631. .upload-progress {
  632. padding: 40rpx 0;
  633. text-align: center;
  634. }
  635. .progress-text {
  636. display: block;
  637. margin-top: 20rpx;
  638. font-size: 24rpx;
  639. color: #606266;
  640. }
  641. </style>