cl-upload.uvue 9.4 KB

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