cl-countdown.uvue 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324
  1. <template>
  2. <view class="cl-countdown" :class="[pt.className]">
  3. <view
  4. class="cl-countdown__item"
  5. :class="[`${item.isSplitor ? pt.splitor?.className : pt.text?.className}`]"
  6. v-for="(item, index) in list"
  7. :key="index"
  8. >
  9. <slot name="item" :item="item">
  10. <cl-text>{{ item.value }}</cl-text>
  11. </slot>
  12. </view>
  13. </view>
  14. </template>
  15. <script lang="ts" setup>
  16. import { ref, watch, nextTick, computed, type PropType, onMounted, onUnmounted } from "vue";
  17. import type { PassThroughProps } from "../../types";
  18. import { dayUts, get, has, isEmpty, parsePt } from "@/.cool";
  19. type Item = {
  20. value: string;
  21. isSplitor: boolean;
  22. };
  23. defineOptions({
  24. name: "cl-countdown"
  25. });
  26. defineSlots<{
  27. item(props: { item: Item }): any;
  28. }>();
  29. const props = defineProps({
  30. // 样式穿透配置
  31. pt: {
  32. type: Object,
  33. default: () => ({})
  34. },
  35. // 格式化模板,支持 {d}天{h}:{m}:{s} 格式
  36. format: {
  37. type: String,
  38. default: "{h}:{m}:{s}"
  39. },
  40. // 是否隐藏为0的单位
  41. hideZero: {
  42. type: Boolean,
  43. default: false
  44. },
  45. // 倒计时天数
  46. day: {
  47. type: Number,
  48. default: 0
  49. },
  50. // 倒计时小时数
  51. hour: {
  52. type: Number,
  53. default: 0
  54. },
  55. // 倒计时分钟数
  56. minute: {
  57. type: Number,
  58. default: 0
  59. },
  60. // 倒计时秒数
  61. second: {
  62. type: Number,
  63. default: 0
  64. },
  65. // 结束时间,可以是Date对象或日期字符串
  66. datetime: {
  67. type: [Date, String] as PropType<Date | string>,
  68. default: null
  69. },
  70. // 是否自动开始倒计时
  71. auto: {
  72. type: Boolean,
  73. default: true
  74. }
  75. });
  76. /**
  77. * 组件事件定义
  78. */
  79. const emit = defineEmits(["stop", "done", "change"]);
  80. /**
  81. * 样式穿透类型定义
  82. */
  83. type PassThrough = {
  84. className?: string;
  85. text?: PassThroughProps;
  86. splitor?: PassThroughProps;
  87. };
  88. // 解析样式穿透配置
  89. const pt = computed(() => parsePt<PassThrough>(props.pt));
  90. // 定时器ID,用于清除定时器
  91. let timer: number = 0;
  92. // 当前剩余秒数
  93. const seconds = ref(0);
  94. // 倒计时运行状态
  95. const isRunning = ref(false);
  96. // 显示列表
  97. const list = ref<Item[]>([]);
  98. /**
  99. * 倒计时选项类型定义
  100. */
  101. type Options = {
  102. day?: number;
  103. hour?: number;
  104. minute?: number;
  105. second?: number;
  106. datetime?: Date | string;
  107. };
  108. /**
  109. * 将时间单位转换为总秒数
  110. * @param options 时间选项,支持天、时、分、秒或具体日期时间
  111. * @returns 总秒数
  112. */
  113. function toSeconds({ day, hour, minute, second, datetime }: Options) {
  114. if (datetime != null) {
  115. // 如果提供了具体日期时间,计算与当前时间的差值
  116. const diff = dayUts(datetime).diff(dayUts());
  117. return Math.max(0, Math.floor(diff / 1000));
  118. } else {
  119. // 否则将各个时间单位转换为秒数
  120. return Math.max(
  121. 0,
  122. (day ?? 0) * 86400 + (hour ?? 0) * 3600 + (minute ?? 0) * 60 + (second ?? 0)
  123. );
  124. }
  125. }
  126. /**
  127. * 执行倒计时逻辑
  128. * 计算剩余时间并格式化显示
  129. */
  130. function countDown() {
  131. // 计算天、时、分、秒,使用更简洁的计算方式
  132. const totalSeconds = Math.floor(seconds.value);
  133. const day = Math.floor(totalSeconds / 86400); // 86400 = 24 * 60 * 60
  134. const hour = Math.floor((totalSeconds % 86400) / 3600); // 3600 = 60 * 60
  135. const minute = Math.floor((totalSeconds % 3600) / 60);
  136. const second = totalSeconds % 60;
  137. // 格式化时间对象,用于模板替换
  138. const t = {
  139. d: day.toString(),
  140. h: hour.toString().padStart(2, "0"),
  141. m: minute.toString().padStart(2, "0"),
  142. s: second.toString().padStart(2, "0")
  143. };
  144. // 控制是否隐藏零值,初始为true表示隐藏
  145. let isHide = true;
  146. // 记录开始隐藏的位置索引,-1表示不隐藏
  147. let start = -1;
  148. // 根据格式模板生成显示列表
  149. list.value = (props.format.split(/[{,}]/) as string[])
  150. .map((e, i) => {
  151. const value = has(t, e) ? (get(t, e) as string) : e;
  152. const isSplitor = /^\D+$/.test(value);
  153. if (props.hideZero) {
  154. if (isHide && !isSplitor) {
  155. if (value == "00" || value == "0" || isEmpty(value)) {
  156. start = i;
  157. isHide = true;
  158. } else {
  159. isHide = false;
  160. }
  161. }
  162. }
  163. return {
  164. value,
  165. isSplitor
  166. } as Item;
  167. })
  168. .filter((e, i) => {
  169. return !isEmpty(e.value) && (start == -1 ? true : start < i);
  170. })
  171. .filter((e, i) => {
  172. if (i == 0 && e.isSplitor) {
  173. return false;
  174. }
  175. return true;
  176. });
  177. // 触发change事件
  178. emit("change", list.value);
  179. }
  180. /**
  181. * 清除定时器并重置状态
  182. */
  183. function clear() {
  184. clearTimeout(timer);
  185. timer = 0;
  186. isRunning.value = false;
  187. }
  188. /**
  189. * 停止倒计时
  190. */
  191. function stop() {
  192. clear();
  193. emit("stop");
  194. }
  195. /**
  196. * 倒计时结束处理
  197. */
  198. function done() {
  199. clear();
  200. emit("done");
  201. }
  202. /**
  203. * 继续倒计时
  204. * 启动定时器循环执行倒计时逻辑
  205. */
  206. function next() {
  207. // 如果时间已到或正在运行,直接返回
  208. if (seconds.value <= 0 || isRunning.value) {
  209. return;
  210. }
  211. isRunning.value = true;
  212. /**
  213. * 倒计时循环函数
  214. * 每秒执行一次,直到时间结束
  215. */
  216. function loop() {
  217. countDown();
  218. if (seconds.value <= 0) {
  219. done();
  220. return;
  221. } else {
  222. seconds.value--;
  223. // @ts-ignore
  224. timer = setTimeout(() => loop(), 1000);
  225. }
  226. }
  227. loop();
  228. }
  229. /**
  230. * 开始倒计时
  231. * @param options 可选的倒计时参数,不传则使用props中的值
  232. */
  233. function start(options: Options | null = null) {
  234. nextTick(() => {
  235. // 计算初始秒数
  236. seconds.value = toSeconds({
  237. day: options?.day ?? props.day,
  238. hour: options?.hour ?? props.hour,
  239. minute: options?.minute ?? props.minute,
  240. second: options?.second ?? props.second,
  241. datetime: options?.datetime ?? props.datetime
  242. });
  243. // 开始倒计时
  244. if (props.auto) {
  245. next();
  246. } else {
  247. countDown();
  248. }
  249. });
  250. }
  251. // 组件销毁前停止倒计时
  252. onUnmounted(() => stop());
  253. // 组件挂载前开始倒计时
  254. onMounted(() => {
  255. start();
  256. // 监听时间单位变化,重新开始倒计时
  257. watch(
  258. computed(() => [props.day, props.hour, props.minute, props.second] as number[]),
  259. () => {
  260. start();
  261. }
  262. );
  263. // 监听结束时间变化,重新开始倒计时
  264. watch(
  265. computed(() => props.datetime),
  266. () => {
  267. start();
  268. }
  269. );
  270. });
  271. defineExpose({
  272. start,
  273. stop,
  274. done,
  275. next,
  276. isRunning
  277. });
  278. </script>
  279. <style lang="scss" scoped>
  280. .cl-countdown {
  281. @apply flex flex-row items-center;
  282. &__item {
  283. @apply flex flex-row justify-center items-center;
  284. }
  285. }
  286. </style>