ut-upload.vue 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376
  1. <template>
  2. <template v-if="style == 'card'">
  3. <view class="ut-upload-card">
  4. <template v-for="(item, index) in fileList" :key="index">
  5. <view :style="{ width, height }" class="card-item">
  6. <template v-if="accept === 'image'">
  7. <image :src="item.status === 'uploading' && item.tempUrl ? item.tempUrl : item.url" :mode="mode" style="width: 100%; height: 100%" @click="onPreview(index)"></image>
  8. </template>
  9. <template v-else-if="accept === 'video'">
  10. <video :src="item.status === 'uploading' && item.tempUrl ? item.tempUrl : item.url" controls style="width: 100%; height: 100%" @click="onPreview(index)"></video>
  11. </template>
  12. <template v-if="accept === 'file'">
  13. <view class="d-flex flex-cln file-box" @click="onPreview(index)">
  14. <view class="flex1">
  15. <view class="f-s-28 c-primary up-line-2">{{ item.fileName || '文件' }}</view>
  16. <view class="f-s-24 c-999">{{ item.fileSize ? (item.fileSize / 1024).toFixed(2) + ' KB' : '' }}</view>
  17. </view>
  18. <view class="d-flex j-ed">
  19. <image :src="getFileIconByUrl(item.tempUrl || item.url)" mode="aspectFit" style="width: 40rpx; height: 40rpx;" />
  20. </view>
  21. </view>
  22. </template>
  23. <view v-if="item.status === 'uploading'" class="uploading-mask">
  24. <view class="uploading-text">上传中...</view>
  25. </view>
  26. <view class="del-btn" @click.stop="onDelete(index)">
  27. <up-icon name="close" color="#fff" size="32rpx"></up-icon>
  28. </view>
  29. </view>
  30. </template>
  31. <view v-if="(fileList.length < maxCount)" :style="{ width, height }" @click="clickBtnUpload" class="card-item btn-select d-flex flex-cln j-c a-c">
  32. <view class="mb-10">
  33. <up-icon :color="iconColor" :name="iconName" :iconSize="iconSize"></up-icon>
  34. </view>
  35. <view class="f-s-24 c-primary">{{ uploadText }}</view>
  36. </view>
  37. </view>
  38. </template>
  39. </template>
  40. <script setup lang="ts">
  41. import upload from '@/utils/upload';
  42. import { fileExt, isUrl } from '@/utils/ruoyi';
  43. import { getFileIconByUrl } from '@/utils/common';
  44. interface FileItem {
  45. url: string;
  46. fileName?: string;
  47. fileSize?: number;
  48. // 封面图
  49. coverUrl?: string;
  50. tempUrl?: string;
  51. status?: 'uploading' | 'done' | 'error';
  52. progress?: number;
  53. }
  54. interface UploadEvent {
  55. file: Array<{ url: string }>;
  56. }
  57. interface DeleteEvent {
  58. index: number;
  59. }
  60. interface Props {
  61. modelValue: string | string[] | Record<string, any> | null;
  62. maxCount: number;
  63. width: string;
  64. height: string;
  65. multiple: boolean;
  66. uploadText: string;
  67. uploadIcon: string;
  68. accept: string; // image/video/file
  69. uploadUrl?: string;
  70. uploadTimeout?: number;
  71. extension?: string | string[]; // 限制选择的文件扩展名(仅在 accept==='file' 生效)
  72. valueType: 'string' | 'array' | 'object';
  73. style: 'card' | 'list';
  74. iconName: string;
  75. iconColor: string;
  76. iconSize: string | number;
  77. mode: 'aspectFill' | 'aspectFit' | 'widthFix' | 'heightFix' | 'top' | 'bottom' | 'center' | 'left' | 'right' | 'top left' | 'top right' | 'bottom left' | 'bottom right';
  78. }
  79. const props = withDefaults(defineProps<Props>(), {
  80. modelValue: () => [],
  81. maxCount: 1,
  82. width: '210rpx',
  83. height: '210rpx',
  84. multiple: true,
  85. uploadText: '点击上传',
  86. uploadIcon: 'plus',
  87. accept: 'image',
  88. uploadUrl: '/resource/oss/upload',
  89. uploadTimeout: 600000,
  90. extension: () => ['pdf'],
  91. valueType: 'string',
  92. style: 'card',
  93. iconName: 'plus', // plus/camera
  94. iconColor: '#37a954',
  95. iconSize: '30rpx',
  96. mode: 'aspectFill',
  97. });
  98. const emit = defineEmits<{
  99. change: [value: any];
  100. 'update:modelValue': [value: any];
  101. }>();
  102. const fileList = ref<FileItem[]>([]);
  103. const buildFileName = (path: string, kind: 'image' | 'video' | 'file') => {
  104. const ext = fileExt(path) || 'dat';
  105. const prefix = kind === 'image' ? 'img' : kind === 'video' ? 'video' : 'file';
  106. return `${prefix}_${Date.now()}.${ext}`;
  107. };
  108. const clickBtnUpload = async () => {
  109. // 判断是图片/视频/文件不同的上传
  110. // 使用chooseMedia选择图片或视频 chooseMessageFile选择文件
  111. try {
  112. const remain = props.maxCount - fileList.value.length;
  113. if (remain <= 0) {
  114. uni.showToast({ title: '已达最大上传数量', icon: 'none' });
  115. return;
  116. }
  117. if (props.accept === 'file') {
  118. const res: any = await uni.chooseMessageFile({
  119. count: props.multiple ? remain : 1,
  120. type: 'file',
  121. extension: Array.isArray(props.extension) ? props.extension : [props.extension]
  122. });
  123. const files = (res?.tempFiles || []) as Array<{ name?: string; size: number; path: string }>;
  124. for (const f of files) {
  125. const name = f.name || buildFileName(f.path, 'file');
  126. const placeholder: FileItem = { url: '', fileName: name, fileSize: f.size, tempUrl: f.path, status: 'uploading' };
  127. const idx = fileList.value.push(placeholder) - 1;
  128. try {
  129. const upRes = await upload({ url: props.uploadUrl!, filePath: f.path, name: 'file', timeout: props.uploadTimeout });
  130. const serverUrl = (upRes as any)?.data?.url || (upRes as any)?.data?.fileUrl || (upRes as any)?.data?.path || (upRes as any)?.data?.uri;
  131. if (upRes.code === 200) {
  132. const current = fileList.value[idx] || placeholder;
  133. fileList.value.splice(idx, 1, { ...current, url: serverUrl || current.tempUrl || '', status: 'done' });
  134. uni.showToast({ title: '上传成功', icon: 'success' });
  135. emitCurrentValue();
  136. } else {
  137. const current = fileList.value[idx] || placeholder;
  138. fileList.value.splice(idx, 1, { ...current, status: 'error' });
  139. uni.showToast({ title: '上传失败', icon: 'none' });
  140. }
  141. } catch (err) {
  142. console.error('upload file error:', err);
  143. const current = fileList.value[idx] || placeholder;
  144. fileList.value.splice(idx, 1, { ...current, status: 'error' });
  145. }
  146. }
  147. } else {
  148. const res: any = await uni.chooseMedia({
  149. count: props.multiple ? remain : 1,
  150. mediaType: props.accept === 'image' ? ['image'] : ['video'],
  151. sourceType: ['album', 'camera'],
  152. });
  153. const files = (res?.tempFiles || []) as Array<{ tempFilePath: string; size: number; thumbTempFilePath?: string }>;
  154. for (const f of files) {
  155. const name = buildFileName(f.tempFilePath, props.accept === 'image' ? 'image' : 'video');
  156. const placeholder: FileItem = { url: '', fileName: name, fileSize: f.size, tempUrl: f.tempFilePath, status: 'uploading' };
  157. const idx = fileList.value.push(placeholder) - 1;
  158. try {
  159. const upRes = await upload({ url: props.uploadUrl!, filePath: f.tempFilePath, name: 'file', timeout: props.uploadTimeout });
  160. const serverUrl = (upRes as any)?.data?.url || (upRes as any)?.data?.fileUrl || (upRes as any)?.data?.path || (upRes as any)?.data?.uri;
  161. if (upRes.code === 200) {
  162. const current = fileList.value[idx] || placeholder;
  163. fileList.value.splice(idx, 1, { ...current, url: serverUrl || current.tempUrl || '', status: 'done' });
  164. uni.showToast({ title: '上传成功', icon: 'success' });
  165. emitCurrentValue();
  166. } else {
  167. const current = fileList.value[idx] || placeholder;
  168. fileList.value.splice(idx, 1, { ...current, status: 'error' });
  169. uni.showToast({ title: '上传失败', icon: 'none' });
  170. }
  171. } catch (err) {
  172. console.error('upload media error:', err);
  173. const current = fileList.value[idx] || placeholder;
  174. fileList.value.splice(idx, 1, { ...current, status: 'error' });
  175. }
  176. }
  177. }
  178. // 最后再统一触发一次
  179. emitCurrentValue();
  180. } catch (e) {
  181. console.log('upload error:', e);
  182. }
  183. };
  184. const onPreview = (index: number) => {
  185. const item = fileList.value[index];
  186. if (!item) return;
  187. // 文件类型:上传中也允许预览本地临时文件
  188. if (props.accept === 'file') {
  189. const localPath = item.status === 'uploading' && item.tempUrl
  190. ? item.tempUrl
  191. : (!isUrl(item.url) ? item.url : '');
  192. if (localPath) {
  193. uni.openDocument({
  194. filePath: localPath,
  195. showMenu: true,
  196. fail: () => {
  197. uni.showToast({ title: '打开文件失败', icon: 'none' });
  198. }
  199. });
  200. return;
  201. }
  202. if (item.url && isUrl(item.url)) {
  203. uni.showLoading({ title: '打开中...' });
  204. uni.downloadFile({
  205. url: item.url,
  206. success: (res) => {
  207. uni.openDocument({
  208. filePath: res.tempFilePath,
  209. showMenu: true,
  210. success: () => {
  211. uni.hideLoading();
  212. },
  213. fail: () => {
  214. uni.hideLoading();
  215. uni.showToast({ title: '打开文件失败', icon: 'none' });
  216. }
  217. });
  218. },
  219. fail: () => {
  220. uni.hideLoading();
  221. uni.showToast({ title: '文件下载失败', icon: 'none' });
  222. }
  223. });
  224. }
  225. return;
  226. }
  227. // 图片/视频:上传中不允许预览
  228. if (item?.status === 'uploading') return;
  229. if (props.accept === 'image') {
  230. const urls = fileList.value.map((i) => i.url);
  231. uni.previewImage({
  232. urls,
  233. current: urls[index]
  234. });
  235. return;
  236. }
  237. if (props.accept === 'video') {
  238. // #ifdef MP-WEIXIN
  239. const sources = fileList.value.map((i) => ({ url: i.url, type: 'video', poster: i.coverUrl || '' }));
  240. // @ts-ignore
  241. wx.previewMedia({
  242. sources,
  243. current: index
  244. });
  245. // #endif
  246. // #ifndef MP-WEIXIN
  247. uni.showToast({ title: '当前平台暂不支持视频预览', icon: 'none' });
  248. // #endif
  249. }
  250. };
  251. const onDelete = (index: number) => {
  252. fileList.value.splice(index, 1);
  253. const urls = fileList.value.map((i) => i.url);
  254. let out: any;
  255. if (props.valueType === 'string') {
  256. out = urls[0] || '';
  257. } else if (props.valueType === 'array') {
  258. out = fileList.value;
  259. } else {
  260. out = props.multiple ? fileList.value : fileList.value[0] || null;
  261. }
  262. emit('update:modelValue', out);
  263. emit('change', out);
  264. };
  265. function emitCurrentValue() {
  266. const validList = fileList.value;
  267. const urls = validList.map((i) => i.url).filter(Boolean);
  268. let out: any;
  269. if (props.valueType === 'string') {
  270. out = urls[0] || '';
  271. } else if (props.valueType === 'array') {
  272. out = validList;
  273. } else {
  274. out = props.multiple ? validList : validList[0] || null;
  275. }
  276. emit('update:modelValue', out);
  277. emit('change', out);
  278. }
  279. watch(
  280. () => props.modelValue,
  281. (val) => {
  282. if (!val) {
  283. fileList.value = [];
  284. return;
  285. }
  286. if (props.valueType === 'string') {
  287. if (typeof val === 'string') {
  288. fileList.value = val ? [{ url: val }] : [];
  289. }
  290. } else if (props.valueType === 'array') {
  291. if (Array.isArray(val)) {
  292. if (val.length && typeof val[0] === 'string') {
  293. fileList.value = (val as string[]).map((u) => ({ url: u }));
  294. } else {
  295. fileList.value = val as FileItem[];
  296. }
  297. }
  298. } else {
  299. if (Array.isArray(val)) {
  300. fileList.value = val as FileItem[];
  301. } else if (typeof val === 'object' && (val as any).url) {
  302. fileList.value = [val as FileItem];
  303. }
  304. }
  305. },
  306. { immediate: true }
  307. );
  308. </script>
  309. <style lang="scss" scoped>
  310. .ut-upload-card {
  311. display: flex;
  312. gap: 10rpx;
  313. flex-wrap: wrap;
  314. .card-item {
  315. width: 210rpx;
  316. height: 210rpx;
  317. background-color: #FAFAFA;
  318. border-radius: 8rpx;
  319. overflow: hidden;
  320. border: 1rpx solid #ccc;
  321. position: relative;
  322. }
  323. .file-box {
  324. width: 100%;
  325. height: 100%;
  326. box-sizing: border-box;
  327. padding: 20rpx;
  328. }
  329. .btn-select {
  330. border-style: dashed;
  331. }
  332. }
  333. .del-btn {
  334. position: absolute;
  335. top: 0rpx;
  336. right: 0rpx;
  337. width: 48rpx;
  338. height: 48rpx;
  339. border-radius: 50%;
  340. background: rgba(0, 0, 0, 0.35);
  341. display: flex;
  342. align-items: center;
  343. justify-content: center;
  344. z-index: 2;
  345. }
  346. .uploading-mask {
  347. position: absolute;
  348. left: 0;
  349. top: 0;
  350. right: 0;
  351. bottom: 0;
  352. background: rgba(0, 0, 0, 0.4);
  353. color: #fff;
  354. display: flex;
  355. align-items: center;
  356. justify-content: center;
  357. z-index: 1;
  358. }
  359. .uploading-text {
  360. font-size: 24rpx;
  361. }
  362. </style>