cl-slide-verify.uvue 11 KB

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