ut-album.vue 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267
  1. <template>
  2. <view class="ut-album-card" :style="albumStyle">
  3. <template v-for="(item, index) in displayItems" :key="index">
  4. <view class="card-item" :style="{ width: itemSize, height: itemSize }" @click="onItemClick(item, index)">
  5. <!-- 图片缩略图 -->
  6. <template v-if="item.type === 'image'">
  7. <image class="thumb" :src="getThumb(item)" mode="aspectFill" />
  8. </template>
  9. <!-- 视频占位图 or 封面 -->
  10. <template v-else-if="item.type === 'video'">
  11. <view class="video-box">
  12. <image v-if="item.coverUrl" class="thumb" :src="item.coverUrl" mode="aspectFill" />
  13. <video
  14. v-else
  15. class="thumb native-video"
  16. :src="item.url"
  17. object-fit="cover"
  18. :controls="false"
  19. :show-center-play-btn="false"
  20. :show-play-btn="false"
  21. :enable-play-gesture="false"
  22. />
  23. <view class="play-mask">
  24. <up-icon name="play-circle" color="#fff" size="48rpx"></up-icon>
  25. </view>
  26. </view>
  27. </template>
  28. <!-- 文件卡片 -->
  29. <template v-else>
  30. <view class="file-box d-flex flex-cln">
  31. <view class="flex1">
  32. <view class="f-s-28 c-primary up-line-2">{{ item.name || '文件' }}</view>
  33. <view class="f-s-24 c-999">{{ formatSize(item.size) }}</view>
  34. </view>
  35. <view class="d-flex j-ed">
  36. <image :src="getFileIconByUrl(item.url)" mode="aspectFit" style="width: 40rpx; height: 40rpx;" />
  37. </view>
  38. </view>
  39. </template>
  40. <!-- 超出数量显示 +N -->
  41. <view v-if="index === maxCount - 1 && items.length > maxCount" class="album-more">
  42. <text class="more-text">+{{ items.length - maxCount }}</text>
  43. </view>
  44. </view>
  45. </template>
  46. </view>
  47. </template>
  48. <script setup>
  49. import { computed } from 'vue';
  50. import { fileExt, isUrl } from '@/utils/ruoyi';
  51. import { getFileIconByUrl } from '@/utils/common';
  52. // 兼容旧用法:urls,同时支持 modelValue 作为输入
  53. const props = defineProps({
  54. // 通用输入:字符串 | 字符串数组 | 对象/对象数组
  55. modelValue: { type: [String, Array, Object], default: null },
  56. urls: { type: [Array, String, Object], default: () => [] },
  57. keyName: { type: String, default: 'url' },
  58. nameKey: { type: String, default: 'fileName' },
  59. sizeKey: { type: String, default: 'fileSize' },
  60. coverKey: { type: String, default: 'coverUrl' },
  61. maxCount: { type: Number, default: 9 },
  62. // 卡片网格样式
  63. space: { type: String, default: '10rpx' },
  64. multipleSize: { type: String, default: '210rpx' },
  65. // 预览
  66. previewFullImage: { type: Boolean, default: true },
  67. // 缩略图缩放因子(像素)
  68. factor: { type: Number, default: 4 },
  69. // 缓存破坏参数
  70. t: { type: String, default: '0' }
  71. });
  72. const itemSize = computed(() => props.multipleSize);
  73. const albumStyle = computed(() => ({ display: 'flex', flexWrap: 'wrap', gap: props.space, width: '100%' }));
  74. const inputValue = computed(() => (props.modelValue !== null && props.modelValue !== undefined ? props.modelValue : props.urls));
  75. // 解析后缀,识别文件类型
  76. const getTypeByUrl = (url) => {
  77. if (!url) return 'file';
  78. // 清理可能包裹的引号并取无查询参数的部分
  79. const cleaned = String(url).trim().replace(/^['"]+|['"]+$/g, '');
  80. const u = (cleaned.split('?')[0] || '').toLowerCase();
  81. // 提取扩展名并去除非字母数字的尾随字符(如引号)
  82. let ext = (u.split('.').pop() || '').trim().replace(/[^a-z0-9]+$/g, '');
  83. const imgExt = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg'];
  84. const videoExt = ['mp4', 'mov', 'avi', 'mkv', 'webm', 'm4v'];
  85. if (imgExt.includes(ext)) return 'image';
  86. if (videoExt.includes(ext)) return 'video';
  87. return 'file';
  88. };
  89. // 将输入统一为 items
  90. const items = computed(() => {
  91. const v = inputValue.value;
  92. const out = [];
  93. if (!v) return out;
  94. const pushItem = (raw) => {
  95. if (!raw) return;
  96. let url = '';
  97. let name = '';
  98. let size = undefined;
  99. let coverUrl = '';
  100. if (typeof raw === 'string') {
  101. url = String(raw).trim().replace(/^['"]+|['"]+$/g, '');
  102. } else if (typeof raw === 'object') {
  103. url = String(raw[props.keyName] || raw.url || '').trim().replace(/^['"]+|['"]+$/g, '');
  104. name = raw[props.nameKey] || raw.name || '';
  105. size = raw[props.sizeKey] || raw.size;
  106. coverUrl = String(raw[props.coverKey] || raw.coverUrl || '').trim().replace(/^['"]+|['"]+$/g, '');
  107. }
  108. if (!url) return;
  109. const originalUrl = (url.split('?')[0] || url);
  110. const type = getTypeByUrl(originalUrl);
  111. out.push({ type, url, originalUrl, name, size, coverUrl });
  112. };
  113. if (Array.isArray(v)) {
  114. v.forEach(pushItem);
  115. } else if (typeof v === 'string') {
  116. v.split(',').forEach((s) => pushItem(s));
  117. } else {
  118. pushItem(v);
  119. }
  120. return out;
  121. });
  122. const displayItems = computed(() => items.value.slice(0, props.maxCount));
  123. // 生成图片缩略图地址(保持原图预览)
  124. const getThumb = (item) => {
  125. if (!item?.url) return '';
  126. const size = (uni?.$u?.getPx ? uni.$u.getPx(props.multipleSize) : 160) * props.factor;
  127. // 保留原有参数的简单拼接策略
  128. return `${item.url}?w=${size}&h=${size}&t=${props.t}`;
  129. };
  130. const formatSize = (bytes) => {
  131. if (!bytes && bytes !== 0) return '';
  132. const kb = bytes / 1024;
  133. if (kb < 1024) return kb.toFixed(2) + ' KB';
  134. const mb = kb / 1024;
  135. return mb.toFixed(2) + ' MB';
  136. };
  137. // 预览逻辑:图片/视频/文件
  138. const onItemClick = (item, displayIndex) => {
  139. if (!item) return;
  140. if (item.type === 'image') return previewImages(item);
  141. if (item.type === 'video') return previewVideo(item, displayIndex);
  142. return previewFile(item);
  143. };
  144. const previewImages = (clickedItem) => {
  145. if (!props.previewFullImage) return;
  146. const allImages = items.value.filter((i) => i.type === 'image');
  147. const urls = allImages.map((i) => i.originalUrl);
  148. const current = urls.indexOf(clickedItem.originalUrl);
  149. if (!urls.length) return;
  150. uni.previewImage({ urls, current: current >= 0 ? current : 0 });
  151. };
  152. const previewVideo = (item, index) => {
  153. // #ifdef MP-WEIXIN
  154. const allVideos = items.value.filter((i) => i.type === 'video');
  155. const sources = allVideos.map((i) => ({ url: i.url, type: 'video', poster: i?.coverUrl || '' }));
  156. const current = allVideos.findIndex((i) => i.url === item.url);
  157. // @ts-ignore
  158. uni.previewMedia({ sources, current: current >= 0 ? current : 0 });
  159. // #endif
  160. // #ifndef MP-WEIXIN
  161. uni.showToast({ title: '当前平台暂不支持视频预览', icon: 'none' });
  162. // #endif
  163. };
  164. const previewFile = (item) => {
  165. // 优先打开本地文件
  166. if (item.url && !isUrl(item.url)) {
  167. return uni.openDocument({ filePath: item.url, showMenu: true });
  168. }
  169. if (!item.url || !isUrl(item.url)) return;
  170. uni.showLoading({ title: '打开中...' });
  171. uni.downloadFile({
  172. url: item.url,
  173. success: (res) => {
  174. uni.openDocument({
  175. filePath: res.tempFilePath,
  176. showMenu: true,
  177. success: () => uni.hideLoading(),
  178. fail: () => {
  179. uni.hideLoading();
  180. uni.showToast({ title: '打开文件失败', icon: 'none' });
  181. }
  182. });
  183. },
  184. fail: () => {
  185. uni.hideLoading();
  186. uni.showToast({ title: '文件下载失败', icon: 'none' });
  187. }
  188. });
  189. };
  190. </script>
  191. <style lang="scss" scoped>
  192. .ut-album-card {
  193. display: flex;
  194. gap: 10rpx;
  195. flex-wrap: wrap;
  196. width: 100%;
  197. .card-item {
  198. background-color: #FAFAFA;
  199. border-radius: 8rpx;
  200. overflow: hidden;
  201. border: 1rpx solid #ccc;
  202. position: relative;
  203. .thumb {
  204. width: 100%;
  205. height: 100%;
  206. object-fit: cover;
  207. background-color: #ccc;
  208. display: block;
  209. }
  210. .video-box {
  211. width: 100%;
  212. height: 100%;
  213. position: relative;
  214. .native-video {
  215. background: #000;
  216. }
  217. .play-mask {
  218. position: absolute;
  219. top: 0;left: 0;right: 0;bottom: 0;
  220. display: flex;
  221. align-items: center;
  222. justify-content: center;
  223. background: rgba(0,0,0,0.15);
  224. }
  225. }
  226. .file-box {
  227. width: 100%;
  228. height: 100%;
  229. box-sizing: border-box;
  230. padding: 20rpx;
  231. }
  232. .album-more {
  233. position: absolute;
  234. top: 0;left: 0;right: 0;bottom: 0;
  235. background: rgba(0, 0, 0, 0.2);
  236. display: flex;
  237. align-items: center;
  238. justify-content: center;
  239. border-radius: 8rpx;
  240. .more-text {
  241. color: #fff;
  242. font-size: 32rpx;
  243. font-weight: 500;
  244. }
  245. }
  246. }
  247. }
  248. </style>