新增登录页图形验证码功能,支持通过Redis全局配置控制验证码的启用状态,优化登录流程以提升用户体验。新增相关API接口和前端配置项,确保验证码逻辑与后端同步。
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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[] = [];
|
||||
// 整合两个子表的数据(priority:1 优先、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: [],
|
||||
},
|
||||
|
||||
@@ -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/loginCaptchaConfig(Redis)一致:关闭时不挂载验证码输入与图片 -->
|
||||
<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/loginCaptchaConfig(Redis + 防火墙),与标准登录页一致 */
|
||||
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) {
|
||||
|
||||
@@ -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: '确认密码',
|
||||
|
||||
Reference in New Issue
Block a user