cl-slide-verify.uvue 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507
  1. <template>
  2. <view
  3. class="cl-slide-verify"
  4. :class="[
  5. {
  6. 'cl-slide-verify--disabled': disabled,
  7. 'cl-slide-verify--success': isSuccess,
  8. 'cl-slide-verify--fail': isFail
  9. },
  10. pt.className
  11. ]"
  12. >
  13. <!-- 背景图片(图片验证模式) -->
  14. <image
  15. v-if="mode == 'image' && imageUrl != ''"
  16. class="cl-slide-verify__image"
  17. :class="[pt.image?.className]"
  18. :src="imageUrl"
  19. :style="{
  20. transform: `rotate(${currentAngle}deg)`,
  21. height: getUnit(imageSize),
  22. width: getUnit(imageSize)
  23. }"
  24. mode="aspectFill"
  25. ></image>
  26. <!-- 滑动轨道 -->
  27. <view
  28. class="cl-slide-verify__track"
  29. :class="[
  30. {
  31. 'cl-slide-verify__track--success': isSuccess,
  32. 'cl-slide-verify__track--fail': isFail,
  33. 'cl-slide-verify__track--dark': isDark
  34. },
  35. pt.track?.className
  36. ]"
  37. :style="{
  38. height: getUnit(size)
  39. }"
  40. >
  41. <!-- 滑动进度条 -->
  42. <view
  43. class="cl-slide-verify__progress"
  44. :class="[
  45. {
  46. 'cl-slide-verify__progress--success': isSuccess,
  47. 'cl-slide-verify__progress--fail': isFail,
  48. 'no-dragging': !isDragging
  49. },
  50. pt.progress?.className
  51. ]"
  52. :style="progressStyle"
  53. ></view>
  54. <!-- 滑动按钮 -->
  55. <view
  56. class="cl-slide-verify__slider"
  57. :class="[
  58. {
  59. 'cl-slide-verify__slider--active': isDragging,
  60. 'cl-slide-verify__slider--success': isSuccess,
  61. 'cl-slide-verify__slider--fail': isFail,
  62. 'cl-slide-verify__slider--dark': isDark,
  63. 'no-dragging': !isDragging
  64. },
  65. pt.slider?.className
  66. ]"
  67. :style="sliderStyle"
  68. @touchstart="onTouchStart"
  69. @touchmove.stop.prevent="onTouchMove"
  70. @touchend="onTouchEnd"
  71. @touchcancel="onTouchEnd"
  72. >
  73. <cl-icon
  74. :name="sliderIcon"
  75. :size="22"
  76. :color="sliderColor"
  77. :pt="{
  78. className: parseClass([pt.icon?.className])
  79. }"
  80. ></cl-icon>
  81. </view>
  82. <!-- 文字提示 -->
  83. <view class="cl-slide-verify__text" :class="[pt.text?.className]">
  84. <cl-text
  85. :color="textColor"
  86. :pt="{
  87. className: parseClass([pt.label?.className])
  88. }"
  89. >
  90. {{ currentText }}
  91. </cl-text>
  92. </view>
  93. </view>
  94. </view>
  95. </template>
  96. <script setup lang="ts">
  97. import { computed, ref, watch, nextTick, getCurrentInstance, type PropType } from "vue";
  98. import { isDark, parseClass, parsePt, getUnit, random, t } from "@/.cool";
  99. import type { PassThroughProps } from "../../types";
  100. defineOptions({
  101. name: "cl-slide-verify"
  102. });
  103. // 组件属性定义
  104. const props = defineProps({
  105. // 样式穿透
  106. pt: {
  107. type: Object,
  108. default: () => ({})
  109. },
  110. // 是否验证成功
  111. modelValue: {
  112. type: Boolean,
  113. default: false
  114. },
  115. // 验证模式:slide-直接滑动验证, image-图片旋转验证
  116. mode: {
  117. type: String as PropType<"slide" | "image">,
  118. default: "slide"
  119. },
  120. // 滑块大小
  121. size: {
  122. type: Number,
  123. default: 40
  124. },
  125. // 是否禁用
  126. disabled: {
  127. type: Boolean,
  128. default: false
  129. },
  130. // 图片URL(图片模式使用)
  131. imageUrl: {
  132. type: String,
  133. default: ""
  134. },
  135. // 图片大小(图片模式使用)
  136. imageSize: {
  137. type: [Number, String],
  138. default: 150
  139. },
  140. // 角度容错范围
  141. angleThreshold: {
  142. type: Number,
  143. default: 10
  144. },
  145. // 提示文字
  146. text: {
  147. type: String,
  148. default: ""
  149. },
  150. // 成功文字
  151. successText: {
  152. type: String,
  153. default: () => t("验证成功")
  154. },
  155. // 是否错误提示
  156. showFail: {
  157. type: Boolean,
  158. default: true
  159. },
  160. // 错误提示文字
  161. failText: {
  162. type: String,
  163. default: () => t("验证失败")
  164. }
  165. });
  166. // 事件定义
  167. const emit = defineEmits(["update:modelValue", "success", "fail", "change"]);
  168. const { proxy } = getCurrentInstance()!;
  169. // 样式穿透类型
  170. type PassThrough = {
  171. className?: string;
  172. track?: PassThroughProps;
  173. image?: PassThroughProps;
  174. progress?: PassThroughProps;
  175. slider?: PassThroughProps;
  176. icon?: PassThroughProps;
  177. text?: PassThroughProps;
  178. label?: PassThroughProps;
  179. };
  180. // 样式穿透计算
  181. const pt = computed(() => parsePt<PassThrough>(props.pt));
  182. // 滑动状态相关变量
  183. const isDragging = ref(false); // 是否正在拖动
  184. const isSuccess = ref(false); // 是否验证成功
  185. const isFail = ref(false); // 是否验证失败
  186. const sliderLeft = ref(0); // 滑块左侧距离
  187. const progressWidth = ref(0); // 进度条宽度
  188. const startX = ref(0); // 触摸起始点X坐标
  189. const currentAngle = ref(0); // 当前图片角度
  190. const initialAngle = ref(0); // 初始图片角度
  191. // 轨道宽度
  192. const trackWidth = ref(0); // 滑动轨道宽度
  193. // 当前显示的提示文字
  194. const currentText = computed(() => {
  195. if (isSuccess.value) {
  196. // 成功时显示成功文字
  197. return props.successText;
  198. }
  199. if (isFail.value) {
  200. // 失败时显示失败文字
  201. return props.failText;
  202. }
  203. if (props.text != "") {
  204. // 有自定义文字时显示自定义文字
  205. return props.text;
  206. }
  207. if (props.mode == "image") {
  208. // 图片模式下默认提示
  209. return t("向右滑动转动图片");
  210. }
  211. return t("向右滑动验证"); // 默认提示
  212. });
  213. // 滑块图标
  214. const sliderIcon = computed(() => {
  215. if (isSuccess.value) {
  216. // 成功时显示对勾
  217. return "check-line";
  218. }
  219. return "arrow-right-double-line"; // 其他情况显示双箭头
  220. });
  221. // 滑块颜色
  222. const sliderColor = computed(() => {
  223. if (isSuccess.value || isFail.value) {
  224. // 成功或失败时为白色
  225. return "white";
  226. }
  227. return "primary"; // 其他情况为主题色
  228. });
  229. // 文字颜色
  230. const textColor = computed(() => {
  231. if (isSuccess.value) {
  232. // 成功时为绿色
  233. return "success";
  234. }
  235. if (isFail.value) {
  236. // 失败时为红色
  237. return "error";
  238. }
  239. if (isDragging.value) {
  240. // 拖动时为主题色
  241. return "primary";
  242. }
  243. return "info"; // 默认为信息色
  244. });
  245. // 进度条样式
  246. const progressStyle = computed(() => {
  247. const style = {}; // 样式对象
  248. let width = progressWidth.value; // 当前进度条宽度
  249. if (width > props.size) {
  250. // 超过滑块宽度时,增加宽度
  251. width += props.size / 2;
  252. }
  253. style["width"] = width + "px"; // 设置宽度
  254. return style; // 返回样式对象
  255. });
  256. // 滑块样式
  257. const sliderStyle = computed(() => {
  258. const style = {
  259. left: sliderLeft.value + "px", // 滑块左侧距离
  260. height: props.size + "px", // 滑块高度
  261. width: props.size + "px" // 滑块宽度
  262. };
  263. return style; // 返回样式对象
  264. });
  265. // 检查验证是否成功
  266. function checkVerification(): boolean {
  267. if (props.mode == "slide") {
  268. // 滑动模式下,滑块到达最右侧即为成功
  269. return sliderLeft.value / (trackWidth.value - props.size) == 1;
  270. } else if (props.mode == "image") {
  271. // 图片模式下,角度在容错范围内即为成功
  272. const angle = currentAngle.value % 360;
  273. return angle <= props.angleThreshold || angle >= 360 - props.angleThreshold;
  274. }
  275. return false; // 其他情况返回失败
  276. }
  277. // 重置组件状态
  278. function reset() {
  279. sliderLeft.value = 0; // 滑块归零
  280. progressWidth.value = 0; // 进度条归零
  281. isSuccess.value = false; // 清除成功状态
  282. isFail.value = false; // 清除失败状态
  283. isDragging.value = false; // 清除拖动状态
  284. // 图片模式下重新设置随机初始角度
  285. if (props.mode == "image") {
  286. initialAngle.value = random(100, 180); // 随机初始角度
  287. currentAngle.value = initialAngle.value; // 当前角度等于初始角度
  288. }
  289. }
  290. // 初始化组件
  291. function init() {
  292. nextTick(() => {
  293. // 等待DOM更新后执行
  294. reset(); // 重置组件状态
  295. // 获取轨道宽度
  296. uni.createSelectorQuery()
  297. .in(proxy)
  298. .select(".cl-slide-verify")
  299. .boundingClientRect()
  300. .exec((res) => {
  301. trackWidth.value = (res[0] as NodeInfo).width ?? 0; // 设置轨道宽度
  302. });
  303. });
  304. }
  305. // 触摸开始事件
  306. function onTouchStart(e: TouchEvent) {
  307. // 禁用或已完成时不处理
  308. if (props.disabled || isSuccess.value || isFail.value) return;
  309. // 标记为拖动中
  310. isDragging.value = true;
  311. // 记录起始X坐标
  312. startX.value = e.touches[0].clientX;
  313. // 震动反馈
  314. uni.$emit("cool.vibrate");
  315. }
  316. // 触摸移动事件
  317. function onTouchMove(e: TouchEvent) {
  318. if (!isDragging.value || props.disabled || isSuccess.value || isFail.value) return; // 非拖动或禁用/完成时不处理
  319. const currentX = e.touches[0].clientX; // 当前X坐标
  320. const deltaX = currentX - startX.value; // 计算滑动距离
  321. // 限制滑动范围
  322. const newLeft = Math.max(0, Math.min(trackWidth.value - props.size, deltaX));
  323. sliderLeft.value = newLeft; // 设置滑块位置
  324. progressWidth.value = newLeft; // 设置进度条宽度
  325. // 图片模式下,根据滑动距离旋转图片
  326. if (props.mode == "image") {
  327. const progress = newLeft / (trackWidth.value - props.size); // 计算滑动进度
  328. // 从初始错误角度线性旋转到正确角度
  329. currentAngle.value = initialAngle.value + initialAngle.value * progress * 3;
  330. }
  331. emit("change", {
  332. progress: newLeft / trackWidth.value, // 当前进度
  333. angle: currentAngle.value // 当前角度
  334. });
  335. }
  336. // 触摸结束事件
  337. function onTouchEnd() {
  338. if (!isDragging.value || props.disabled || isSuccess.value || isFail.value) return; // 非拖动或禁用/完成时不处理
  339. isDragging.value = false; // 结束拖动
  340. // 检查验证是否成功
  341. const isComplete = checkVerification();
  342. if (isComplete) {
  343. // 验证成功
  344. isSuccess.value = true;
  345. emit("update:modelValue", true); // 通知父组件
  346. emit("success", {
  347. mode: props.mode,
  348. progress: sliderLeft.value / trackWidth.value,
  349. angle: currentAngle.value
  350. });
  351. } else {
  352. if (props.showFail) {
  353. isFail.value = true; // 显示失败状态
  354. } else {
  355. // 验证失败,重置位置
  356. reset();
  357. }
  358. emit("update:modelValue", false); // 通知父组件
  359. emit("fail", {
  360. mode: props.mode,
  361. progress: sliderLeft.value / trackWidth.value,
  362. angle: currentAngle.value
  363. });
  364. }
  365. // 震动反馈
  366. uni.$emit("cool.vibrate");
  367. }
  368. // 监听模式变化,重新初始化
  369. watch(
  370. computed(() => props.mode),
  371. () => {
  372. reset();
  373. init();
  374. },
  375. { immediate: true }
  376. );
  377. // 监听图片URL变化
  378. watch(
  379. computed(() => props.imageUrl),
  380. () => {
  381. if (props.mode == "image") {
  382. reset();
  383. }
  384. }
  385. );
  386. defineExpose({
  387. init,
  388. reset
  389. });
  390. </script>
  391. <style lang="scss" scoped>
  392. .cl-slide-verify {
  393. @apply relative rounded-lg w-full flex flex-col items-center justify-center;
  394. .no-dragging {
  395. @apply duration-300;
  396. transition-property: left;
  397. }
  398. &__track {
  399. @apply relative w-full h-full;
  400. @apply bg-surface-100 rounded-lg;
  401. &--success {
  402. @apply bg-green-50;
  403. }
  404. &--fail {
  405. @apply bg-red-50;
  406. }
  407. &--dark {
  408. @apply bg-surface-700;
  409. }
  410. }
  411. &__image {
  412. @apply rounded-full mb-3;
  413. }
  414. &__progress {
  415. @apply absolute left-0 top-0 h-full transition-none;
  416. @apply bg-primary-100;
  417. &--success {
  418. @apply bg-green-200;
  419. }
  420. &--fail {
  421. @apply bg-red-200;
  422. }
  423. }
  424. &__slider {
  425. @apply absolute top-1/2 left-0 z-20 transition-none;
  426. @apply bg-white rounded-lg;
  427. @apply flex items-center justify-center;
  428. @apply border border-surface-200;
  429. transform: translateY(-50%);
  430. &--active {
  431. @apply shadow-lg border-primary-300;
  432. }
  433. &--success {
  434. @apply bg-green-500 border-green-500;
  435. }
  436. &--fail {
  437. @apply bg-red-500 border-red-500;
  438. }
  439. &--dark {
  440. @apply bg-surface-900;
  441. }
  442. }
  443. &__text {
  444. @apply absolute flex items-center justify-center h-full w-full;
  445. @apply pointer-events-none z-10;
  446. }
  447. &--disabled {
  448. @apply opacity-50;
  449. }
  450. &--success {
  451. @apply border-green-300;
  452. }
  453. &--fail {
  454. @apply border-red-300;
  455. }
  456. }
  457. </style>