新增登录页图形验证码功能,支持通过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

@@ -109,6 +109,27 @@ export function getCodeInfo(currdatetime) {
let url = Api.getInputCode + `/${currdatetime}`;
return defHttp.get({ url: url });
}
/**
* 服务端是否启用登录图形验证码Redis 全局开关优先,否则 jeecg.firewall.enable-login-captcha
* 未登录页调用:不带 token失败时不弹全局错误提示
*/
export function getLoginCaptchaServerConfig() {
return defHttp.get<boolean>(
{ url: '/sys/loginCaptchaConfig' },
{ withToken: false, errorMessageMode: 'none' },
);
}
/**
* 保存登录验证码开关(写入后端 Redis全站生效需登录且具备 system:project:setting:loginCaptcha
*/
export function setLoginCaptchaServerConfig(enabled: boolean) {
return defHttp.post<string>(
{ url: '/sys/setLoginCaptchaConfig', data: { enabled } },
{ errorMessageMode: 'message' },
);
}
/**
* @description: 获取短信验证码
*/

View File

@@ -4,6 +4,7 @@ import { computed } from 'vue';
import { useAppStore } from '/@/store/modules/app';
import { ContentEnum, ThemeEnum } from '/@/enums/appEnum';
import projectSetting from '/@/settings/projectSetting';
type RootSetting = Omit<ProjectConfig, 'locale' | 'headerSetting' | 'menuSetting' | 'multiTabsSetting'>;
@@ -45,6 +46,18 @@ export function useRootSetting() {
const getGrayMode = computed(() => appStore.getProjectConfig.grayMode);
// 代码逻辑说明: 【QQYUN-10952】AI助手支持通过设置来配置是否显示
const getAiIconShow = computed(() => appStore.getProjectConfig.aiIconShow);
/** 与 projectSetting 默认合并:旧缓存无该字段时不再误判为「开启验证码」;兼容异常字符串缓存 */
const getLoginCaptchaEnabled = computed(() => {
const v = appStore.getProjectConfig.loginCaptchaEnabled;
if (v === undefined || v === null) {
return projectSetting.loginCaptchaEnabled;
}
if (typeof v === 'string') {
const s = v.trim().toLowerCase();
return s === 'true' || s === '1';
}
return Boolean(v);
});
const getLockTime = computed(() => appStore.getProjectConfig.lockTime);
const getShowDarkModeToggle = computed(() => appStore.getProjectConfig.showDarkModeToggle);
@@ -86,5 +99,6 @@ export function useRootSetting() {
setDarkMode,
getShowDarkModeToggle,
getAiIconShow,
getLoginCaptchaEnabled,
};
}

View File

@@ -1,4 +1,5 @@
import { defineComponent, computed, unref } from 'vue';
import { usePermission } from '/@/hooks/web/usePermission';
import { BasicDrawer } from '/@/components/Drawer/index';
import { Divider } from 'ant-design-vue';
import { TypePicker, ThemeColorPicker, SettingFooter, SwitchItem, SelectItem, InputNumberItem } from './components';
@@ -47,8 +48,13 @@ export default defineComponent({
getShowDarkModeToggle,
getThemeColor,
getAiIconShow,
getLoginCaptchaEnabled,
} = useRootSetting();
const { hasPermission } = usePermission();
/** 项目配置中「验证码登录」开关仅对有权限的用户展示 */
const showLoginCaptchaSetting = computed(() => hasPermission('system:project:setting:loginCaptcha'));
const { getOpenPageLoading, getBasicTransition, getEnableTransition, getOpenNProgress } = useTransitionSetting();
const {
@@ -318,6 +324,13 @@ export default defineComponent({
<SwitchItem title={t('layout.setting.colorWeak')} event={HandlerEnum.COLOR_WEAK} def={unref(getColorWeak)} />
<SwitchItem title={t('layout.setting.aiIconSHow')} event={HandlerEnum.AI_ICON_SHOW} def={unref(getAiIconShow)} />
{unref(showLoginCaptchaSetting) && (
<SwitchItem
title={t('layout.setting.loginCaptcha')}
event={HandlerEnum.LOGIN_CAPTCHA_ENABLED}
def={unref(getLoginCaptchaEnabled)}
/>
)}
</>
);
}

View File

@@ -41,10 +41,14 @@
const { t } = useI18n();
const getBindValue = computed(() => {
return props.def ? { checked: props.def } : {};
// 必须为 false 时也绑定 checked否则 def===false 会被当成无绑定,开关与持久化异常
if (props.def === undefined || props.def === null) {
return {};
}
return { checked: props.def };
});
function handleChange(e: ChangeEvent) {
props.event && baseHandler(props.event, e);
function handleChange(checked: boolean) {
props.event && baseHandler(props.event, checked);
}
return {
prefixCls,

View File

@@ -53,6 +53,8 @@ export enum HandlerEnum {
OPEN_PAGE_LOADING,
OPEN_ROUTE_TRANSITION,
AI_ICON_SHOW,
/** 登录页图形验证码开关 */
LOGIN_CAPTCHA_ENABLED,
}
// 标签页样式

View File

@@ -3,6 +3,8 @@ import { updateHeaderBgColor, updateSidebarBgColor } from '/@/logics/theme/updat
import { updateColorWeak } from '/@/logics/theme/updateColorWeak';
import { updateGrayMode } from '/@/logics/theme/updateGrayMode';
import { message } from 'ant-design-vue';
import { setLoginCaptchaServerConfig } from '/@/api/sys/user';
import { useAppStore } from '/@/store/modules/app';
import { ProjectConfig } from '/#/config';
import { changeTheme } from '/@/logics/theme';
@@ -80,6 +82,12 @@ export function baseHandler(event: HandlerEnum, value: any) {
const appStore = useAppStore();
const config = handler(event, value);
appStore.setProjectConfig(config);
// 验证码登录开关同步到后端 Redis登录页仅依赖 GET /sys/loginCaptchaConfig
if (event === HandlerEnum.LOGIN_CAPTCHA_ENABLED) {
setLoginCaptchaServerConfig(!!value).catch(() => {
message.error('验证码开关同步到后端失败,请检查网络或是否具备 system:project:setting:loginCaptcha 权限');
});
}
if (event === HandlerEnum.CHANGE_THEME) {
updateHeaderBgColor();
updateSidebarBgColor();
@@ -210,6 +218,8 @@ export function handler(event: HandlerEnum, value: any): DeepPartial<ProjectConf
// 代码逻辑说明: 【QQYUN-10952】AI助手支持通过设置来配置是否显示
case HandlerEnum.AI_ICON_SHOW:
return { aiIconShow: value };
case HandlerEnum.LOGIN_CAPTCHA_ENABLED:
return { loginCaptchaEnabled: value };
case HandlerEnum.SHOW_LOGO:
return { showLogo: value };

View File

@@ -108,6 +108,7 @@ export default {
fullContent: 'Full content',
grayMode: 'Gray mode',
colorWeak: 'Color Weak Mode',
loginCaptcha: 'Captcha login',
progress: 'Progress',
switchLoading: 'Switch Loading',

View File

@@ -111,6 +111,7 @@ export default {
grayMode: '灰色模式',
colorWeak: '色弱模式',
aiIconSHow: 'Ai图标显示',
loginCaptcha: '验证码登录',
progress: '顶部进度条',
switchLoading: '切换loading',

View File

@@ -21,12 +21,28 @@ import { primaryColor } from '../../build/config/themeConfig';
import { Persistent } from '/@/utils/cache/persistent';
import { deepMerge } from '/@/utils';
import { ThemeEnum } from '/@/enums/appEnum';
import { getLoginCaptchaServerConfig } from '/@/api/sys/user';
/** 解析 GET /sys/loginCaptchaConfig 的 result兼容 boolean / 字符串) */
function parseLoginCaptchaEnabledFromServer(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;
}
// Initial project configuration
export function initAppConfigStore() {
const localeStore = useLocaleStore();
const appStore = useAppStore();
let projCfg: ProjectConfig = Persistent.getLocal(PROJ_CFG_KEY) as ProjectConfig;
// 合并前判断是否已在本地持久化过「验证码登录」键;有则不再用接口覆盖,避免侧边栏已选「开」却被同步成「关」
const persistedHadLoginCaptchaKey = !!(
projCfg && Object.prototype.hasOwnProperty.call(projCfg, 'loginCaptchaEnabled')
);
projCfg = deepMerge(projectSetting, projCfg || {});
const darkMode = appStore.getDarkMode;
const {
@@ -48,6 +64,15 @@ export function initAppConfigStore() {
console.log(error);
}
appStore.setProjectConfig(projCfg);
// 仅当本地从未保存过该开关时,用后端 Redis/防火墙 初始化一次;用户一旦在侧边栏保存过,完全信任本地持久化
getLoginCaptchaServerConfig()
.then((enabled) => {
if (persistedHadLoginCaptchaKey) {
return;
}
appStore.setProjectConfig({ loginCaptchaEnabled: parseLoginCaptchaEnabledFromServer(enabled) });
})
.catch(() => {});
// init dark mode
updateDarkTheme(darkMode);

View File

@@ -71,6 +71,9 @@ const setting: ProjectConfig = {
// ai图标显示
aiIconShow: false,
// 登录页是否展示图形验证码(未写入本地配置时以此为准;需要验证码时在项目配置中开启)
loginCaptchaEnabled: false,
// 头部配置
headerSetting: {
// 背景色

View File

@@ -0,0 +1,57 @@
/**
* 系统登录密码校验:与「低代码开发 → 系统校验规则」中规则编码 sys_login_password 对应,
* 服务端 /sys/checkRule/checkByCode 按 ruleJson 校验(多条全局规则顺序执行);库表见 Flyway V3.9.2_1 / V3.9.2_2。
*/
import type { Rule } from '/@/components/Form/src/types/form';
import { validateCheckRule } from '/@/views/system/checkRule/check.rule.api';
/** 与 sys_check_rule.rule_code 一致,勿随意修改 */
export const SYS_LOGIN_PASSWORD_CHECK_RULE_CODE = 'sys_login_password';
/** 与库中多条规则合并效果一致(规则不存在或接口失败时兜底) */
export const SYS_LOGIN_PASSWORD_PATTERN =
/^(?=.*[a-zA-Z])(?=.*\d)(?=.*[~!@#$%^&*()_+\-={}[\]:;"'<>,.?/]).{8,}$/;
export const SYS_LOGIN_PASSWORD_PATTERN_MESSAGE =
'密码由 8 位及以上数字、大小写字母和特殊符号组成!';
export const SYS_LOGIN_PASSWORD_REQUIRED_MESSAGE = '请输入登录密码';
/** 适用于 FormSchema.rules必填 + 调用系统校验规则(失败时本地正则兜底) */
export function getSysLoginPasswordRules(): Rule[] {
return [
{
required: true,
message: SYS_LOGIN_PASSWORD_REQUIRED_MESSAGE,
},
{
async validator(_rule, value: string) {
if (value === undefined || value === null || String(value).trim() === '') {
return Promise.reject(SYS_LOGIN_PASSWORD_REQUIRED_MESSAGE);
}
const str = String(value);
try {
const res = (await validateCheckRule(SYS_LOGIN_PASSWORD_CHECK_RULE_CODE, str)) as {
success?: boolean;
message?: string;
};
if (res?.success) {
return Promise.resolve();
}
const msg = String(res?.message || '');
if (msg.includes('不存在') || msg.includes('该编码')) {
return SYS_LOGIN_PASSWORD_PATTERN.test(str)
? Promise.resolve()
: Promise.reject(SYS_LOGIN_PASSWORD_PATTERN_MESSAGE);
}
return Promise.reject(msg || SYS_LOGIN_PASSWORD_PATTERN_MESSAGE);
} catch {
return SYS_LOGIN_PASSWORD_PATTERN.test(str)
? Promise.resolve()
: Promise.reject(SYS_LOGIN_PASSWORD_PATTERN_MESSAGE);
}
},
trigger: 'change',
},
] as Rule[];
}

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: '确认密码',

View File

@@ -141,6 +141,8 @@ export interface ProjectConfig {
// Whether to cancel the http request that has been sent but not responded when switching the interface.
removeAllHttpPending: boolean;
aiIconShow: boolean;
/** 登录页是否展示图形验证码(需与后端 firewall.enable-login-captcha 配合;服务端要求验证码时始终展示) */
loginCaptchaEnabled: boolean;
}
export interface GlobConfig {