feat:登录-新增用户账号登录 验证码 ipBlock

1. 新增 登录的 图形校验码
2. 遗落问题,登录页的提交,表单全部统一了。没有区分类型,而且类型的枚举和校验没真正使用。还有其他功能如注册,改密码都没开出来
This commit is contained in:
lvjunzhao
2025-04-10 17:12:15 +08:00
parent 54fa34028e
commit daae3c89e7
6 changed files with 231 additions and 7 deletions

View File

@ -34,6 +34,7 @@ enum Api {
loginConfig = '/system/loginConfig', loginConfig = '/system/loginConfig',
mobileLoginCode = '/system/captcha', mobileLoginCode = '/system/captcha',
mobileLoginImg = '/system/captchaImg', mobileLoginImg = '/system/captchaImg',
checkAccountCaptcha = '/system/checkAccountCaptcha',
} }
/** /**
@ -262,3 +263,14 @@ export function sendMobileLoginCode(params, mode: ErrorMessageMode = 'modal') {
}, },
); );
} }
export function checkAccountCaptchaApi(params, mode: ErrorMessageMode = 'modal') {
return defHttp.get(
{
url: Api.checkAccountCaptcha,
params: params,
},
{
errorMessageMode: mode,
},
);
}

View File

@ -8,6 +8,7 @@ export interface LoginParams {
password: string; password: string;
tenantCode: string; tenantCode: string;
deviceType?: number; deviceType?: number;
captchaCode: string;
} }
export interface RoleInfo { export interface RoleInfo {

View File

@ -279,3 +279,8 @@
} }
} }
</style> </style>
<style scoped>
:deep(.center-box) {
width: 540px;
}
</style>

View File

@ -3,7 +3,7 @@
<div> <div>
<FormItem class="enter-x" name="account" v-if="loginType =='pw'"> <FormItem class="enter-x" name="account" v-if="loginType =='pw'">
<label class="form-title"> {{ t('账号') }}</label> <label class="form-title"> {{ t('账号') }}</label>
<Input v-model:value="formData.account" :placeholder="t('账号')" class="fix-auto-fill" size="large" style="height: 58px;"> <Input v-model:value="formData.account" :placeholder="t('账号')" class="fix-auto-fill" size="large" style="height: 58px;" @blur="handleBlur">
<template #prefix> <template #prefix>
<IconFontSymbol class="user-icon" icon="yonghu-xianxing" /> <IconFontSymbol class="user-icon" icon="yonghu-xianxing" />
</template> </template>
@ -17,6 +17,22 @@
</template> </template>
</InputPassword> </InputPassword>
</FormItem> </FormItem>
<FormItem class="enter-x" name="captchaCode" v-if="loginType =='pw' && loginUseType == 'captcha'">
<label class="form-title"> {{ t('图形验证码') }}</label>
<Input v-model:value="formData.captchaCode" :placeholder="t('图形验证码')" class="fix-auto-fill" size="large" style="height: 58px;" visibilityToggle>
<template #prefix>
<PictureOutlined :style="{fontSize: '25px', color: '#717c91'}"/>
</template>
<template #suffix>
<a-image
:src="captchaImage.imgBase64 || ''"
alt="验证码"
class="captcha-image"
/>
<ReloadOutlined :style="{fontSize: '25px', color: '#717c91'}" @click="refreshCaptcha"/>
</template>
</Input>
</FormItem>
<FormItem v-if="getAppEnvConfig().VITE_TENANT_ENABLED && loginType =='pw'" name="tenantCode" class="enter-x"> <FormItem v-if="getAppEnvConfig().VITE_TENANT_ENABLED && loginType =='pw'" name="tenantCode" class="enter-x">
<label class="form-title"> {{ t('租户码') }}</label> <label class="form-title"> {{ t('租户码') }}</label>
@ -43,8 +59,10 @@
<FormItem class="enter-x" name="code" v-if="loginType =='mobile'"> <FormItem class="enter-x" name="code" v-if="loginType =='mobile'">
<label class="form-title"> {{ t('验证码') }}</label> <label class="form-title"> {{ t('验证码') }}</label>
<Input v-model:value="formData.code" :placeholder="t('验证码')" class="fix-auto-fill" size="large" style="height: 58px;"> <Input v-model:value="formData.code" :placeholder="t('验证码')" class="fix-auto-fill" size="large" style="height: 58px;">
<template #prefix>
<PhoneOutlined :style="{fontSize: '25px', color: '#717c91'}"/>
</template>
<template #suffix> <template #suffix>
<!-- <span>111</span> -->
<Button type="link" class="f-16" @click="getLoginCode" size="small" :disabled="codeButtonDisabled"> <Button type="link" class="f-16" @click="getLoginCode" size="small" :disabled="codeButtonDisabled">
{{ getCodeButtonName }} {{ getCodeButtonName }}
</Button> </Button>
@ -64,7 +82,7 @@
<FormItem> <FormItem>
<!-- No logic, you need to deal with it yourself --> <!-- No logic, you need to deal with it yourself -->
<Button type="link" class="f-16" @click="changeLoginType" size="small"> <Button type="link" class="f-16" @click="changeLoginType" size="small">
{{ loginType == 'mobile' ? t('账号密码登录') : t('验证码登录') }} {{ loginType == 'mobile' ? t('账号密码登录') : t('手机登录') }}
</Button> </Button>
</FormItem> </FormItem>
</ACol> </ACol>
@ -118,7 +136,7 @@
import { useUserStore } from '/@/store/modules/user'; import { useUserStore } from '/@/store/modules/user';
import { LoginStateEnum, useLoginState, useFormRules, useFormValid } from '/@/views/sys/login/useLogin'; import { LoginStateEnum, useLoginState, useFormRules, useFormValid } from '/@/views/sys/login/useLogin';
import { getMobileLoginCode, getMobileLoginImg, sendMobileLoginCode } from '/@/api/system/login'; import { getMobileLoginCode, getMobileLoginImg, sendMobileLoginCode, checkAccountCaptchaApi, } from '/@/api/system/login';
import { useDesign } from '/@/hooks/web/useDesign'; import { useDesign } from '/@/hooks/web/useDesign';
import { Base64 } from 'js-base64'; import { Base64 } from 'js-base64';
@ -127,6 +145,8 @@
import { getAppEnvConfig } from '/@/utils/env'; import { getAppEnvConfig } from '/@/utils/env';
import Icon from '/@/components/Icon/index'; import Icon from '/@/components/Icon/index';
import { PhoneOutlined, PictureOutlined, ReloadOutlined } from '@ant-design/icons-vue';
const ACol = Col; const ACol = Col;
const ARow = Row; const ARow = Row;
const FormItem = Form.Item; const FormItem = Form.Item;
@ -138,7 +158,7 @@
const { currentRoute } = useRouter(); const { currentRoute } = useRouter();
const { getLoginState } = useLoginState(); const { getLoginState, setLoginState } = useLoginState();
const { getFormRules } = useFormRules(); const { getFormRules } = useFormRules();
const formRef = ref(); const formRef = ref();
@ -146,6 +166,7 @@
const loading = ref(false); const loading = ref(false);
const rememberMe = ref(false); const rememberMe = ref(false);
const loginType = ref('mobile') const loginType = ref('mobile')
const loginUseType = ref();
const countdown = ref(60) const countdown = ref(60)
const visible = ref(false); const visible = ref(false);
@ -153,6 +174,9 @@
const imgObj = ref({ const imgObj = ref({
imgBase64: '' imgBase64: ''
}) })
const captchaImage = ref({
imgBase64: ''
})
const imgCode = ref('') const imgCode = ref('')
const formData = reactive({ const formData = reactive({
@ -160,7 +184,8 @@
password: '', password: '',
mobile: '', mobile: '',
code: '', code: '',
tenantCode: 'system' tenantCode: 'system',
captchaCode: ''
}); });
const getCodeButtonName = ref('获取验证码') const getCodeButtonName = ref('获取验证码')
const codeButtonDisabled = ref(false) const codeButtonDisabled = ref(false)
@ -228,6 +253,24 @@
} }
} }
/**
* 账号失焦 请求后台获取业务状态
*/
async function handleBlur() {
if (!formData.account) {
return;
}
let checkAccountCaptcha = await checkAccountCaptchaApi({username: formData.account})
if(checkAccountCaptcha == true) {
loginUseType.value = 'captcha';
// setLoginState(LoginStateEnum.LOGIN_WITH_CAPTCHA)
refreshCaptcha();
} else {
loginUseType.value = '';
// setLoginState(LoginStateEnum.LOGIN)
}
}
function refreshTodo() { function refreshTodo() {
refreshLoading.value = true refreshLoading.value = true
onMobileLoginImg() onMobileLoginImg()
@ -236,7 +279,12 @@
// 图形验证 // 图形验证
async function onMobileLoginImg() { async function onMobileLoginImg() {
imgObj.value = await getMobileLoginImg({mobile: formData.mobile}) imgObj.value = await getMobileLoginImg({account: formData.mobile})
}
// 图形账号验证
async function refreshCaptcha() {
captchaImage.value = await getMobileLoginImg({account: formData.account})
} }
async function handleOk() { async function handleOk() {
@ -283,11 +331,22 @@
if (!data) return; if (!data) return;
if (loginType.value == 'pw') { if (loginType.value == 'pw') {
try { try {
// 校验有没带图形验证码提交
if (loginUseType.value == 'captcha' && !data.captchaCode) {
createErrorModal({
title: t('错误提示'),
content: '请输入图形验证码',
getContainer: () => document.body.querySelector(`.${prefixCls}`) || document.body
});
return;
}
loading.value = true; loading.value = true;
const userInfo = await userStore.login({ const userInfo = await userStore.login({
password: data.password, password: data.password,
userName: data.account, userName: data.account,
tenantCode: data.tenantCode, tenantCode: data.tenantCode,
captchaCode: data.captchaCode,
deviceType: 0, //pc-0,app-1 deviceType: 0, //pc-0,app-1
mode: 'none' //不要默认的错误提示 mode: 'none' //不要默认的错误提示
}); });
@ -309,6 +368,11 @@
} }
} }
} catch (error) { } catch (error) {
if ((error as unknown as Error).message.includes('验证码')) {
loginUseType.value = 'captcha';
// setLoginState(LoginStateEnum.LOGIN_WITH_CAPTCHA);
refreshCaptcha();
}
createErrorModal({ createErrorModal({
title: t('错误提示'), title: t('错误提示'),
content: (error as unknown as Error).message || t('网络异常,请检查您的网络连接是否正常!'), content: (error as unknown as Error).message || t('网络异常,请检查您的网络连接是否正常!'),
@ -316,6 +380,7 @@
}); });
} finally { } finally {
loading.value = false; loading.value = false;
handleBlur();
} }
} else { } else {
try { try {
@ -400,4 +465,14 @@
cursor: pointer; cursor: pointer;
} }
} }
:deep(.captcha-image) {
cursor: pointer;
border: 1px solid #ddd;
border-radius: 4px;
height: 48px;
}
// :deep(.phone-outlined-class) {
// height: 25.99px;
// width: 25.99px;
// }
</style> </style>

View File

@ -9,6 +9,7 @@ export enum LoginStateEnum {
RESET_PASSWORD, RESET_PASSWORD,
MOBILE, MOBILE,
QR_CODE, QR_CODE,
LOGIN_WITH_CAPTCHA,
} }
const currentState = ref(LoginStateEnum.LOGIN); const currentState = ref(LoginStateEnum.LOGIN);
@ -43,6 +44,7 @@ export function useFormRules(formData?: Recordable) {
const getAccountFormRule = computed(() => createRule(t('请输入账号'))); const getAccountFormRule = computed(() => createRule(t('请输入账号')));
const getPasswordFormRule = computed(() => createRule(t('请输入密码'))); const getPasswordFormRule = computed(() => createRule(t('请输入密码')));
const getCaptchaCodeFormRule = computed(() => createRule(t('请输入图形验证码')));
const getSmsFormRule = computed(() => createRule(t('请输入验证码'))); const getSmsFormRule = computed(() => createRule(t('请输入验证码')));
const getMobileFormRule = computed(() => createRule(t('请输入手机号码'))); const getMobileFormRule = computed(() => createRule(t('请输入手机号码')));
@ -65,6 +67,7 @@ export function useFormRules(formData?: Recordable) {
const getFormRules = computed((): { [k: string]: ValidationRule | ValidationRule[] } => { const getFormRules = computed((): { [k: string]: ValidationRule | ValidationRule[] } => {
const accountFormRule = unref(getAccountFormRule); const accountFormRule = unref(getAccountFormRule);
const passwordFormRule = unref(getPasswordFormRule); const passwordFormRule = unref(getPasswordFormRule);
const captchaCodeFormRule = unref(getCaptchaCodeFormRule);
const smsFormRule = unref(getSmsFormRule); const smsFormRule = unref(getSmsFormRule);
const mobileFormRule = unref(getMobileFormRule); const mobileFormRule = unref(getMobileFormRule);
@ -96,6 +99,14 @@ export function useFormRules(formData?: Recordable) {
case LoginStateEnum.MOBILE: case LoginStateEnum.MOBILE:
return mobileRule; return mobileRule;
// 枚举没实现,暂不适用
case LoginStateEnum.LOGIN_WITH_CAPTCHA:
return {
account: accountFormRule,
password: passwordFormRule,
captchaCode: captchaCodeFormRule,
};
// login form rules // login form rules
default: default:
return { return {

View File

@ -117,6 +117,100 @@
/> />
</a-form-item> </a-form-item>
· <a-form-item name="checkErrorLoginCaptcha">
<div class="flex">
<div
class="gouBox"
:class="formState.checkErrorLoginCaptcha ? 'active' : ''"
@click="handleCaptchaClick()"
>{{ t('密码错误超过最大次数需要填写验证码') }}
<div class="triangle" v-if="formState.checkErrorLoginCaptcha"
><Icon color="#fff" icon="uil:check"
/></div>
</div>
</div>
<template #label>
<div>验证码策略</div>
<a-tooltip placement="bottomLeft" class="left-reset">
<template #title>
<div style="max-width: 336px">
<p>1.登录验证码策略是指在登录过程中出现密码错误情况时的系统处理方式</p>
<p>2.默认密码错误超过最大次需要验证码登录的策略状态为启用</p>
<p>3.如果设置验证码最大次数为0 即每次登录都需要验证码</p>
</div>
</template>
<QuestionCircleFilled
style="
font-size: 16px;
color: rgb(204 204 204);
position: absolute;
left: -90px;
top: 8px;
"
/>
</a-tooltip>
</template>
</a-form-item>
<a-form-item :label="t('最大次数')" name="checkErrorLoginCaptchaCount">
<a-input-number
v-model:value="formState.checkErrorLoginCaptchaCount"
:placeholder="t('请输入最大次数')"
style="width: 50%"
:min="0"
/>
</a-form-item>
<a-form-item :label="t('锁ip策略')" name="checkErrorLoginBlockIp">
<div class="flex">
<div
class="gouBox"
:class="formState.checkErrorLoginBlockIp ? 'active' : ''"
@click="handleBlockIpClick()"
>{{ t('密码错误超过最大次数锁定') }}
<div class="triangle" v-if="formState.checkErrorLoginBlockIp"
><Icon color="#fff" icon="uil:check"
/></div>
</div>
</div>
<a-tooltip placement="bottomLeft">
<template #title>
<div style="max-width: 336px">
<p>1.锁定ip策略是指在登录过程中出现密码错误情况时的系统处理方式</p>
<p>2.默认密码错误超过最大次数锁定ip的策略状态为启用</p>
<p
>3.当用户的ip被锁定后需要等待配置时间恢复后才能重新登录或者请联系管理员</p
>
</div>
</template>
<QuestionCircleFilled
style="
font-size: 16px;
color: rgb(204 204 204);
position: absolute;
left: -90px;
top: 8px;
"
/>
</a-tooltip>
</a-form-item>
<a-form-item :label="t('最大次数')" name="checkErrorLoginBlockIpCount">
<a-input-number
v-model:value="formState.checkErrorLoginBlockIpCount"
:placeholder="t('请输入最大次数')"
style="width: 50%"
:min="1"
/>
</a-form-item>
<a-form-item :label="t('恢复时间(小时)')" name="checkErrorLoginBlockIpRestoreTime">
<a-input-number
v-model:value="formState.checkErrorLoginBlockIpRestoreTime"
:placeholder="t('请输入恢复时间(小时)')"
style="width: 50%"
:min="1"
/>
</a-form-item>
<a-form-item :wrapper-col="{ offset: 6, span: 24 }"> <a-form-item :wrapper-col="{ offset: 6, span: 24 }">
<a-button type="primary" html-type="submit" class="ml-4">{{ t('提交') }}</a-button> <a-button type="primary" html-type="submit" class="ml-4">{{ t('提交') }}</a-button>
</a-form-item> </a-form-item>
@ -145,6 +239,11 @@
withoutLogin?: number; withoutLogin?: number;
strategyMaxNumber?: number | null; strategyMaxNumber?: number | null;
passwordStrategy?: number; passwordStrategy?: number;
checkErrorLoginCaptcha?: boolean;
checkErrorLoginCaptchaCount?: number | null;
checkErrorLoginBlockIp?: boolean;
checkErrorLoginBlockIpCount?: number | null;
checkErrorLoginBlockIpRestoreTime?: number | null;
} }
let formState = ref<FormState>({}); let formState = ref<FormState>({});
@ -162,6 +261,11 @@
withoutLogin: res.withoutLogin, withoutLogin: res.withoutLogin,
strategyMaxNumber: res.strategyMaxNumber, strategyMaxNumber: res.strategyMaxNumber,
passwordStrategy: res.passwordStrategy, passwordStrategy: res.passwordStrategy,
checkErrorLoginCaptcha: res.checkErrorLoginCaptcha,
checkErrorLoginCaptchaCount: res.checkErrorLoginCaptchaCount,
checkErrorLoginBlockIp: res.checkErrorLoginBlockIp,
checkErrorLoginBlockIpCount: res.checkErrorLoginBlockIpCount,
checkErrorLoginBlockIpRestoreTime: res.checkErrorLoginBlockIpRestoreTime,
}; };
id.value = res.id; id.value = res.id;
}); });
@ -198,6 +302,22 @@
} }
} }
function handleCaptchaClick() {
if (formState.value.checkErrorLoginCaptcha) {
formState.value.checkErrorLoginCaptcha = false;
} else {
formState.value.checkErrorLoginCaptcha = true;
}
}
function handleBlockIpClick() {
if (formState.value.checkErrorLoginBlockIp) {
formState.value.checkErrorLoginBlockIp = false;
} else {
formState.value.checkErrorLoginBlockIp = true;
}
}
function handleClick(val) { function handleClick(val) {
if (proDisabled.value) return; if (proDisabled.value) return;
if (formState.value.mulLogin?.includes(val)) { if (formState.value.mulLogin?.includes(val)) {