cl-slide-verify.uvue 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501
  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. },
  49. pt.progress?.className
  50. ]"
  51. :style="progressStyle"
  52. ></view>
  53. <!-- 滑动按钮 -->
  54. <view
  55. class="cl-slide-verify__slider"
  56. :class="[
  57. {
  58. 'cl-slide-verify__slider--active': isDragging,
  59. 'cl-slide-verify__slider--success': isSuccess,
  60. 'cl-slide-verify__slider--fail': isFail,
  61. 'cl-slide-verify__slider--dark': isDark
  62. },
  63. pt.slider?.className
  64. ]"
  65. :style="sliderStyle"
  66. @touchstart="onTouchStart"
  67. @touchmove.stop.prevent="onTouchMove"
  68. @touchend="onTouchEnd"
  69. @touchcancel="onTouchEnd"
  70. >
  71. <cl-icon
  72. :name="sliderIcon"
  73. :size="44"
  74. :color="sliderColor"
  75. :pt="{
  76. className: parseClass([pt.icon?.className])
  77. }"
  78. ></cl-icon>
  79. </view>
  80. <!-- 文字提示 -->
  81. <view class="cl-slide-verify__text" :class="[pt.text?.className]">
  82. <cl-text
  83. :color="textColor"
  84. :pt="{
  85. className: parseClass([pt.label?.className])
  86. }"
  87. >
  88. {{ currentText }}
  89. </cl-text>
  90. </view>
  91. </view>
  92. </view>
  93. </template>
  94. <script setup lang="ts">
  95. import { computed, ref, watch, nextTick, getCurrentInstance, type PropType } from "vue";
  96. import { isDark, parseClass, parsePt, parseRpx, random } from "@/cool";
  97. import type { PassThroughProps } from "../../types";
  98. import { vibrate } from "@/uni_modules/cool-vibrate";
  99. import { t } from "@/locale";
  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: 300
  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. if (!isDragging.value) {
  255. // 非拖动时添加过渡动画
  256. style["transition-duration"] = "300ms";
  257. }
  258. return style; // 返回样式对象
  259. });
  260. // 滑块样式
  261. const sliderStyle = computed(() => {
  262. const style = {
  263. left: sliderLeft.value + "px", // 滑块左侧距离
  264. height: props.size + "px", // 滑块高度
  265. width: props.size + "px" // 滑块宽度
  266. };
  267. if (!isDragging.value) {
  268. // 非拖动时添加过渡动画
  269. style["transition-duration"] = "300ms";
  270. }
  271. return style; // 返回样式对象
  272. });
  273. // 检查验证是否成功
  274. function checkVerification(): boolean {
  275. if (props.mode == "slide") {
  276. // 滑动模式下,滑块到达最右侧即为成功
  277. return sliderLeft.value / (trackWidth.value - props.size) == 1;
  278. } else if (props.mode == "image") {
  279. // 图片模式下,角度在容错范围内即为成功
  280. const angle = currentAngle.value % 360;
  281. return angle <= props.angleThreshold || angle >= 360 - props.angleThreshold;
  282. }
  283. return false; // 其他情况返回失败
  284. }
  285. // 重置组件状态
  286. function reset() {
  287. sliderLeft.value = 0; // 滑块归零
  288. progressWidth.value = 0; // 进度条归零
  289. isSuccess.value = false; // 清除成功状态
  290. isFail.value = false; // 清除失败状态
  291. isDragging.value = false; // 清除拖动状态
  292. // 图片模式下重新设置随机初始角度
  293. if (props.mode == "image") {
  294. initialAngle.value = random(100, 180); // 随机初始角度
  295. currentAngle.value = initialAngle.value; // 当前角度等于初始角度
  296. }
  297. }
  298. // 初始化组件
  299. function init() {
  300. nextTick(() => {
  301. // 等待DOM更新后执行
  302. reset(); // 重置组件状态
  303. // 获取轨道宽度
  304. uni.createSelectorQuery()
  305. .in(proxy)
  306. .select(".cl-slide-verify")
  307. .boundingClientRect()
  308. .exec((res) => {
  309. trackWidth.value = (res[0] as NodeInfo).width ?? 0; // 设置轨道宽度
  310. });
  311. });
  312. }
  313. // 触摸开始事件
  314. function onTouchStart(e: TouchEvent) {
  315. if (props.disabled || isSuccess.value || isFail.value) return; // 禁用或已完成时不处理
  316. isDragging.value = true; // 标记为拖动中
  317. startX.value = e.touches[0].clientX; // 记录起始X坐标
  318. vibrate(1); // 震动反馈
  319. }
  320. // 触摸移动事件
  321. function onTouchMove(e: TouchEvent) {
  322. if (!isDragging.value || props.disabled || isSuccess.value || isFail.value) return; // 非拖动或禁用/完成时不处理
  323. const currentX = e.touches[0].clientX; // 当前X坐标
  324. const deltaX = currentX - startX.value; // 计算滑动距离
  325. // 限制滑动范围
  326. const newLeft = Math.max(0, Math.min(trackWidth.value - props.size, deltaX));
  327. sliderLeft.value = newLeft; // 设置滑块位置
  328. progressWidth.value = newLeft; // 设置进度条宽度
  329. // 图片模式下,根据滑动距离旋转图片
  330. if (props.mode == "image") {
  331. const progress = newLeft / (trackWidth.value - props.size); // 计算滑动进度
  332. // 从初始错误角度线性旋转到正确角度
  333. currentAngle.value = initialAngle.value + initialAngle.value * progress * 3;
  334. }
  335. emit("change", {
  336. progress: newLeft / trackWidth.value, // 当前进度
  337. angle: currentAngle.value // 当前角度
  338. });
  339. }
  340. // 触摸结束事件
  341. function onTouchEnd() {
  342. if (!isDragging.value || props.disabled || isSuccess.value || isFail.value) return; // 非拖动或禁用/完成时不处理
  343. isDragging.value = false; // 结束拖动
  344. // 检查验证是否成功
  345. const isComplete = checkVerification();
  346. if (isComplete) {
  347. // 验证成功
  348. isSuccess.value = true;
  349. emit("update:modelValue", true); // 通知父组件
  350. emit("success", {
  351. mode: props.mode,
  352. progress: sliderLeft.value / trackWidth.value,
  353. angle: currentAngle.value
  354. });
  355. } else {
  356. if (props.showFail) {
  357. isFail.value = true; // 显示失败状态
  358. } else {
  359. // 验证失败,重置位置
  360. reset();
  361. }
  362. emit("update:modelValue", false); // 通知父组件
  363. emit("fail", {
  364. mode: props.mode,
  365. progress: sliderLeft.value / trackWidth.value,
  366. angle: currentAngle.value
  367. });
  368. }
  369. vibrate(2); // 震动反馈
  370. }
  371. // 监听模式变化,重新初始化
  372. watch(
  373. computed(() => props.mode),
  374. () => {
  375. reset();
  376. init();
  377. },
  378. { immediate: true }
  379. );
  380. // 监听图片URL变化
  381. watch(
  382. computed(() => props.imageUrl),
  383. () => {
  384. if (props.mode == "image") {
  385. reset();
  386. }
  387. }
  388. );
  389. defineExpose({
  390. init,
  391. reset
  392. });
  393. </script>
  394. <style lang="scss" scoped>
  395. .cl-slide-verify {
  396. @apply relative rounded-lg w-full flex flex-col items-center justify-center;
  397. &__track {
  398. @apply relative w-full h-full;
  399. @apply bg-surface-100 rounded-lg;
  400. &--success {
  401. @apply bg-green-50;
  402. }
  403. &--fail {
  404. @apply bg-red-50;
  405. }
  406. &--dark {
  407. @apply bg-surface-700;
  408. }
  409. }
  410. &__image {
  411. @apply rounded-full mb-3;
  412. }
  413. &__progress {
  414. @apply absolute left-0 top-0 h-full;
  415. @apply bg-primary-100;
  416. &--success {
  417. @apply bg-green-200;
  418. }
  419. &--fail {
  420. @apply bg-red-200;
  421. }
  422. }
  423. &__slider {
  424. @apply absolute top-1/2 left-0 z-20;
  425. @apply bg-white rounded-lg;
  426. @apply flex items-center justify-center;
  427. @apply border border-surface-200;
  428. transform: translateY(-50%);
  429. &--active {
  430. @apply shadow-lg border-primary-300;
  431. }
  432. &--success {
  433. @apply bg-green-500 border-green-500;
  434. }
  435. &--fail {
  436. @apply bg-red-500 border-red-500;
  437. }
  438. &--dark {
  439. @apply bg-surface-900;
  440. }
  441. }
  442. &__text {
  443. @apply absolute flex items-center justify-center h-full w-full;
  444. @apply pointer-events-none z-10;
  445. }
  446. &--disabled {
  447. @apply opacity-50;
  448. }
  449. &--success {
  450. @apply border-green-300;
  451. }
  452. &--fail {
  453. @apply border-red-300;
  454. }
  455. }
  456. </style>