陈薪名
9 months ago
9 changed files with 1060 additions and 0 deletions
@ -0,0 +1,284 @@ |
|||||
|
<template> |
||||
|
<el-form v-show="getShow" ref="formLogin" :model="loginData.loginForm" :rules="LoginRules" class="login-form" |
||||
|
label-position="top" label-width="120px" size="large"> |
||||
|
<el-row style="margin-right: -10px; margin-left: -10px"> |
||||
|
<el-col :span="24" style="padding-right: 10px; padding-left: 10px"> |
||||
|
<el-form-item> |
||||
|
<LoginFormTitle style="width: 100%" /> |
||||
|
</el-form-item> |
||||
|
</el-col> |
||||
|
<el-col :span="24" style="padding-right: 10px; padding-left: 10px"> |
||||
|
<el-form-item v-if="loginData.tenantEnable === 'true'" prop="tenantName"> |
||||
|
<el-input v-model="loginData.loginForm.tenantName" :placeholder="t('login.tenantNamePlaceholder')" |
||||
|
:prefix-icon="iconHouse" link type="primary" /> |
||||
|
</el-form-item> |
||||
|
</el-col> |
||||
|
<el-col :span="24" style="padding-right: 10px; padding-left: 10px"> |
||||
|
<el-form-item prop="username"> |
||||
|
<el-input v-model="loginData.loginForm.username" :placeholder="t('login.usernamePlaceholder')" |
||||
|
:prefix-icon="iconAvatar" style="height: 42px;" /> |
||||
|
</el-form-item> |
||||
|
</el-col> |
||||
|
<el-col :span="24" style="padding-right: 10px; padding-left: 10px"> |
||||
|
<el-form-item prop="password"> |
||||
|
<el-input v-model="loginData.loginForm.password" :placeholder="t('login.passwordPlaceholder')" |
||||
|
:prefix-icon="iconLock" show-password type="password" @keyup.enter="getCode()" style="height: 42px;" /> |
||||
|
</el-form-item> |
||||
|
</el-col> |
||||
|
<el-col :span="24" style="padding-right: 10px; padding-left: 10px"> |
||||
|
<el-form-item prop="code"> |
||||
|
<div class="flex w-[100%]"> |
||||
|
<el-input v-model="loginData.loginForm.code" :placeholder="t('login.codePlaceholder')" |
||||
|
style="width: 76%;margin-right: 10px;height: 42px;" @keyup.enter="handleLogin()"> |
||||
|
<template #prefix> |
||||
|
<img src="@/assets/imgs/code.png" alt="" style="width: 16px;height: 16px;" /> |
||||
|
</template> |
||||
|
</el-input> |
||||
|
<div class="login-code flex-1"> |
||||
|
<img :src="codeUrl" @click="getCode" class="login-code-img" /> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
</el-form-item> |
||||
|
</el-col> |
||||
|
|
||||
|
|
||||
|
<el-col :span="24" style="padding-right: 10px; padding-left: 10px; margin-top: -20px; margin-bottom: -20px"> |
||||
|
<el-form-item> |
||||
|
<el-row justify="space-between" style="width: 100%"> |
||||
|
<el-col :span="6"> |
||||
|
<el-checkbox v-model="loginData.loginForm.rememberMe"> |
||||
|
{{ t('login.remember') }} |
||||
|
</el-checkbox> |
||||
|
</el-col> |
||||
|
<el-col :offset="6" :span="12"> |
||||
|
<el-link style="float: right" type="primary">{{ t('login.forgetPassword') }}</el-link> |
||||
|
</el-col> |
||||
|
</el-row> |
||||
|
</el-form-item> |
||||
|
</el-col> |
||||
|
|
||||
|
<el-col :span="24" style="padding-right: 10px; padding-left: 10px"> |
||||
|
<el-form-item> |
||||
|
<XButton :loading="loginLoading" :title="t('login.login')" class="w-[100%]" type="primary" |
||||
|
@click="handleLogin()" /> |
||||
|
</el-form-item> |
||||
|
</el-col> |
||||
|
<!-- <Verify |
||||
|
ref="verify" |
||||
|
:captchaType="captchaType" |
||||
|
:imgSize="{ width: '400px', height: '200px' }" |
||||
|
mode="pop" |
||||
|
@success="handleLogin" |
||||
|
/> --> |
||||
|
</el-row> |
||||
|
</el-form> |
||||
|
</template> |
||||
|
<script lang="ts" setup> |
||||
|
import { ElLoading } from 'element-plus' |
||||
|
import LoginFormTitle from './LoginFormTitle.vue' |
||||
|
import type { RouteLocationNormalizedLoaded } from 'vue-router' |
||||
|
|
||||
|
import { useIcon } from '@/hooks/web/useIcon' |
||||
|
|
||||
|
import * as authUtil from '@/utils/auth' |
||||
|
import { usePermissionStore } from '@/store/modules/permission' |
||||
|
import * as LoginApi from '@/api/login' |
||||
|
import { LoginStateEnum, useFormValid, useLoginState } from './useLogin' |
||||
|
import { getCodeImg } from "@/api/login"; |
||||
|
|
||||
|
import { CACHE_KEY, useCache } from '@/hooks/web/useCache' |
||||
|
import * as DeptApi from '@/api/system/dept' |
||||
|
|
||||
|
defineOptions({ name: 'LoginForm' }) |
||||
|
|
||||
|
const { t } = useI18n() |
||||
|
const message = useMessage() |
||||
|
const iconHouse = useIcon({ icon: 'ep:house' }) |
||||
|
const iconAvatar = useIcon({ icon: 'ep:avatar' }) |
||||
|
const iconLock = useIcon({ icon: 'ep:lock' }) |
||||
|
const formLogin = ref() |
||||
|
const { validForm } = useFormValid(formLogin) |
||||
|
const { setLoginState, getLoginState } = useLoginState() |
||||
|
const { currentRoute, push } = useRouter() |
||||
|
const permissionStore = usePermissionStore() |
||||
|
const redirect = ref<string>('') |
||||
|
const loginLoading = ref(false) |
||||
|
const verify = ref() |
||||
|
const captchaType = ref('blockPuzzle') // blockPuzzle 滑块 clickWord 点击文字 |
||||
|
|
||||
|
const getShow = computed(() => unref(getLoginState) === LoginStateEnum.LOGIN) |
||||
|
// 验证码开关 |
||||
|
const captchaEnabled = ref(true); |
||||
|
const codeUrl = ref(""); |
||||
|
|
||||
|
const LoginRules = { |
||||
|
tenantName: [required], |
||||
|
username: [required], |
||||
|
password: [required] |
||||
|
} |
||||
|
const loginData = reactive({ |
||||
|
isShowPassword: false, |
||||
|
captchaEnable: import.meta.env.VITE_APP_CAPTCHA_ENABLE, |
||||
|
tenantEnable: import.meta.env.VITE_APP_TENANT_ENABLE, |
||||
|
loginForm: { |
||||
|
tenantName: '闻荫源码', |
||||
|
username: 'admin', |
||||
|
password: '123456', |
||||
|
captchaVerification: '', |
||||
|
rememberMe: false, |
||||
|
code: '', |
||||
|
uuid: '' |
||||
|
} |
||||
|
}) |
||||
|
|
||||
|
const socialList = [ |
||||
|
{ icon: 'ant-design:github-filled', type: 0 }, |
||||
|
{ icon: 'ant-design:wechat-filled', type: 30 }, |
||||
|
{ icon: 'ant-design:alipay-circle-filled', type: 0 }, |
||||
|
{ icon: 'ant-design:dingtalk-circle-filled', type: 20 } |
||||
|
] |
||||
|
|
||||
|
// 获取验证码 |
||||
|
// const getCode = async () => { |
||||
|
// // 情况一,未开启:则直接登录 |
||||
|
// if (loginData.captchaEnable === 'false') { |
||||
|
// await handleLogin({}) |
||||
|
// } else { |
||||
|
// // 情况二,已开启:则展示验证码;只有完成验证码的情况,才进行登录 |
||||
|
// // 弹出验证码 |
||||
|
// verify.value.show() |
||||
|
// } |
||||
|
// } |
||||
|
function getCode() { |
||||
|
getCodeImg().then(res => { |
||||
|
captchaEnabled.value = res.captchaEnabled === undefined ? true : res.captchaEnabled; |
||||
|
if (captchaEnabled.value) { |
||||
|
codeUrl.value = "data:image/gif;base64," + res.img; |
||||
|
loginData.loginForm.uuid = res.uuid; |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
//获取租户ID |
||||
|
const getTenantId = async () => { |
||||
|
if (loginData.tenantEnable === 'true') { |
||||
|
const res = await LoginApi.getTenantIdByName(loginData.loginForm.tenantName) |
||||
|
authUtil.setTenantId(res) |
||||
|
} |
||||
|
} |
||||
|
// 记住我 |
||||
|
const getCookie = () => { |
||||
|
const loginForm = authUtil.getLoginForm() |
||||
|
if (loginForm) { |
||||
|
loginData.loginForm = { |
||||
|
...loginData.loginForm, |
||||
|
username: loginForm.username ? loginForm.username : loginData.loginForm.username, |
||||
|
password: loginForm.password ? loginForm.password : loginData.loginForm.password, |
||||
|
rememberMe: loginForm.rememberMe ? true : false, |
||||
|
tenantName: loginForm.tenantName ? loginForm.tenantName : loginData.loginForm.tenantName |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
const loading = ref() // ElLoading.service 返回的实例 |
||||
|
// 登录 |
||||
|
const handleLogin = async (params) => { |
||||
|
loginLoading.value = true |
||||
|
try { |
||||
|
await getTenantId() |
||||
|
const data = await validForm() |
||||
|
if (!data) { |
||||
|
return |
||||
|
} |
||||
|
const res = await LoginApi.login(loginData.loginForm) |
||||
|
if (!res) { |
||||
|
getCode() |
||||
|
return |
||||
|
} |
||||
|
loading.value = ElLoading.service({ |
||||
|
lock: true, |
||||
|
text: '正在加载系统中...', |
||||
|
background: 'rgba(0, 0, 0, 0.7)' |
||||
|
}) |
||||
|
if (loginData.loginForm.rememberMe) { |
||||
|
authUtil.setLoginForm(loginData.loginForm) |
||||
|
} else { |
||||
|
authUtil.removeLoginForm() |
||||
|
} |
||||
|
authUtil.setToken(res) |
||||
|
if (!redirect.value) { |
||||
|
redirect.value = '/' |
||||
|
} |
||||
|
// 判断是否为SSO登录 |
||||
|
if (redirect.value.indexOf('sso') !== -1) { |
||||
|
window.location.href = window.location.href.replace('/login?redirect=', '') |
||||
|
} else { |
||||
|
console.log(redirect.value) |
||||
|
push({ path: redirect.value || permissionStore.addRouters[0].path }) |
||||
|
} |
||||
|
// 存储 部门信息 |
||||
|
const { wsCache } = useCache() |
||||
|
wsCache.set(CACHE_KEY.DEPT, await DeptApi.getSimpleDeptList()) |
||||
|
} finally { |
||||
|
getCode() |
||||
|
loginLoading.value = false |
||||
|
loading.value.close() |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 社交登录 |
||||
|
const doSocialLogin = async (type: number) => { |
||||
|
if (type === 0) { |
||||
|
message.error('此方式未配置') |
||||
|
} else { |
||||
|
loginLoading.value = true |
||||
|
if (loginData.tenantEnable === 'true') { |
||||
|
await message.prompt('请输入租户名称', t('common.reminder')).then(async ({ value }) => { |
||||
|
const res = await LoginApi.getTenantIdByName(value) |
||||
|
authUtil.setTenantId(res) |
||||
|
}) |
||||
|
} |
||||
|
// 计算 redirectUri |
||||
|
const redirectUri = |
||||
|
location.origin + '/social-login?type=' + type + '&redirect=' + (redirect.value || '/') |
||||
|
// 进行跳转 |
||||
|
const res = await LoginApi.socialAuthRedirect(type, encodeURIComponent(redirectUri)) |
||||
|
console.log(33) |
||||
|
window.location.href = res |
||||
|
} |
||||
|
} |
||||
|
watch( |
||||
|
() => currentRoute.value, |
||||
|
(route: RouteLocationNormalizedLoaded) => { |
||||
|
redirect.value = route?.query?.redirect as string |
||||
|
}, |
||||
|
{ |
||||
|
immediate: true |
||||
|
} |
||||
|
) |
||||
|
onMounted(() => { |
||||
|
getCode(); |
||||
|
getCookie() |
||||
|
}) |
||||
|
</script> |
||||
|
|
||||
|
<style lang="scss" scoped> |
||||
|
:deep(.anticon) { |
||||
|
&:hover { |
||||
|
color: var(--el-color-primary) !important; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.login-code { |
||||
|
float: right; |
||||
|
width: 100%; |
||||
|
height: 38px; |
||||
|
|
||||
|
img { |
||||
|
width: 100%; |
||||
|
height: auto; |
||||
|
max-width: 100px; |
||||
|
vertical-align: middle; |
||||
|
cursor: pointer; |
||||
|
} |
||||
|
} |
||||
|
</style> |
@ -0,0 +1,26 @@ |
|||||
|
<template> |
||||
|
<h2 class="enter-x mb-3 text-center text-2xl font-bold xl:text-center xl:text-3xl"> |
||||
|
{{ getFormTitle }} |
||||
|
</h2> |
||||
|
</template> |
||||
|
<script lang="ts" setup> |
||||
|
import { LoginStateEnum, useLoginState } from './useLogin' |
||||
|
|
||||
|
defineOptions({ name: 'LoginFormTitle' }) |
||||
|
|
||||
|
const { t } = useI18n() |
||||
|
|
||||
|
const { getLoginState } = useLoginState() |
||||
|
|
||||
|
const getFormTitle = computed(() => { |
||||
|
const titleObj = { |
||||
|
[LoginStateEnum.RESET_PASSWORD]: t('sys.login.forgetFormTitle'), |
||||
|
[LoginStateEnum.LOGIN]: t('sys.login.signInFormTitle'), |
||||
|
[LoginStateEnum.REGISTER]: t('sys.login.signUpFormTitle'), |
||||
|
[LoginStateEnum.MOBILE]: t('sys.login.mobileSignInFormTitle'), |
||||
|
[LoginStateEnum.QR_CODE]: t('sys.login.qrSignInFormTitle'), |
||||
|
[LoginStateEnum.SSO]: t('sys.login.ssoFormTitle') |
||||
|
} |
||||
|
return titleObj[unref(getLoginState)] |
||||
|
}) |
||||
|
</script> |
@ -0,0 +1,225 @@ |
|||||
|
<template> |
||||
|
<el-form |
||||
|
v-show="getShow" |
||||
|
ref="formSmsLogin" |
||||
|
:model="loginData.loginForm" |
||||
|
:rules="rules" |
||||
|
class="login-form" |
||||
|
label-position="top" |
||||
|
label-width="120px" |
||||
|
size="large" |
||||
|
> |
||||
|
<el-row style="margin-right: -10px; margin-left: -10px"> |
||||
|
<!-- 租户名 --> |
||||
|
<el-col :span="24" style="padding-right: 10px; padding-left: 10px"> |
||||
|
<el-form-item> |
||||
|
<LoginFormTitle style="width: 100%" /> |
||||
|
</el-form-item> |
||||
|
</el-col> |
||||
|
<el-col :span="24" style="padding-right: 10px; padding-left: 10px"> |
||||
|
<el-form-item v-if="loginData.tenantEnable === 'true'" prop="tenantName"> |
||||
|
<el-input |
||||
|
v-model="loginData.loginForm.tenantName" |
||||
|
:placeholder="t('login.tenantNamePlaceholder')" |
||||
|
:prefix-icon="iconHouse" |
||||
|
type="primary" |
||||
|
link |
||||
|
/> |
||||
|
</el-form-item> |
||||
|
</el-col> |
||||
|
<!-- 手机号 --> |
||||
|
<el-col :span="24" style="padding-right: 10px; padding-left: 10px"> |
||||
|
<el-form-item prop="mobileNumber"> |
||||
|
<el-input |
||||
|
v-model="loginData.loginForm.mobileNumber" |
||||
|
:placeholder="t('login.mobileNumberPlaceholder')" |
||||
|
:prefix-icon="iconCellphone" |
||||
|
/> |
||||
|
</el-form-item> |
||||
|
</el-col> |
||||
|
<!-- 验证码 --> |
||||
|
<el-col :span="24" style="padding-right: 10px; padding-left: 10px"> |
||||
|
<el-form-item prop="code"> |
||||
|
<el-row :gutter="5" justify="space-between" style="width: 100%"> |
||||
|
<el-col :span="24"> |
||||
|
<el-input |
||||
|
v-model="loginData.loginForm.code" |
||||
|
:placeholder="t('login.codePlaceholder')" |
||||
|
:prefix-icon="iconCircleCheck" |
||||
|
> |
||||
|
<!-- <el-button class="w-[100%]"> --> |
||||
|
<template #append> |
||||
|
<span |
||||
|
v-if="mobileCodeTimer <= 0" |
||||
|
class="getMobileCode" |
||||
|
style="cursor: pointer" |
||||
|
@click="getSmsCode" |
||||
|
> |
||||
|
{{ t('login.getSmsCode') }} |
||||
|
</span> |
||||
|
<span v-if="mobileCodeTimer > 0" class="getMobileCode" style="cursor: pointer"> |
||||
|
{{ mobileCodeTimer }}秒后可重新获取 |
||||
|
</span> |
||||
|
</template> |
||||
|
</el-input> |
||||
|
<!-- </el-button> --> |
||||
|
</el-col> |
||||
|
</el-row> |
||||
|
</el-form-item> |
||||
|
</el-col> |
||||
|
<!-- 登录按钮 / 返回按钮 --> |
||||
|
<el-col :span="24" style="padding-right: 10px; padding-left: 10px"> |
||||
|
<el-form-item> |
||||
|
<XButton |
||||
|
:loading="loginLoading" |
||||
|
:title="t('login.login')" |
||||
|
class="w-[100%]" |
||||
|
type="primary" |
||||
|
@click="signIn()" |
||||
|
/> |
||||
|
</el-form-item> |
||||
|
</el-col> |
||||
|
<el-col :span="24" style="padding-right: 10px; padding-left: 10px"> |
||||
|
<el-form-item> |
||||
|
<XButton |
||||
|
:loading="loginLoading" |
||||
|
:title="t('login.backLogin')" |
||||
|
class="w-[100%]" |
||||
|
@click="handleBackLogin()" |
||||
|
/> |
||||
|
</el-form-item> |
||||
|
</el-col> |
||||
|
</el-row> |
||||
|
</el-form> |
||||
|
</template> |
||||
|
<script lang="ts" setup> |
||||
|
import type { RouteLocationNormalizedLoaded } from 'vue-router' |
||||
|
|
||||
|
import { useIcon } from '@/hooks/web/useIcon' |
||||
|
|
||||
|
import { setTenantId, setToken } from '@/utils/auth' |
||||
|
import { usePermissionStore } from '@/store/modules/permission' |
||||
|
import { getTenantIdByName, sendSmsCode, smsLogin } from '@/api/login' |
||||
|
import LoginFormTitle from './LoginFormTitle.vue' |
||||
|
import { LoginStateEnum, useFormValid, useLoginState } from './useLogin' |
||||
|
|
||||
|
defineOptions({ name: 'MobileForm' }) |
||||
|
|
||||
|
const { t } = useI18n() |
||||
|
const message = useMessage() |
||||
|
const permissionStore = usePermissionStore() |
||||
|
const { currentRoute, push } = useRouter() |
||||
|
const formSmsLogin = ref() |
||||
|
const loginLoading = ref(false) |
||||
|
const iconHouse = useIcon({ icon: 'ep:house' }) |
||||
|
const iconCellphone = useIcon({ icon: 'ep:cellphone' }) |
||||
|
const iconCircleCheck = useIcon({ icon: 'ep:circle-check' }) |
||||
|
const { validForm } = useFormValid(formSmsLogin) |
||||
|
const { handleBackLogin, getLoginState } = useLoginState() |
||||
|
const getShow = computed(() => unref(getLoginState) === LoginStateEnum.MOBILE) |
||||
|
|
||||
|
const rules = { |
||||
|
tenantName: [required], |
||||
|
mobileNumber: [required], |
||||
|
code: [required] |
||||
|
} |
||||
|
const loginData = reactive({ |
||||
|
codeImg: '', |
||||
|
tenantEnable: import.meta.env.VITE_APP_TENANT_ENABLE, |
||||
|
token: '', |
||||
|
loading: { |
||||
|
signIn: false |
||||
|
}, |
||||
|
loginForm: { |
||||
|
uuid: '', |
||||
|
tenantName: '闻荫源码', |
||||
|
mobileNumber: '', |
||||
|
code: '' |
||||
|
} |
||||
|
}) |
||||
|
const smsVO = reactive({ |
||||
|
smsCode: { |
||||
|
mobile: '', |
||||
|
scene: 21 |
||||
|
}, |
||||
|
loginSms: { |
||||
|
mobile: '', |
||||
|
code: '' |
||||
|
} |
||||
|
}) |
||||
|
const mobileCodeTimer = ref(0) |
||||
|
const redirect = ref<string>('') |
||||
|
const getSmsCode = async () => { |
||||
|
await getTenantId() |
||||
|
smsVO.smsCode.mobile = loginData.loginForm.mobileNumber |
||||
|
await sendSmsCode(smsVO.smsCode).then(async () => { |
||||
|
message.success(t('login.SmsSendMsg')) |
||||
|
// 设置倒计时 |
||||
|
mobileCodeTimer.value = 60 |
||||
|
let msgTimer = setInterval(() => { |
||||
|
mobileCodeTimer.value = mobileCodeTimer.value - 1 |
||||
|
if (mobileCodeTimer.value <= 0) { |
||||
|
clearInterval(msgTimer) |
||||
|
} |
||||
|
}, 1000) |
||||
|
}) |
||||
|
} |
||||
|
watch( |
||||
|
() => currentRoute.value, |
||||
|
(route: RouteLocationNormalizedLoaded) => { |
||||
|
redirect.value = route?.query?.redirect as string |
||||
|
}, |
||||
|
{ |
||||
|
immediate: true |
||||
|
} |
||||
|
) |
||||
|
// 获取租户 ID |
||||
|
const getTenantId = async () => { |
||||
|
if (loginData.tenantEnable === 'true') { |
||||
|
const res = await getTenantIdByName(loginData.loginForm.tenantName) |
||||
|
setTenantId(res) |
||||
|
} |
||||
|
} |
||||
|
// 登录 |
||||
|
const signIn = async () => { |
||||
|
await getTenantId() |
||||
|
const data = await validForm() |
||||
|
if (!data) return |
||||
|
ElLoading.service({ |
||||
|
lock: true, |
||||
|
text: '正在加载系统中...', |
||||
|
background: 'rgba(0, 0, 0, 0.7)' |
||||
|
}) |
||||
|
loginLoading.value = true |
||||
|
smsVO.loginSms.mobile = loginData.loginForm.mobileNumber |
||||
|
smsVO.loginSms.code = loginData.loginForm.code |
||||
|
await smsLogin(smsVO.loginSms) |
||||
|
.then(async (res) => { |
||||
|
setToken(res) |
||||
|
if (!redirect.value) { |
||||
|
redirect.value = '/' |
||||
|
} |
||||
|
push({ path: redirect.value || permissionStore.addRouters[0].path }) |
||||
|
}) |
||||
|
.catch(() => {}) |
||||
|
.finally(() => { |
||||
|
loginLoading.value = false |
||||
|
setTimeout(() => { |
||||
|
const loadingInstance = ElLoading.service() |
||||
|
loadingInstance.close() |
||||
|
}, 400) |
||||
|
}) |
||||
|
} |
||||
|
</script> |
||||
|
|
||||
|
<style lang="scss" scoped> |
||||
|
:deep(.anticon) { |
||||
|
&:hover { |
||||
|
color: var(--el-color-primary) !important; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.smsbtn { |
||||
|
margin-top: 33px; |
||||
|
} |
||||
|
</style> |
@ -0,0 +1,30 @@ |
|||||
|
<template> |
||||
|
<el-row v-show="getShow" style="margin-right: -10px; margin-left: -10px"> |
||||
|
<el-col :span="24" style="padding-right: 10px; padding-left: 10px"> |
||||
|
<LoginFormTitle style="width: 100%" /> |
||||
|
</el-col> |
||||
|
<el-col :span="24" style="padding-right: 10px; padding-left: 10px"> |
||||
|
<el-card class="mb-10px text-center" shadow="hover"> |
||||
|
<Qrcode :logo="logoImg" /> |
||||
|
</el-card> |
||||
|
</el-col> |
||||
|
<el-divider class="enter-x">{{ t('login.qrcode') }}</el-divider> |
||||
|
<el-col :span="24" style="padding-right: 10px; padding-left: 10px"> |
||||
|
<div class="mt-15px w-[100%]"> |
||||
|
<XButton :title="t('login.backLogin')" class="w-[100%]" @click="handleBackLogin()" /> |
||||
|
</div> |
||||
|
</el-col> |
||||
|
</el-row> |
||||
|
</template> |
||||
|
<script lang="ts" setup> |
||||
|
import logoImg from '@/assets/imgs/logo.png' |
||||
|
|
||||
|
import LoginFormTitle from './LoginFormTitle.vue' |
||||
|
import { LoginStateEnum, useLoginState } from './useLogin' |
||||
|
|
||||
|
defineOptions({ name: 'QrCodeForm' }) |
||||
|
|
||||
|
const { t } = useI18n() |
||||
|
const { handleBackLogin, getLoginState } = useLoginState() |
||||
|
const getShow = computed(() => unref(getLoginState) === LoginStateEnum.QR_CODE) |
||||
|
</script> |
@ -0,0 +1,142 @@ |
|||||
|
<template> |
||||
|
<Form |
||||
|
v-show="getShow" |
||||
|
:rules="rules" |
||||
|
:schema="schema" |
||||
|
class="dark:(border-1 border-[var(--el-border-color)] border-solid)" |
||||
|
hide-required-asterisk |
||||
|
label-position="top" |
||||
|
size="large" |
||||
|
@register="register" |
||||
|
> |
||||
|
<template #title> |
||||
|
<LoginFormTitle style="width: 100%" /> |
||||
|
</template> |
||||
|
|
||||
|
<template #code="form"> |
||||
|
<div class="w-[100%] flex"> |
||||
|
<el-input v-model="form['code']" :placeholder="t('login.codePlaceholder')" /> |
||||
|
</div> |
||||
|
</template> |
||||
|
|
||||
|
<template #register> |
||||
|
<div class="w-[100%]"> |
||||
|
<XButton |
||||
|
:loading="loading" |
||||
|
:title="t('login.register')" |
||||
|
class="w-[100%]" |
||||
|
type="primary" |
||||
|
@click="loginRegister()" |
||||
|
/> |
||||
|
</div> |
||||
|
<div class="mt-15px w-[100%]"> |
||||
|
<XButton :title="t('login.hasUser')" class="w-[100%]" @click="handleBackLogin()" /> |
||||
|
</div> |
||||
|
</template> |
||||
|
</Form> |
||||
|
</template> |
||||
|
<script lang="ts" setup> |
||||
|
import type { FormRules } from 'element-plus' |
||||
|
|
||||
|
import { useForm } from '@/hooks/web/useForm' |
||||
|
import { useValidator } from '@/hooks/web/useValidator' |
||||
|
import LoginFormTitle from './LoginFormTitle.vue' |
||||
|
import { LoginStateEnum, useLoginState } from './useLogin' |
||||
|
import { FormSchema } from '@/types/form' |
||||
|
|
||||
|
defineOptions({ name: 'RegisterForm' }) |
||||
|
|
||||
|
const { t } = useI18n() |
||||
|
const { required } = useValidator() |
||||
|
const { register, elFormRef } = useForm() |
||||
|
const { handleBackLogin, getLoginState } = useLoginState() |
||||
|
const getShow = computed(() => unref(getLoginState) === LoginStateEnum.REGISTER) |
||||
|
|
||||
|
const schema = reactive<FormSchema[]>([ |
||||
|
{ |
||||
|
field: 'title', |
||||
|
colProps: { |
||||
|
span: 24 |
||||
|
} |
||||
|
}, |
||||
|
{ |
||||
|
field: 'username', |
||||
|
label: t('login.username'), |
||||
|
value: '', |
||||
|
component: 'Input', |
||||
|
colProps: { |
||||
|
span: 24 |
||||
|
}, |
||||
|
componentProps: { |
||||
|
placeholder: t('login.usernamePlaceholder') |
||||
|
} |
||||
|
}, |
||||
|
{ |
||||
|
field: 'password', |
||||
|
label: t('login.password'), |
||||
|
value: '', |
||||
|
component: 'InputPassword', |
||||
|
colProps: { |
||||
|
span: 24 |
||||
|
}, |
||||
|
componentProps: { |
||||
|
style: { |
||||
|
width: '100%' |
||||
|
}, |
||||
|
strength: true, |
||||
|
placeholder: t('login.passwordPlaceholder') |
||||
|
} |
||||
|
}, |
||||
|
{ |
||||
|
field: 'check_password', |
||||
|
label: t('login.checkPassword'), |
||||
|
value: '', |
||||
|
component: 'InputPassword', |
||||
|
colProps: { |
||||
|
span: 24 |
||||
|
}, |
||||
|
componentProps: { |
||||
|
style: { |
||||
|
width: '100%' |
||||
|
}, |
||||
|
strength: true, |
||||
|
placeholder: t('login.passwordPlaceholder') |
||||
|
} |
||||
|
}, |
||||
|
{ |
||||
|
field: 'code', |
||||
|
label: t('login.code'), |
||||
|
colProps: { |
||||
|
span: 24 |
||||
|
} |
||||
|
}, |
||||
|
{ |
||||
|
field: 'register', |
||||
|
colProps: { |
||||
|
span: 24 |
||||
|
} |
||||
|
} |
||||
|
]) |
||||
|
|
||||
|
const rules: FormRules = { |
||||
|
username: [required()], |
||||
|
password: [required()], |
||||
|
check_password: [required()], |
||||
|
code: [required()] |
||||
|
} |
||||
|
|
||||
|
const loading = ref(false) |
||||
|
|
||||
|
const loginRegister = async () => { |
||||
|
const formRef = unref(elFormRef) |
||||
|
formRef?.validate(async (valid) => { |
||||
|
if (valid) { |
||||
|
try { |
||||
|
loading.value = true |
||||
|
} finally { |
||||
|
loading.value = false |
||||
|
} |
||||
|
} |
||||
|
}) |
||||
|
} |
||||
|
</script> |
@ -0,0 +1,199 @@ |
|||||
|
<template> |
||||
|
<div v-show="ssoVisible" class="form-cont"> |
||||
|
<!-- 应用名 --> |
||||
|
<LoginFormTitle style="width: 100%" /> |
||||
|
<el-tabs class="form" style="float: none" value="uname"> |
||||
|
<el-tab-pane :label="client.name" name="uname" /> |
||||
|
</el-tabs> |
||||
|
<div> |
||||
|
<el-form :model="formData" class="login-form"> |
||||
|
<!-- 授权范围的选择 --> |
||||
|
此第三方应用请求获得以下权限: |
||||
|
<el-form-item prop="scopes"> |
||||
|
<el-checkbox-group v-model="formData.scopes"> |
||||
|
<el-checkbox |
||||
|
v-for="scope in queryParams.scopes" |
||||
|
:key="scope" |
||||
|
:label="scope" |
||||
|
style="display: block; margin-bottom: -10px" |
||||
|
> |
||||
|
{{ formatScope(scope) }} |
||||
|
</el-checkbox> |
||||
|
</el-checkbox-group> |
||||
|
</el-form-item> |
||||
|
<!-- 下方的登录按钮 --> |
||||
|
<el-form-item class="w-1/1"> |
||||
|
<el-button |
||||
|
:loading="formLoading" |
||||
|
class="w-6/10" |
||||
|
type="primary" |
||||
|
@click.prevent="handleAuthorize(true)" |
||||
|
> |
||||
|
<span v-if="!formLoading">同意授权</span> |
||||
|
<span v-else>授 权 中...</span> |
||||
|
</el-button> |
||||
|
<el-button class="w-3/10" @click.prevent="handleAuthorize(false)">拒绝</el-button> |
||||
|
</el-form-item> |
||||
|
</el-form> |
||||
|
</div> |
||||
|
</div> |
||||
|
</template> |
||||
|
<script lang="ts" setup> |
||||
|
import LoginFormTitle from './LoginFormTitle.vue' |
||||
|
import * as OAuth2Api from '@/api/login/oauth2' |
||||
|
import { LoginStateEnum, useLoginState } from './useLogin' |
||||
|
import type { RouteLocationNormalizedLoaded } from 'vue-router' |
||||
|
|
||||
|
defineOptions({ name: 'SSOLogin' }) |
||||
|
|
||||
|
const route = useRoute() // 路由 |
||||
|
const { currentRoute } = useRouter() // 路由 |
||||
|
const { getLoginState, setLoginState } = useLoginState() |
||||
|
|
||||
|
const client = ref({ |
||||
|
// 客户端信息 |
||||
|
name: '', |
||||
|
logo: '' |
||||
|
}) |
||||
|
interface queryType { |
||||
|
responseType: string |
||||
|
clientId: string |
||||
|
redirectUri: string |
||||
|
state: string |
||||
|
scopes: string[] |
||||
|
} |
||||
|
const queryParams = reactive<queryType>({ |
||||
|
// URL 上的 client_id、scope 等参数 |
||||
|
responseType: '', |
||||
|
clientId: '', |
||||
|
redirectUri: '', |
||||
|
state: '', |
||||
|
scopes: [] // 优先从 query 参数获取;如果未传递,从后端获取 |
||||
|
}) |
||||
|
const ssoVisible = computed(() => unref(getLoginState) === LoginStateEnum.SSO) // 是否展示 SSO 登录的表单 |
||||
|
interface formType { |
||||
|
scopes: string[] |
||||
|
} |
||||
|
const formData = reactive<formType>({ |
||||
|
scopes: [] // 已选中的 scope 数组 |
||||
|
}) |
||||
|
const formLoading = ref(false) // 表单是否提交中 |
||||
|
|
||||
|
/** 初始化授权信息 */ |
||||
|
const init = async () => { |
||||
|
// 防止在没有登录的情况下循环弹窗 |
||||
|
if (typeof route.query.client_id === 'undefined') return |
||||
|
// 解析参数 |
||||
|
// 例如说【自动授权不通过】:client_id=default&redirect_uri=https%3A%2F%2Fwww.iocoder.cn&response_type=code&scope=user.read%20user.write |
||||
|
// 例如说【自动授权通过】:client_id=default&redirect_uri=https%3A%2F%2Fwww.iocoder.cn&response_type=code&scope=user.read |
||||
|
queryParams.responseType = route.query.response_type as string |
||||
|
queryParams.clientId = route.query.client_id as string |
||||
|
queryParams.redirectUri = route.query.redirect_uri as string |
||||
|
queryParams.state = route.query.state as string |
||||
|
if (route.query.scope) { |
||||
|
queryParams.scopes = (route.query.scope as string).split(' ') |
||||
|
} |
||||
|
|
||||
|
// 如果有 scope 参数,先执行一次自动授权,看看是否之前都授权过了。 |
||||
|
if (queryParams.scopes.length > 0) { |
||||
|
const data = await doAuthorize(true, queryParams.scopes, []) |
||||
|
if (data) { |
||||
|
location.href = data |
||||
|
return |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 获取授权页的基本信息 |
||||
|
const data = await OAuth2Api.getAuthorize(queryParams.clientId) |
||||
|
client.value = data.client |
||||
|
// 解析 scope |
||||
|
let scopes |
||||
|
// 1.1 如果 params.scope 非空,则过滤下返回的 scopes |
||||
|
if (queryParams.scopes.length > 0) { |
||||
|
scopes = [] |
||||
|
for (const scope of data.scopes) { |
||||
|
if (queryParams.scopes.indexOf(scope.key) >= 0) { |
||||
|
scopes.push(scope) |
||||
|
} |
||||
|
} |
||||
|
// 1.2 如果 params.scope 为空,则使用返回的 scopes 设置它 |
||||
|
} else { |
||||
|
scopes = data.scopes |
||||
|
for (const scope of scopes) { |
||||
|
queryParams.scopes.push(scope.key) |
||||
|
} |
||||
|
} |
||||
|
// 生成已选中的 checkedScopes |
||||
|
for (const scope of scopes) { |
||||
|
if (scope.value) { |
||||
|
formData.scopes.push(scope.key) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** 处理授权的提交 */ |
||||
|
const handleAuthorize = async (approved) => { |
||||
|
// 计算 checkedScopes + uncheckedScopes |
||||
|
let checkedScopes |
||||
|
let uncheckedScopes |
||||
|
if (approved) { |
||||
|
// 同意授权,按照用户的选择 |
||||
|
checkedScopes = formData.scopes |
||||
|
uncheckedScopes = queryParams.scopes.filter((item) => checkedScopes.indexOf(item) === -1) |
||||
|
} else { |
||||
|
// 拒绝,则都是取消 |
||||
|
checkedScopes = [] |
||||
|
uncheckedScopes = queryParams.scopes |
||||
|
} |
||||
|
// 提交授权的请求 |
||||
|
formLoading.value = true |
||||
|
try { |
||||
|
const data = await doAuthorize(false, checkedScopes, uncheckedScopes) |
||||
|
if (!data) { |
||||
|
return |
||||
|
} |
||||
|
location.href = data |
||||
|
} finally { |
||||
|
formLoading.value = false |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** 调用授权 API 接口 */ |
||||
|
const doAuthorize = (autoApprove, checkedScopes, uncheckedScopes) => { |
||||
|
return OAuth2Api.authorize( |
||||
|
queryParams.responseType, |
||||
|
queryParams.clientId, |
||||
|
queryParams.redirectUri, |
||||
|
queryParams.state, |
||||
|
autoApprove, |
||||
|
checkedScopes, |
||||
|
uncheckedScopes |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
/** 格式化 scope 文本 */ |
||||
|
const formatScope = (scope) => { |
||||
|
// 格式化 scope 授权范围,方便用户理解。 |
||||
|
// 这里仅仅是一个 demo,可以考虑录入到字典数据中,例如说字典类型 "system_oauth2_scope",它的每个 scope 都是一条字典数据。 |
||||
|
switch (scope) { |
||||
|
case 'user.read': |
||||
|
return '访问你的个人信息' |
||||
|
case 'user.write': |
||||
|
return '修改你的个人信息' |
||||
|
default: |
||||
|
return scope |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** 监听当前路由为 SSOLogin 时,进行数据的初始化 */ |
||||
|
watch( |
||||
|
() => currentRoute.value, |
||||
|
(route: RouteLocationNormalizedLoaded) => { |
||||
|
if (route.name === 'SSOLogin') { |
||||
|
setLoginState(LoginStateEnum.SSO) |
||||
|
init() |
||||
|
} |
||||
|
}, |
||||
|
{ immediate: true } |
||||
|
) |
||||
|
</script> |
@ -0,0 +1,8 @@ |
|||||
|
import LoginForm from './LoginForm.vue' |
||||
|
import MobileForm from './MobileForm.vue' |
||||
|
import LoginFormTitle from './LoginFormTitle.vue' |
||||
|
import RegisterForm from './RegisterForm.vue' |
||||
|
import QrCodeForm from './QrCodeForm.vue' |
||||
|
import SSOLoginVue from './SSOLogin.vue' |
||||
|
|
||||
|
export { LoginForm, MobileForm, LoginFormTitle, RegisterForm, QrCodeForm, SSOLoginVue } |
@ -0,0 +1,42 @@ |
|||||
|
import { Ref } from 'vue' |
||||
|
|
||||
|
export enum LoginStateEnum { |
||||
|
LOGIN, |
||||
|
REGISTER, |
||||
|
RESET_PASSWORD, |
||||
|
MOBILE, |
||||
|
QR_CODE, |
||||
|
SSO |
||||
|
} |
||||
|
|
||||
|
const currentState = ref(LoginStateEnum.LOGIN) |
||||
|
|
||||
|
export function useLoginState() { |
||||
|
function setLoginState(state: LoginStateEnum) { |
||||
|
currentState.value = state |
||||
|
} |
||||
|
const getLoginState = computed(() => currentState.value) |
||||
|
|
||||
|
function handleBackLogin() { |
||||
|
setLoginState(LoginStateEnum.LOGIN) |
||||
|
} |
||||
|
|
||||
|
return { |
||||
|
setLoginState, |
||||
|
getLoginState, |
||||
|
handleBackLogin |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
export function useFormValid<T extends Object = any>(formRef: Ref<any>) { |
||||
|
async function validForm() { |
||||
|
const form = unref(formRef) |
||||
|
if (!form) return |
||||
|
const data = await form.validate() |
||||
|
return data as T |
||||
|
} |
||||
|
|
||||
|
return { |
||||
|
validForm |
||||
|
} |
||||
|
} |
@ -0,0 +1,104 @@ |
|||||
|
<template> |
||||
|
<div |
||||
|
:class="prefixCls" |
||||
|
class="relative h-[100%] lt-xl:bg-[var(--login-bg-color)] lt-md:px-10px lt-sm:px-10px lt-xl:px-10px" |
||||
|
> |
||||
|
<div class="relative mx-auto h-full flex"> |
||||
|
<div |
||||
|
:class="`${prefixCls}__left flex-1 bg-gray-500 bg-opacity-20 relative p-30px lt-xl:hidden`" |
||||
|
> |
||||
|
<!-- 左上角的 logo + 系统标题 --> |
||||
|
<div class="relative flex items-center text-white"> |
||||
|
<img alt="" class="mr-10px h-24px w-48px" src="@/assets/imgs/logo.png" /> |
||||
|
<span class="text-20px font-bold">{{ underlineToHump(appStore.getTitle) }}</span> |
||||
|
</div> |
||||
|
<!-- 左边的背景图 + 欢迎语 --> |
||||
|
<div class="h-[calc(100%-60px)] flex items-center justify-center"> |
||||
|
<TransitionGroup |
||||
|
appear |
||||
|
enter-active-class="animate__animated animate__bounceInLeft" |
||||
|
tag="div" |
||||
|
> |
||||
|
<img key="1" alt="" class="w-350px" src="@/assets/svgs/login-box-bg.svg" /> |
||||
|
<div key="2" class="text-3xl text-white">{{ t('login.welcome') }}</div> |
||||
|
<div key="3" class="mt-5 text-14px font-normal text-white"> |
||||
|
{{ t('login.message') }} |
||||
|
</div> |
||||
|
</TransitionGroup> |
||||
|
</div> |
||||
|
</div> |
||||
|
<div class="relative flex-1 p-30px dark:bg-[var(--login-bg-color)] lt-sm:p-10px"> |
||||
|
<!-- 右上角的主题、语言选择 --> |
||||
|
<div |
||||
|
class="flex items-center justify-between text-white at-2xl:justify-end at-xl:justify-end" |
||||
|
> |
||||
|
<div class="flex items-center at-2xl:hidden at-xl:hidden"> |
||||
|
<img alt="" class="mr-10px h-48px w-48px" src="@/assets/imgs/logo.png" /> |
||||
|
<span class="text-20px font-bold">{{ underlineToHump(appStore.getTitle) }}</span> |
||||
|
</div> |
||||
|
<div class="flex items-center justify-end space-x-10px"> |
||||
|
<!-- <ThemeSwitch /> --> |
||||
|
<LocaleDropdown class="dark:text-white lt-xl:text-white" /> |
||||
|
</div> |
||||
|
</div> |
||||
|
<!-- 右边的登录界面 --> |
||||
|
<Transition appear enter-active-class="animate__animated animate__bounceInRight"> |
||||
|
<div |
||||
|
class="m-auto h-full w-[100%] flex items-center at-2xl:max-w-500px at-lg:max-w-500px at-md:max-w-500px at-xl:max-w-500px" |
||||
|
> |
||||
|
<!-- 账号登录 --> |
||||
|
<LoginForm class="m-auto h-auto p-20px lt-xl:(rounded-3xl light:bg-white)" /> |
||||
|
<!-- 手机登录 --> |
||||
|
<MobileForm class="m-auto h-auto p-20px lt-xl:(rounded-3xl light:bg-white)" /> |
||||
|
<!-- 二维码登录 --> |
||||
|
<QrCodeForm class="m-auto h-auto p-20px lt-xl:(rounded-3xl light:bg-white)" /> |
||||
|
<!-- 注册 --> |
||||
|
<RegisterForm class="m-auto h-auto p-20px lt-xl:(rounded-3xl light:bg-white)" /> |
||||
|
<!-- 三方登录 --> |
||||
|
<SSOLoginVue class="m-auto h-auto p-20px lt-xl:(rounded-3xl light:bg-white)" /> |
||||
|
</div> |
||||
|
</Transition> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</template> |
||||
|
<script lang="ts" setup> |
||||
|
import { underlineToHump } from '@/utils' |
||||
|
|
||||
|
import { useDesign } from '@/hooks/web/useDesign' |
||||
|
import { useAppStore } from '@/store/modules/app' |
||||
|
import { ThemeSwitch } from '@/layout/components/ThemeSwitch' |
||||
|
import { LocaleDropdown } from '@/layout/components/LocaleDropdown' |
||||
|
|
||||
|
import { LoginForm, MobileForm, QrCodeForm, RegisterForm, SSOLoginVue } from './components' |
||||
|
|
||||
|
defineOptions({ name: 'Login' }) |
||||
|
|
||||
|
const { t } = useI18n() |
||||
|
const appStore = useAppStore() |
||||
|
const { getPrefixCls } = useDesign() |
||||
|
const prefixCls = getPrefixCls('login') |
||||
|
</script> |
||||
|
|
||||
|
<style lang="scss" scoped> |
||||
|
$prefix-cls: #{$namespace}-login; |
||||
|
|
||||
|
.#{$prefix-cls} { |
||||
|
overflow: auto; |
||||
|
|
||||
|
&__left { |
||||
|
&::before { |
||||
|
position: absolute; |
||||
|
top: 0; |
||||
|
left: 0; |
||||
|
z-index: -1; |
||||
|
width: 100%; |
||||
|
height: 100%; |
||||
|
background-image: url('@/assets/svgs/login-bg.svg'); |
||||
|
background-position: center; |
||||
|
background-repeat: no-repeat; |
||||
|
content: ''; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
</style> |
Loading…
Reference in new issue