Parcourir la source

新增滑块拼图验证组件my-slide-captcha,滑块拼图已优化适配任何宽度容器,无需手工指定滑块拼图的宽度和高度。
短信验证码组件使用滑块验证组件来发送短信
新增用户名登录和手机号登录表单验证
修复生成api和同步接口因为后端修改路径前缀异常的问题
修改国际化信息提示不正确的问题

zhontai il y a 2 ans
Parent
commit
ad67aeeb8d

+ 1 - 1
gen/gen-api.js

@@ -5,7 +5,7 @@ const path = require('path')
 const apis = [
   {
     output: path.resolve(__dirname, '../src/api/admin'),
-    url: 'http://localhost:8000/swagger/admin/swagger.json',
+    url: 'http://localhost:8000/admin/swagger/admin/swagger.json',
   },
 ]
 

+ 59 - 0
src/api/admin/Captcha.ts

@@ -0,0 +1,59 @@
+/* eslint-disable */
+/* tslint:disable */
+/*
+ * ---------------------------------------------------------------
+ * ## THIS FILE WAS GENERATED VIA SWAGGER-TYPESCRIPT-API        ##
+ * ##                                                           ##
+ * ## AUTHOR: acacode                                           ##
+ * ## SOURCE: https://github.com/acacode/swagger-typescript-api ##
+ * ---------------------------------------------------------------
+ */
+
+import { ResultOutputCaptchaData, ResultOutputValidateResult, SlideTrack } from './data-contracts'
+import { ContentType, HttpClient, RequestParams } from './http-client'
+
+export class CaptchaApi<SecurityDataType = unknown> extends HttpClient<SecurityDataType> {
+  /**
+   * No description
+   *
+   * @tags captcha
+   * @name Generate
+   * @summary 生成
+   * @request POST:/api/admin/captcha/generate
+   * @secure
+   */
+  generate = (params: RequestParams = {}) =>
+    this.request<ResultOutputCaptchaData, any>({
+      path: `/api/admin/captcha/generate`,
+      method: 'POST',
+      secure: true,
+      format: 'json',
+      ...params,
+    })
+  /**
+   * No description
+   *
+   * @tags captcha
+   * @name Check
+   * @summary 验证
+   * @request POST:/api/admin/captcha/check
+   * @secure
+   */
+  check = (
+    data: SlideTrack,
+    query?: {
+      id?: string
+    },
+    params: RequestParams = {}
+  ) =>
+    this.request<ResultOutputValidateResult, any>({
+      path: `/api/admin/captcha/check`,
+      method: 'POST',
+      query: query,
+      body: data,
+      secure: true,
+      type: ContentType.Json,
+      format: 'json',
+      ...params,
+    })
+}

+ 67 - 0
src/api/admin/data-contracts.ts

@@ -305,6 +305,12 @@ export interface AuthUserProfileDto {
   avatar?: string | null
 }
 
+export interface CaptchaData {
+  id?: string | null
+  backgroundImage?: string | null
+  sliderImage?: string | null
+}
+
 export interface CaptchaInput {
   /** 校验唯一标识 */
   token?: string | null
@@ -2105,6 +2111,17 @@ export interface ResultOutputAuthUserProfileDto {
   data?: AuthUserProfileDto
 }
 
+/** 结果输出 */
+export interface ResultOutputCaptchaData {
+  /** 是否成功标记 */
+  success?: boolean
+  /** 编码 */
+  code?: string | null
+  /** 消息 */
+  msg?: string | null
+  data?: CaptchaData
+}
+
 /** 结果输出 */
 export interface ResultOutputCaptchaOutput {
   /** 是否成功标记 */
@@ -2633,6 +2650,17 @@ export interface ResultOutputUserGetOutput {
   data?: UserGetOutput
 }
 
+/** 结果输出 */
+export interface ResultOutputValidateResult {
+  /** 是否成功标记 */
+  success?: boolean
+  /** 编码 */
+  code?: string | null
+  /** 消息 */
+  msg?: string | null
+  data?: ValidateResult
+}
+
 /** 结果输出 */
 export interface ResultOutputViewGetOutput {
   /** 是否成功标记 */
@@ -2901,6 +2929,24 @@ export interface RoleUpdateInput {
  */
 export type Sex = 0 | 1 | 2
 
+export interface SlideTrack {
+  /** @format int32 */
+  backgroundImageWidth?: number
+  /** @format int32 */
+  backgroundImageHeight?: number
+  /** @format int32 */
+  sliderImageWidth?: number
+  /** @format int32 */
+  sliderImageHeight?: number
+  /** @format date-time */
+  startTime?: string
+  /** @format date-time */
+  endTime?: string
+  tracks?: Track[] | null
+  /** @format float */
+  percent?: number
+}
+
 /** 员工添加 */
 export interface StaffAddInput {
   /** 工号 */
@@ -3302,6 +3348,15 @@ export interface TenantUpdateInput {
   id: number
 }
 
+export interface Track {
+  /** @format int32 */
+  x?: number
+  /** @format int32 */
+  y?: number
+  /** @format int32 */
+  t?: number
+}
+
 /** 添加 */
 export interface UserAddInput {
   /**
@@ -3778,6 +3833,18 @@ export interface UserUpdateMemberInput {
   id: number
 }
 
+export interface ValidateResult {
+  /** Success=0,ValidateFail=1,Timeout=2 */
+  result?: ValidateResultType
+  message?: string | null
+}
+
+/**
+ * Success=0,ValidateFail=1,Timeout=2
+ * @format int32
+ */
+export type ValidateResultType = 0 | 1 | 2
+
 /** 添加 */
 export interface ViewAddInput {
   /**

+ 119 - 18
src/components/my-input-code/index.vue

@@ -1,25 +1,43 @@
 <template>
-  <el-input text maxlength="4" placeholder="请输入验证码" clearable autocomplete="off" v-bind="$attrs">
-    <template #prefix>
-      <el-icon class="el-input__icon"><ele-Message /></el-icon>
-    </template>
-    <template #suffix>
-      <el-link type="primary" :underline="false" v-show="state.status !== 'countdown'" :disabled="state.status === 'countdown'" @click="onClick">{{
-        state.status === 'ready' ? state.startText : state.endText
-      }}</el-link>
-      <el-countdown
-        v-show="state.status === 'countdown'"
-        :format="state.changeText"
-        :value="state.countdown"
-        value-style="font-size:var(--el-font-size-base);color:var(--el-color-primary)"
-        @change="onChange"
+  <div class="w100">
+    <el-input text maxlength="4" placeholder="请输入验证码" autocomplete="off" v-bind="$attrs">
+      <template #prefix>
+        <el-icon class="el-input__icon"><ele-Message /></el-icon>
+      </template>
+      <template #suffix>
+        <el-link type="primary" :underline="false" v-show="state.status !== 'countdown'" :disabled="state.status === 'countdown'" @click="onClick">{{
+          state.status === 'ready' ? state.startText : state.endText
+        }}</el-link>
+        <el-countdown
+          v-show="state.status === 'countdown'"
+          :format="state.changeText"
+          :value="state.countdown"
+          value-style="font-size:var(--el-font-size-base);color:var(--el-color-primary)"
+          @change="onChange"
+        />
+      </template>
+    </el-input>
+    <el-dialog ref="dialogRef" v-model="state.showDialog" class="my-captcha" title="请完成安全验证" draggable append-to-body width="380px">
+      <MySlideCaptcha
+        ref="slideCaptchaRef"
+        :fail-tip="state.failTip"
+        :success-tip="state.successTip"
+        width="100%"
+        height="auto"
+        @refresh="onRefresh"
+        @finish="onFinish"
       />
-    </template>
-  </el-input>
+    </el-dialog>
+  </div>
 </template>
 
 <script lang="ts" setup name="my-input-code">
-import { reactive } from 'vue'
+import { ref, reactive, defineAsyncComponent } from 'vue'
+import { CaptchaApi } from '/@/api/admin/Captcha'
+import { isMobile } from '/@/utils/test'
+import { ElMessage } from 'element-plus'
+
+const MySlideCaptcha = defineAsyncComponent(() => import('/@/components/my-slide-captcha/index.vue'))
 
 const props = defineProps({
   seconds: {
@@ -38,24 +56,99 @@ const props = defineProps({
     type: String,
     default: '重新获取',
   },
+  mobile: {
+    type: String,
+    default: '',
+  },
+  validate: {
+    type: Function,
+    default: null,
+  },
 })
 
+const slideCaptchaRef = ref()
+const dialogRef = ref()
+
 const state = reactive({
   status: 'ready',
   startText: props.startText,
   changeText: props.changeText,
   endText: props.endText,
   countdown: Date.now(),
+
+  showDialog: false,
+  requestId: '',
+  failTip: '',
+  successTip: '',
 })
 
-const onClick = () => {
+const startCountdown = () => {
   state.status = 'countdown'
   state.countdown = Date.now() + (props.seconds + 1) * 1000
 }
 
+const onClick = () => {
+  if (state.status !== 'countdown') {
+    if (props.validate) {
+      props.validate(onGetCode)
+    } else {
+      onGetCode()
+    }
+  }
+}
+
 const onChange = (value: number) => {
   if (value < 1000) state.status = 'finish'
 }
+
+//刷新滑块验证码
+const onRefresh = async () => {
+  slideCaptchaRef.value.startRequestGenerate()
+  const res = await new CaptchaApi().generate().catch(() => {
+    slideCaptchaRef.value.endRequestGenerate(null, null)
+  })
+  if (res?.success && res.data) {
+    state.requestId = res.data.id || ''
+    slideCaptchaRef.value.endRequestGenerate(res.data.backgroundImage, res.data.sliderImage)
+  }
+}
+
+//验证滑块验证码
+const onFinish = async (data: any) => {
+  slideCaptchaRef.value.startRequestVerify()
+  const res = await new CaptchaApi().check(data, { id: state.requestId }).catch(() => {
+    state.failTip = '服务异常,请稍后重试'
+    slideCaptchaRef.value.endRequestVerify(false)
+  })
+  if (res?.success && res.data) {
+    let success = res.data.result === 0
+    state.failTip = res.data.result == 1 ? '验证未通过,拖动滑块将悬浮图像正确合并' : '验证超时, 请重新操作'
+    state.successTip = '验证通过'
+    slideCaptchaRef.value.endRequestVerify(success)
+    if (success) {
+      state.showDialog = false
+      startCountdown()
+      //发送短信验证码
+    } else {
+      setTimeout(() => {
+        onRefresh()
+      }, 1000)
+    }
+  }
+}
+
+//获得验证码
+const onGetCode = () => {
+  //验证手机号
+  if (!isMobile(props.mobile)) {
+    ElMessage.warning({ message: '请输入正确的手机号码', grouping: true })
+    return
+  }
+
+  state.showDialog = true
+  //刷新验证码
+  slideCaptchaRef.value?.handleRefresh()
+}
 </script>
 
 <style scoped lang="scss">
@@ -63,3 +156,11 @@ const onChange = (value: number) => {
   font-size: var(--el-font-size-base);
 }
 </style>
+<style lang="scss">
+.my-captcha .el-dialog__body {
+  padding-top: 10px;
+}
+.my-captcha .captcha__bar {
+  border-color: var(--el-border-color) !important;
+}
+</style>

+ 4 - 0
src/components/my-layout/index.vue

@@ -34,4 +34,8 @@ onBeforeMount(() => {
 :deep(.splitpanes.default-theme .splitpanes__pane) {
   background-color: transparent;
 }
+
+:deep(.splitpanes__pane) {
+  transition: none;
+}
 </style>

Fichier diff supprimé car celui-ci est trop grand
+ 410 - 0
src/components/my-slide-captcha/index.vue


+ 2 - 2
src/i18n/pages/login/en.ts

@@ -9,8 +9,8 @@ export default {
     two4: 'Links',
   },
   account: {
-    accountPlaceholder1: 'The user name admin or not is common',
-    accountPlaceholder2: 'Password: 123456',
+    accountPlaceholder1: 'The user name admin or user',
+    accountPlaceholder2: 'Password: 111111',
     accountPlaceholder3: 'Please enter the verification code',
     accountBtnText: 'Sign in',
   },

+ 2 - 2
src/i18n/pages/login/zh-cn.ts

@@ -9,8 +9,8 @@ export default {
     two4: '友情链接',
   },
   account: {
-    accountPlaceholder1: '用户名 admin 或不输均为 common',
-    accountPlaceholder2: '密码:123456',
+    accountPlaceholder1: '用户名 admin 或 user',
+    accountPlaceholder2: '密码:111111',
     accountPlaceholder3: '请输入验证码',
     accountBtnText: '登 录',
   },

+ 2 - 2
src/i18n/pages/login/zh-tw.ts

@@ -9,8 +9,8 @@ export default {
     two4: '友情連結',
   },
   account: {
-    accountPlaceholder1: '用戶名admin或不輸均為common',
-    accountPlaceholder2: '密碼:123456',
+    accountPlaceholder1: '用戶名admin或user',
+    accountPlaceholder2: '密碼:111111',
     accountPlaceholder3: '請輸入驗證碼',
     accountBtnText: '登入',
   },

+ 3 - 3
src/theme/element.scss

@@ -231,9 +231,9 @@
     .el-dialog {
       margin: 0 auto !important;
       position: absolute;
-      .el-dialog__body {
-        padding: 20px !important;
-      }
+      // .el-dialog__body {
+      //   padding: 20px !important;
+      // }
     }
   }
 }

+ 1 - 1
src/views/admin/api/index.vue

@@ -179,7 +179,7 @@ const syncApi = async (swaggerResource: any) => {
 
 const onSync = () => {
   state.syncLoading = true
-  const swaggerResources = ['/swagger-resources']
+  const swaggerResources = ['/admin/swagger-resources']
   const lastSwaggerResourcesIndex = swaggerResources.length - 1
   swaggerResources.forEach(async (swaggerResource, swaggerResourcesIndex) => {
     const resSwaggerResources = await new ApiExtApi().getSwaggerResources(swaggerResource, { showErrorMessage: false }).catch(() => {

+ 23 - 18
src/views/admin/login/component/account.vue

@@ -1,6 +1,6 @@
 <template>
-  <el-form size="large" class="login-content-form">
-    <el-form-item class="login-animation1">
+  <el-form ref="formRef" :model="state.ruleForm" size="large" class="login-content-form">
+    <el-form-item class="login-animation1" prop="userName" :rules="[{ required: true, message: '请输入用户名', trigger: ['blur', 'change'] }]">
       <el-input
         text
         :placeholder="$t('message.account.accountPlaceholder1')"
@@ -14,7 +14,7 @@
         </template>
       </el-input>
     </el-form-item>
-    <el-form-item class="login-animation2">
+    <el-form-item class="login-animation2" prop="password" :rules="[{ required: true, message: '请输入密码', trigger: ['blur', 'change'] }]">
       <el-input
         :type="state.isShowPassword ? 'text' : 'password'"
         :placeholder="$t('message.account.accountPlaceholder2')"
@@ -64,7 +64,7 @@
 </template>
 
 <script setup lang="ts" name="loginAccount">
-import { reactive, computed } from 'vue'
+import { reactive, computed, ref } from 'vue'
 import { useRoute, useRouter } from 'vue-router'
 import { ElMessage } from 'element-plus'
 import { useI18n } from 'vue-i18n'
@@ -86,6 +86,7 @@ const { t } = useI18n()
 // const { themeConfig } = storeToRefs(storesThemeConfig)
 const route = useRoute()
 const router = useRouter()
+const formRef = ref()
 const state = reactive({
   isShowPassword: false,
   ruleForm: {
@@ -104,21 +105,25 @@ const currentTime = computed(() => {
 })
 // 登录
 const onSignIn = async () => {
-  state.loading.signIn = true
-  const res = await new AuthApi().login(state.ruleForm).catch(() => {
-    state.loading.signIn = false
-  })
-  if (!res?.success) {
-    state.loading.signIn = false
-    return
-  }
+  formRef.value.validate(async (valid: boolean) => {
+    if (!valid) return
 
-  const token = res.data?.token
-  useUserInfo().setToken(token)
-  // 添加完动态路由,再进行 router 跳转,否则可能报错 No match found for location with path "/"
-  const isNoPower = await initBackEndControlRoutes()
-  // 执行完 initBackEndControlRoutes,再执行 signInSuccess
-  signInSuccess(isNoPower)
+    state.loading.signIn = true
+    const res = await new AuthApi().login(state.ruleForm).catch(() => {
+      state.loading.signIn = false
+    })
+    if (!res?.success) {
+      state.loading.signIn = false
+      return
+    }
+
+    const token = res.data?.token
+    useUserInfo().setToken(token)
+    // 添加完动态路由,再进行 router 跳转,否则可能报错 No match found for location with path "/"
+    const isNoPower = await initBackEndControlRoutes()
+    // 执行完 initBackEndControlRoutes,再执行 signInSuccess
+    signInSuccess(isNoPower)
+  })
 }
 // 登录成功后的跳转
 const signInSuccess = (isNoPower: boolean | undefined) => {

+ 59 - 20
src/views/admin/login/component/mobile.vue

@@ -1,37 +1,76 @@
 <template>
-  <el-form size="large" class="login-content-form">
-    <el-form-item class="login-animation1">
-      <el-input text :placeholder="$t('message.mobile.placeholder1')" v-model="state.ruleForm.userName" clearable autocomplete="off">
-        <template #prefix>
-          <el-icon class="el-input__icon"><ele-Iphone /></el-icon>
-        </template>
-      </el-input>
-    </el-form-item>
-    <el-form-item class="login-animation2">
-      <MyInputCode v-model="state.ruleForm.code"></MyInputCode>
-    </el-form-item>
-    <el-form-item class="login-animation3">
-      <el-button round type="primary" v-waves class="login-content-submit">
-        <span>{{ $t('message.mobile.btnText') }}</span>
-      </el-button>
-    </el-form-item>
+  <div>
+    <el-form ref="formRef" :model="state.ruleForm" size="large" class="login-content-form">
+      <el-form-item
+        class="login-animation1"
+        prop="mobile"
+        :rules="[
+          { required: true, message: '请输入手机号', trigger: ['blur', 'change'] },
+          { validator: testMobile, trigger: ['blur', 'change'] },
+        ]"
+      >
+        <el-input
+          text
+          :placeholder="$t('message.mobile.placeholder1')"
+          maxlength="11"
+          v-model="state.ruleForm.mobile"
+          clearable
+          autocomplete="off"
+          @keyup.enter="onSignIn"
+        >
+          <template #prefix>
+            <el-icon class="el-input__icon"><ele-Iphone /></el-icon>
+          </template>
+        </el-input>
+      </el-form-item>
+      <el-form-item class="login-animation2" prop="code" :rules="[{ required: true, message: '请输入短信验证码', trigger: ['blur', 'change'] }]">
+        <MyInputCode v-model="state.ruleForm.code" @keyup.enter="onSignIn" :mobile="state.ruleForm.mobile" :validate="validate" />
+      </el-form-item>
+      <el-form-item class="login-animation3">
+        <el-button round type="primary" v-waves class="login-content-submit" @click="onSignIn" :loading="state.loading.signIn">
+          <span>{{ $t('message.mobile.btnText') }}</span>
+        </el-button>
+      </el-form-item>
 
-    <div class="font12 mt30 login-animation4 login-msg">{{ $t('message.mobile.msgText') }}</div>
-  </el-form>
+      <div class="font12 mt30 login-animation4 login-msg">{{ $t('message.mobile.msgText') }}</div>
+    </el-form>
+  </div>
 </template>
 
 <script lang="ts" setup name="loginMobile">
-import { reactive, defineAsyncComponent } from 'vue'
+import { reactive, defineAsyncComponent, ref } from 'vue'
+import { testMobile } from '/@/utils/test'
 
 const MyInputCode = defineAsyncComponent(() => import('/@/components/my-input-code/index.vue'))
 
+const formRef = ref()
 // 定义变量内容
 const state = reactive({
   ruleForm: {
-    userName: '',
+    mobile: '',
     code: '',
   },
+  loading: {
+    signIn: false,
+  },
 })
+
+//验证手机号
+const validate = (callback: Function) => {
+  formRef.value.validateField('mobile', (isValid: boolean) => {
+    if (!isValid) {
+      return
+    }
+    callback?.()
+  })
+}
+
+// 登录
+const onSignIn = async () => {
+  formRef.value.validate(async (valid: boolean) => {
+    if (!valid) return
+  })
+}
 </script>
 
 <style scoped lang="scss">

+ 1 - 1
src/views/admin/user/index.vue

@@ -5,7 +5,7 @@
         <org-menu @node-click="onOrgNodeClick" select-first-node></org-menu>
       </div>
     </pane>
-    <pane>
+    <pane size="80">
       <div class="my-flex-column w100 h100">
         <el-card class="mt8" shadow="never" :body-style="{ paddingBottom: '0' }">
           <el-form @submit.stop.prevent style="max-width: 640px">

Certains fichiers n'ont pas été affichés car il y a eu trop de fichiers modifiés dans ce diff