cwg-file-picker.vue 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817
  1. <template>
  2. <view class="common-file-uploader" :class="customClass">
  3. <!-- 编辑状态:显示可上传的文件选择器 -->
  4. <view v-if="editable" class="upload-wrapper">
  5. <uni-file-picker :limit="multiple ? limit : 1" :title="title" :file-mediatype="fileMediatype" :mode="mode"
  6. :auto-upload="false" :value="fileList" :disabled="disabled" :readonly="readonly"
  7. :image-styles="imageStyles" :list-styles="listStyles" @select="handleSelect" @delete="handleDelete">
  8. <!-- 自定义上传按钮(单张图片且已有图片时显示替换) -->
  9. <template v-if="$slots.default || showCustomButton">
  10. <slot name="default">
  11. <view class="custom-upload-btn" :class="{ 'replace-btn': !multiple && fileList.length > 0 }"
  12. :style="customButtonStyle">
  13. <template v-if="!multiple && fileList.length > 0">
  14. <text class="replace-icon">↻</text>
  15. <text class="tip">{{ replaceText || t('Common.Replace') }}</text>
  16. </template>
  17. <template v-else>
  18. <text class="plus">+</text>
  19. <text class="tip">{{ uploadText || t('Common.Upload') }}</text>
  20. </template>
  21. </view>
  22. </slot>
  23. </template>
  24. </uni-file-picker>
  25. <!-- 上传进度 -->
  26. <view v-if="showProgress && uploadProgress > 0 && uploadProgress < 100" class="upload-progress"
  27. :style="progressStyle">
  28. <view class="progress-bar" :style="{ width: uploadProgress + '%' }"></view>
  29. <text class="progress-text">{{ uploadProgress }}%</text>
  30. </view>
  31. <!-- 上传错误提示 -->
  32. <view v-if="uploadError" class="upload-error">
  33. <text class="error-text">{{ uploadError }}</text>
  34. </view>
  35. </view>
  36. <!-- 非编辑状态:显示图片预览 -->
  37. <view v-else class="image-preview" :class="previewClass">
  38. <!-- 单张图片预览 -->
  39. <template v-if="!multiple">
  40. <view class="single-preview">
  41. <image v-if="getImageUrl(modelValue)" :src="getImageUrl(modelValue)" :mode="imageMode"
  42. class="preview-image" :style="previewImageStyle" @tap="handlePreview(getImageUrl(modelValue))"
  43. @error="handleImageError" :lazy-load="true" />
  44. <view v-else class="no-image" :style="noImageStyle">
  45. <text :style="noImageTextStyle">{{ noImageText || t('Common.NoImage') }}</text>
  46. </view>
  47. </view>
  48. </template>
  49. <!-- 多张图片预览 -->
  50. <template v-else>
  51. <view v-if="getFileList(modelValue).length" class="image-list" :style="listContainerStyle">
  52. <view v-for="(file, idx) in getFileList(modelValue)" :key="idx" class="image-item"
  53. :style="imageItemStyle">
  54. <image :src="getFileUrl(file)" :mode="imageMode" class="preview-image"
  55. :style="previewImageStyle" @tap="handlePreview(getFilePath(file))" @error="handleImageError"
  56. :lazy-load="true" />
  57. <!-- 预览时的删除按钮(如果有权限) -->
  58. <view v-if="showPreviewDelete && canDelete" class="preview-delete-btn" :style="deleteBtnStyle"
  59. @tap.stop="handlePreviewDelete(file, idx)">
  60. <text class="delete-icon">×</text>
  61. </view>
  62. </view>
  63. </view>
  64. <view v-else class="no-image" :style="noImageStyle">
  65. <text :style="noImageTextStyle">{{ noImageText || t('Common.NoImage') }}</text>
  66. </view>
  67. </template>
  68. </view>
  69. </view>
  70. </template>
  71. <script setup>
  72. import { computed, ref, watch } from 'vue'
  73. import { useI18n } from 'vue-i18n'
  74. import config from '@/config';
  75. import { userToken } from '@/composables/config'
  76. const { t } = useI18n()
  77. const props = defineProps({
  78. // 基础配置
  79. modelValue: {
  80. type: [String, Array, Object],
  81. default: null
  82. },
  83. editable: {
  84. type: Boolean,
  85. default: false
  86. },
  87. multiple: {
  88. type: Boolean,
  89. default: false
  90. },
  91. limit: {
  92. type: Number,
  93. default: 1
  94. },
  95. // 上传配置
  96. title: {
  97. type: String,
  98. default: ''
  99. },
  100. fileMediatype: {
  101. type: String,
  102. default: 'image'
  103. },
  104. mode: {
  105. type: String,
  106. default: 'grid'
  107. },
  108. disabled: {
  109. type: Boolean,
  110. default: false
  111. },
  112. readonly: {
  113. type: Boolean,
  114. default: false
  115. },
  116. // 上传API配置
  117. uploadUrl: {
  118. type: String,
  119. required: true,
  120. default: '/custom/bank/upload'
  121. },
  122. uploadHeaders: {
  123. type: Object,
  124. default: () => ({})
  125. },
  126. uploadName: {
  127. type: String,
  128. default: 'file'
  129. },
  130. uploadData: {
  131. type: Object,
  132. default: () => ({})
  133. },
  134. // 图片服务配置
  135. baseUrl: {
  136. type: String,
  137. default: config.Host80
  138. },
  139. imagePathPrefix: {
  140. type: String,
  141. default: ''
  142. },
  143. // 样式配置
  144. customClass: {
  145. type: String,
  146. default: ''
  147. },
  148. previewClass: {
  149. type: String,
  150. default: ''
  151. },
  152. // 尺寸配置
  153. imageWidth: {
  154. type: [String, Number],
  155. default: 160
  156. },
  157. imageHeight: {
  158. type: [String, Number],
  159. default: 160
  160. },
  161. imageGap: {
  162. type: [String, Number],
  163. default: 16
  164. },
  165. imageBorderRadius: {
  166. type: [String, Number],
  167. default: 8
  168. },
  169. // 图片配置
  170. imageMode: {
  171. type: String,
  172. default: 'aspectFill'
  173. },
  174. // 上传按钮配置
  175. showCustomButton: {
  176. type: Boolean,
  177. default: true
  178. },
  179. uploadText: {
  180. type: String,
  181. default: ''
  182. },
  183. replaceText: {
  184. type: String,
  185. default: ''
  186. },
  187. // 暂无图片配置
  188. noImageText: {
  189. type: String,
  190. default: ''
  191. },
  192. // 预览配置
  193. showPreviewDelete: {
  194. type: Boolean,
  195. default: false
  196. },
  197. canDelete: {
  198. type: Boolean,
  199. default: true
  200. },
  201. // 进度显示
  202. showProgress: {
  203. type: Boolean,
  204. default: true
  205. },
  206. // 是否自动上传
  207. autoUpload: {
  208. type: Boolean,
  209. default: true
  210. },
  211. // 响应数据处理函数
  212. responseHandler: {
  213. type: Function,
  214. default: (res) => {
  215. try {
  216. const data = typeof res === 'string' ? JSON.parse(res) : res
  217. return {
  218. success: data.code === 200,
  219. path: data.data?.path || data.data,
  220. message: data.msg || '上传成功',
  221. data: data
  222. }
  223. } catch (e) {
  224. return {
  225. success: false,
  226. path: null,
  227. message: '解析响应失败',
  228. data: null
  229. }
  230. }
  231. }
  232. },
  233. // uni-file-picker 的图片样式
  234. imageStyles: {
  235. type: Object,
  236. default: () => ({})
  237. },
  238. // uni-file-picker 的列表样式
  239. listStyles: {
  240. type: Object,
  241. default: () => ({})
  242. },
  243. // 自定义样式对象
  244. customStyles: {
  245. type: Object,
  246. default: () => ({})
  247. }
  248. })
  249. const emit = defineEmits([
  250. 'update:modelValue',
  251. 'select',
  252. 'delete',
  253. 'progress',
  254. 'success',
  255. 'fail',
  256. 'preview',
  257. 'image-error',
  258. 'preview-delete',
  259. 'upload-start',
  260. 'upload-complete'
  261. ])
  262. // 上传进度
  263. const uploadProgress = ref(0)
  264. const uploadError = ref('')
  265. const isUploading = ref(false)
  266. // 文件列表(用于uni-file-picker回显)
  267. const fileList = computed(() => {
  268. return getFileValue(props.modelValue)
  269. })
  270. // 计算样式
  271. const imageWidthPx = computed(() => {
  272. return typeof props.imageWidth === 'number' ? px2rpx(props.imageWidth) : props.imageWidth
  273. })
  274. const imageHeightPx = computed(() => {
  275. return typeof props.imageHeight === 'number' ? px2rpx(props.imageHeight) : props.imageHeight
  276. })
  277. const imageGapPx = computed(() => {
  278. return typeof props.imageGap === 'number' ? px2rpx(props.imageGap) : props.imageGap
  279. })
  280. const imageBorderRadiusPx = computed(() => {
  281. return typeof props.imageBorderRadius === 'number' ? px2rpx(props.imageBorderRadius) : props.imageBorderRadius
  282. })
  283. // 自定义按钮样式
  284. const customButtonStyle = computed(() => ({
  285. width: imageWidthPx.value,
  286. height: imageHeightPx.value,
  287. ...props.customStyles.customButton
  288. }))
  289. // 预览图片样式
  290. const previewImageStyle = computed(() => ({
  291. width: imageWidthPx.value,
  292. height: imageHeightPx.value,
  293. borderRadius: imageBorderRadiusPx.value,
  294. ...props.customStyles.previewImage
  295. }))
  296. // 图片项样式
  297. const imageItemStyle = computed(() => ({
  298. width: imageWidthPx.value,
  299. height: imageHeightPx.value,
  300. borderRadius: imageBorderRadiusPx.value,
  301. ...props.customStyles.imageItem
  302. }))
  303. // 列表容器样式
  304. const listContainerStyle = computed(() => ({
  305. gap: imageGapPx.value,
  306. ...props.customStyles.listContainer
  307. }))
  308. // 暂无图片样式
  309. const noImageStyle = computed(() => ({
  310. width: imageWidthPx.value,
  311. height: imageHeightPx.value,
  312. borderRadius: imageBorderRadiusPx.value,
  313. ...props.customStyles.noImage
  314. }))
  315. const noImageTextStyle = computed(() => props.customStyles.noImageText || {})
  316. const progressStyle = computed(() => props.customStyles.progress || {})
  317. const deleteBtnStyle = computed(() => props.customStyles.deleteBtn || {})
  318. // 获取文件值,用于回显
  319. const getFileValue = (value) => {
  320. if (!value) return props.multiple ? [] : []
  321. if (props.multiple) {
  322. if (Array.isArray(value)) {
  323. return value.map(item => ({
  324. url: getFullUrl(item.path || item),
  325. path: item.path || item,
  326. name: item.name || '图片',
  327. uuid: item.id || item.path || Date.now() + Math.random()
  328. }))
  329. }
  330. return []
  331. } else {
  332. console.log(value, [{
  333. url: getFullUrl(value),
  334. path: value,
  335. name: '图片',
  336. uuid: value
  337. }], 121212121);
  338. if (typeof value === 'string') {
  339. return value ? [{
  340. url: getFullUrl(value),
  341. path: value,
  342. name: '图片',
  343. uuid: value
  344. }] : []
  345. }
  346. if (value && value.path) {
  347. return [{
  348. url: getFullUrl(value.path),
  349. path: value.path,
  350. name: value.name || '图片',
  351. uuid: value.id || value.path
  352. }]
  353. }
  354. return []
  355. }
  356. }
  357. // 获取文件列表
  358. const getFileList = (value) => {
  359. if (!value) return []
  360. if (props.multiple) {
  361. return Array.isArray(value) ? value : []
  362. } else {
  363. if (typeof value === 'string') {
  364. return value ? [{ path: value }] : []
  365. }
  366. return value ? [value] : []
  367. }
  368. }
  369. // 获取完整URL
  370. const getFullUrl = (path) => {
  371. if (!path) return ''
  372. if (path.startsWith('http')) return path
  373. return (props.baseUrl || config.Host80) + (props.imagePathPrefix || '') + path
  374. }
  375. // 获取图片URL
  376. const getImageUrl = (value) => {
  377. if (!value) return ''
  378. if (typeof value === 'string') return getFullUrl(value)
  379. if (value.url) return value.url
  380. if (value.path) return getFullUrl(value.path)
  381. return ''
  382. }
  383. // 获取文件路径
  384. const getFilePath = (file) => {
  385. if (!file) return ''
  386. if (typeof file === 'string') return file
  387. return file.path || file.url || ''
  388. }
  389. // 获取文件URL(用于预览)
  390. const getFileUrl = (file) => {
  391. if (!file) return ''
  392. if (typeof file === 'string') return getFullUrl(file)
  393. return getFullUrl(file.path || file.url || '')
  394. }
  395. // 处理选择文件
  396. const handleSelect = async (e) => {
  397. uploadError.value = ''
  398. emit('select', e)
  399. if (props.autoUpload) {
  400. await uploadFiles(e.tempFiles)
  401. }
  402. }
  403. // 上传文件
  404. const uploadFiles = async (tempFiles) => {
  405. if (!tempFiles || tempFiles.length === 0) return
  406. isUploading.value = true
  407. emit('upload-start')
  408. try {
  409. const uploadedPaths = []
  410. for (let i = 0; i < tempFiles.length; i++) {
  411. const file = tempFiles[i]
  412. uploadProgress.value = Math.round(((i) / tempFiles.length) * 100)
  413. const result = await uploadSingleFile(file)
  414. if (result.success) {
  415. uploadedPaths.push(result.path)
  416. } else {
  417. throw new Error(result.message)
  418. }
  419. uploadProgress.value = Math.round(((i + 1) / tempFiles.length) * 100)
  420. }
  421. // 更新modelValue
  422. if (props.multiple) {
  423. // 多张图片:合并新旧数据
  424. const currentValue = Array.isArray(props.modelValue) ? [...props.modelValue] : []
  425. const newValue = [...currentValue, ...uploadedPaths.map(path => ({ path }))]
  426. emit('update:modelValue', newValue)
  427. } else {
  428. // 单张图片:直接替换
  429. const newPath = uploadedPaths[0]
  430. console.log(3333, props.modelValue);
  431. emit('update:modelValue', newPath)
  432. }
  433. uploadProgress.value = 100
  434. setTimeout(() => {
  435. uploadProgress.value = 0
  436. }, 500)
  437. emit('upload-complete', { success: true, paths: uploadedPaths })
  438. } catch (error) {
  439. uploadError.value = error.message || '上传失败'
  440. uploadProgress.value = 0
  441. emit('upload-complete', { success: false, error: error.message })
  442. uni.showToast({
  443. title: error.message || '上传失败',
  444. icon: 'none'
  445. })
  446. } finally {
  447. isUploading.value = false
  448. }
  449. }
  450. // 上传单个文件
  451. const uploadSingleFile = (file) => {
  452. return new Promise((resolve, reject) => {
  453. const uploadTask = uni.uploadFile({
  454. url: config.Host80 + props.uploadUrl,
  455. filePath: file.path,
  456. name: props.uploadName,
  457. header: props.uploadHeaders,
  458. name: 'file',
  459. header: {
  460. 'Access-Token': userToken.value
  461. },
  462. formData: props.uploadData,
  463. success: (res) => {
  464. const result = props.responseHandler(res.data)
  465. if (result.success) {
  466. resolve(result)
  467. } else {
  468. reject(new Error(result.message))
  469. }
  470. },
  471. fail: (err) => {
  472. console.error('上传失败:', err)
  473. reject(new Error('网络错误,请重试'))
  474. }
  475. })
  476. // 监听上传进度
  477. uploadTask.onProgressUpdate((res) => {
  478. // 这里可以处理单个文件的上传进度
  479. console.log('单文件上传进度:', res.progress)
  480. })
  481. })
  482. }
  483. // 处理删除文件
  484. const handleDelete = (e) => {
  485. emit('delete', e)
  486. // 更新modelValue
  487. if (props.multiple) {
  488. const currentValue = Array.isArray(props.modelValue) ? [...props.modelValue] : []
  489. const newValue = currentValue.filter((_, index) => index !== e.index)
  490. emit('update:modelValue', newValue)
  491. } else {
  492. emit('update:modelValue', '')
  493. }
  494. }
  495. // 处理预览
  496. const handlePreview = (path) => {
  497. if (!path) return
  498. const urls = getFileList(props.modelValue).map(f => getFullUrl(getFilePath(f)))
  499. uni.previewImage({
  500. current: getFullUrl(path),
  501. urls: urls.length ? urls : [getFullUrl(path)]
  502. })
  503. emit('preview', path)
  504. }
  505. // 处理图片加载错误
  506. const handleImageError = (e) => {
  507. console.error('图片加载失败:', e)
  508. emit('image-error', e)
  509. }
  510. // 处理预览时的删除
  511. const handlePreviewDelete = (file, index) => {
  512. emit('preview-delete', { file, index })
  513. // 更新modelValue
  514. if (props.multiple) {
  515. const currentValue = Array.isArray(props.modelValue) ? [...props.modelValue] : []
  516. currentValue.splice(index, 1)
  517. emit('update:modelValue', currentValue)
  518. }
  519. }
  520. // 单位转换函数
  521. const px2rpx = (px) => {
  522. // 假设设计稿750px,1px = 2rpx
  523. return px * 2 + 'rpx'
  524. }
  525. // 对外暴露方法
  526. defineExpose({
  527. uploadFiles,
  528. clearError: () => uploadError.value = ''
  529. })
  530. </script>
  531. <style lang="scss" scoped>
  532. .common-file-uploader {
  533. width: 100%;
  534. .upload-wrapper {
  535. width: 100%;
  536. :deep(.uni-file-picker) {
  537. .file-picker__box {
  538. border: none;
  539. background: transparent;
  540. }
  541. .file-picker__box-list {
  542. display: flex;
  543. gap: v-bind(imageGapPx);
  544. flex-wrap: wrap;
  545. .file-picker__box-item {
  546. position: relative;
  547. border: 1px solid #e5e7eb;
  548. overflow: hidden;
  549. .file-picker__box-item-image {
  550. width: 100%;
  551. height: 100%;
  552. object-fit: cover;
  553. }
  554. .file-picker__box-item-close {
  555. position: absolute;
  556. top: 4px;
  557. right: 4px;
  558. width: 24px;
  559. height: 24px;
  560. background: rgba(0, 0, 0, 0.5);
  561. border-radius: 50%;
  562. display: flex;
  563. align-items: center;
  564. justify-content: center;
  565. &::before {
  566. content: '×';
  567. color: #fff;
  568. font-size: 28px;
  569. line-height: 1;
  570. }
  571. .close-icon {
  572. display: none;
  573. }
  574. }
  575. }
  576. }
  577. .file-picker__box-add {
  578. margin: 0;
  579. border: 2rpx dashed #d1d5db;
  580. background: #f9fafb;
  581. transition: all 0.3s;
  582. cursor: pointer;
  583. &:hover {
  584. border-color: #ea2027;
  585. background: #fef2f2;
  586. }
  587. .custom-upload-btn {
  588. width: 100%;
  589. height: 100%;
  590. display: flex;
  591. flex-direction: column;
  592. align-items: center;
  593. justify-content: center;
  594. &.replace-btn {
  595. background: rgba(0, 0, 0, 0.03);
  596. .replace-icon {
  597. font-size: 40px;
  598. color: #666;
  599. margin-bottom: 4px;
  600. transform: rotate(90deg);
  601. }
  602. .tip {
  603. color: #666;
  604. }
  605. &:hover {
  606. background: rgba(234, 32, 39, 0.05);
  607. .replace-icon,
  608. .tip {
  609. color: #ea2027;
  610. }
  611. }
  612. }
  613. .plus {
  614. font-size: 48px;
  615. color: #9ca3af;
  616. line-height: 1;
  617. margin-bottom: 8px;
  618. }
  619. .tip {
  620. font-size: 24px;
  621. color: #6b7280;
  622. }
  623. }
  624. .add-icon {
  625. display: none;
  626. }
  627. }
  628. .file-picker__box-progress {
  629. border-radius: v-bind(imageBorderRadiusPx);
  630. background: rgba(0, 0, 0, 0.5);
  631. color: #fff;
  632. }
  633. }
  634. .upload-progress {
  635. margin-top: 8px;
  636. padding: 8px;
  637. background: #f5f5f5;
  638. border-radius: 4px;
  639. position: relative;
  640. .progress-bar {
  641. height: 4px;
  642. background: #ea2027;
  643. border-radius: 2px;
  644. transition: width 0.3s;
  645. }
  646. .progress-text {
  647. position: absolute;
  648. right: 8px;
  649. top: 8px;
  650. font-size: 24px;
  651. color: #666;
  652. }
  653. }
  654. .upload-error {
  655. margin-top: 8px;
  656. padding: 8px;
  657. background: #fee2e2;
  658. border-radius: 4px;
  659. .error-text {
  660. color: #dc2626;
  661. font-size: 24px;
  662. }
  663. }
  664. }
  665. .image-preview {
  666. .image-list {
  667. display: flex;
  668. gap: v-bind(imageGapPx);
  669. flex-wrap: wrap;
  670. .image-item {
  671. position: relative;
  672. overflow: hidden;
  673. border: 1px solid #e5e7eb;
  674. .preview-image {
  675. width: 100%;
  676. height: 100%;
  677. cursor: pointer;
  678. }
  679. .preview-delete-btn {
  680. position: absolute;
  681. top: 4px;
  682. right: 4px;
  683. width: 24px;
  684. height: 24px;
  685. background: rgba(0, 0, 0, 0.5);
  686. border-radius: 50%;
  687. display: flex;
  688. align-items: center;
  689. justify-content: center;
  690. cursor: pointer;
  691. .delete-icon {
  692. color: #fff;
  693. font-size: 28px;
  694. line-height: 1;
  695. }
  696. }
  697. }
  698. }
  699. .single-preview {
  700. .preview-image {
  701. cursor: pointer;
  702. border: 1px solid #e5e7eb;
  703. }
  704. }
  705. .no-image {
  706. display: flex;
  707. align-items: center;
  708. justify-content: center;
  709. background: #f5f5f5;
  710. border: 1px solid #e5e7eb;
  711. text {
  712. color: #999;
  713. font-size: 24px;
  714. }
  715. }
  716. }
  717. }
  718. </style>