新增登录页图形验证码功能,支持通过Redis全局配置控制验证码的启用状态,优化登录流程以提升用户体验。新增相关API接口和前端配置项,确保验证码逻辑与后端同步。

This commit is contained in:
geht
2026-04-20 14:21:36 +08:00
parent 7a648b20be
commit 73426a7af3
23 changed files with 450 additions and 103 deletions

View File

@@ -8,25 +8,27 @@
<InputPassword size="large" visibilityToggle v-model:value="formData.password" :placeholder="t('sys.login.password')" />
</FormItem>
<!--验证码-->
<ARow class="enter-x">
<ACol :span="12">
<FormItem name="inputCode" class="enter-x">
<Input size="large" v-model:value="formData.inputCode" :placeholder="t('sys.login.inputCode')" style="min-width: 100px" />
</FormItem>
</ACol>
<ACol :span="8">
<FormItem :style="{ 'text-align': 'right', 'margin-left': '20px' }" class="enter-x">
<img
v-if="randCodeData.requestCodeSuccess"
style="margin-top: 2px; max-width: initial"
:src="randCodeData.randCodeImage"
@click="handleChangeCheckCode"
/>
<img v-else style="margin-top: 2px; max-width: initial" src="../../../assets/images/checkcode.png" @click="handleChangeCheckCode" />
</FormItem>
</ACol>
</ARow>
<!-- 无需验证码时下列整块输入框 + 图片均不挂载不请求 randomImage -->
<template v-if="captchaVisible">
<ARow class="enter-x">
<ACol :span="12">
<FormItem name="inputCode" class="enter-x">
<Input size="large" v-model:value="formData.inputCode" :placeholder="t('sys.login.inputCode')" style="min-width: 100px" />
</FormItem>
</ACol>
<ACol :span="8">
<FormItem :style="{ 'text-align': 'right', 'margin-left': '20px' }" class="enter-x">
<img
v-if="randCodeData.requestCodeSuccess"
style="margin-top: 2px; max-width: initial"
:src="randCodeData.randCodeImage"
@click="handleChangeCheckCode"
/>
<img v-else style="margin-top: 2px; max-width: initial" src="../../../assets/images/checkcode.png" @click="handleChangeCheckCode" />
</FormItem>
</ACol>
</ARow>
</template>
<ARow class="enter-x">
<ACol :span="12">
@@ -98,7 +100,7 @@
import { useUserStore } from '/@/store/modules/user';
import { LoginStateEnum, useLoginState, useFormRules, useFormValid } from './useLogin';
import { useDesign } from '/@/hooks/web/useDesign';
import { getCodeInfo } from '/@/api/sys/user';
import { getCodeInfo, getLoginCaptchaServerConfig } from '/@/api/sys/user';
import { encryptAESCBC } from '/@/utils/cipher';
const ACol = Col;
@@ -113,6 +115,26 @@
const { prefixCls } = useDesign('login');
const userStore = useUserStore();
/**
* 是否展示验证码(与后端是否校验图形码一致)。
* 浏览器无法直连 Redis调用 GET /sys/loginCaptchaConfig由后端读取 Redis 全局开关,无值时再回落 jeecg.firewall.enable-login-captcha。
* null 表示尚未拉取完成,不展示验证码区域,避免误请求 randomImage。
*/
const serverRequiresCaptcha = ref<boolean | null>(null);
const captchaVisible = computed(() => serverRequiresCaptcha.value === true);
/** 兼容接口 result 为 boolean / 字符串 */
function parseCaptchaRequired(raw: unknown): boolean {
if (raw === true || raw === 'true' || raw === 1 || raw === '1') {
return true;
}
if (raw === false || raw === 'false' || raw === 0 || raw === '0') {
return false;
}
return false;
}
const { setLoginState, getLoginState } = useLoginState();
const { getFormRules } = useFormRules();
@@ -132,6 +154,13 @@
checkKey: null,
});
function clearCaptchaUiState() {
formData.inputCode = '';
randCodeData.randCodeImage = '';
randCodeData.requestCodeSuccess = false;
randCodeData.checkKey = null;
}
const { validForm } = useFormValid(formRef);
//onKeyStroke('Enter', handleLogin);
@@ -146,15 +175,16 @@
// 密码使用AES加密传输
const encryptedPassword = encryptAESCBC(data.password);
const { userInfo } = await userStore.login(
toRaw({
password: encryptedPassword,
username: data.account,
captcha: data.inputCode,
checkKey: randCodeData.checkKey,
mode: 'none', //不要默认的错误提示
})
);
const loginPayload: Recordable = {
password: encryptedPassword,
username: data.account,
mode: 'none', //不要默认的错误提示
};
if (unref(captchaVisible)) {
loginPayload.captcha = data.inputCode;
loginPayload.checkKey = randCodeData.checkKey;
}
const { userInfo } = await userStore.login(toRaw(loginPayload));
if (userInfo) {
notification.success({
message: t('sys.login.loginSuccessTitle'),
@@ -169,7 +199,9 @@
duration: 3,
});
loading.value = false;
handleChangeCheckCode();
if (unref(captchaVisible)) {
handleChangeCheckCode();
}
}
}
function handleChangeCheckCode() {
@@ -189,8 +221,17 @@
function onThirdLogin(type) {
thirdModalRef.value.onThirdLogin(type);
}
//初始化验证码
onMounted(() => {
onMounted(async () => {
try {
const enabled = await getLoginCaptchaServerConfig();
serverRequiresCaptcha.value = parseCaptchaRequired(enabled);
} catch {
serverRequiresCaptcha.value = false;
}
if (!unref(captchaVisible)) {
clearCaptchaUiState();
return;
}
handleChangeCheckCode();
});
</script>

View File

@@ -23,7 +23,12 @@
:columns="columns2"
>
<template #toolbarAfter>
<a-alert type="info" showIcon message="全局规则可校验用户输入的所有字符;全局规则的优先级比局部规则的要高。" style="margin-bottom: 8px" />
<a-alert
type="info"
showIcon
message="全局规则可校验用户输入的所有字符;全局规则的优先级比局部规则的要高。优先级选「不执行」时本条规则仅保留配置、不参与校验,便于临时关闭某项密码要求。"
style="margin-bottom: 8px"
/>
</template>
</JVxeTable>
</a-tab-pane>
@@ -75,16 +80,14 @@
let ruleJson = data.record.ruleJson;
if (ruleJson) {
let ruleList = JSON.parse(ruleJson);
// 筛选出全局规则和局部规则
let global: any[] = [],
design: any[] = [],
priority = '1';
ruleList.forEach((rule) => {
const ruleList = JSON.parse(ruleJson);
const global: any[] = [];
const design: any[] = [];
ruleList.forEach((rule: any) => {
if (rule.digits === '*') {
global.push(Object.assign(rule, { priority }));
const p = rule.priority != null && String(rule.priority) !== '' ? String(rule.priority) : '1';
global.push({ ...rule, priority: p });
} else {
priority = '0';
design.push(rule);
}
});
@@ -132,20 +135,26 @@
if (tableData2 && tableData2.length > 0) {
globalValues = tableData2;
}
// 整合两个子表的数据
let firstGlobal: any[] = [],
afterGlobal: any[] = [];
// 整合两个子表的数据priority1 优先、0 最后、2 不执行2 放末尾仅便于存储,后端会跳过)
const firstGlobal: any[] = [];
const afterGlobal: any[] = [];
const inactiveGlobal: any[] = [];
for (let i = 0; i < globalValues.length; i++) {
let v: any = globalValues[i];
const v: any = globalValues[i];
v.digits = '*';
if (v.priority === '1') {
const p = String(v.priority ?? '1');
if (p === '1') {
firstGlobal.push(v);
} else {
} else if (p === '0') {
afterGlobal.push(v);
} else if (p === '2') {
inactiveGlobal.push(v);
} else {
firstGlobal.push(v);
}
}
let concatValues = firstGlobal.concat(designValues).concat(afterGlobal);
let subValues = concatValues.map((i) => pick(i, 'digits', 'pattern', 'message'));
const concatValues = firstGlobal.concat(designValues).concat(afterGlobal).concat(inactiveGlobal);
const subValues = concatValues.map((i) => pick(i, 'digits', 'pattern', 'message', 'priority'));
// 生成 formData用于传入后台
let ruleJson = JSON.stringify(subValues);
let formData = Object.assign({}, mainData, { ruleJson });
@@ -226,6 +235,7 @@
options: [
{ title: '优先运行', value: '1' },
{ title: '最后运行', value: '0' },
{ title: '不执行', value: '2' },
],
validateRules: [],
},

View File

@@ -46,7 +46,8 @@
<a-input class="fix-auto-fill" type="password" :placeholder="t('sys.login.password')" v-model:value="formData.password" />
</a-form-item>
</div>
<div class="aui-inputClear">
<!-- GET /sys/loginCaptchaConfigRedis一致关闭时不挂载验证码输入与图片 -->
<div class="aui-inputClear" v-if="captchaVisible">
<i class="icon icon-code"></i>
<a-form-item>
<a-input class="fix-auto-fill" type="text" :placeholder="t('sys.login.inputCode')" v-model:value="formData.inputCode" />
@@ -174,7 +175,7 @@
</div>
</template>
<script lang="ts" setup name="login-mini">
import { getCaptcha, getCodeInfo } from '/@/api/sys/user';
import { getCaptcha, getCodeInfo, getLoginCaptchaServerConfig } from '/@/api/sys/user';
import { computed, onMounted, reactive, ref, toRaw, unref, watch } from 'vue';
import codeImg from '/@/assets/images/checkcode.png';
import { Rule } from '/@/components/Form';
@@ -215,6 +216,18 @@
requestCodeSuccess: false,
checkKey: null,
});
/** 后端 GET /sys/loginCaptchaConfigRedis + 防火墙),与标准登录页一致 */
const serverRequiresCaptcha = ref<boolean | null>(null);
const captchaVisible = computed(() => serverRequiresCaptcha.value === true);
function parseCaptchaRequired(raw: unknown): boolean {
if (raw === true || raw === 'true' || raw === 1 || raw === '1') {
return true;
}
if (raw === false || raw === 'false' || raw === 0 || raw === '0') {
return false;
}
return false;
}
// 记住用户名
const rememberMe = ref<boolean>(false);
const REMEMBER_USERNAME_KEY = 'LOGIN_REMEMBER_USERNAME';
@@ -228,6 +241,12 @@
password: '123456',
loginOrgCode: '',
});
function clearCaptchaUiState() {
formData.inputCode = '';
randCodeData.randCodeImage = '';
randCodeData.requestCodeSuccess = false;
randCodeData.checkKey = null;
}
//手机登录表单字段
const phoneFormData = reactive<any>({
mobile: '',
@@ -274,13 +293,29 @@
return deptName;
};
})
//监听验证码和输入框的修改
// 监听验证码和输入框的修改(账号登录无图形验证码时凭账密即可触发部门查询)
watch(
() => [formData.inputCode, phoneFormData.smscode],
() => [
formData.inputCode,
phoneFormData.smscode,
formData.username,
formData.password,
captchaVisible.value,
activeIndex.value,
],
() => {
if ((formData.inputCode && formData.inputCode.length == 4)
|| (phoneFormData.smscode && phoneFormData.smscode.length == 6)) {
checkAccount()
if (activeIndex.value === 'phoneLogin') {
if (phoneFormData.smscode && phoneFormData.smscode.length == 6) {
checkAccount();
}
return;
}
if (captchaVisible.value) {
if (formData.inputCode && formData.inputCode.length == 4) {
checkAccount();
}
} else if (formData.username && formData.password) {
checkAccount();
}
},
);
@@ -288,16 +323,25 @@
* 监听账号变化,清除部门信息
*/
watch(
() => [formData.username,phoneFormData.mobile,activeIndex.value],
() => [formData.username, phoneFormData.mobile, activeIndex.value, captchaVisible.value],
() => {
formData.loginOrgCode = null;
phoneFormData.loginOrgCode = null;
departList.value = [];
if ((formData.inputCode && formData.inputCode.length == 4)
|| (phoneFormData.smscode && phoneFormData.smscode.length == 6)) {
checkAccount()
if (activeIndex.value === 'phoneLogin') {
if (phoneFormData.smscode && phoneFormData.smscode.length == 6) {
checkAccount();
}
return;
}
}
if (captchaVisible.value) {
if (formData.inputCode && formData.inputCode.length == 4) {
checkAccount();
}
} else if (formData.username && formData.password) {
checkAccount();
}
},
);
//初始化数据
@@ -318,7 +362,9 @@
let params = {...finalFormData, loginType: activeIndex.value === 'accountLogin' ? 'account' : 'phone'};
if (loginType == 'account') {
params['password'] = encryptAESCBC(formData.password);
params['checkKey'] = randCodeData.checkKey;
if (captchaVisible.value) {
params['checkKey'] = randCodeData.checkKey;
}
}
const res = await defHttp.post({
url: '/sys/loginGetUserDeparts',
@@ -394,16 +440,17 @@
// 密码使用AES加密传输
const encryptedPassword = encryptAESCBC(formData.password);
const { userInfo } = await userStore.login(
toRaw({
password: encryptedPassword,
username: formData.username,
loginOrgCode: formData.loginOrgCode,
captcha: formData.inputCode,
checkKey: randCodeData.checkKey,
mode: 'none', //不要默认的错误提示
})
);
const loginPayload: Recordable = {
password: encryptedPassword,
username: formData.username,
loginOrgCode: formData.loginOrgCode,
mode: 'none', //不要默认的错误提示
};
if (captchaVisible.value) {
loginPayload.captcha = formData.inputCode;
loginPayload.checkKey = randCodeData.checkKey;
}
const { userInfo } = await userStore.login(toRaw(loginPayload));
if (userInfo) {
notification.success({
message: t('sys.login.loginSuccessTitle'),
@@ -423,7 +470,9 @@
description: error.message || t('sys.login.networkExceptionMsg'),
duration: 3,
});
handleChangeCheckCode();
if (captchaVisible.value) {
handleChangeCheckCode();
}
} finally {
loginLoading.value = false;
}
@@ -534,7 +583,11 @@
Object.assign(phoneFormData, { mobile: "", smscode: "" });
type.value = 'login';
activeIndex.value = 'accountLogin';
handleChangeCheckCode();
if (captchaVisible.value) {
handleChangeCheckCode();
} else {
clearCaptchaUiState();
}
}
/**
@@ -557,9 +610,18 @@
}, 300);
}
onMounted(() => {
//加载验证码
handleChangeCheckCode();
onMounted(async () => {
try {
const enabled = await getLoginCaptchaServerConfig();
serverRequiresCaptcha.value = parseCaptchaRequired(enabled);
} catch {
serverRequiresCaptcha.value = false;
}
if (captchaVisible.value) {
handleChangeCheckCode();
} else {
clearCaptchaUiState();
}
// 恢复已记住的用户名
const saved = $ls.get(REMEMBER_USERNAME_KEY);
if (saved) {

View File

@@ -2,6 +2,7 @@ import { BasicColumn } from '/@/components/Table';
import { FormSchema } from '/@/components/Table';
import { getAllRolesListNoByTenant, getDepPostIdByDepId } from './user.api';
import { rules } from '/@/utils/helper/validator';
import { getSysLoginPasswordRules } from '/@/utils/sys/sysPasswordRules';
import { render } from '/@/utils/common/renderUtils';
import { getDepartPathNameByOrgCode, getDepartName, getMultiDepartPathName, getDepartPathName } from '@/utils/common/compUtils';
import { h } from 'vue';
@@ -210,16 +211,7 @@ export const formSchema: FormSchema[] = [
componentProps:{
autocomplete: 'new-password',
},
rules: [
{
required: true,
message: '请输入登录密码',
},
{
pattern: /^(?=.*[a-zA-Z])(?=.*\d)(?=.*[~!@#$%^&*()_+`\-={}:";'<>?,./]).{8,}$/,
message: '密码由 8 位及以上数字、大小写字母和特殊符号组成!',
},
],
rules: getSysLoginPasswordRules(),
},
{
label: '确认密码',
@@ -510,16 +502,7 @@ export const formPasswordSchema: FormSchema[] = [
componentProps: {
placeholder: '请输入登录密码',
},
rules: [
{
required: true,
message: '请输入登录密码',
},
{
pattern: /^(?=.*[a-zA-Z])(?=.*\d)(?=.*[~!@#$%^&*()_+`\-={}:";'<>?,./]).{8,}$/,
message: '密码由 8 位及以上数字、大小写字母和特殊符号组成!',
},
],
rules: getSysLoginPasswordRules(),
},
{
label: '确认密码',