cl-upload.uvue 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431
  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"
  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. const emit = defineEmits([
  144. "update:modelValue", // 更新modelValue值
  145. "change", // 值发生变化时触发
  146. "exceed", // 超出数量限制时触发
  147. "success", // 上传成功时触发
  148. "error", // 上传失败时触发
  149. "progress" // 上传进度更新时触发
  150. ]);
  151. // cl-form 上下文
  152. const { disabled } = useForm();
  153. // 是否禁用
  154. const isDisabled = computed(() => {
  155. return disabled.value || props.disabled;
  156. });
  157. // 透传属性的类型定义
  158. type PassThrough = {
  159. className?: string;
  160. item?: PassThroughProps;
  161. add?: PassThroughProps;
  162. image?: PassThroughProps;
  163. icon?: PassThroughProps;
  164. text?: PassThroughProps;
  165. };
  166. // 解析透传属性
  167. const pt = computed(() => parsePt<PassThrough>(props.pt));
  168. // 上传文件列表
  169. const list = ref<ClUploadItem[]>([]);
  170. // 当前操作的文件索引,-1表示新增,其他表示替换指定索引的文件
  171. const activeIndex = ref(0);
  172. // 计算是否显示添加按钮
  173. const isAdd = computed(() => {
  174. const n = list.value.length;
  175. if (isDisabled.value) {
  176. // 禁用状态下,只有没有文件时才显示添加按钮
  177. return n == 0;
  178. } else {
  179. // 根据multiple模式判断是否可以继续添加
  180. return n < (props.multiple ? props.limit : 1);
  181. }
  182. });
  183. // 计算上传区域的样式
  184. const uploadStyle = computed(() => {
  185. return {
  186. height: parseRpx(props.height),
  187. width: parseRpx(props.width)
  188. };
  189. });
  190. /**
  191. * 获取已成功上传的文件URL列表
  192. */
  193. function getUrls() {
  194. return list.value.filter((e) => e.url != "" && e.progress == 100).map((e) => e.url);
  195. }
  196. /**
  197. * 获取当前的值,根据multiple模式返回不同格式
  198. */
  199. function getValue() {
  200. const urls = getUrls();
  201. if (props.multiple) {
  202. return urls;
  203. } else {
  204. return urls[0];
  205. }
  206. }
  207. /**
  208. * 添加新的上传项或更新已有项
  209. * @param {string} url - 预览图片的本地路径
  210. */
  211. function append(url: string) {
  212. // 创建新的上传项
  213. const item =
  214. activeIndex.value == -1
  215. ? reactive<ClUploadItem>({
  216. uid: uuid(), // 生成唯一ID
  217. preview: url, // 预览图片路径
  218. url: "", // 最终上传后的URL
  219. progress: 0 // 上传进度
  220. })
  221. : list.value[activeIndex.value];
  222. // 更新已有项或添加新项
  223. if (activeIndex.value == -1) {
  224. // 添加新项到列表末尾
  225. list.value.push(item);
  226. } else {
  227. // 替换已有项的内容
  228. item.progress = 0;
  229. item.preview = url;
  230. item.url = "";
  231. }
  232. return item.uid;
  233. }
  234. /**
  235. * 触发值变化事件
  236. */
  237. function change() {
  238. const value = getValue();
  239. emit("update:modelValue", value);
  240. emit("change", value);
  241. }
  242. /**
  243. * 更新指定上传项的数据
  244. * @param {string} uid - 上传项的唯一ID
  245. * @param {any} data - 要更新的数据对象
  246. */
  247. function update(uid: string, data: any) {
  248. const item = list.value.find((e) => e.uid == uid);
  249. if (item != null) {
  250. // 遍历更新数据对象的所有属性
  251. forInObject(data, (value, key) => {
  252. item[key] = value;
  253. });
  254. // 当上传完成且有URL时,触发change事件
  255. if (item.progress == 100 && item.url != "") {
  256. change();
  257. }
  258. }
  259. }
  260. /**
  261. * 删除指定的上传项
  262. * @param {string} uid - 要删除的上传项唯一ID
  263. */
  264. function remove(uid: string) {
  265. list.value.splice(
  266. list.value.findIndex((e) => e.uid == uid),
  267. 1
  268. );
  269. change();
  270. }
  271. /**
  272. * 选择图片文件
  273. * @param {number} index - 操作的索引,-1表示新增,其他表示替换
  274. */
  275. function choose(index: number) {
  276. if (isDisabled.value) {
  277. return;
  278. }
  279. activeIndex.value = index;
  280. // 计算可选择的图片数量
  281. const count = activeIndex.value == -1 ? props.limit - list.value.length : 1;
  282. if (count <= 0) {
  283. // 超出数量限制
  284. emit("exceed", list.value);
  285. return;
  286. }
  287. // 调用uni-app的选择图片API
  288. uni.chooseImage({
  289. count: count, // 最多可以选择的图片张数
  290. sizeType: props.sizeType as string[], // 压缩方式
  291. sourceType: props.sourceType as string[], // 图片来源
  292. success(res) {
  293. // 选择成功后处理每个文件
  294. if (Array.isArray(res.tempFiles)) {
  295. (res.tempFiles as ChooseImageTempFile[]).forEach((file) => {
  296. // 添加到列表并获取唯一ID
  297. const uid = append(file.path);
  298. // 开始上传文件
  299. uploadFile(file, {
  300. // 上传进度回调
  301. onProgressUpdate: ({ progress }) => {
  302. update(uid, { progress });
  303. emit("progress", progress);
  304. }
  305. })
  306. .then((url) => {
  307. // 上传成功,更新URL和进度
  308. update(uid, { url, progress: 100 });
  309. emit("success", url, uid);
  310. })
  311. .catch((err) => {
  312. // 上传失败,触发错误事件并删除该项
  313. emit("error", err as string);
  314. remove(uid);
  315. });
  316. });
  317. }
  318. },
  319. fail(err) {
  320. // 选择图片失败
  321. console.error(err);
  322. emit("error", err.errMsg);
  323. }
  324. });
  325. }
  326. // 监听modelValue的变化,同步更新内部列表
  327. watch(
  328. computed(() => props.modelValue!),
  329. (val: string | string[]) => {
  330. // 将当前列表的URL转为字符串用于比较
  331. const currentUrls = getUrls().join(",");
  332. // 将传入的值标准化为字符串进行比较
  333. const newUrls = Array.isArray(val) ? val.join(",") : val;
  334. // 如果值发生变化,更新内部列表
  335. if (currentUrls != newUrls) {
  336. // 标准化为数组格式
  337. const urls = Array.isArray(val) ? val : [val];
  338. // 过滤空值并转换为Item对象
  339. list.value = urls
  340. .filter((url) => url != "")
  341. .map((url) => {
  342. return {
  343. uid: uuid(),
  344. preview: url,
  345. url,
  346. progress: 100 // 外部传入的URL认为已上传完成
  347. } as ClUploadItem;
  348. });
  349. }
  350. },
  351. {
  352. immediate: true // 立即执行一次
  353. }
  354. );
  355. </script>
  356. <style lang="scss" scoped>
  357. .cl-upload-list {
  358. @apply flex flex-row flex-wrap;
  359. .cl-upload {
  360. @apply relative bg-surface-100 rounded-xl flex flex-col items-center justify-center;
  361. @apply mr-2 mb-2;
  362. &.is-dark {
  363. @apply bg-surface-700;
  364. }
  365. &.is-disabled {
  366. @apply opacity-50;
  367. }
  368. &__image {
  369. @apply w-full h-full absolute top-0 left-0;
  370. transition-property: opacity;
  371. transition-duration: 0.2s;
  372. &.is-uploading {
  373. @apply opacity-70;
  374. }
  375. }
  376. &__close {
  377. @apply absolute top-1 right-1;
  378. }
  379. &__progress {
  380. @apply absolute bottom-2 left-0 w-full z-10 px-2;
  381. }
  382. }
  383. }
  384. </style>