新增登录页图形验证码功能,支持通过Redis全局配置控制验证码的启用状态,优化登录流程以提升用户体验。新增相关API接口和前端配置项,确保验证码逻辑与后端同步。
This commit is contained in:
@@ -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: 获取短信验证码
|
||||
*/
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -53,6 +53,8 @@ export enum HandlerEnum {
|
||||
OPEN_PAGE_LOADING,
|
||||
OPEN_ROUTE_TRANSITION,
|
||||
AI_ICON_SHOW,
|
||||
/** 登录页图形验证码开关 */
|
||||
LOGIN_CAPTCHA_ENABLED,
|
||||
}
|
||||
|
||||
// 标签页样式
|
||||
|
||||
@@ -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 };
|
||||
|
||||
|
||||
@@ -108,6 +108,7 @@ export default {
|
||||
fullContent: 'Full content',
|
||||
grayMode: 'Gray mode',
|
||||
colorWeak: 'Color Weak Mode',
|
||||
loginCaptcha: 'Captcha login',
|
||||
|
||||
progress: 'Progress',
|
||||
switchLoading: 'Switch Loading',
|
||||
|
||||
@@ -111,6 +111,7 @@ export default {
|
||||
grayMode: '灰色模式',
|
||||
colorWeak: '色弱模式',
|
||||
aiIconSHow: 'Ai图标显示',
|
||||
loginCaptcha: '验证码登录',
|
||||
|
||||
progress: '顶部进度条',
|
||||
switchLoading: '切换loading',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -71,6 +71,9 @@ const setting: ProjectConfig = {
|
||||
// ai图标显示
|
||||
aiIconShow: false,
|
||||
|
||||
// 登录页是否展示图形验证码(未写入本地配置时以此为准;需要验证码时在项目配置中开启)
|
||||
loginCaptchaEnabled: false,
|
||||
|
||||
// 头部配置
|
||||
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')" />
|
||||
</FormItem>
|
||||
|
||||
<!--验证码-->
|
||||
<ARow class="enter-x">
|
||||
<ACol :span="12">
|
||||
<FormItem name="inputCode" class="enter-x">
|
||||
<Input size="large" v-model:value="formData.inputCode" :placeholder="t('sys.login.inputCode')" style="min-width: 100px" />
|
||||
</FormItem>
|
||||
</ACol>
|
||||
<ACol :span="8">
|
||||
<FormItem :style="{ 'text-align': 'right', 'margin-left': '20px' }" class="enter-x">
|
||||
<img
|
||||
v-if="randCodeData.requestCodeSuccess"
|
||||
style="margin-top: 2px; max-width: initial"
|
||||
:src="randCodeData.randCodeImage"
|
||||
@click="handleChangeCheckCode"
|
||||
/>
|
||||
<img v-else style="margin-top: 2px; max-width: initial" src="../../../assets/images/checkcode.png" @click="handleChangeCheckCode" />
|
||||
</FormItem>
|
||||
</ACol>
|
||||
</ARow>
|
||||
<!-- 无需验证码时:下列整块(输入框 + 图片)均不挂载,不请求 randomImage -->
|
||||
<template v-if="captchaVisible">
|
||||
<ARow class="enter-x">
|
||||
<ACol :span="12">
|
||||
<FormItem name="inputCode" class="enter-x">
|
||||
<Input size="large" v-model:value="formData.inputCode" :placeholder="t('sys.login.inputCode')" style="min-width: 100px" />
|
||||
</FormItem>
|
||||
</ACol>
|
||||
<ACol :span="8">
|
||||
<FormItem :style="{ 'text-align': 'right', 'margin-left': '20px' }" class="enter-x">
|
||||
<img
|
||||
v-if="randCodeData.requestCodeSuccess"
|
||||
style="margin-top: 2px; max-width: initial"
|
||||
:src="randCodeData.randCodeImage"
|
||||
@click="handleChangeCheckCode"
|
||||
/>
|
||||
<img v-else style="margin-top: 2px; max-width: initial" src="../../../assets/images/checkcode.png" @click="handleChangeCheckCode" />
|
||||
</FormItem>
|
||||
</ACol>
|
||||
</ARow>
|
||||
</template>
|
||||
|
||||
<ARow class="enter-x">
|
||||
<ACol :span="12">
|
||||
@@ -98,7 +100,7 @@
|
||||
import { useUserStore } from '/@/store/modules/user';
|
||||
import { LoginStateEnum, useLoginState, useFormRules, useFormValid } from './useLogin';
|
||||
import { useDesign } from '/@/hooks/web/useDesign';
|
||||
import { getCodeInfo } from '/@/api/sys/user';
|
||||
import { getCodeInfo, getLoginCaptchaServerConfig } from '/@/api/sys/user';
|
||||
import { encryptAESCBC } from '/@/utils/cipher';
|
||||
|
||||
const ACol = Col;
|
||||
@@ -113,6 +115,26 @@
|
||||
const { prefixCls } = useDesign('login');
|
||||
const userStore = useUserStore();
|
||||
|
||||
/**
|
||||
* 是否展示验证码(与后端是否校验图形码一致)。
|
||||
* 浏览器无法直连 Redis:调用 GET /sys/loginCaptchaConfig,由后端读取 Redis 全局开关,无值时再回落 jeecg.firewall.enable-login-captcha。
|
||||
* null 表示尚未拉取完成,不展示验证码区域,避免误请求 randomImage。
|
||||
*/
|
||||
const serverRequiresCaptcha = ref<boolean | null>(null);
|
||||
|
||||
const captchaVisible = computed(() => serverRequiresCaptcha.value === true);
|
||||
|
||||
/** 兼容接口 result 为 boolean / 字符串 */
|
||||
function parseCaptchaRequired(raw: unknown): boolean {
|
||||
if (raw === true || raw === 'true' || raw === 1 || raw === '1') {
|
||||
return true;
|
||||
}
|
||||
if (raw === false || raw === 'false' || raw === 0 || raw === '0') {
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const { setLoginState, getLoginState } = useLoginState();
|
||||
const { getFormRules } = useFormRules();
|
||||
|
||||
@@ -132,6 +154,13 @@
|
||||
checkKey: null,
|
||||
});
|
||||
|
||||
function clearCaptchaUiState() {
|
||||
formData.inputCode = '';
|
||||
randCodeData.randCodeImage = '';
|
||||
randCodeData.requestCodeSuccess = false;
|
||||
randCodeData.checkKey = null;
|
||||
}
|
||||
|
||||
const { validForm } = useFormValid(formRef);
|
||||
|
||||
//onKeyStroke('Enter', handleLogin);
|
||||
@@ -146,15 +175,16 @@
|
||||
|
||||
// 密码使用AES加密传输
|
||||
const encryptedPassword = encryptAESCBC(data.password);
|
||||
const { userInfo } = await userStore.login(
|
||||
toRaw({
|
||||
password: encryptedPassword,
|
||||
username: data.account,
|
||||
captcha: data.inputCode,
|
||||
checkKey: randCodeData.checkKey,
|
||||
mode: 'none', //不要默认的错误提示
|
||||
})
|
||||
);
|
||||
const loginPayload: Recordable = {
|
||||
password: encryptedPassword,
|
||||
username: data.account,
|
||||
mode: 'none', //不要默认的错误提示
|
||||
};
|
||||
if (unref(captchaVisible)) {
|
||||
loginPayload.captcha = data.inputCode;
|
||||
loginPayload.checkKey = randCodeData.checkKey;
|
||||
}
|
||||
const { userInfo } = await userStore.login(toRaw(loginPayload));
|
||||
if (userInfo) {
|
||||
notification.success({
|
||||
message: t('sys.login.loginSuccessTitle'),
|
||||
@@ -169,7 +199,9 @@
|
||||
duration: 3,
|
||||
});
|
||||
loading.value = false;
|
||||
handleChangeCheckCode();
|
||||
if (unref(captchaVisible)) {
|
||||
handleChangeCheckCode();
|
||||
}
|
||||
}
|
||||
}
|
||||
function handleChangeCheckCode() {
|
||||
@@ -189,8 +221,17 @@
|
||||
function onThirdLogin(type) {
|
||||
thirdModalRef.value.onThirdLogin(type);
|
||||
}
|
||||
//初始化验证码
|
||||
onMounted(() => {
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const enabled = await getLoginCaptchaServerConfig();
|
||||
serverRequiresCaptcha.value = parseCaptchaRequired(enabled);
|
||||
} catch {
|
||||
serverRequiresCaptcha.value = false;
|
||||
}
|
||||
if (!unref(captchaVisible)) {
|
||||
clearCaptchaUiState();
|
||||
return;
|
||||
}
|
||||
handleChangeCheckCode();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -23,7 +23,12 @@
|
||||
:columns="columns2"
|
||||
>
|
||||
<template #toolbarAfter>
|
||||
<a-alert type="info" showIcon message="全局规则可校验用户输入的所有字符;全局规则的优先级比局部规则的要高。" style="margin-bottom: 8px" />
|
||||
<a-alert
|
||||
type="info"
|
||||
showIcon
|
||||
message="全局规则可校验用户输入的所有字符;全局规则的优先级比局部规则的要高。优先级选「不执行」时本条规则仅保留配置、不参与校验,便于临时关闭某项密码要求。"
|
||||
style="margin-bottom: 8px"
|
||||
/>
|
||||
</template>
|
||||
</JVxeTable>
|
||||
</a-tab-pane>
|
||||
@@ -75,16 +80,14 @@
|
||||
|
||||
let ruleJson = data.record.ruleJson;
|
||||
if (ruleJson) {
|
||||
let ruleList = JSON.parse(ruleJson);
|
||||
// 筛选出全局规则和局部规则
|
||||
let global: any[] = [],
|
||||
design: any[] = [],
|
||||
priority = '1';
|
||||
ruleList.forEach((rule) => {
|
||||
const ruleList = JSON.parse(ruleJson);
|
||||
const global: any[] = [];
|
||||
const design: any[] = [];
|
||||
ruleList.forEach((rule: any) => {
|
||||
if (rule.digits === '*') {
|
||||
global.push(Object.assign(rule, { priority }));
|
||||
const p = rule.priority != null && String(rule.priority) !== '' ? String(rule.priority) : '1';
|
||||
global.push({ ...rule, priority: p });
|
||||
} else {
|
||||
priority = '0';
|
||||
design.push(rule);
|
||||
}
|
||||
});
|
||||
@@ -132,20 +135,26 @@
|
||||
if (tableData2 && tableData2.length > 0) {
|
||||
globalValues = tableData2;
|
||||
}
|
||||
// 整合两个子表的数据
|
||||
let firstGlobal: any[] = [],
|
||||
afterGlobal: any[] = [];
|
||||
// 整合两个子表的数据(priority:1 优先、0 最后、2 不执行;2 放末尾仅便于存储,后端会跳过)
|
||||
const firstGlobal: any[] = [];
|
||||
const afterGlobal: any[] = [];
|
||||
const inactiveGlobal: any[] = [];
|
||||
for (let i = 0; i < globalValues.length; i++) {
|
||||
let v: any = globalValues[i];
|
||||
const v: any = globalValues[i];
|
||||
v.digits = '*';
|
||||
if (v.priority === '1') {
|
||||
const p = String(v.priority ?? '1');
|
||||
if (p === '1') {
|
||||
firstGlobal.push(v);
|
||||
} else {
|
||||
} else if (p === '0') {
|
||||
afterGlobal.push(v);
|
||||
} else if (p === '2') {
|
||||
inactiveGlobal.push(v);
|
||||
} else {
|
||||
firstGlobal.push(v);
|
||||
}
|
||||
}
|
||||
let concatValues = firstGlobal.concat(designValues).concat(afterGlobal);
|
||||
let subValues = concatValues.map((i) => pick(i, 'digits', 'pattern', 'message'));
|
||||
const concatValues = firstGlobal.concat(designValues).concat(afterGlobal).concat(inactiveGlobal);
|
||||
const subValues = concatValues.map((i) => pick(i, 'digits', 'pattern', 'message', 'priority'));
|
||||
// 生成 formData,用于传入后台
|
||||
let ruleJson = JSON.stringify(subValues);
|
||||
let formData = Object.assign({}, mainData, { ruleJson });
|
||||
@@ -226,6 +235,7 @@
|
||||
options: [
|
||||
{ title: '优先运行', value: '1' },
|
||||
{ title: '最后运行', value: '0' },
|
||||
{ title: '不执行', value: '2' },
|
||||
],
|
||||
validateRules: [],
|
||||
},
|
||||
|
||||
@@ -46,7 +46,8 @@
|
||||
<a-input class="fix-auto-fill" type="password" :placeholder="t('sys.login.password')" v-model:value="formData.password" />
|
||||
</a-form-item>
|
||||
</div>
|
||||
<div class="aui-inputClear">
|
||||
<!-- 与 GET /sys/loginCaptchaConfig(Redis)一致:关闭时不挂载验证码输入与图片 -->
|
||||
<div class="aui-inputClear" v-if="captchaVisible">
|
||||
<i class="icon icon-code"></i>
|
||||
<a-form-item>
|
||||
<a-input class="fix-auto-fill" type="text" :placeholder="t('sys.login.inputCode')" v-model:value="formData.inputCode" />
|
||||
@@ -174,7 +175,7 @@
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup name="login-mini">
|
||||
import { getCaptcha, getCodeInfo } from '/@/api/sys/user';
|
||||
import { getCaptcha, getCodeInfo, getLoginCaptchaServerConfig } from '/@/api/sys/user';
|
||||
import { computed, onMounted, reactive, ref, toRaw, unref, watch } from 'vue';
|
||||
import codeImg from '/@/assets/images/checkcode.png';
|
||||
import { Rule } from '/@/components/Form';
|
||||
@@ -215,6 +216,18 @@
|
||||
requestCodeSuccess: false,
|
||||
checkKey: null,
|
||||
});
|
||||
/** 后端 GET /sys/loginCaptchaConfig(Redis + 防火墙),与标准登录页一致 */
|
||||
const serverRequiresCaptcha = ref<boolean | null>(null);
|
||||
const captchaVisible = computed(() => serverRequiresCaptcha.value === true);
|
||||
function parseCaptchaRequired(raw: unknown): boolean {
|
||||
if (raw === true || raw === 'true' || raw === 1 || raw === '1') {
|
||||
return true;
|
||||
}
|
||||
if (raw === false || raw === 'false' || raw === 0 || raw === '0') {
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
// 记住用户名
|
||||
const rememberMe = ref<boolean>(false);
|
||||
const REMEMBER_USERNAME_KEY = 'LOGIN_REMEMBER_USERNAME';
|
||||
@@ -228,6 +241,12 @@
|
||||
password: '123456',
|
||||
loginOrgCode: '',
|
||||
});
|
||||
function clearCaptchaUiState() {
|
||||
formData.inputCode = '';
|
||||
randCodeData.randCodeImage = '';
|
||||
randCodeData.requestCodeSuccess = false;
|
||||
randCodeData.checkKey = null;
|
||||
}
|
||||
//手机登录表单字段
|
||||
const phoneFormData = reactive<any>({
|
||||
mobile: '',
|
||||
@@ -274,13 +293,29 @@
|
||||
return deptName;
|
||||
};
|
||||
})
|
||||
//监听验证码和输入框的修改
|
||||
// 监听验证码和输入框的修改(账号登录无图形验证码时凭账密即可触发部门查询)
|
||||
watch(
|
||||
() => [formData.inputCode, phoneFormData.smscode],
|
||||
() => [
|
||||
formData.inputCode,
|
||||
phoneFormData.smscode,
|
||||
formData.username,
|
||||
formData.password,
|
||||
captchaVisible.value,
|
||||
activeIndex.value,
|
||||
],
|
||||
() => {
|
||||
if ((formData.inputCode && formData.inputCode.length == 4)
|
||||
|| (phoneFormData.smscode && phoneFormData.smscode.length == 6)) {
|
||||
checkAccount()
|
||||
if (activeIndex.value === 'phoneLogin') {
|
||||
if (phoneFormData.smscode && phoneFormData.smscode.length == 6) {
|
||||
checkAccount();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (captchaVisible.value) {
|
||||
if (formData.inputCode && formData.inputCode.length == 4) {
|
||||
checkAccount();
|
||||
}
|
||||
} else if (formData.username && formData.password) {
|
||||
checkAccount();
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -288,16 +323,25 @@
|
||||
* 监听账号变化,清除部门信息
|
||||
*/
|
||||
watch(
|
||||
() => [formData.username,phoneFormData.mobile,activeIndex.value],
|
||||
() => [formData.username, phoneFormData.mobile, activeIndex.value, captchaVisible.value],
|
||||
() => {
|
||||
formData.loginOrgCode = null;
|
||||
phoneFormData.loginOrgCode = null;
|
||||
departList.value = [];
|
||||
if ((formData.inputCode && formData.inputCode.length == 4)
|
||||
|| (phoneFormData.smscode && phoneFormData.smscode.length == 6)) {
|
||||
checkAccount()
|
||||
if (activeIndex.value === 'phoneLogin') {
|
||||
if (phoneFormData.smscode && phoneFormData.smscode.length == 6) {
|
||||
checkAccount();
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (captchaVisible.value) {
|
||||
if (formData.inputCode && formData.inputCode.length == 4) {
|
||||
checkAccount();
|
||||
}
|
||||
} else if (formData.username && formData.password) {
|
||||
checkAccount();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
//初始化数据
|
||||
@@ -318,7 +362,9 @@
|
||||
let params = {...finalFormData, loginType: activeIndex.value === 'accountLogin' ? 'account' : 'phone'};
|
||||
if (loginType == 'account') {
|
||||
params['password'] = encryptAESCBC(formData.password);
|
||||
params['checkKey'] = randCodeData.checkKey;
|
||||
if (captchaVisible.value) {
|
||||
params['checkKey'] = randCodeData.checkKey;
|
||||
}
|
||||
}
|
||||
const res = await defHttp.post({
|
||||
url: '/sys/loginGetUserDeparts',
|
||||
@@ -394,16 +440,17 @@
|
||||
|
||||
// 密码使用AES加密传输
|
||||
const encryptedPassword = encryptAESCBC(formData.password);
|
||||
const { userInfo } = await userStore.login(
|
||||
toRaw({
|
||||
password: encryptedPassword,
|
||||
username: formData.username,
|
||||
loginOrgCode: formData.loginOrgCode,
|
||||
captcha: formData.inputCode,
|
||||
checkKey: randCodeData.checkKey,
|
||||
mode: 'none', //不要默认的错误提示
|
||||
})
|
||||
);
|
||||
const loginPayload: Recordable = {
|
||||
password: encryptedPassword,
|
||||
username: formData.username,
|
||||
loginOrgCode: formData.loginOrgCode,
|
||||
mode: 'none', //不要默认的错误提示
|
||||
};
|
||||
if (captchaVisible.value) {
|
||||
loginPayload.captcha = formData.inputCode;
|
||||
loginPayload.checkKey = randCodeData.checkKey;
|
||||
}
|
||||
const { userInfo } = await userStore.login(toRaw(loginPayload));
|
||||
if (userInfo) {
|
||||
notification.success({
|
||||
message: t('sys.login.loginSuccessTitle'),
|
||||
@@ -423,7 +470,9 @@
|
||||
description: error.message || t('sys.login.networkExceptionMsg'),
|
||||
duration: 3,
|
||||
});
|
||||
handleChangeCheckCode();
|
||||
if (captchaVisible.value) {
|
||||
handleChangeCheckCode();
|
||||
}
|
||||
} finally {
|
||||
loginLoading.value = false;
|
||||
}
|
||||
@@ -534,7 +583,11 @@
|
||||
Object.assign(phoneFormData, { mobile: "", smscode: "" });
|
||||
type.value = 'login';
|
||||
activeIndex.value = 'accountLogin';
|
||||
handleChangeCheckCode();
|
||||
if (captchaVisible.value) {
|
||||
handleChangeCheckCode();
|
||||
} else {
|
||||
clearCaptchaUiState();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -557,9 +610,18 @@
|
||||
}, 300);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
//加载验证码
|
||||
handleChangeCheckCode();
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const enabled = await getLoginCaptchaServerConfig();
|
||||
serverRequiresCaptcha.value = parseCaptchaRequired(enabled);
|
||||
} catch {
|
||||
serverRequiresCaptcha.value = false;
|
||||
}
|
||||
if (captchaVisible.value) {
|
||||
handleChangeCheckCode();
|
||||
} else {
|
||||
clearCaptchaUiState();
|
||||
}
|
||||
// 恢复已记住的用户名
|
||||
const saved = $ls.get(REMEMBER_USERNAME_KEY);
|
||||
if (saved) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { BasicColumn } from '/@/components/Table';
|
||||
import { FormSchema } from '/@/components/Table';
|
||||
import { getAllRolesListNoByTenant, getDepPostIdByDepId } from './user.api';
|
||||
import { rules } from '/@/utils/helper/validator';
|
||||
import { getSysLoginPasswordRules } from '/@/utils/sys/sysPasswordRules';
|
||||
import { render } from '/@/utils/common/renderUtils';
|
||||
import { getDepartPathNameByOrgCode, getDepartName, getMultiDepartPathName, getDepartPathName } from '@/utils/common/compUtils';
|
||||
import { h } from 'vue';
|
||||
@@ -210,16 +211,7 @@ export const formSchema: FormSchema[] = [
|
||||
componentProps:{
|
||||
autocomplete: 'new-password',
|
||||
},
|
||||
rules: [
|
||||
{
|
||||
required: true,
|
||||
message: '请输入登录密码',
|
||||
},
|
||||
{
|
||||
pattern: /^(?=.*[a-zA-Z])(?=.*\d)(?=.*[~!@#$%^&*()_+`\-={}:";'<>?,./]).{8,}$/,
|
||||
message: '密码由 8 位及以上数字、大小写字母和特殊符号组成!',
|
||||
},
|
||||
],
|
||||
rules: getSysLoginPasswordRules(),
|
||||
},
|
||||
{
|
||||
label: '确认密码',
|
||||
@@ -510,16 +502,7 @@ export const formPasswordSchema: FormSchema[] = [
|
||||
componentProps: {
|
||||
placeholder: '请输入登录密码',
|
||||
},
|
||||
rules: [
|
||||
{
|
||||
required: true,
|
||||
message: '请输入登录密码',
|
||||
},
|
||||
{
|
||||
pattern: /^(?=.*[a-zA-Z])(?=.*\d)(?=.*[~!@#$%^&*()_+`\-={}:";'<>?,./]).{8,}$/,
|
||||
message: '密码由 8 位及以上数字、大小写字母和特殊符号组成!',
|
||||
},
|
||||
],
|
||||
rules: getSysLoginPasswordRules(),
|
||||
},
|
||||
{
|
||||
label: '确认密码',
|
||||
|
||||
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.
|
||||
removeAllHttpPending: boolean;
|
||||
aiIconShow: boolean;
|
||||
/** 登录页是否展示图形验证码(需与后端 firewall.enable-login-captcha 配合;服务端要求验证码时始终展示) */
|
||||
loginCaptchaEnabled: boolean;
|
||||
}
|
||||
|
||||
export interface GlobConfig {
|
||||
|
||||
Reference in New Issue
Block a user