cl-upload.uvue 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447
  1. <template>
  2. <view class="cl-upload-list" :class="[pt.className]">
  3. <view
  4. v-for="(item, index) in list"
  5. :key="item.uid"
  6. class="cl-upload"
  7. :class="[
  8. {
  9. 'is-dark': isDark,
  10. 'is-disabled': isDisabled
  11. },
  12. pt.item?.className
  13. ]"
  14. :style="uploadStyle"
  15. @tap="choose(index)"
  16. >
  17. <image
  18. class="cl-upload__image"
  19. :class="[
  20. {
  21. 'is-uploading': item.progress < 100
  22. },
  23. pt.image?.className
  24. ]"
  25. :src="item.preview"
  26. mode="aspectFill"
  27. ></image>
  28. <cl-icon
  29. name="close-line"
  30. color="white"
  31. :pt="{
  32. className: 'cl-upload__close'
  33. }"
  34. @tap.stop="remove(item.uid)"
  35. v-if="!isDisabled"
  36. ></cl-icon>
  37. <view class="cl-upload__progress" v-if="item.progress < 100">
  38. <cl-progress :value="item.progress" :show-text="false"></cl-progress>
  39. </view>
  40. </view>
  41. <view
  42. v-if="isAdd"
  43. class="cl-upload is-add"
  44. :class="[
  45. {
  46. 'is-dark': isDark,
  47. 'is-disabled': isDisabled
  48. },
  49. pt.add?.className
  50. ]"
  51. :style="uploadStyle"
  52. @tap="choose(-1)"
  53. >
  54. <cl-icon
  55. :name="icon"
  56. :pt="{
  57. className: parseClass([
  58. [isDark, '!text-white', '!text-surface-400'],
  59. pt.icon?.className
  60. ])
  61. }"
  62. :size="50"
  63. ></cl-icon>
  64. <cl-text
  65. :pt="{
  66. className: parseClass([
  67. [isDark, '!text-white', '!text-surface-500'],
  68. '!text-xs mt-1 text-center',
  69. pt.text?.className
  70. ])
  71. }"
  72. >{{ text }}</cl-text
  73. >
  74. </view>
  75. </view>
  76. </template>
  77. <script lang="ts" setup>
  78. import { forInObject, isDark, parseClass, parsePt, parseRpx, uploadFile, uuid } from "@/cool";
  79. import { t } from "@/locale";
  80. import { computed, reactive, ref, watch, type PropType } from "vue";
  81. import type { ClUploadItem, PassThroughProps } from "../../types";
  82. import { useForm } from "../../hooks";
  83. defineOptions({
  84. name: "cl-upload"
  85. });
  86. const props = defineProps({
  87. // 透传属性,用于自定义样式类名
  88. pt: {
  89. type: Object,
  90. default: () => ({})
  91. },
  92. // 双向绑定的值,支持字符串或字符串数组
  93. modelValue: {
  94. type: [Array, String] as PropType<string[] | string>,
  95. default: () => []
  96. },
  97. // 上传按钮的图标
  98. icon: {
  99. type: String,
  100. default: "camera-fill"
  101. },
  102. // 上传按钮显示的文本
  103. text: {
  104. type: String,
  105. default: () => t("上传 / 拍摄")
  106. },
  107. // 图片压缩方式:original原图,compressed压缩图
  108. sizeType: {
  109. type: [String, Array] as PropType<string[] | string>,
  110. default: () => ["original", "compressed"]
  111. },
  112. // 图片选择来源:album相册,camera拍照
  113. sourceType: {
  114. type: Array as PropType<string[]>,
  115. default: () => ["album", "camera"]
  116. },
  117. // 上传区域高度
  118. height: {
  119. type: [Number, String],
  120. default: 150
  121. },
  122. // 上传区域宽度
  123. width: {
  124. type: [Number, String],
  125. default: 150
  126. },
  127. // 是否支持多选
  128. multiple: {
  129. type: Boolean,
  130. default: false
  131. },
  132. // 最大上传数量限制
  133. limit: {
  134. type: Number,
  135. default: 9
  136. },
  137. // 是否禁用组件
  138. disabled: {
  139. type: Boolean,
  140. default: false
  141. },
  142. // 演示用,本地预览
  143. test: {
  144. type: Boolean,
  145. default: false
  146. }
  147. });
  148. const emit = defineEmits([
  149. "update:modelValue", // 更新modelValue值
  150. "change", // 值发生变化时触发
  151. "exceed", // 超出数量限制时触发
  152. "success", // 上传成功时触发
  153. "error", // 上传失败时触发
  154. "progress" // 上传进度更新时触发
  155. ]);
  156. // cl-form 上下文
  157. const { disabled } = useForm();
  158. // 是否禁用
  159. const isDisabled = computed(() => {
  160. return disabled.value || props.disabled;
  161. });
  162. // 透传属性的类型定义
  163. type PassThrough = {
  164. className?: string;
  165. item?: PassThroughProps;
  166. add?: PassThroughProps;
  167. image?: PassThroughProps;
  168. icon?: PassThroughProps;
  169. text?: PassThroughProps;
  170. };
  171. // 解析透传属性
  172. const pt = computed(() => parsePt<PassThrough>(props.pt));
  173. // 上传文件列表
  174. const list = ref<ClUploadItem[]>([]);
  175. // 当前操作的文件索引,-1表示新增,其他表示替换指定索引的文件
  176. const activeIndex = ref(0);
  177. // 计算是否显示添加按钮
  178. const isAdd = computed(() => {
  179. const n = list.value.length;
  180. if (isDisabled.value) {
  181. // 禁用状态下,只有没有文件时才显示添加按钮
  182. return n == 0;
  183. } else {
  184. // 根据multiple模式判断是否可以继续添加
  185. return n < (props.multiple ? props.limit : 1);
  186. }
  187. });
  188. // 计算上传区域的样式
  189. const uploadStyle = computed(() => {
  190. return {
  191. height: parseRpx(props.height),
  192. width: parseRpx(props.width)
  193. };
  194. });
  195. /**
  196. * 获取已成功上传的文件URL列表
  197. */
  198. function getUrls() {
  199. return list.value.filter((e) => e.url != "" && e.progress == 100).map((e) => e.url);
  200. }
  201. /**
  202. * 获取当前的值,根据multiple模式返回不同格式
  203. */
  204. function getValue() {
  205. const urls = getUrls();
  206. if (props.multiple) {
  207. return urls;
  208. } else {
  209. return urls[0];
  210. }
  211. }
  212. /**
  213. * 添加新的上传项或更新已有项
  214. * @param {string} url - 预览图片的本地路径
  215. */
  216. function append(url: string) {
  217. // 创建新的上传项
  218. const item =
  219. activeIndex.value == -1
  220. ? reactive<ClUploadItem>({
  221. uid: uuid(), // 生成唯一ID
  222. preview: url, // 预览图片路径
  223. url: "", // 最终上传后的URL
  224. progress: 0 // 上传进度
  225. })
  226. : list.value[activeIndex.value];
  227. // 更新已有项或添加新项
  228. if (activeIndex.value == -1) {
  229. // 添加新项到列表末尾
  230. list.value.push(item);
  231. } else {
  232. // 替换已有项的内容
  233. item.progress = 0;
  234. item.preview = url;
  235. item.url = "";
  236. }
  237. return item.uid;
  238. }
  239. /**
  240. * 触发值变化事件
  241. */
  242. function change() {
  243. const value = getValue();
  244. emit("update:modelValue", value);
  245. emit("change", value);
  246. }
  247. /**
  248. * 更新指定上传项的数据
  249. * @param {string} uid - 上传项的唯一ID
  250. * @param {any} data - 要更新的数据对象
  251. */
  252. function update(uid: string, data: any) {
  253. const item = list.value.find((e) => e.uid == uid);
  254. if (item != null) {
  255. // 遍历更新数据对象的所有属性
  256. forInObject(data, (value, key) => {
  257. item[key] = value;
  258. });
  259. // 当上传完成且有URL时,触发change事件
  260. if (item.progress == 100 && item.url != "") {
  261. change();
  262. }
  263. }
  264. }
  265. /**
  266. * 删除指定的上传项
  267. * @param {string} uid - 要删除的上传项唯一ID
  268. */
  269. function remove(uid: string) {
  270. list.value.splice(
  271. list.value.findIndex((e) => e.uid == uid),
  272. 1
  273. );
  274. change();
  275. }
  276. /**
  277. * 选择图片文件
  278. * @param {number} index - 操作的索引,-1表示新增,其他表示替换
  279. */
  280. function choose(index: number) {
  281. if (isDisabled.value) {
  282. return;
  283. }
  284. activeIndex.value = index;
  285. // 计算可选择的图片数量
  286. const count = activeIndex.value == -1 ? props.limit - list.value.length : 1;
  287. if (count <= 0) {
  288. // 超出数量限制
  289. emit("exceed", list.value);
  290. return;
  291. }
  292. // 调用uni-app的选择图片API
  293. uni.chooseImage({
  294. count: count, // 最多可以选择的图片张数
  295. sizeType: props.sizeType as string[], // 压缩方式
  296. sourceType: props.sourceType as string[], // 图片来源
  297. success(res) {
  298. // 选择成功后处理每个文件
  299. if (Array.isArray(res.tempFiles)) {
  300. (res.tempFiles as ChooseImageTempFile[]).forEach((file) => {
  301. // 添加到列表并获取唯一ID
  302. const uid = append(file.path);
  303. // 测试用,本地预览
  304. if (props.test) {
  305. update(uid, { url: file.path, progress: 100 });
  306. emit("success", file.path, uid);
  307. return;
  308. }
  309. // 开始上传文件
  310. uploadFile(file, {
  311. // 上传进度回调
  312. onProgressUpdate: ({ progress }) => {
  313. update(uid, { progress });
  314. emit("progress", progress);
  315. }
  316. })
  317. .then((url) => {
  318. // 上传成功,更新URL和进度
  319. update(uid, { url, progress: 100 });
  320. emit("success", url, uid);
  321. })
  322. .catch((err) => {
  323. // 上传失败,触发错误事件并删除该项
  324. emit("error", err as string);
  325. remove(uid);
  326. });
  327. });
  328. }
  329. },
  330. fail(err) {
  331. // 选择图片失败
  332. console.error(err);
  333. emit("error", err.errMsg);
  334. }
  335. });
  336. }
  337. // 监听modelValue的变化,同步更新内部列表
  338. watch(
  339. computed(() => props.modelValue!),
  340. (val: string | string[]) => {
  341. // 将当前列表的URL转为字符串用于比较
  342. const currentUrls = getUrls().join(",");
  343. // 将传入的值标准化为字符串进行比较
  344. const newUrls = Array.isArray(val) ? val.join(",") : val;
  345. // 如果值发生变化,更新内部列表
  346. if (currentUrls != newUrls) {
  347. // 标准化为数组格式
  348. const urls = Array.isArray(val) ? val : [val];
  349. // 过滤空值并转换为Item对象
  350. list.value = urls
  351. .filter((url) => url != "")
  352. .map((url) => {
  353. return {
  354. uid: uuid(),
  355. preview: url,
  356. url,
  357. progress: 100 // 外部传入的URL认为已上传完成
  358. } as ClUploadItem;
  359. });
  360. }
  361. },
  362. {
  363. immediate: true // 立即执行一次
  364. }
  365. );
  366. </script>
  367. <style lang="scss" scoped>
  368. .cl-upload-list {
  369. @apply flex flex-row flex-wrap;
  370. .cl-upload {
  371. @apply relative bg-surface-100 rounded-xl flex flex-col items-center justify-center;
  372. @apply mr-2 mb-2;
  373. &.is-dark {
  374. @apply bg-surface-700;
  375. }
  376. &.is-disabled {
  377. @apply opacity-50;
  378. }
  379. &.is-add {
  380. @apply p-1;
  381. }
  382. &__image {
  383. @apply w-full h-full absolute top-0 left-0;
  384. transition-property: opacity;
  385. transition-duration: 0.2s;
  386. &.is-uploading {
  387. @apply opacity-70;
  388. }
  389. }
  390. &__close {
  391. @apply absolute top-1 right-1;
  392. }
  393. &__progress {
  394. @apply absolute bottom-2 left-0 w-full z-10 px-2;
  395. }
  396. }
  397. }
  398. </style>