bastion_sso/src/pages/LoginPage.vue

386 lines
12 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 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>