386 lines
12 KiB
Vue
386 lines
12 KiB
Vue
<template>
|
||
<div class='login-page'>
|
||
<div class='bg-geometry'></div>
|
||
<div class='login-stack'>
|
||
<el-card class='login-card' shadow='never'>
|
||
<div class='brand-row'>
|
||
<div class='brand-chip'>B</div>
|
||
<span class='brand-name'>Bastion SSO</span>
|
||
</div>
|
||
<h1 class='login-title'>登录</h1>
|
||
<p class='login-subtitle'>使用邮箱或手机号继续</p>
|
||
<el-form :model='form' :rules='rules' ref='formRef' @submit.prevent='handleLogin'>
|
||
<el-form-item prop='account'>
|
||
<el-input v-model='form.account' placeholder='邮箱、手机号' class='login-input' />
|
||
</el-form-item>
|
||
<el-form-item prop='password'>
|
||
<el-input v-model='form.password' type='password' show-password placeholder='密码' class='login-input' />
|
||
</el-form-item>
|
||
<div class='assist-links'>
|
||
<a href='javascript:void(0)'>无法访问您的帐户?</a>
|
||
<a href='javascript:void(0)' @click='applyDialogVisible = true'>账号申请</a>
|
||
</div>
|
||
<div class='btn-row'>
|
||
<el-button :loading='submitting' type='primary' class='login-btn' @click='handleLogin'>登录</el-button>
|
||
</div>
|
||
</el-form>
|
||
</el-card>
|
||
</div>
|
||
|
||
<el-dialog v-model='forcePasswordDialogVisible' title='请先修改密码' width='480px' :close-on-click-modal='true' :show-close='true'>
|
||
<el-form :model='forcePasswordForm' label-width='96px'>
|
||
<el-form-item label='当前密码'>
|
||
<el-input v-model='forcePasswordForm.current_password' type='password' show-password />
|
||
</el-form-item>
|
||
<el-form-item label='新密码'>
|
||
<el-input v-model='forcePasswordForm.password' type='password' show-password />
|
||
</el-form-item>
|
||
<el-form-item label='确认新密码'>
|
||
<el-input v-model='forcePasswordForm.password_confirmation' type='password' show-password />
|
||
</el-form-item>
|
||
</el-form>
|
||
<template #footer>
|
||
<el-button @click='forcePasswordDialogVisible = false'>稍后修改</el-button>
|
||
<el-button @click='handleForceUserLogout'>退出当前账号</el-button>
|
||
<el-button type='primary' :loading='forcingPassword' @click='submitForcePasswordChange'>确认修改并进入系统</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<el-dialog v-model='applyDialogVisible' title='账号申请' width='520px'>
|
||
<el-form ref='applyFormRef' :model='applyForm' :rules='applyRules' label-width='90px'>
|
||
<el-form-item label='昵称' prop='nickname'>
|
||
<el-input v-model='applyForm.nickname' placeholder='请输入昵称' />
|
||
</el-form-item>
|
||
<el-form-item label='邮箱' prop='email'>
|
||
<el-input v-model='applyForm.email' placeholder='请输入邮箱' />
|
||
</el-form-item>
|
||
<el-form-item label='手机号' prop='phone'>
|
||
<el-input v-model='applyForm.phone' placeholder='请输入手机号' />
|
||
</el-form-item>
|
||
<el-form-item label='密码' prop='password'>
|
||
<el-input v-model='applyForm.password' type='password' show-password placeholder='请输入密码' />
|
||
</el-form-item>
|
||
<el-form-item label='确认密码' prop='password_confirmation'>
|
||
<el-input v-model='applyForm.password_confirmation' type='password' show-password placeholder='请再次输入密码' />
|
||
</el-form-item>
|
||
</el-form>
|
||
<template #footer>
|
||
<el-button @click='applyDialogVisible = false'>取消</el-button>
|
||
<el-button type='primary' :loading='applying' @click='submitApplyAccount'>提交申请</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang='ts'>
|
||
import type { FormInstance, FormRules } from 'element-plus'
|
||
import { ElMessage } from 'element-plus'
|
||
import { onMounted, reactive, ref } from 'vue'
|
||
import { useRouter } from 'vue-router'
|
||
import { authApi } from '@/api/auth'
|
||
import { getToken } from '@/composables/token'
|
||
import { removeToken, setToken } from '@/composables/token'
|
||
import { useAuthStore } from '@/stores/auth'
|
||
|
||
const router = useRouter()
|
||
const authStore = useAuthStore()
|
||
const formRef = ref<FormInstance>()
|
||
const submitting = ref(false)
|
||
const forcingPassword = ref(false)
|
||
const forcePasswordDialogVisible = ref(false)
|
||
const applyDialogVisible = ref(false)
|
||
const applying = ref(false)
|
||
const applyFormRef = ref<FormInstance>()
|
||
const forcePasswordForm = reactive({ current_password: '', password: '', password_confirmation: '' })
|
||
|
||
const form = reactive({ account: '', password: '' })
|
||
const applyForm = reactive({
|
||
nickname: '',
|
||
email: '',
|
||
phone: '',
|
||
password: '',
|
||
password_confirmation: '',
|
||
})
|
||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||
const phoneRegex = /^1\d{10}$/
|
||
const rules: FormRules = {
|
||
account: [{
|
||
required: true,
|
||
trigger: 'blur',
|
||
validator: (_rule, value, callback) => {
|
||
const account = String(value || '').trim()
|
||
if (!account) {
|
||
callback(new Error('请输入邮箱或手机号'))
|
||
return
|
||
}
|
||
if (!emailRegex.test(account) && !phoneRegex.test(account)) {
|
||
callback(new Error('请输入正确的邮箱或手机号'))
|
||
return
|
||
}
|
||
callback()
|
||
},
|
||
}],
|
||
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
|
||
}
|
||
const applyRules: FormRules = {
|
||
nickname: [{ required: true, message: '请输入昵称', trigger: 'blur' }],
|
||
email: [{ required: true, message: '请输入邮箱', trigger: 'blur' }],
|
||
phone: [{ required: true, message: '请输入手机号', trigger: 'blur' }],
|
||
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
|
||
password_confirmation: [{ required: true, message: '请再次输入密码', trigger: 'blur' }],
|
||
}
|
||
|
||
async function handleLogin(): Promise<void> {
|
||
const valid = await formRef.value?.validate().catch(() => false)
|
||
if (!valid) {
|
||
return
|
||
}
|
||
|
||
submitting.value = true
|
||
try {
|
||
const account = form.account.trim()
|
||
const isEmail = emailRegex.test(account)
|
||
const payload = {
|
||
email: isEmail ? account : undefined,
|
||
phone: isEmail ? undefined : account,
|
||
password: form.password,
|
||
}
|
||
const response = await authApi.login(payload)
|
||
setToken(response.data.token)
|
||
const me = await authApi.me()
|
||
authStore.setAuth(me.data.user, me.data.permissions)
|
||
if (Boolean((me.data.user as any)?.force_password_change)) {
|
||
forcePasswordForm.current_password = form.password
|
||
forcePasswordForm.password = ''
|
||
forcePasswordForm.password_confirmation = ''
|
||
forcePasswordDialogVisible.value = true
|
||
return
|
||
}
|
||
ElMessage.success('登录成功')
|
||
await router.replace('/')
|
||
} catch (error: any) {
|
||
const first = error?.errors ? (Object.values(error.errors)[0] as string[])?.[0] : null
|
||
ElMessage.error(first || error?.message || '登录失败')
|
||
} finally {
|
||
submitting.value = false
|
||
}
|
||
}
|
||
|
||
async function submitForcePasswordChange(): Promise<void> {
|
||
if (!forcePasswordForm.current_password || !forcePasswordForm.password || !forcePasswordForm.password_confirmation) {
|
||
ElMessage.warning('请完整填写密码信息')
|
||
return
|
||
}
|
||
|
||
forcingPassword.value = true
|
||
try {
|
||
await authApi.updatePassword(forcePasswordForm)
|
||
const me = await authApi.me()
|
||
authStore.setAuth(me.data.user, me.data.permissions)
|
||
forcePasswordDialogVisible.value = false
|
||
ElMessage.success('密码修改成功,欢迎使用')
|
||
await router.replace('/')
|
||
} catch (error: any) {
|
||
const first = error?.errors ? (Object.values(error.errors)[0] as string[])?.[0] : null
|
||
ElMessage.error(first || error?.message || '修改密码失败')
|
||
} finally {
|
||
forcingPassword.value = false
|
||
}
|
||
}
|
||
|
||
async function handleForceUserLogout(): Promise<void> {
|
||
try {
|
||
await authApi.logout()
|
||
} catch (_error) {
|
||
// ignore
|
||
}
|
||
removeToken()
|
||
authStore.clearAuth()
|
||
forcePasswordDialogVisible.value = false
|
||
form.password = ''
|
||
ElMessage.success('已退出当前账号')
|
||
}
|
||
|
||
async function submitApplyAccount(): Promise<void> {
|
||
const valid = await applyFormRef.value?.validate().catch(() => false)
|
||
if (!valid) {
|
||
return
|
||
}
|
||
if (applyForm.password !== applyForm.password_confirmation) {
|
||
ElMessage.warning('两次输入的密码不一致')
|
||
return
|
||
}
|
||
|
||
applying.value = true
|
||
try {
|
||
await authApi.applyAccount(applyForm)
|
||
ElMessage.success('申请提交成功,请联系或等待管理员添加权限')
|
||
applyDialogVisible.value = false
|
||
Object.assign(applyForm, {
|
||
nickname: '',
|
||
email: '',
|
||
phone: '',
|
||
password: '',
|
||
password_confirmation: '',
|
||
})
|
||
} catch (error: any) {
|
||
const first = error?.errors ? (Object.values(error.errors)[0] as string[])?.[0] : null
|
||
ElMessage.error(first || error?.message || '账号申请失败')
|
||
} finally {
|
||
applying.value = false
|
||
}
|
||
}
|
||
|
||
onMounted(async () => {
|
||
const token = getToken()
|
||
if (!token) {
|
||
return
|
||
}
|
||
|
||
try {
|
||
const me = await authApi.me()
|
||
authStore.setAuth(me.data.user, me.data.permissions)
|
||
if (Boolean((me.data.user as any)?.force_password_change)) {
|
||
forcePasswordForm.current_password = ''
|
||
forcePasswordForm.password = ''
|
||
forcePasswordForm.password_confirmation = ''
|
||
forcePasswordDialogVisible.value = true
|
||
return
|
||
} else {
|
||
await router.replace('/')
|
||
}
|
||
} catch (_error) {
|
||
// ignore: invalid token will be handled by route guard
|
||
}
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
.login-page {
|
||
min-height: 100vh;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
background: #f4f6f9;
|
||
position: relative;
|
||
overflow: hidden;
|
||
padding: 40px 18px;
|
||
}
|
||
.bg-geometry {
|
||
position: absolute;
|
||
inset: 0;
|
||
background:
|
||
linear-gradient(120deg, rgba(179, 186, 197, 0.2) 0 36%, transparent 36% 100%),
|
||
linear-gradient(330deg, rgba(180, 184, 210, 0.18) 0 28%, transparent 28% 100%);
|
||
pointer-events: none;
|
||
}
|
||
.login-stack {
|
||
width: min(560px, 100%);
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 16px;
|
||
position: relative;
|
||
z-index: 2;
|
||
}
|
||
.login-card {
|
||
border: 1px solid #e5e7eb;
|
||
border-radius: 10px;
|
||
box-shadow: 0 8px 22px rgba(15, 23, 42, 0.1);
|
||
}
|
||
.brand-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
margin-bottom: 14px;
|
||
}
|
||
.brand-chip {
|
||
width: 26px;
|
||
height: 26px;
|
||
border-radius: 6px;
|
||
background: #0f766e;
|
||
color: #fff;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 14px;
|
||
font-weight: 700;
|
||
}
|
||
.brand-name {
|
||
color: #475569;
|
||
font-size: 14px;
|
||
font-weight: 700;
|
||
letter-spacing: 0.02em;
|
||
}
|
||
.login-title {
|
||
margin: 0 0 10px;
|
||
font-size: 28px;
|
||
font-weight: 600;
|
||
color: #1f2937;
|
||
}
|
||
.login-subtitle {
|
||
margin: 0 0 22px;
|
||
color: #4b5563;
|
||
font-size: 14px;
|
||
}
|
||
.assist-links {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
margin-top: 2px;
|
||
}
|
||
.assist-links a {
|
||
color: #0067b8;
|
||
text-decoration: none;
|
||
font-size: 14px;
|
||
}
|
||
.assist-links a:hover { text-decoration: underline; }
|
||
.btn-row {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
margin-top: 22px;
|
||
}
|
||
.login-btn {
|
||
min-width: 110px;
|
||
height: 38px;
|
||
border-radius: 6px;
|
||
font-size: 14px;
|
||
}
|
||
:deep(.el-card__body) {
|
||
padding: 34px 38px 30px;
|
||
}
|
||
|
||
:deep(.el-form-item) {
|
||
margin-bottom: 16px;
|
||
}
|
||
:deep(.login-input .el-input__wrapper) {
|
||
box-shadow: none !important;
|
||
border-radius: 6px;
|
||
border-bottom: 1px solid #737373;
|
||
padding-left: 0;
|
||
padding-right: 0;
|
||
}
|
||
:deep(.login-input .el-input__wrapper.is-focus) {
|
||
border-bottom: 1px solid #0067b8;
|
||
}
|
||
:deep(.login-input .el-input__inner) {
|
||
height: 34px;
|
||
font-size: 14px;
|
||
}
|
||
:deep(.el-form-item__error) {
|
||
font-size: 12px;
|
||
}
|
||
|
||
@media (max-width: 640px) {
|
||
.login-page {
|
||
padding: 24px 14px;
|
||
}
|
||
:deep(.el-card__body) {
|
||
padding: 24px 22px;
|
||
}
|
||
.login-title { font-size: 24px; }
|
||
.login-subtitle { font-size: 13px; }
|
||
.assist-links a { font-size: 13px; }
|
||
.login-btn { font-size: 13px; }
|
||
}
|
||
</style>
|