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

@@ -117,6 +117,11 @@ public interface CommonConstant {
/** 登录二维码token */ /** 登录二维码token */
String LOGIN_QRCODE_TOKEN = "LQT:"; String LOGIN_QRCODE_TOKEN = "LQT:";
/**
* 登录页图形验证码全局开关Redis 存储 true/false 字符串;存在时覆盖 jeecg.firewall.enable-login-captcha与侧边栏项目配置同步
*/
String SYS_LOGIN_CAPTCHA_ENABLED = "sys:login:captcha:enabled";
/** /**
* 0一级菜单 * 0一级菜单

View File

@@ -91,6 +91,7 @@ public class ShiroConfig {
filterChainDefinitionMap.put("/sys/smsCheckCaptcha", "anon"); //短信次数发送太多验证码排除 filterChainDefinitionMap.put("/sys/smsCheckCaptcha", "anon"); //短信次数发送太多验证码排除
filterChainDefinitionMap.put("/sys/login", "anon"); //登录接口排除 filterChainDefinitionMap.put("/sys/login", "anon"); //登录接口排除
filterChainDefinitionMap.put("/sys/mLogin", "anon"); //登录接口排除 filterChainDefinitionMap.put("/sys/mLogin", "anon"); //登录接口排除
filterChainDefinitionMap.put("/sys/loginCaptchaConfig", "anon"); // 登录页是否需图形验证码(未登录可访问)
filterChainDefinitionMap.put("/sys/logout", "anon"); //登出接口排除 filterChainDefinitionMap.put("/sys/logout", "anon"); //登出接口排除
filterChainDefinitionMap.put("/sys/thirdLogin/**", "anon"); //第三方登录 filterChainDefinitionMap.put("/sys/thirdLogin/**", "anon"); //第三方登录
filterChainDefinitionMap.put("/sys/getEncryptedString", "anon"); //获取加密串 filterChainDefinitionMap.put("/sys/getEncryptedString", "anon"); //获取加密串

View File

@@ -12,6 +12,7 @@ import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.SecurityUtils; import org.apache.shiro.SecurityUtils;
import org.apache.shiro.authz.annotation.RequiresPermissions;
import org.apache.shiro.authz.annotation.RequiresRoles; import org.apache.shiro.authz.annotation.RequiresRoles;
import org.jeecg.common.api.vo.Result; import org.jeecg.common.api.vo.Result;
import org.jeecg.common.constant.CacheConstant; import org.jeecg.common.constant.CacheConstant;
@@ -737,6 +738,46 @@ public class LoginController {
} }
return Result.ok(); return Result.ok();
} }
/**
* 登录页是否启用图形验证码(未登录可访问)。优先 Redis 全局配置(与侧边栏项目配置同步),否则沿用 jeecg.firewall.enable-login-captcha
*/
@IgnoreAuth
@GetMapping("/loginCaptchaConfig")
@Operation(summary = "登录页是否启用图形验证码")
public Result<Boolean> loginCaptchaConfig() {
return Result.OK(isLoginCaptchaRequired());
}
/**
* 保存登录页图形验证码开关(写入 Redis全站生效需权限 system:project:setting:loginCaptcha
*/
@PostMapping("/setLoginCaptchaConfig")
@Operation(summary = "保存登录页图形验证码开关")
@RequiresPermissions("system:project:setting:loginCaptcha")
public Result<String> setLoginCaptchaConfig(@RequestBody JSONObject body) {
Boolean enabled = body.getBoolean("enabled");
if (enabled == null) {
return Result.error("参数 enabled 不能为空");
}
redisUtil.set(CommonConstant.SYS_LOGIN_CAPTCHA_ENABLED, enabled.toString());
baseCommonService.addLog("保存登录验证码开关: " + enabled, CommonConstant.LOG_TYPE_2, CommonConstant.OPERATE_TYPE_3);
return Result.OK("保存成功");
}
/**
* 是否需要在登录时校验图形验证码Redis 覆盖 &gt; firewall 配置)
*/
private boolean isLoginCaptchaRequired() {
Object v = redisUtil.get(CommonConstant.SYS_LOGIN_CAPTCHA_ENABLED);
if (v != null) {
return Boolean.parseBoolean(v.toString());
}
return jeecgBaseConfig.getFirewall() == null
|| jeecgBaseConfig.getFirewall().getEnableLoginCaptcha() == null
|| Boolean.TRUE.equals(jeecgBaseConfig.getFirewall().getEnableLoginCaptcha());
}
/** /**
* 登录二维码 * 登录二维码
*/ */
@@ -917,9 +958,9 @@ public class LoginController {
* 校验验证码工具方法校验失败直接返回Result校验通过返回realKey * 校验验证码工具方法校验失败直接返回Result校验通过返回realKey
*/ */
private String validateCaptcha(SysLoginModel sysLoginModel, Result<JSONObject> result) { private String validateCaptcha(SysLoginModel sysLoginModel, Result<JSONObject> result) {
// 判断是否启用登录验证码校验 // 是否启用登录验证码(含 Redis 全局开关与 firewall 配置)
if (jeecgBaseConfig.getFirewall() != null && Boolean.FALSE.equals(jeecgBaseConfig.getFirewall().getEnableLoginCaptcha())) { if (!isLoginCaptchaRequired()) {
log.warn("关闭登录验证码校验,跳过验证码校验!"); log.warn("关闭登录验证码校验Redis 全局或 firewall,跳过验证码校验!");
return "LoginWithoutVerifyCode"; return "LoginWithoutVerifyCode";
} }

View File

@@ -52,6 +52,11 @@ public class SysCheckRuleServiceImpl extends ServiceImpl<SysCheckRuleMapper, Sys
for (int i = 0; i < rules.size(); i++) { for (int i = 0; i < rules.size(); i++) {
JSONObject result = new JSONObject(); JSONObject result = new JSONObject();
JSONObject rule = rules.getJSONObject(i); JSONObject rule = rules.getJSONObject(i);
// 全局规则可选「不执行」priority 为 2 时跳过本条(仅保留配置,不参与校验)
String priority = rule.getString("priority");
if ("2".equals(priority)) {
continue;
}
// 位数 // 位数
String digits = rule.getString("digits"); String digits = rule.getString("digits");
result.put("digits", digits); result.put("digits", digits);

View File

@@ -0,0 +1,21 @@
-- 编码校验规则系统登录密码规则编码 sys_login_password
-- 全局规则拆成多条长度 字母 数字 特殊字符按顺序校验先失败先提示
DELETE FROM sys_check_rule WHERE rule_code = 'sys_login_password';
INSERT INTO sys_check_rule (
id,
rule_name,
rule_code,
rule_json,
rule_description,
create_by,
create_time
) VALUES (
'1990000000000000001',
'系统登录密码',
'sys_login_password',
CONVERT(UNHEX('5b7b22646967697473223a222a222c227061747465726e223a225e2e7b382c7d24222c226d657373616765223a22e5af86e7a081e995bfe5baa6e887b3e5b091e4b8ba203820e4bd8d222c227072696f72697479223a2231227d2c7b22646967697473223a222a222c227061747465726e223a225e283f3d2e2a5b612d7a412d5a5d292e2a24222c226d657373616765223a22e5af86e7a081e5bf85e9a1bbe58c85e590abe5ad97e6af8d222c227072696f72697479223a2231227d2c7b22646967697473223a222a222c227061747465726e223a225e283f3d2e2a5c5c64292e2a24222c226d657373616765223a22e5af86e7a081e5bf85e9a1bbe58c85e590abe695b0e5ad97222c227072696f72697479223a2231227d2c7b22646967697473223a222a222c227061747465726e223a225e283f3d2e2a5b7e21402324255e262a28295f2b5c5c2d3d7b7d5c5c5b5c5c5d3a3b5c225c5c75303032373c3e3f2c2e2f5d292e2a24222c226d657373616765223a22e5af86e7a081e5bf85e9a1bbe58c85e590abe789b9e6ae8ae5ad97e7aca6efbc88e5a682207e214023242520e7ad89efbc89222c227072696f72697479223a2231227d5d') USING utf8mb4),
'供用户管理修改密码等调用全局规则共 4 长度/字母/数字/特殊字符与前端 utils/sys/sysPasswordRules 约定规则编码 sys_login_password',
'admin',
NOW()
);

View File

@@ -0,0 +1,20 @@
-- 已部署环境若已执行过 V3.9.2_1将单条正则升级为多条全局规则拆分 V3.9.2_1 最终内容一致可重复执行
DELETE FROM sys_check_rule WHERE rule_code = 'sys_login_password';
INSERT INTO sys_check_rule (
id,
rule_name,
rule_code,
rule_json,
rule_description,
create_by,
create_time
) VALUES (
'1990000000000000001',
'系统登录密码',
'sys_login_password',
CONVERT(UNHEX('5b7b22646967697473223a222a222c227061747465726e223a225e2e7b382c7d24222c226d657373616765223a22e5af86e7a081e995bfe5baa6e887b3e5b091e4b8ba203820e4bd8d222c227072696f72697479223a2231227d2c7b22646967697473223a222a222c227061747465726e223a225e283f3d2e2a5b612d7a412d5a5d292e2a24222c226d657373616765223a22e5af86e7a081e5bf85e9a1bbe58c85e590abe5ad97e6af8d222c227072696f72697479223a2231227d2c7b22646967697473223a222a222c227061747465726e223a225e283f3d2e2a5c5c64292e2a24222c226d657373616765223a22e5af86e7a081e5bf85e9a1bbe58c85e590abe695b0e5ad97222c227072696f72697479223a2231227d2c7b22646967697473223a222a222c227061747465726e223a225e283f3d2e2a5b7e21402324255e262a28295f2b5c5c2d3d7b7d5c5c5b5c5c5d3a3b5c225c5c75303032373c3e3f2c2e2f5d292e2a24222c226d657373616765223a22e5af86e7a081e5bf85e9a1bbe58c85e590abe789b9e6ae8ae5ad97e7aca6efbc88e5a682207e214023242520e7ad89efbc89222c227072696f72697479223a2231227d5d') USING utf8mb4),
'供用户管理修改密码等调用全局规则共 4 长度/字母/数字/特殊字符与前端 utils/sys/sysPasswordRules 约定规则编码 sys_login_password',
'admin',
NOW()
);

View File

@@ -0,0 +1,5 @@
-- 项目配置抽屉验证码登录开关的按钮权限授权后可在界面设置中看到该开关
INSERT INTO `sys_permission` (`id`, `parent_id`, `name`, `url`, `component`, `is_route`, `component_name`, `redirect`, `menu_type`, `perms`, `perms_type`, `sort_no`, `always_show`, `icon`, `is_leaf`, `keep_alive`, `hidden`, `hide_tab`, `description`, `create_by`, `create_time`, `update_by`, `update_time`, `del_flag`, `rule_flag`, `status`, `internal_or_external`) VALUES ('2000210000000000001', 'd7d6e2e4e2934f2c9385a623fd98c6f3', '项目配置-验证码登录开关', NULL, NULL, 0, NULL, NULL, 2, 'system:project:setting:loginCaptcha', '1', 99.00, 0, NULL, 1, 0, 0, 0, '控制项目配置抽屉中验证码登录开关是否展示', 'admin', NOW(), NULL, NULL, 0, 0, '1', 0);
-- 默认授权给管理员角色与历史脚本中 admin 角色 id 一致
INSERT INTO `sys_role_permission` (`id`, `role_id`, `permission_id`, `data_rule_ids`, `operate_date`, `operate_ip`) VALUES ('2000210000000000002', 'f6817f48af4fb3af11b9e8bf182f618b', '2000210000000000001', NULL, NOW(), '127.0.0.1');

View File

@@ -109,6 +109,27 @@ export function getCodeInfo(currdatetime) {
let url = Api.getInputCode + `/${currdatetime}`; let url = Api.getInputCode + `/${currdatetime}`;
return defHttp.get({ url: url }); 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: 获取短信验证码 * @description: 获取短信验证码
*/ */

View File

@@ -4,6 +4,7 @@ import { computed } from 'vue';
import { useAppStore } from '/@/store/modules/app'; import { useAppStore } from '/@/store/modules/app';
import { ContentEnum, ThemeEnum } from '/@/enums/appEnum'; import { ContentEnum, ThemeEnum } from '/@/enums/appEnum';
import projectSetting from '/@/settings/projectSetting';
type RootSetting = Omit<ProjectConfig, 'locale' | 'headerSetting' | 'menuSetting' | 'multiTabsSetting'>; type RootSetting = Omit<ProjectConfig, 'locale' | 'headerSetting' | 'menuSetting' | 'multiTabsSetting'>;
@@ -45,6 +46,18 @@ export function useRootSetting() {
const getGrayMode = computed(() => appStore.getProjectConfig.grayMode); const getGrayMode = computed(() => appStore.getProjectConfig.grayMode);
// 代码逻辑说明: 【QQYUN-10952】AI助手支持通过设置来配置是否显示 // 代码逻辑说明: 【QQYUN-10952】AI助手支持通过设置来配置是否显示
const getAiIconShow = computed(() => appStore.getProjectConfig.aiIconShow); 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 getLockTime = computed(() => appStore.getProjectConfig.lockTime);
const getShowDarkModeToggle = computed(() => appStore.getProjectConfig.showDarkModeToggle); const getShowDarkModeToggle = computed(() => appStore.getProjectConfig.showDarkModeToggle);
@@ -86,5 +99,6 @@ export function useRootSetting() {
setDarkMode, setDarkMode,
getShowDarkModeToggle, getShowDarkModeToggle,
getAiIconShow, getAiIconShow,
getLoginCaptchaEnabled,
}; };
} }

View File

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

View File

@@ -53,6 +53,8 @@ export enum HandlerEnum {
OPEN_PAGE_LOADING, OPEN_PAGE_LOADING,
OPEN_ROUTE_TRANSITION, OPEN_ROUTE_TRANSITION,
AI_ICON_SHOW, 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 { updateColorWeak } from '/@/logics/theme/updateColorWeak';
import { updateGrayMode } from '/@/logics/theme/updateGrayMode'; 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 { useAppStore } from '/@/store/modules/app';
import { ProjectConfig } from '/#/config'; import { ProjectConfig } from '/#/config';
import { changeTheme } from '/@/logics/theme'; import { changeTheme } from '/@/logics/theme';
@@ -80,6 +82,12 @@ export function baseHandler(event: HandlerEnum, value: any) {
const appStore = useAppStore(); const appStore = useAppStore();
const config = handler(event, value); const config = handler(event, value);
appStore.setProjectConfig(config); 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) { if (event === HandlerEnum.CHANGE_THEME) {
updateHeaderBgColor(); updateHeaderBgColor();
updateSidebarBgColor(); updateSidebarBgColor();
@@ -210,6 +218,8 @@ export function handler(event: HandlerEnum, value: any): DeepPartial<ProjectConf
// 代码逻辑说明: 【QQYUN-10952】AI助手支持通过设置来配置是否显示 // 代码逻辑说明: 【QQYUN-10952】AI助手支持通过设置来配置是否显示
case HandlerEnum.AI_ICON_SHOW: case HandlerEnum.AI_ICON_SHOW:
return { aiIconShow: value }; return { aiIconShow: value };
case HandlerEnum.LOGIN_CAPTCHA_ENABLED:
return { loginCaptchaEnabled: value };
case HandlerEnum.SHOW_LOGO: case HandlerEnum.SHOW_LOGO:
return { showLogo: value }; return { showLogo: value };

View File

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

View File

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

View File

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

View File

@@ -71,6 +71,9 @@ const setting: ProjectConfig = {
// ai图标显示 // ai图标显示
aiIconShow: false, aiIconShow: false,
// 登录页是否展示图形验证码(未写入本地配置时以此为准;需要验证码时在项目配置中开启)
loginCaptchaEnabled: false,
// 头部配置 // 头部配置
headerSetting: { 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')" /> <InputPassword size="large" visibilityToggle v-model:value="formData.password" :placeholder="t('sys.login.password')" />
</FormItem> </FormItem>
<!--验证码--> <!-- 无需验证码时下列整块输入框 + 图片均不挂载不请求 randomImage -->
<ARow class="enter-x"> <template v-if="captchaVisible">
<ACol :span="12"> <ARow class="enter-x">
<FormItem name="inputCode" class="enter-x"> <ACol :span="12">
<Input size="large" v-model:value="formData.inputCode" :placeholder="t('sys.login.inputCode')" style="min-width: 100px" /> <FormItem name="inputCode" class="enter-x">
</FormItem> <Input size="large" v-model:value="formData.inputCode" :placeholder="t('sys.login.inputCode')" style="min-width: 100px" />
</ACol> </FormItem>
<ACol :span="8"> </ACol>
<FormItem :style="{ 'text-align': 'right', 'margin-left': '20px' }" class="enter-x"> <ACol :span="8">
<img <FormItem :style="{ 'text-align': 'right', 'margin-left': '20px' }" class="enter-x">
v-if="randCodeData.requestCodeSuccess" <img
style="margin-top: 2px; max-width: initial" v-if="randCodeData.requestCodeSuccess"
:src="randCodeData.randCodeImage" style="margin-top: 2px; max-width: initial"
@click="handleChangeCheckCode" :src="randCodeData.randCodeImage"
/> @click="handleChangeCheckCode"
<img v-else style="margin-top: 2px; max-width: initial" src="../../../assets/images/checkcode.png" @click="handleChangeCheckCode" /> />
</FormItem> <img v-else style="margin-top: 2px; max-width: initial" src="../../../assets/images/checkcode.png" @click="handleChangeCheckCode" />
</ACol> </FormItem>
</ARow> </ACol>
</ARow>
</template>
<ARow class="enter-x"> <ARow class="enter-x">
<ACol :span="12"> <ACol :span="12">
@@ -98,7 +100,7 @@
import { useUserStore } from '/@/store/modules/user'; import { useUserStore } from '/@/store/modules/user';
import { LoginStateEnum, useLoginState, useFormRules, useFormValid } from './useLogin'; import { LoginStateEnum, useLoginState, useFormRules, useFormValid } from './useLogin';
import { useDesign } from '/@/hooks/web/useDesign'; import { useDesign } from '/@/hooks/web/useDesign';
import { getCodeInfo } from '/@/api/sys/user'; import { getCodeInfo, getLoginCaptchaServerConfig } from '/@/api/sys/user';
import { encryptAESCBC } from '/@/utils/cipher'; import { encryptAESCBC } from '/@/utils/cipher';
const ACol = Col; const ACol = Col;
@@ -113,6 +115,26 @@
const { prefixCls } = useDesign('login'); const { prefixCls } = useDesign('login');
const userStore = useUserStore(); 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 { setLoginState, getLoginState } = useLoginState();
const { getFormRules } = useFormRules(); const { getFormRules } = useFormRules();
@@ -132,6 +154,13 @@
checkKey: null, checkKey: null,
}); });
function clearCaptchaUiState() {
formData.inputCode = '';
randCodeData.randCodeImage = '';
randCodeData.requestCodeSuccess = false;
randCodeData.checkKey = null;
}
const { validForm } = useFormValid(formRef); const { validForm } = useFormValid(formRef);
//onKeyStroke('Enter', handleLogin); //onKeyStroke('Enter', handleLogin);
@@ -146,15 +175,16 @@
// 密码使用AES加密传输 // 密码使用AES加密传输
const encryptedPassword = encryptAESCBC(data.password); const encryptedPassword = encryptAESCBC(data.password);
const { userInfo } = await userStore.login( const loginPayload: Recordable = {
toRaw({ password: encryptedPassword,
password: encryptedPassword, username: data.account,
username: data.account, mode: 'none', //不要默认的错误提示
captcha: data.inputCode, };
checkKey: randCodeData.checkKey, if (unref(captchaVisible)) {
mode: 'none', //不要默认的错误提示 loginPayload.captcha = data.inputCode;
}) loginPayload.checkKey = randCodeData.checkKey;
); }
const { userInfo } = await userStore.login(toRaw(loginPayload));
if (userInfo) { if (userInfo) {
notification.success({ notification.success({
message: t('sys.login.loginSuccessTitle'), message: t('sys.login.loginSuccessTitle'),
@@ -169,7 +199,9 @@
duration: 3, duration: 3,
}); });
loading.value = false; loading.value = false;
handleChangeCheckCode(); if (unref(captchaVisible)) {
handleChangeCheckCode();
}
} }
} }
function handleChangeCheckCode() { function handleChangeCheckCode() {
@@ -189,8 +221,17 @@
function onThirdLogin(type) { function onThirdLogin(type) {
thirdModalRef.value.onThirdLogin(type); thirdModalRef.value.onThirdLogin(type);
} }
//初始化验证码 onMounted(async () => {
onMounted(() => { try {
const enabled = await getLoginCaptchaServerConfig();
serverRequiresCaptcha.value = parseCaptchaRequired(enabled);
} catch {
serverRequiresCaptcha.value = false;
}
if (!unref(captchaVisible)) {
clearCaptchaUiState();
return;
}
handleChangeCheckCode(); handleChangeCheckCode();
}); });
</script> </script>

View File

@@ -23,7 +23,12 @@
:columns="columns2" :columns="columns2"
> >
<template #toolbarAfter> <template #toolbarAfter>
<a-alert type="info" showIcon message="全局规则可校验用户输入的所有字符;全局规则的优先级比局部规则的要高。" style="margin-bottom: 8px" /> <a-alert
type="info"
showIcon
message="全局规则可校验用户输入的所有字符;全局规则的优先级比局部规则的要高。优先级选「不执行」时本条规则仅保留配置、不参与校验,便于临时关闭某项密码要求。"
style="margin-bottom: 8px"
/>
</template> </template>
</JVxeTable> </JVxeTable>
</a-tab-pane> </a-tab-pane>
@@ -75,16 +80,14 @@
let ruleJson = data.record.ruleJson; let ruleJson = data.record.ruleJson;
if (ruleJson) { if (ruleJson) {
let ruleList = JSON.parse(ruleJson); const ruleList = JSON.parse(ruleJson);
// 筛选出全局规则和局部规则 const global: any[] = [];
let global: any[] = [], const design: any[] = [];
design: any[] = [], ruleList.forEach((rule: any) => {
priority = '1';
ruleList.forEach((rule) => {
if (rule.digits === '*') { 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 { } else {
priority = '0';
design.push(rule); design.push(rule);
} }
}); });
@@ -132,20 +135,26 @@
if (tableData2 && tableData2.length > 0) { if (tableData2 && tableData2.length > 0) {
globalValues = tableData2; globalValues = tableData2;
} }
// 整合两个子表的数据 // 整合两个子表的数据priority1 优先、0 最后、2 不执行2 放末尾仅便于存储,后端会跳过)
let firstGlobal: any[] = [], const firstGlobal: any[] = [];
afterGlobal: any[] = []; const afterGlobal: any[] = [];
const inactiveGlobal: any[] = [];
for (let i = 0; i < globalValues.length; i++) { for (let i = 0; i < globalValues.length; i++) {
let v: any = globalValues[i]; const v: any = globalValues[i];
v.digits = '*'; v.digits = '*';
if (v.priority === '1') { const p = String(v.priority ?? '1');
if (p === '1') {
firstGlobal.push(v); firstGlobal.push(v);
} else { } else if (p === '0') {
afterGlobal.push(v); afterGlobal.push(v);
} else if (p === '2') {
inactiveGlobal.push(v);
} else {
firstGlobal.push(v);
} }
} }
let concatValues = firstGlobal.concat(designValues).concat(afterGlobal); const concatValues = firstGlobal.concat(designValues).concat(afterGlobal).concat(inactiveGlobal);
let subValues = concatValues.map((i) => pick(i, 'digits', 'pattern', 'message')); const subValues = concatValues.map((i) => pick(i, 'digits', 'pattern', 'message', 'priority'));
// 生成 formData用于传入后台 // 生成 formData用于传入后台
let ruleJson = JSON.stringify(subValues); let ruleJson = JSON.stringify(subValues);
let formData = Object.assign({}, mainData, { ruleJson }); let formData = Object.assign({}, mainData, { ruleJson });
@@ -226,6 +235,7 @@
options: [ options: [
{ title: '优先运行', value: '1' }, { title: '优先运行', value: '1' },
{ title: '最后运行', value: '0' }, { title: '最后运行', value: '0' },
{ title: '不执行', value: '2' },
], ],
validateRules: [], 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-input class="fix-auto-fill" type="password" :placeholder="t('sys.login.password')" v-model:value="formData.password" />
</a-form-item> </a-form-item>
</div> </div>
<div class="aui-inputClear"> <!-- GET /sys/loginCaptchaConfigRedis一致关闭时不挂载验证码输入与图片 -->
<div class="aui-inputClear" v-if="captchaVisible">
<i class="icon icon-code"></i> <i class="icon icon-code"></i>
<a-form-item> <a-form-item>
<a-input class="fix-auto-fill" type="text" :placeholder="t('sys.login.inputCode')" v-model:value="formData.inputCode" /> <a-input class="fix-auto-fill" type="text" :placeholder="t('sys.login.inputCode')" v-model:value="formData.inputCode" />
@@ -174,7 +175,7 @@
</div> </div>
</template> </template>
<script lang="ts" setup name="login-mini"> <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 { computed, onMounted, reactive, ref, toRaw, unref, watch } from 'vue';
import codeImg from '/@/assets/images/checkcode.png'; import codeImg from '/@/assets/images/checkcode.png';
import { Rule } from '/@/components/Form'; import { Rule } from '/@/components/Form';
@@ -215,6 +216,18 @@
requestCodeSuccess: false, requestCodeSuccess: false,
checkKey: null, 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 rememberMe = ref<boolean>(false);
const REMEMBER_USERNAME_KEY = 'LOGIN_REMEMBER_USERNAME'; const REMEMBER_USERNAME_KEY = 'LOGIN_REMEMBER_USERNAME';
@@ -228,6 +241,12 @@
password: '123456', password: '123456',
loginOrgCode: '', loginOrgCode: '',
}); });
function clearCaptchaUiState() {
formData.inputCode = '';
randCodeData.randCodeImage = '';
randCodeData.requestCodeSuccess = false;
randCodeData.checkKey = null;
}
//手机登录表单字段 //手机登录表单字段
const phoneFormData = reactive<any>({ const phoneFormData = reactive<any>({
mobile: '', mobile: '',
@@ -274,13 +293,29 @@
return deptName; return deptName;
}; };
}) })
//监听验证码和输入框的修改 // 监听验证码和输入框的修改(账号登录无图形验证码时凭账密即可触发部门查询)
watch( watch(
() => [formData.inputCode, phoneFormData.smscode], () => [
formData.inputCode,
phoneFormData.smscode,
formData.username,
formData.password,
captchaVisible.value,
activeIndex.value,
],
() => { () => {
if ((formData.inputCode && formData.inputCode.length == 4) if (activeIndex.value === 'phoneLogin') {
|| (phoneFormData.smscode && phoneFormData.smscode.length == 6)) { if (phoneFormData.smscode && phoneFormData.smscode.length == 6) {
checkAccount() 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( watch(
() => [formData.username,phoneFormData.mobile,activeIndex.value], () => [formData.username, phoneFormData.mobile, activeIndex.value, captchaVisible.value],
() => { () => {
formData.loginOrgCode = null; formData.loginOrgCode = null;
phoneFormData.loginOrgCode = null; phoneFormData.loginOrgCode = null;
departList.value = []; departList.value = [];
if ((formData.inputCode && formData.inputCode.length == 4) if (activeIndex.value === 'phoneLogin') {
|| (phoneFormData.smscode && phoneFormData.smscode.length == 6)) { if (phoneFormData.smscode && phoneFormData.smscode.length == 6) {
checkAccount() 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'}; let params = {...finalFormData, loginType: activeIndex.value === 'accountLogin' ? 'account' : 'phone'};
if (loginType == 'account') { if (loginType == 'account') {
params['password'] = encryptAESCBC(formData.password); params['password'] = encryptAESCBC(formData.password);
params['checkKey'] = randCodeData.checkKey; if (captchaVisible.value) {
params['checkKey'] = randCodeData.checkKey;
}
} }
const res = await defHttp.post({ const res = await defHttp.post({
url: '/sys/loginGetUserDeparts', url: '/sys/loginGetUserDeparts',
@@ -394,16 +440,17 @@
// 密码使用AES加密传输 // 密码使用AES加密传输
const encryptedPassword = encryptAESCBC(formData.password); const encryptedPassword = encryptAESCBC(formData.password);
const { userInfo } = await userStore.login( const loginPayload: Recordable = {
toRaw({ password: encryptedPassword,
password: encryptedPassword, username: formData.username,
username: formData.username, loginOrgCode: formData.loginOrgCode,
loginOrgCode: formData.loginOrgCode, mode: 'none', //不要默认的错误提示
captcha: formData.inputCode, };
checkKey: randCodeData.checkKey, if (captchaVisible.value) {
mode: 'none', //不要默认的错误提示 loginPayload.captcha = formData.inputCode;
}) loginPayload.checkKey = randCodeData.checkKey;
); }
const { userInfo } = await userStore.login(toRaw(loginPayload));
if (userInfo) { if (userInfo) {
notification.success({ notification.success({
message: t('sys.login.loginSuccessTitle'), message: t('sys.login.loginSuccessTitle'),
@@ -423,7 +470,9 @@
description: error.message || t('sys.login.networkExceptionMsg'), description: error.message || t('sys.login.networkExceptionMsg'),
duration: 3, duration: 3,
}); });
handleChangeCheckCode(); if (captchaVisible.value) {
handleChangeCheckCode();
}
} finally { } finally {
loginLoading.value = false; loginLoading.value = false;
} }
@@ -534,7 +583,11 @@
Object.assign(phoneFormData, { mobile: "", smscode: "" }); Object.assign(phoneFormData, { mobile: "", smscode: "" });
type.value = 'login'; type.value = 'login';
activeIndex.value = 'accountLogin'; activeIndex.value = 'accountLogin';
handleChangeCheckCode(); if (captchaVisible.value) {
handleChangeCheckCode();
} else {
clearCaptchaUiState();
}
} }
/** /**
@@ -557,9 +610,18 @@
}, 300); }, 300);
} }
onMounted(() => { onMounted(async () => {
//加载验证码 try {
handleChangeCheckCode(); 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); const saved = $ls.get(REMEMBER_USERNAME_KEY);
if (saved) { if (saved) {

View File

@@ -2,6 +2,7 @@ import { BasicColumn } from '/@/components/Table';
import { FormSchema } from '/@/components/Table'; import { FormSchema } from '/@/components/Table';
import { getAllRolesListNoByTenant, getDepPostIdByDepId } from './user.api'; import { getAllRolesListNoByTenant, getDepPostIdByDepId } from './user.api';
import { rules } from '/@/utils/helper/validator'; import { rules } from '/@/utils/helper/validator';
import { getSysLoginPasswordRules } from '/@/utils/sys/sysPasswordRules';
import { render } from '/@/utils/common/renderUtils'; import { render } from '/@/utils/common/renderUtils';
import { getDepartPathNameByOrgCode, getDepartName, getMultiDepartPathName, getDepartPathName } from '@/utils/common/compUtils'; import { getDepartPathNameByOrgCode, getDepartName, getMultiDepartPathName, getDepartPathName } from '@/utils/common/compUtils';
import { h } from 'vue'; import { h } from 'vue';
@@ -210,16 +211,7 @@ export const formSchema: FormSchema[] = [
componentProps:{ componentProps:{
autocomplete: 'new-password', autocomplete: 'new-password',
}, },
rules: [ rules: getSysLoginPasswordRules(),
{
required: true,
message: '请输入登录密码',
},
{
pattern: /^(?=.*[a-zA-Z])(?=.*\d)(?=.*[~!@#$%^&*()_+`\-={}:";'<>?,./]).{8,}$/,
message: '密码由 8 位及以上数字、大小写字母和特殊符号组成!',
},
],
}, },
{ {
label: '确认密码', label: '确认密码',
@@ -510,16 +502,7 @@ export const formPasswordSchema: FormSchema[] = [
componentProps: { componentProps: {
placeholder: '请输入登录密码', placeholder: '请输入登录密码',
}, },
rules: [ rules: getSysLoginPasswordRules(),
{
required: true,
message: '请输入登录密码',
},
{
pattern: /^(?=.*[a-zA-Z])(?=.*\d)(?=.*[~!@#$%^&*()_+`\-={}:";'<>?,./]).{8,}$/,
message: '密码由 8 位及以上数字、大小写字母和特殊符号组成!',
},
],
}, },
{ {
label: '确认密码', 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. // Whether to cancel the http request that has been sent but not responded when switching the interface.
removeAllHttpPending: boolean; removeAllHttpPending: boolean;
aiIconShow: boolean; aiIconShow: boolean;
/** 登录页是否展示图形验证码(需与后端 firewall.enable-login-captcha 配合;服务端要求验证码时始终展示) */
loginCaptchaEnabled: boolean;
} }
export interface GlobConfig { export interface GlobConfig {