| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267 |
- <template>
- <view class="ut-album-card" :style="albumStyle">
- <template v-for="(item, index) in displayItems" :key="index">
- <view class="card-item" :style="{ width: itemSize, height: itemSize }" @click="onItemClick(item, index)">
- <!-- 图片缩略图 -->
- <template v-if="item.type === 'image'">
- <image class="thumb" :src="getThumb(item)" mode="aspectFill" />
- </template>
- <!-- 视频占位图 or 封面 -->
- <template v-else-if="item.type === 'video'">
- <view class="video-box">
- <image v-if="item.coverUrl" class="thumb" :src="item.coverUrl" mode="aspectFill" />
- <video
- v-else
- class="thumb native-video"
- :src="item.url"
- object-fit="cover"
- :controls="false"
- :show-center-play-btn="false"
- :show-play-btn="false"
- :enable-play-gesture="false"
- />
- <view class="play-mask">
- <up-icon name="play-circle" color="#fff" size="48rpx"></up-icon>
- </view>
- </view>
- </template>
- <!-- 文件卡片 -->
- <template v-else>
- <view class="file-box d-flex flex-cln">
- <view class="flex1">
- <view class="f-s-28 c-primary up-line-2">{{ item.name || '文件' }}</view>
- <view class="f-s-24 c-999">{{ formatSize(item.size) }}</view>
- </view>
- <view class="d-flex j-ed">
- <image :src="getFileIconByUrl(item.url)" mode="aspectFit" style="width: 40rpx; height: 40rpx;" />
- </view>
- </view>
- </template>
- <!-- 超出数量显示 +N -->
- <view v-if="index === maxCount - 1 && items.length > maxCount" class="album-more">
- <text class="more-text">+{{ items.length - maxCount }}</text>
- </view>
- </view>
- </template>
- </view>
- </template>
- <script setup>
- import { computed } from 'vue';
- import { fileExt, isUrl } from '@/utils/ruoyi';
- import { getFileIconByUrl } from '@/utils/common';
- // 兼容旧用法:urls,同时支持 modelValue 作为输入
- const props = defineProps({
- // 通用输入:字符串 | 字符串数组 | 对象/对象数组
- modelValue: { type: [String, Array, Object], default: null },
- urls: { type: [Array, String, Object], default: () => [] },
- keyName: { type: String, default: 'url' },
- nameKey: { type: String, default: 'fileName' },
- sizeKey: { type: String, default: 'fileSize' },
- coverKey: { type: String, default: 'coverUrl' },
- maxCount: { type: Number, default: 9 },
- // 卡片网格样式
- space: { type: String, default: '10rpx' },
- multipleSize: { type: String, default: '210rpx' },
- // 预览
- previewFullImage: { type: Boolean, default: true },
- // 缩略图缩放因子(像素)
- factor: { type: Number, default: 4 },
- // 缓存破坏参数
- t: { type: String, default: '0' }
- });
- const itemSize = computed(() => props.multipleSize);
- const albumStyle = computed(() => ({ display: 'flex', flexWrap: 'wrap', gap: props.space, width: '100%' }));
- const inputValue = computed(() => (props.modelValue !== null && props.modelValue !== undefined ? props.modelValue : props.urls));
- // 解析后缀,识别文件类型
- const getTypeByUrl = (url) => {
- if (!url) return 'file';
- // 清理可能包裹的引号并取无查询参数的部分
- const cleaned = String(url).trim().replace(/^['"]+|['"]+$/g, '');
- const u = (cleaned.split('?')[0] || '').toLowerCase();
- // 提取扩展名并去除非字母数字的尾随字符(如引号)
- let ext = (u.split('.').pop() || '').trim().replace(/[^a-z0-9]+$/g, '');
- const imgExt = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg'];
- const videoExt = ['mp4', 'mov', 'avi', 'mkv', 'webm', 'm4v'];
- if (imgExt.includes(ext)) return 'image';
- if (videoExt.includes(ext)) return 'video';
- return 'file';
- };
- // 将输入统一为 items
- const items = computed(() => {
- const v = inputValue.value;
- const out = [];
- if (!v) return out;
- const pushItem = (raw) => {
- if (!raw) return;
- let url = '';
- let name = '';
- let size = undefined;
- let coverUrl = '';
- if (typeof raw === 'string') {
- url = String(raw).trim().replace(/^['"]+|['"]+$/g, '');
- } else if (typeof raw === 'object') {
- url = String(raw[props.keyName] || raw.url || '').trim().replace(/^['"]+|['"]+$/g, '');
- name = raw[props.nameKey] || raw.name || '';
- size = raw[props.sizeKey] || raw.size;
- coverUrl = String(raw[props.coverKey] || raw.coverUrl || '').trim().replace(/^['"]+|['"]+$/g, '');
- }
- if (!url) return;
- const originalUrl = (url.split('?')[0] || url);
- const type = getTypeByUrl(originalUrl);
-
- out.push({ type, url, originalUrl, name, size, coverUrl });
- };
- if (Array.isArray(v)) {
- v.forEach(pushItem);
- } else if (typeof v === 'string') {
- v.split(',').forEach((s) => pushItem(s));
- } else {
- pushItem(v);
- }
- return out;
- });
- const displayItems = computed(() => items.value.slice(0, props.maxCount));
- // 生成图片缩略图地址(保持原图预览)
- const getThumb = (item) => {
- if (!item?.url) return '';
- const size = (uni?.$u?.getPx ? uni.$u.getPx(props.multipleSize) : 160) * props.factor;
- // 保留原有参数的简单拼接策略
- return `${item.url}?w=${size}&h=${size}&t=${props.t}`;
- };
- const formatSize = (bytes) => {
- if (!bytes && bytes !== 0) return '';
- const kb = bytes / 1024;
- if (kb < 1024) return kb.toFixed(2) + ' KB';
- const mb = kb / 1024;
- return mb.toFixed(2) + ' MB';
- };
- // 预览逻辑:图片/视频/文件
- const onItemClick = (item, displayIndex) => {
- if (!item) return;
- if (item.type === 'image') return previewImages(item);
- if (item.type === 'video') return previewVideo(item, displayIndex);
- return previewFile(item);
- };
- const previewImages = (clickedItem) => {
- if (!props.previewFullImage) return;
- const allImages = items.value.filter((i) => i.type === 'image');
- const urls = allImages.map((i) => i.originalUrl);
- const current = urls.indexOf(clickedItem.originalUrl);
- if (!urls.length) return;
- uni.previewImage({ urls, current: current >= 0 ? current : 0 });
- };
- const previewVideo = (item, index) => {
- // #ifdef MP-WEIXIN
- const allVideos = items.value.filter((i) => i.type === 'video');
- const sources = allVideos.map((i) => ({ url: i.url, type: 'video', poster: i?.coverUrl || '' }));
- const current = allVideos.findIndex((i) => i.url === item.url);
- // @ts-ignore
- uni.previewMedia({ sources, current: current >= 0 ? current : 0 });
- // #endif
- // #ifndef MP-WEIXIN
- uni.showToast({ title: '当前平台暂不支持视频预览', icon: 'none' });
- // #endif
- };
- const previewFile = (item) => {
- // 优先打开本地文件
- if (item.url && !isUrl(item.url)) {
- return uni.openDocument({ filePath: item.url, showMenu: true });
- }
- if (!item.url || !isUrl(item.url)) return;
- uni.showLoading({ title: '打开中...' });
- uni.downloadFile({
- url: item.url,
- success: (res) => {
- uni.openDocument({
- filePath: res.tempFilePath,
- showMenu: true,
- success: () => uni.hideLoading(),
- fail: () => {
- uni.hideLoading();
- uni.showToast({ title: '打开文件失败', icon: 'none' });
- }
- });
- },
- fail: () => {
- uni.hideLoading();
- uni.showToast({ title: '文件下载失败', icon: 'none' });
- }
- });
- };
- </script>
- <style lang="scss" scoped>
- .ut-album-card {
- display: flex;
- gap: 10rpx;
- flex-wrap: wrap;
- width: 100%;
- .card-item {
- background-color: #FAFAFA;
- border-radius: 8rpx;
- overflow: hidden;
- border: 1rpx solid #ccc;
- position: relative;
- .thumb {
- width: 100%;
- height: 100%;
- object-fit: cover;
- background-color: #ccc;
- display: block;
- }
- .video-box {
- width: 100%;
- height: 100%;
- position: relative;
- .native-video {
- background: #000;
- }
- .play-mask {
- position: absolute;
- top: 0;left: 0;right: 0;bottom: 0;
- display: flex;
- align-items: center;
- justify-content: center;
- background: rgba(0,0,0,0.15);
- }
- }
- .file-box {
- width: 100%;
- height: 100%;
- box-sizing: border-box;
- padding: 20rpx;
- }
- .album-more {
- position: absolute;
- top: 0;left: 0;right: 0;bottom: 0;
- background: rgba(0, 0, 0, 0.2);
- display: flex;
- align-items: center;
- justify-content: center;
- border-radius: 8rpx;
- .more-text {
- color: #fff;
- font-size: 32rpx;
- font-weight: 500;
- }
- }
- }
- }
- </style>
|