li.shaoyi 5 месяцев назад
Родитель
Сommit
dfe8ffd40b

+ 29 - 0
src/components/base/click-verify/index.less

@@ -0,0 +1,29 @@
+.click-verify {
+    &__title {
+        display: flex;
+        align-items: center;
+        justify-content: space-between;
+        margin-bottom: 10px;
+
+        b {
+            color: red;
+        }
+    }
+
+    &__image {
+        position: relative;
+        width: 100%;
+        height: 180px;
+        background-color: #cdc8c8;
+
+        span {
+            position: absolute;
+            display: inline-flex;
+            justify-content: center;
+            align-items: center;
+            text-decoration: underline;
+            cursor: pointer;
+            background-color: #000;
+        }
+    }
+}

+ 187 - 0
src/components/base/click-verify/index.vue

@@ -0,0 +1,187 @@
+<!-- 文本点击验证组件 -->
+<template>
+    <div class="click-verify">
+        <div class="click-verify__title">
+            <div>
+                <slot name="tip" :mode="state.compareMode">
+                    <span>请点击 <b>{{ state.compareMode ? '最大' : '最小' }}</b> 的数字</span>
+                </slot>
+            </div>
+            <div @click="onRefresh">
+                <slot name="refresh">
+                    <span>刷新</span>
+                </slot>
+            </div>
+        </div>
+        <div ref="imageRef" class="click-verify__image">
+            <template v-for="(item, index) in state.textList" :key="index">
+                <span :style="item.styles" @click="onClick(item.value)">{{ item.value }}</span>
+            </template>
+        </div>
+    </div>
+</template>
+
+<script lang="ts" setup>
+import { onMounted, ref, reactive, CSSProperties } from 'vue'
+
+const props = defineProps({
+    count: {
+        type: Number,
+        default: 4
+    },
+    // 随机旋转
+    transform: {
+        type: Boolean,
+        default: false
+    }
+})
+
+const emit = defineEmits(['success', 'error'])
+
+const imageRef = ref<HTMLDivElement>()
+
+const state = reactive<{
+    textList: {
+        styles: CSSProperties;
+        value: number;
+    }[]
+    textSize: number;
+    positionX: number[];
+    positionY: number[];
+    compareMode: number; // 取大或取小
+}>({
+    textList: [],
+    textSize: 40,
+    positionX: [],
+    positionY: [],
+    compareMode: 0
+})
+
+// 生成随机数组
+const randomNumbers = (min = 1, max = 100) => {
+    // 创建包含所有可能数字的数组
+    const allNumbers = Array.from({ length: max - min + 1 }, (_, i) => i + min);
+
+    // Fisher-Yates洗牌算法
+    for (let i = allNumbers.length - 1; i > 0; i--) {
+        const j = Math.floor(Math.random() * (i + 1));
+        [allNumbers[i], allNumbers[j]] = [allNumbers[j], allNumbers[i]];
+    }
+
+    // 返回前count个元素
+    return allNumbers.slice(0, props.count);
+}
+
+const onRefresh = () => {
+    const el = imageRef.value
+
+    if (el) {
+        const numbers = randomNumbers(4)
+        state.compareMode = Math.floor(Math.random() * 2)
+        state.positionX = []
+        state.positionY = []
+
+        state.textList = numbers.map((value) => {
+            const { top, left } = getItemRandomTopLeft(el.clientWidth, el.clientHeight)
+            return {
+                styles: {
+                    top: top + 'px',
+                    left: left + 'px',
+                    color: getRandomColor(),
+                    transform: props.transform ? `rotate(${Math.random() * 360}deg)` : 'none',
+                    width: state.textSize + 'px',
+                    height: state.textSize + 'px',
+                    fontSize: `clamp(12px, calc(${state.textSize}px * 0.5), ${state.textSize}px)`
+                },
+                value
+            }
+        })
+    }
+}
+
+const onClick = (value: number) => {
+    const values = state.textList.map((e) => e.value)
+    const checkedValue = state.compareMode ? Math.max(...values) : Math.min(...values)
+
+    if (checkedValue === value) {
+        emit('success')
+    } else {
+        onRefresh()
+        emit('error')
+    }
+}
+
+// 生成随机的RGB颜色值
+const getRandomColor = (minBrightness = 128) => {
+    return '#' + Array.from({ length: 3 }, () => {
+        const value = Math.floor(Math.random() * (256 - minBrightness)) + minBrightness;
+        return value.toString(16).padStart(2, '0');
+    }).join('');
+}
+
+// 判断生成的边界值
+const getItemRandomTopLeft = (width: number, height: number) => {
+    const padding = 20; // 元素之间的最小间距
+
+    // 生成所有可能的位置
+    const positions = [];
+    const cols = Math.floor(width / (state.textSize + padding));
+    const rows = Math.floor(height / (state.textSize + padding));
+
+    for (let i = 0; i < rows; i++) {
+        for (let j = 0; j < cols; j++) {
+            positions.push({
+                x: padding + j * (state.textSize + padding),
+                y: padding + i * (state.textSize + padding)
+            });
+        }
+    }
+
+    // 随机打乱位置数组
+    for (let i = positions.length - 1; i > 0; i--) {
+        const j = Math.floor(Math.random() * (i + 1));
+        [positions[i], positions[j]] = [positions[j], positions[i]];
+    }
+
+    // 选择一个不重叠的位置
+    for (const pos of positions) {
+        let collision = false;
+
+        // 检查是否与已放置的元素重叠
+        for (let k = 0; k < state.positionX.length; k++) {
+            const existingX = state.positionX[k];
+            const existingY = state.positionY[k];
+
+            // 检查两个矩形是否重叠
+            if (!(pos.x + state.textSize < existingX ||
+                pos.x > existingX + state.textSize ||
+                pos.y + state.textSize < existingY ||
+                pos.y > existingY + state.textSize)) {
+                collision = true;
+                break;
+            }
+        }
+
+        if (!collision) {
+            state.positionX.push(pos.x);
+            state.positionY.push(pos.y);
+            return {
+                left: pos.x,
+                top: pos.y
+            }
+        }
+    }
+
+    // 如果所有位置都重叠(不太可能),返回一个安全位置
+    return {
+        left: padding,
+        top: padding
+    }
+}
+
+onMounted(() => onRefresh())
+</script>
+
+<style lang="less">
+@import './index.less';
+</style>

+ 1 - 0
src/components/base/qrcode/index.vue

@@ -8,6 +8,7 @@ import { fillRoundRect } from '@/utils/canvas'
 import QRCode from 'qrcode'
 
 const props = defineProps({
+    // 二维码图片数据
     modelValue: String,
     text: {
         type: String,

+ 7 - 0
src/packages/mobile/components/base/click-verify/index.less

@@ -0,0 +1,7 @@
+.app-click-verify {
+    &__dialog {
+        .click-verify {
+            padding: 15px;
+        }
+    }
+}

+ 53 - 0
src/packages/mobile/components/base/click-verify/index.vue

@@ -0,0 +1,53 @@
+<!-- 文本点击验证组件 -->
+<template>
+    <div class="app-click-verify">
+        <slot>
+            <Button @click="showDialog = true">验证</Button>
+        </slot>
+        <Dialog class="app-click-verify__dialog" teleport="body" v-model:show="showDialog" :show-confirm-button="false"
+            show-cancel-button destroy-on-close>
+            <ClickVerify @success="onSuccess" @error="onError">
+                <template #refresh>
+                    <Icon name="replay" size="20px" />
+                </template>
+            </ClickVerify>
+        </Dialog>
+    </div>
+</template>
+
+<script lang="ts" setup>
+import { computed } from 'vue'
+import { Button, Dialog, Icon, showToast } from 'vant'
+import ClickVerify from '@/components/base/click-verify/index.vue'
+
+const props = defineProps({
+    show: {
+        type: Boolean,
+        default: false,
+        required: true
+    }
+})
+
+const emit = defineEmits(['update:show', 'success'])
+
+const showDialog = computed({
+    get: () => props.show,
+    set: (val) => emit('update:show', val)
+})
+
+const onSuccess = () => {
+    showDialog.value = false
+    emit('success')
+}
+
+const onError = () => {
+    showToast({
+        message: '验证失败',
+        position: 'top'
+    })
+}
+</script>
+
+<style lang="less">
+@import './index.less';
+</style>

+ 28 - 45
src/packages/sbyj/views/user/login/Index.vue

@@ -4,26 +4,21 @@
     <div class="login-logo">
       <img :src="'./img/login-logo.png'" />
     </div>
-    <Form ref="formRef" class="login-form" @submit="formSubmit">
+    <Form ref="formRef" class="login-form" @submit="formValidate">
       <CellGroup inset>
-        <Field v-model="formData.userName" name="account" label="账号登录" label-align="top" size="large" placeholder="请输入登录账号/手机号"
-          :rules="[{ required: true, message: '请输入账号登录' }]" />
+        <Field v-model="formData.userName" name="account" label="账号登录" label-align="top" size="large"
+          placeholder="请输入登录账号/手机号" :rules="[{ required: true, message: '请输入账号登录' }]" />
         <Field v-model="formData.password" name="password" type="password" label="密码" label-align="top" size="large"
           placeholder="请输入密码" :rules="[{ required: true, message: '请输入密码' }]" autocomplete="off" />
-        <!-- <Field>
-          <template #input>
-            <SliderVerify @statu="slide" style="max-width: 100%;margin: auto;" v-if="showSliderVerify" />
-          </template>
-        </Field> -->
       </CellGroup>
     </Form>
     <div class="login-link">
       <span @click="navigationTo('user-register')">用户注册</span>
       <span @click="navigationTo('user-forget')">忘记密码</span>
     </div>
-    <div class="login-submit">
+    <ClickVerify class="login-submit" v-model:show="showClickVerify" @success="formSubmit">
       <Button type="primary" native-type="submit" @click="formRef?.submit" round block>登录</Button>
-    </div>
+    </ClickVerify>
     <div class="login-footer">
       <div class="login-footer__trem">
         <Checkbox shape="square" icon-size="16px" v-model="checked">我已阅读并同意</Checkbox>
@@ -48,14 +43,13 @@ import { useLogin } from '@/business/login'
 import { useNavigation } from '@mobile/router/navigation'
 import service from '@/services'
 import plus from '@/utils/h5plus'
-import SliderVerify from '@/components/base/slider-verify/index.vue' // 临时调用,待优化
+import ClickVerify from '@mobile/components/base/click-verify/index.vue'
 
 const { routerBack, setGlobalUrlParams, routerTo } = useNavigation()
 const { formData, userLogin } = useLogin()
 const formRef = shallowRef<FormInstance>()
 const checked = shallowRef(false) // 是否同意协议管理
-const showSliderVerify = shallowRef(true) // 验证滑块组件重载
-const sliderVerifyStatus = shallowRef(true) // 滑块验证状态
+const showClickVerify = shallowRef(false) // 显示验证窗口
 
 const meta = document.getElementsByTagName('meta')
 const appVersion = meta.namedItem('revised')?.content ?? '0'
@@ -72,44 +66,33 @@ const navigationTo = (name: string) => {
   }, '加载中...')
 }
 
-// 滑块验证 
-const slide = (vfcStatu: { statu: string }) => {
-  if (vfcStatu.statu === 'success') {
-    sliderVerifyStatus.value = true
+// 表单验证
+const formValidate = () => {
+  if (checked.value) {
+    showClickVerify.value = true
   } else {
-    sliderVerifyStatus.value = false
+    showToast('请先同意使用条款')
   }
 }
 
+// 表单提交
 const formSubmit = () => {
-  if (sliderVerifyStatus.value) {
-    if (checked.value) {
-      fullloading((hideLoading) => {
-        userLogin().then((forcedPasswordChange) => {
-          hideLoading()
-          if (forcedPasswordChange) {
-            dialog('为了您的账户安全,请修改密码!').then(() => {
-              setGlobalUrlParams({ forcedPasswordChange })
-              routerTo('user-password', true)
-            })
-          } else {
-            routerBack()
-          }
-        }).catch((err) => {
-          showSliderVerify.value = false
-          sliderVerifyStatus.value = false
-          formData.password = ''
-          showFailToast(err)
-
-          setTimeout(() => {
-            showSliderVerify.value = true
-          }, 0)
+  fullloading((hideLoading) => {
+    userLogin().then((forcedPasswordChange) => {
+      hideLoading()
+      if (forcedPasswordChange) {
+        dialog('为了您的账户安全,请修改密码!').then(() => {
+          setGlobalUrlParams({ forcedPasswordChange })
+          routerTo('user-password', true)
         })
-      }, '登录中...')
-    } else {
-      showToast('请先同意使用条款')
-    }
-  }
+      } else {
+        routerBack()
+      }
+    }).catch((err) => {
+      formData.password = ''
+      showFailToast(err)
+    })
+  }, '登录中...')
 }
 
 onMounted(() => plus.setStatusBarStyle('dark'))

+ 33 - 11
src/packages/tss/views/bank/wallet/components/inoutapply/Index.vue

@@ -40,27 +40,40 @@
                 </div>
             </app-pull-refresh>
         </app-view>
+        <ActionSheet v-model:show="showQRCode" title="扫码付款" @closed="selectedItem = undefined">
+            <div style="display: flex;flex-direction: column;align-items: center;padding-bottom: 100px;"
+                v-if="selectedItem">
+                <app-qrcode :text="selectedItem.url" :width="240" :margin="3" />
+                <span style="color: #999;" v-if="selectedItem.bankcode === 'wechat'">请使用微信扫码支付</span>
+                <span style="color: #999;" v-if="selectedItem.bankcode === 'alipay'">请使用支付宝扫码支付</span>
+            </div>
+        </ActionSheet>
     </app-modal>
 </template>
 
 <script lang="ts" setup>
 import { shallowRef } from 'vue'
 import { Button } from 'vant'
-import { showFailToast } from 'vant'
+import { showFailToast, ActionSheet } from 'vant'
+import { fullloading } from '@/utils/vant'
 import { useRequest } from '@/hooks/request'
 import { queryAccountInOutApply, getAmtInByPaidUrl } from '@/services/api/bank'
 import { getInOutApplyStatusName, getInOutExecuteTypeName } from '@/constants/order'
 import { currencyFormat, handleNoneValue, formatDate } from '@/filters'
+import { i18n } from '@/stores'
+import plus from '@/utils/h5plus'
 import AppModal from '@/components/base/modal/index.vue'
 import AppPullRefresh from '@mobile/components/base/pull-refresh/index.vue'
-import plus from '@/utils/h5plus'
-import { i18n } from '@/stores'
+import AppQrcode from '@/components/base/qrcode/index.vue'
 
+const t = i18n.global.t
 const showModal = shallowRef(true)
 const error = shallowRef(false)
 const pullRefreshRef = shallowRef()
 const refresh = shallowRef(false) // 是否刷新父组件数据
-const { global: { t }} = i18n
+
+const showQRCode = shallowRef(false) // 是否弹出二维码
+const selectedItem = shallowRef<Model.AmtInByPaidUrlRsp>() // 当前选择的数据项
 
 const { loading, pageIndex, pageCount, run, dataList } = useRequest(queryAccountInOutApply)
 
@@ -75,13 +88,22 @@ const closed = (isRefresh = false) => {
 }
 
 const goToAmtInByPaidUrl = (item: Model.AccountOutInApplyRsp) => {
-    getAmtInByPaidUrl({
-        data: {
-            exchticket: item.exchticket
-        }
-    }).then((res) => {
-        plus.openURL(res.data.url, (error) => {
-            showFailToast(error.message || t('banksign.wallet.inoutapply.tips'))
+    fullloading((hideLoading) => {
+        getAmtInByPaidUrl({
+            data: {
+                exchticket: item.exchticket
+            }
+        }).then((res) => {
+            if (['wechat', 'alipay'].includes(res.data.bankcode)) {
+                selectedItem.value = res.data
+                showQRCode.value = true
+            } else {
+                plus.openURL(res.data.url, (error) => {
+                    showFailToast(error.message || t('banksign.wallet.inoutapply.tips'))
+                })
+            }
+        }).finally(() => {
+            hideLoading()
         })
     })
 }

+ 1 - 0
src/types/model/bank.d.ts

@@ -105,6 +105,7 @@ declare namespace Model {
 
     /** 获取银行支付地址 回应 */
     interface AmtInByPaidUrlRsp {
+        bankcode: string;
         channelmode: string;// 渠道类型:ChillPay,PayerMax,AsiaPay
         params: string;// 支付参数,只有 AsiaPay 渠道需要
         url: string;// 支付跳转地址