| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376 |
- <template>
- <template v-if="style == 'card'">
- <view class="ut-upload-card">
- <template v-for="(item, index) in fileList" :key="index">
- <view :style="{ width, height }" class="card-item">
- <template v-if="accept === 'image'">
- <image :src="item.status === 'uploading' && item.tempUrl ? item.tempUrl : item.url" :mode="mode" style="width: 100%; height: 100%" @click="onPreview(index)"></image>
- </template>
- <template v-else-if="accept === 'video'">
- <video :src="item.status === 'uploading' && item.tempUrl ? item.tempUrl : item.url" controls style="width: 100%; height: 100%" @click="onPreview(index)"></video>
- </template>
- <template v-if="accept === 'file'">
- <view class="d-flex flex-cln file-box" @click="onPreview(index)">
- <view class="flex1">
- <view class="f-s-28 c-primary up-line-2">{{ item.fileName || '文件' }}</view>
- <view class="f-s-24 c-999">{{ item.fileSize ? (item.fileSize / 1024).toFixed(2) + ' KB' : '' }}</view>
- </view>
- <view class="d-flex j-ed">
- <image :src="getFileIconByUrl(item.tempUrl || item.url)" mode="aspectFit" style="width: 40rpx; height: 40rpx;" />
- </view>
- </view>
- </template>
- <view v-if="item.status === 'uploading'" class="uploading-mask">
- <view class="uploading-text">上传中...</view>
- </view>
- <view class="del-btn" @click.stop="onDelete(index)">
- <up-icon name="close" color="#fff" size="32rpx"></up-icon>
- </view>
- </view>
- </template>
- <view v-if="(fileList.length < maxCount)" :style="{ width, height }" @click="clickBtnUpload" class="card-item btn-select d-flex flex-cln j-c a-c">
- <view class="mb-10">
- <up-icon :color="iconColor" :name="iconName" :iconSize="iconSize"></up-icon>
- </view>
- <view class="f-s-24 c-primary">{{ uploadText }}</view>
- </view>
- </view>
- </template>
- </template>
- <script setup lang="ts">
- import upload from '@/utils/upload';
- import { fileExt, isUrl } from '@/utils/ruoyi';
- import { getFileIconByUrl } from '@/utils/common';
- interface FileItem {
- url: string;
- fileName?: string;
- fileSize?: number;
- // 封面图
- coverUrl?: string;
- tempUrl?: string;
- status?: 'uploading' | 'done' | 'error';
- progress?: number;
- }
- interface UploadEvent {
- file: Array<{ url: string }>;
- }
- interface DeleteEvent {
- index: number;
- }
- interface Props {
- modelValue: string | string[] | Record<string, any> | null;
- maxCount: number;
- width: string;
- height: string;
- multiple: boolean;
- uploadText: string;
- uploadIcon: string;
- accept: string; // image/video/file
- uploadUrl?: string;
- uploadTimeout?: number;
- extension?: string | string[]; // 限制选择的文件扩展名(仅在 accept==='file' 生效)
- valueType: 'string' | 'array' | 'object';
- style: 'card' | 'list';
- iconName: string;
- iconColor: string;
- iconSize: string | number;
- mode: 'aspectFill' | 'aspectFit' | 'widthFix' | 'heightFix' | 'top' | 'bottom' | 'center' | 'left' | 'right' | 'top left' | 'top right' | 'bottom left' | 'bottom right';
- }
- const props = withDefaults(defineProps<Props>(), {
- modelValue: () => [],
- maxCount: 1,
- width: '210rpx',
- height: '210rpx',
- multiple: true,
- uploadText: '点击上传',
- uploadIcon: 'plus',
- accept: 'image',
- uploadUrl: '/resource/oss/upload',
- uploadTimeout: 600000,
- extension: () => ['pdf'],
- valueType: 'string',
- style: 'card',
- iconName: 'plus', // plus/camera
- iconColor: '#37a954',
- iconSize: '30rpx',
- mode: 'aspectFill',
- });
- const emit = defineEmits<{
- change: [value: any];
- 'update:modelValue': [value: any];
- }>();
- const fileList = ref<FileItem[]>([]);
- const buildFileName = (path: string, kind: 'image' | 'video' | 'file') => {
- const ext = fileExt(path) || 'dat';
- const prefix = kind === 'image' ? 'img' : kind === 'video' ? 'video' : 'file';
- return `${prefix}_${Date.now()}.${ext}`;
- };
- const clickBtnUpload = async () => {
- // 判断是图片/视频/文件不同的上传
- // 使用chooseMedia选择图片或视频 chooseMessageFile选择文件
- try {
- const remain = props.maxCount - fileList.value.length;
- if (remain <= 0) {
- uni.showToast({ title: '已达最大上传数量', icon: 'none' });
- return;
- }
- if (props.accept === 'file') {
- const res: any = await uni.chooseMessageFile({
- count: props.multiple ? remain : 1,
- type: 'file',
- extension: Array.isArray(props.extension) ? props.extension : [props.extension]
- });
- const files = (res?.tempFiles || []) as Array<{ name?: string; size: number; path: string }>;
- for (const f of files) {
- const name = f.name || buildFileName(f.path, 'file');
- const placeholder: FileItem = { url: '', fileName: name, fileSize: f.size, tempUrl: f.path, status: 'uploading' };
- const idx = fileList.value.push(placeholder) - 1;
- try {
- const upRes = await upload({ url: props.uploadUrl!, filePath: f.path, name: 'file', timeout: props.uploadTimeout });
- const serverUrl = (upRes as any)?.data?.url || (upRes as any)?.data?.fileUrl || (upRes as any)?.data?.path || (upRes as any)?.data?.uri;
- if (upRes.code === 200) {
- const current = fileList.value[idx] || placeholder;
- fileList.value.splice(idx, 1, { ...current, url: serverUrl || current.tempUrl || '', status: 'done' });
- uni.showToast({ title: '上传成功', icon: 'success' });
- emitCurrentValue();
- } else {
- const current = fileList.value[idx] || placeholder;
- fileList.value.splice(idx, 1, { ...current, status: 'error' });
- uni.showToast({ title: '上传失败', icon: 'none' });
- }
- } catch (err) {
- console.error('upload file error:', err);
- const current = fileList.value[idx] || placeholder;
- fileList.value.splice(idx, 1, { ...current, status: 'error' });
- }
- }
- } else {
- const res: any = await uni.chooseMedia({
- count: props.multiple ? remain : 1,
- mediaType: props.accept === 'image' ? ['image'] : ['video'],
- sourceType: ['album', 'camera'],
- });
- const files = (res?.tempFiles || []) as Array<{ tempFilePath: string; size: number; thumbTempFilePath?: string }>;
- for (const f of files) {
- const name = buildFileName(f.tempFilePath, props.accept === 'image' ? 'image' : 'video');
- const placeholder: FileItem = { url: '', fileName: name, fileSize: f.size, tempUrl: f.tempFilePath, status: 'uploading' };
- const idx = fileList.value.push(placeholder) - 1;
- try {
- const upRes = await upload({ url: props.uploadUrl!, filePath: f.tempFilePath, name: 'file', timeout: props.uploadTimeout });
- const serverUrl = (upRes as any)?.data?.url || (upRes as any)?.data?.fileUrl || (upRes as any)?.data?.path || (upRes as any)?.data?.uri;
- if (upRes.code === 200) {
- const current = fileList.value[idx] || placeholder;
- fileList.value.splice(idx, 1, { ...current, url: serverUrl || current.tempUrl || '', status: 'done' });
- uni.showToast({ title: '上传成功', icon: 'success' });
- emitCurrentValue();
- } else {
- const current = fileList.value[idx] || placeholder;
- fileList.value.splice(idx, 1, { ...current, status: 'error' });
- uni.showToast({ title: '上传失败', icon: 'none' });
- }
- } catch (err) {
- console.error('upload media error:', err);
- const current = fileList.value[idx] || placeholder;
- fileList.value.splice(idx, 1, { ...current, status: 'error' });
- }
- }
- }
- // 最后再统一触发一次
- emitCurrentValue();
- } catch (e) {
- console.log('upload error:', e);
- }
- };
- const onPreview = (index: number) => {
- const item = fileList.value[index];
- if (!item) return;
- // 文件类型:上传中也允许预览本地临时文件
- if (props.accept === 'file') {
- const localPath = item.status === 'uploading' && item.tempUrl
- ? item.tempUrl
- : (!isUrl(item.url) ? item.url : '');
- if (localPath) {
- uni.openDocument({
- filePath: localPath,
- showMenu: true,
- fail: () => {
- uni.showToast({ title: '打开文件失败', icon: 'none' });
- }
- });
- return;
- }
- if (item.url && isUrl(item.url)) {
- 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' });
- }
- });
- }
- return;
- }
- // 图片/视频:上传中不允许预览
- if (item?.status === 'uploading') return;
- if (props.accept === 'image') {
- const urls = fileList.value.map((i) => i.url);
- uni.previewImage({
- urls,
- current: urls[index]
- });
- return;
- }
- if (props.accept === 'video') {
- // #ifdef MP-WEIXIN
- const sources = fileList.value.map((i) => ({ url: i.url, type: 'video', poster: i.coverUrl || '' }));
- // @ts-ignore
- wx.previewMedia({
- sources,
- current: index
- });
- // #endif
- // #ifndef MP-WEIXIN
- uni.showToast({ title: '当前平台暂不支持视频预览', icon: 'none' });
- // #endif
- }
- };
- const onDelete = (index: number) => {
- fileList.value.splice(index, 1);
- const urls = fileList.value.map((i) => i.url);
- let out: any;
- if (props.valueType === 'string') {
- out = urls[0] || '';
- } else if (props.valueType === 'array') {
- out = fileList.value;
- } else {
- out = props.multiple ? fileList.value : fileList.value[0] || null;
- }
- emit('update:modelValue', out);
- emit('change', out);
- };
- function emitCurrentValue() {
- const validList = fileList.value;
- const urls = validList.map((i) => i.url).filter(Boolean);
- let out: any;
- if (props.valueType === 'string') {
- out = urls[0] || '';
- } else if (props.valueType === 'array') {
- out = validList;
- } else {
- out = props.multiple ? validList : validList[0] || null;
- }
- emit('update:modelValue', out);
- emit('change', out);
- }
- watch(
- () => props.modelValue,
- (val) => {
- if (!val) {
- fileList.value = [];
- return;
- }
- if (props.valueType === 'string') {
- if (typeof val === 'string') {
- fileList.value = val ? [{ url: val }] : [];
- }
- } else if (props.valueType === 'array') {
- if (Array.isArray(val)) {
- if (val.length && typeof val[0] === 'string') {
- fileList.value = (val as string[]).map((u) => ({ url: u }));
- } else {
- fileList.value = val as FileItem[];
- }
- }
- } else {
- if (Array.isArray(val)) {
- fileList.value = val as FileItem[];
- } else if (typeof val === 'object' && (val as any).url) {
- fileList.value = [val as FileItem];
- }
- }
- },
- { immediate: true }
- );
- </script>
- <style lang="scss" scoped>
- .ut-upload-card {
- display: flex;
- gap: 10rpx;
- flex-wrap: wrap;
- .card-item {
- width: 210rpx;
- height: 210rpx;
- background-color: #FAFAFA;
- border-radius: 8rpx;
- overflow: hidden;
- border: 1rpx solid #ccc;
- position: relative;
- }
- .file-box {
- width: 100%;
- height: 100%;
- box-sizing: border-box;
- padding: 20rpx;
- }
- .btn-select {
- border-style: dashed;
- }
- }
- .del-btn {
- position: absolute;
- top: 0rpx;
- right: 0rpx;
- width: 48rpx;
- height: 48rpx;
- border-radius: 50%;
- background: rgba(0, 0, 0, 0.35);
- display: flex;
- align-items: center;
- justify-content: center;
- z-index: 2;
- }
- .uploading-mask {
- position: absolute;
- left: 0;
- top: 0;
- right: 0;
- bottom: 0;
- background: rgba(0, 0, 0, 0.4);
- color: #fff;
- display: flex;
- align-items: center;
- justify-content: center;
- z-index: 1;
- }
- .uploading-text {
- font-size: 24rpx;
- }
- </style>
|