Files
mayfly-go/frontend/src/views/login/component/AccountLogin.vue
2025-08-02 22:08:56 +08:00

515 lines
17 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div>
<el-form ref="loginFormRef" :model="loginForm" :rules="rules" class="login-content-form" size="large">
<el-form-item prop="username">
<el-input type="text" :placeholder="$t('common.username')" prefix-icon="user" v-model="loginForm.username" clearable autocomplete="off">
</el-input>
</el-form-item>
<el-form-item prop="password">
<el-input
type="password"
:placeholder="$t('common.password')"
prefix-icon="lock"
v-model="loginForm.password"
autocomplete="off"
@keyup.enter="login"
show-password
>
</el-input>
</el-form-item>
<el-form-item v-if="accountLoginSecurity.useCaptcha" prop="captcha">
<el-row :gutter="15">
<el-col :span="16">
<el-input
type="text"
maxlength="6"
:placeholder="$t('common.captcha')"
prefix-icon="position"
v-model="loginForm.captcha"
clearable
autocomplete="off"
@keyup.enter="login"
></el-input>
</el-col>
<el-col :span="8">
<div class="login-content-code">
<img class="login-content-code-img cursor-pointer" @click="getCaptcha" width="130px" height="40px" :src="captchaImage" />
</div>
</el-col>
</el-row>
</el-form-item>
<el-form-item v-if="ldapEnabled" prop="ldapLogin">
<el-checkbox v-model="loginForm.ldapLogin" :label="$t('login.ldapLogin')" size="small" />
</el-form-item>
<span v-if="showLoginFailTips" style="color: #f56c6c; font-size: 12px">
{{
$t('login.loginFailTip', {
loginFailCount: accountLoginSecurity.loginFailCount,
loginFailMin: accountLoginSecurity.loginFailMin,
})
}}
</span>
<el-form-item>
<el-button type="primary" class="login-content-submit" round @click="login" :loading="loading.signIn">
<span>{{ $t('login.login') }}</span>
</el-button>
</el-form-item>
</el-form>
<el-dialog :title="$t('login.changePassword')" v-model="changePwdDialog.visible" :close-on-click-modal="false" width="450px" :destroy-on-close="true">
<el-form :model="changePwdDialog.form" :rules="changePwdDialog.rules" ref="changePwdFormRef" label-width="auto">
<el-form-item prop="username" :label="$t('common.username')" required>
<el-input v-model.trim="changePwdDialog.form.username" disabled></el-input>
</el-form-item>
<el-form-item prop="oldPassword" :label="$t('login.oldPassword')" required>
<el-input v-model.trim="changePwdDialog.form.oldPassword" autocomplete="new-password" type="password"></el-input>
</el-form-item>
<el-form-item prop="newPassword" :label="$t('login.newPassword')" required>
<el-input
v-model.trim="changePwdDialog.form.newPassword"
:placeholder="$t('login.passwordRuleTip')"
type="password"
autocomplete="new-password"
></el-input>
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="cancelChangePwd">{{ $t('common.cancel') }}</el-button>
<el-button @click="changePwd" type="primary" :loading="loading.changePwd">
{{ $t('common.confirm') }}
</el-button>
</div>
</template>
</el-dialog>
<el-dialog
:title="$t('login.otpValidation')"
v-model="otpDialog.visible"
@close="loading.signIn = false"
:close-on-click-modal="false"
width="350px"
:destroy-on-close="true"
>
<el-form ref="otpFormRef" :model="otpDialog.form" :rules="otpDialog.rules" @submit.native.prevent label-width="auto">
<el-form-item v-if="otpDialog.otpUrl" :label="$t('login.qrCode')">
<qrcode-vue :value="otpDialog.otpUrl" :size="200" level="H" />
</el-form-item>
<el-form-item prop="code" label="OTP" required>
<el-input
style="width: 220px"
ref="otpCodeInputRef"
v-model.trim="otpDialog.form.code"
clearable
@keyup.enter="otpVerify"
:placeholder="$t('login.enterOtpCodeTip')"
></el-input>
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="otpVerify" type="primary" :loading="loading.otpConfirm">
{{ $t('common.confirm') }}
</el-button>
</div>
</template>
</el-dialog>
<el-dialog :title="$t('updateBasicInfo')" v-model="baseInfoDialog.visible" :close-on-click-modal="false" width="450px" :destroy-on-close="true">
<el-form :model="baseInfoDialog.form" :rules="baseInfoDialog.rules" ref="baseInfoFormRef" label-width="auto">
<el-form-item prop="username" :label="$t('common.username')" required>
<el-input v-model.trim="baseInfoDialog.form.username"></el-input>
</el-form-item>
<el-form-item prop="name" :label="$t('login.name')" required>
<el-input v-model.trim="baseInfoDialog.form.name"></el-input>
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="updateUserInfo()" type="primary" :loading="loading.updateUserConfirm">
{{ $t('common.confirm') }}
</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script lang="ts" setup>
import { nextTick, onMounted, ref, toRefs, reactive } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { ElMessage } from 'element-plus';
import { initRouter } from '@/router/index';
import { getRefreshToken, saveRefreshToken, saveToken, saveUser } from '@/common/utils/storage';
import openApi from '@/common/openApi';
import { RsaEncrypt } from '@/common/crypto';
import { getAccountLoginSecurity, getLdapEnabled } from '@/common/sysconfig';
import { letterAvatar } from '@/common/utils/string';
import { useUserInfo } from '@/store/userInfo';
import QrcodeVue from 'qrcode.vue';
import { personApi } from '@/views/personal/api';
import { getToken } from '@/common/utils/storage';
import { useThemeConfig } from '@/store/themeConfig';
import { getFileUrl } from '@/common/request';
import { useI18n } from 'vue-i18n';
import { Rules } from '@/common/rule';
const { t } = useI18n();
const rules = {
username: [Rules.requiredInput('common.username')],
password: [Rules.requiredInput('common.password')],
captcha: [Rules.requiredInput('common.captcha')],
};
// 定义变量内容
const storesThemeConfig = useThemeConfig();
const route = useRoute();
const router = useRouter();
const loginFormRef: any = ref(null);
const changePwdFormRef: any = ref(null);
const otpFormRef: any = ref(null);
const otpCodeInputRef: any = ref(null);
const baseInfoFormRef: any = ref(null);
const state = reactive({
accountLoginSecurity: {
useCaptcha: false,
useOtp: false,
loginFailCount: 5,
loginFailMin: 10,
},
showLoginFailTips: false,
captchaImage: '',
loginForm: {
username: '',
password: '',
captcha: '',
cid: '',
ldapLogin: false,
},
loginRes: {} as any,
changePwdDialog: {
visible: false,
form: {
username: '',
oldPassword: '',
newPassword: '',
},
rules: {
newPassword: [Rules.requiredInput('login.newPassword'), Rules.accountPassword],
},
},
otpDialog: {
visible: false,
otpUrl: '',
form: {
code: '',
otpToken: '',
},
rules: {
code: [Rules.requiredInput('OTP')],
},
},
baseInfoDialog: {
visible: false,
form: {
username: '',
name: '',
},
rules: {
username: [Rules.requiredInput('common.username'), Rules.accountUsername],
name: [Rules.requiredInput('common.name')],
},
},
loading: {
signIn: false,
changePwd: false,
otpConfirm: false,
updateUserConfirm: false,
},
ldapEnabled: false,
});
const { accountLoginSecurity, showLoginFailTips, captchaImage, loginForm, changePwdDialog, otpDialog, baseInfoDialog, loading, ldapEnabled } = toRefs(state);
onMounted(async () => {
nextTick(async () => {
const res = await getAccountLoginSecurity();
if (res) {
state.accountLoginSecurity = res;
}
getCaptcha();
const ldap = await getLdapEnabled();
state.ldapEnabled = ldap;
state.loginForm.ldapLogin = ldap;
});
// 移除公钥, 方便后续重新获取
sessionStorage.removeItem('RsaPublicKey');
});
const getCaptcha = async () => {
if (!state.accountLoginSecurity.useCaptcha) {
return;
}
let res: any = await openApi.captcha();
state.captchaImage = res.base64Captcha;
state.loginForm.cid = res.cid;
};
// 校验登录表单并登录
const login = () => {
loginFormRef.value.validate((valid: boolean) => {
if (valid) {
onSignIn();
} else {
return false;
}
});
};
const otpVerify = async () => {
try {
await otpFormRef.value.validate();
} catch (e: any) {
return false;
}
try {
state.loading.otpConfirm = true;
const res = await openApi.otpVerify(state.otpDialog.form);
await signInSuccess(res.token, res.refresh_token);
state.otpDialog.visible = false;
} finally {
state.loading.otpConfirm = false;
}
};
// 登录
const onSignIn = async () => {
state.loading.signIn = true;
let loginRes;
const originPwd = state.loginForm.password;
try {
const loginReq = { ...state.loginForm };
loginReq.password = await RsaEncrypt(originPwd);
if (state.loginForm.ldapLogin) {
loginRes = await openApi.ldapLogin(loginReq);
} else {
loginRes = await openApi.login(loginReq);
}
} catch (e: any) {
state.loading.signIn = false;
state.loginForm.captcha = '';
// 密码强度不足
if (e.code && e.code == 401) {
state.changePwdDialog.form.username = state.loginForm.username;
state.changePwdDialog.form.oldPassword = originPwd;
state.changePwdDialog.form.newPassword = '';
state.changePwdDialog.visible = true;
} else {
getCaptcha();
state.showLoginFailTips = true;
}
return;
}
state.showLoginFailTips = false;
loginResDeal(loginRes);
};
const updateUserInfo = async () => {
try {
await baseInfoFormRef.value.validate();
} catch (e: any) {
return false;
}
try {
state.loading.updateUserConfirm = true;
const form = state.baseInfoDialog.form;
await personApi.updateAccount.request(state.baseInfoDialog.form);
state.baseInfoDialog.visible = false;
useUserInfo().userInfo.username = form.username;
useUserInfo().userInfo.name = form.name;
await toIndex();
} finally {
state.loading.updateUserConfirm = false;
}
};
const loginResDeal = async (loginRes: any) => {
state.loginRes = loginRes;
// 用户信息
const userInfos = {
name: loginRes.name,
username: loginRes.username,
time: new Date().getTime(),
lastLoginTime: loginRes.lastLoginTime,
lastLoginIp: loginRes.lastLoginIp,
photo: '',
};
const avatarFileKey = `avatar_${loginRes.username}`;
const avatarFileDetail = await openApi.getFileDetail([avatarFileKey]);
// 说明存在头像文件
if (avatarFileDetail.length > 0) {
userInfos.photo = getFileUrl(avatarFileKey);
} else {
userInfos.photo = letterAvatar(loginRes.username);
}
// 存储用户信息到浏览器缓存
saveUser(userInfos);
// 1、请注意执行顺序(存储用户信息到vuex)
useUserInfo().setUserInfo(userInfos);
const token = loginRes.token;
// 如果不需要otp校验则该token即为accessToken否则为otp校验token
if (loginRes.otp == -1) {
signInSuccess(token, loginRes.refresh_token);
return;
}
state.otpDialog.form.otpToken = token;
state.otpDialog.otpUrl = loginRes.otpUrl;
state.otpDialog.visible = true;
setTimeout(() => {
otpCodeInputRef.value.focus();
}, 400);
};
// 登录成功后的跳转
const signInSuccess = async (accessToken: string = '', refreshToken = '') => {
if (!accessToken) {
accessToken = getToken();
}
if (!refreshToken) {
refreshToken = getRefreshToken();
}
// 存储 token 到浏览器缓存
saveToken(accessToken);
saveRefreshToken(refreshToken);
// 初始化路由
await initRouter();
// 判断是否为第一次oauth2登录是的话需要用户填写姓名和用户名
if (state.loginRes.isFirstOauth2Login) {
state.baseInfoDialog.form.username = state.loginRes.username;
state.baseInfoDialog.visible = true;
} else {
await toIndex();
}
};
const toIndex = async () => {
// 登录成功,跳到转首页
// 添加完动态路由,再进行 router 跳转,否则可能报错 No match found for location with path "/"
// 如果是复制粘贴的路径,非首页/登录页,那么登录成功后重定向到对应的路径中
route.query?.redirect ? router.push(route.query.redirect as string) : router.push('/');
// 登录成功提示
setTimeout(async () => {
// 关闭 loading
state.loading.signIn = true;
ElMessage.success(t('login.loginSuccessTip'));
// 水印设置用户信息
storesThemeConfig.setWatermarkUser();
}, 300);
};
const changePwd = async () => {
try {
await changePwdFormRef.value.validate();
} catch (e: any) {
return false;
}
try {
state.loading.changePwd = true;
const form = state.changePwdDialog.form;
const changePwdReq: any = { ...form };
changePwdReq.oldPassword = await RsaEncrypt(form.oldPassword);
changePwdReq.newPassword = await RsaEncrypt(form.newPassword);
await openApi.changePwd(changePwdReq);
ElMessage.success(t('login.passwordChangeSuccessTip'));
state.loginForm.password = state.changePwdDialog.form.newPassword;
state.changePwdDialog.visible = false;
getCaptcha();
} finally {
state.loading.changePwd = false;
}
};
const cancelChangePwd = () => {
state.changePwdDialog.visible = false;
state.changePwdDialog.form.newPassword = '';
state.changePwdDialog.form.oldPassword = '';
state.changePwdDialog.form.username = '';
getCaptcha();
};
defineExpose({
loginResDeal,
});
</script>
<style scoped lang="scss">
.login-content-form {
margin-top: 20px;
.login-content-code {
display: flex;
align-items: center;
justify-content: space-around;
.login-content-code-img {
width: 100%;
height: 40px;
line-height: 40px;
background-color: #ffffff;
border: 1px solid rgb(220, 223, 230);
color: #333;
font-size: 16px;
font-weight: 700;
letter-spacing: 5px;
text-indent: 5px;
text-align: center;
cursor: pointer;
transition: all ease 0.2s;
border-radius: 4px;
user-select: none;
&:hover {
border-color: #c0c4cc;
transition: all ease 0.2s;
}
}
}
.login-content-submit {
width: 100%;
letter-spacing: 2px;
font-weight: 300;
margin-top: 15px;
}
}
// 修复表单label与输入框不对齐的问题
:deep(.el-form-item .el-form-item__label) {
align-items: center;
display: flex;
height: 100%;
}
</style>