新增登录页图形验证码功能,支持通过Redis全局配置控制验证码的启用状态,优化登录流程以提升用户体验。新增相关API接口和前端配置项,确保验证码逻辑与后端同步。
This commit is contained in:
@@ -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:一级菜单
|
||||||
|
|||||||
@@ -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"); //获取加密串
|
||||||
|
|||||||
@@ -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 覆盖 > 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";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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()
|
||||||
|
);
|
||||||
@@ -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()
|
||||||
|
);
|
||||||
@@ -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');
|
||||||
@@ -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: 获取短信验证码
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
// 标签页样式
|
// 标签页样式
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -111,6 +111,7 @@ export default {
|
|||||||
grayMode: '灰色模式',
|
grayMode: '灰色模式',
|
||||||
colorWeak: '色弱模式',
|
colorWeak: '色弱模式',
|
||||||
aiIconSHow: 'Ai图标显示',
|
aiIconSHow: 'Ai图标显示',
|
||||||
|
loginCaptcha: '验证码登录',
|
||||||
|
|
||||||
progress: '顶部进度条',
|
progress: '顶部进度条',
|
||||||
switchLoading: '切换loading',
|
switchLoading: '切换loading',
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -71,6 +71,9 @@ const setting: ProjectConfig = {
|
|||||||
// ai图标显示
|
// ai图标显示
|
||||||
aiIconShow: false,
|
aiIconShow: false,
|
||||||
|
|
||||||
|
// 登录页是否展示图形验证码(未写入本地配置时以此为准;需要验证码时在项目配置中开启)
|
||||||
|
loginCaptchaEnabled: false,
|
||||||
|
|
||||||
// 头部配置
|
// 头部配置
|
||||||
headerSetting: {
|
headerSetting: {
|
||||||
// 背景色
|
// 背景色
|
||||||
|
|||||||
57
jeecgboot-vue3/src/utils/sys/sysPasswordRules.ts
Normal file
57
jeecgboot-vue3/src/utils/sys/sysPasswordRules.ts
Normal 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[];
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
// 整合两个子表的数据
|
// 整合两个子表的数据(priority:1 优先、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: [],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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/loginCaptchaConfig(Redis)一致:关闭时不挂载验证码输入与图片 -->
|
||||||
|
<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/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 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) {
|
||||||
|
|||||||
@@ -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: '确认密码',
|
||||||
|
|||||||
2
jeecgboot-vue3/types/config.d.ts
vendored
2
jeecgboot-vue3/types/config.d.ts
vendored
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user