index.vue 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686
  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);
  377. // 在移动端,文件会自动保存到本地
  378. uni.hideLoading();
  379. uni.showToast({
  380. title: '文件下载完成',
  381. icon: 'success',
  382. });
  383. } catch (error) {
  384. console.error('下载文件失败:', error);
  385. uni.hideLoading();
  386. uni.showToast({
  387. title: '下载文件失败',
  388. icon: 'none',
  389. });
  390. }
  391. },
  392. // 删除文件
  393. handleDelete(index) {
  394. console.log('handleDelete', index);
  395. this.deleteIndex = index;
  396. this.showDeleteConfirm = true;
  397. },
  398. // 确认删除
  399. async confirmDelete() {
  400. try {
  401. const file = this.fileList[this.deleteIndex];
  402. // 如果有ossId,调用删除API
  403. if (file.ossId) {
  404. try {
  405. await this.request({
  406. url: `/resource/oss/${file.ossId}`,
  407. method: 'DELETE',
  408. });
  409. } catch (error) {
  410. console.error('删除文件失败:', error);
  411. }
  412. }
  413. this.fileList.splice(this.deleteIndex, 1);
  414. this.emitChange();
  415. uni.showToast({
  416. title: '删除成功',
  417. icon: 'success',
  418. });
  419. } catch (error) {
  420. console.error('删除文件失败:', error);
  421. uni.showToast({
  422. title: '删除失败',
  423. icon: 'none',
  424. });
  425. } finally {
  426. this.showDeleteConfirm = false;
  427. this.deleteIndex = -1;
  428. }
  429. },
  430. // 取消删除
  431. cancelDelete() {
  432. this.showDeleteConfirm = false;
  433. this.deleteIndex = -1;
  434. },
  435. // 获取文件名
  436. getFileName(name) {
  437. if (!name) return '未知文件';
  438. if (name.lastIndexOf('/') > -1) {
  439. return name.slice(name.lastIndexOf('/') + 1);
  440. }
  441. return name;
  442. },
  443. // 格式化文件大小
  444. formatFileSize(size) {
  445. if (!size) return '';
  446. if (size < 1024) {
  447. return size + 'B';
  448. } else if (size < 1024 * 1024) {
  449. return (size / 1024).toFixed(1) + 'KB';
  450. } else {
  451. return (size / 1024 / 1024).toFixed(1) + 'MB';
  452. }
  453. },
  454. // 发出变更事件
  455. emitChange() {
  456. const value = this.listToString(this.fileList);
  457. console.log('longwu---emitChange', value);
  458. this.$emit('input', value);
  459. },
  460. // 转换为字符串
  461. listToString(list, separator = ',') {
  462. return list
  463. .filter((item) => item.ossId)
  464. .map((item) => item.ossId)
  465. .join(separator);
  466. },
  467. // 显示确认对话框
  468. showConfirm(content) {
  469. return new Promise((resolve) => {
  470. uni.showModal({
  471. title: '提示',
  472. content,
  473. success: resolve,
  474. });
  475. });
  476. },
  477. // 提交文件
  478. handleSubmit() {
  479. if (this.fileList.length === 0) {
  480. uni.showToast({
  481. title: '请先选择文件',
  482. icon: 'none',
  483. });
  484. return;
  485. }
  486. // 发出提交事件,传递文件列表
  487. this.$emit('submit', {
  488. fileList: this.fileList,
  489. ossIds: this.listToString(this.fileList),
  490. });
  491. },
  492. // 取消操作
  493. handleCancel() {
  494. if (this.fileList.length > 0) {
  495. this.showClearConfirm = true;
  496. } else {
  497. this.$emit('cancel');
  498. }
  499. },
  500. // 确认清空文件
  501. confirmClear() {
  502. try {
  503. this.fileList = [];
  504. this.emitChange();
  505. this.$emit('cancel');
  506. uni.showToast({
  507. title: '已清空文件列表',
  508. icon: 'success',
  509. });
  510. } catch (error) {
  511. console.error('清空文件失败:', error);
  512. uni.showToast({
  513. title: '清空文件失败',
  514. icon: 'none',
  515. });
  516. } finally {
  517. this.showClearConfirm = false;
  518. }
  519. },
  520. // 取消清空文件
  521. cancelClear() {
  522. this.showClearConfirm = false;
  523. },
  524. // 请求方法
  525. async request(options) {
  526. const { request } = await import('@/common/request.js');
  527. return request(options);
  528. },
  529. },
  530. };
  531. </script>
  532. <style scoped>
  533. .file-upload-container {
  534. width: 90%;
  535. text-align: left;
  536. }
  537. .upload-section {
  538. margin-bottom: 20rpx;
  539. display: flex;
  540. justify-content: flex-start;
  541. align-items: flex-start;
  542. text-align: left;
  543. }
  544. .upload-btn {
  545. width: 160rpx;
  546. height: 64rpx;
  547. font-size: 26rpx;
  548. margin-left: 0;
  549. margin-right: auto;
  550. }
  551. .upload-tip {
  552. margin-bottom: 20rpx;
  553. padding: 16rpx;
  554. background: #f5f7fa;
  555. border-radius: 8rpx;
  556. }
  557. .tip-text {
  558. font-size: 24rpx;
  559. color: #606266;
  560. }
  561. .tip-highlight {
  562. color: #f56c6c;
  563. font-weight: 500;
  564. }
  565. .file-list {
  566. border: 1rpx solid #dcdfe6;
  567. border-radius: 8rpx;
  568. overflow: hidden;
  569. max-height: 400rpx;
  570. overflow-y: auto;
  571. }
  572. .file-item {
  573. display: flex;
  574. align-items: center;
  575. justify-content: space-between;
  576. padding: 24rpx;
  577. border-bottom: 1rpx solid #ebeef5;
  578. }
  579. .file-item:last-child {
  580. border-bottom: none;
  581. }
  582. .file-info {
  583. display: flex;
  584. align-items: center;
  585. flex: 1;
  586. margin-right: 20rpx;
  587. }
  588. .file-detail {
  589. margin-left: 16rpx;
  590. flex: 1;
  591. }
  592. .file-name {
  593. display: block;
  594. font-size: 28rpx;
  595. color: #303133;
  596. margin-bottom: 8rpx;
  597. word-break: break-all;
  598. }
  599. .file-size {
  600. font-size: 24rpx;
  601. color: #909399;
  602. }
  603. .file-actions {
  604. display: flex;
  605. gap: 16rpx;
  606. }
  607. .action-btn {
  608. height: 60rpx;
  609. padding: 0 24rpx;
  610. font-size: 24rpx;
  611. }
  612. .upload-progress {
  613. padding: 40rpx 0;
  614. text-align: center;
  615. }
  616. .progress-text {
  617. display: block;
  618. margin-top: 20rpx;
  619. font-size: 24rpx;
  620. color: #606266;
  621. }
  622. </style>