接入前端登录权限底座
This commit is contained in:
240
client/src/views/auth/LoginView.vue
Normal file
240
client/src/views/auth/LoginView.vue
Normal file
@@ -0,0 +1,240 @@
|
||||
<template>
|
||||
<main class="login-page">
|
||||
<section class="login-panel">
|
||||
<div class="login-brand">
|
||||
<span class="login-brand__mark" />
|
||||
<div>
|
||||
<strong>{{ t('app.name') }}</strong>
|
||||
<small>OMS</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<header>
|
||||
<h1>{{ t('auth.loginTitle') }}</h1>
|
||||
<p>{{ t('auth.loginSubtitle') }}</p>
|
||||
</header>
|
||||
|
||||
<form
|
||||
class="login-form"
|
||||
@submit.prevent="submitLogin"
|
||||
>
|
||||
<label>
|
||||
<span>{{ t('auth.username') }}</span>
|
||||
<input
|
||||
v-model.trim="username"
|
||||
name="username"
|
||||
type="text"
|
||||
autocomplete="username"
|
||||
:disabled="submitting"
|
||||
>
|
||||
</label>
|
||||
<label>
|
||||
<span>{{ t('auth.password') }}</span>
|
||||
<input
|
||||
v-model="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
:disabled="submitting"
|
||||
>
|
||||
</label>
|
||||
|
||||
<p
|
||||
v-if="errorMessage"
|
||||
class="login-error"
|
||||
>
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="login-submit"
|
||||
:disabled="submitting || !username || !password"
|
||||
>
|
||||
<i
|
||||
class="pi pi-sign-in"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{{ submitting ? t('auth.loggingIn') : t('auth.loginSubmit') }}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import { ApiError } from '@/services/httpClient'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
|
||||
const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const username = ref('')
|
||||
const password = ref('')
|
||||
const submitting = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const preferredHotelId = computed(() => authStore.selectedHotelId ?? undefined)
|
||||
|
||||
async function submitLogin(): Promise<void> {
|
||||
errorMessage.value = ''
|
||||
submitting.value = true
|
||||
try {
|
||||
await authStore.login({
|
||||
username: username.value,
|
||||
password: password.value,
|
||||
preferred_hotel_id: preferredHotelId.value,
|
||||
})
|
||||
await router.push(resolveRedirectPath())
|
||||
} catch (error) {
|
||||
errorMessage.value = loginErrorMessage(error)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function resolveRedirectPath(): string {
|
||||
const redirect = route.query.redirect
|
||||
if (typeof redirect === 'string' && redirect.startsWith('/') && !redirect.startsWith('//')) {
|
||||
return redirect === '/login' ? authStore.firstMenuPath ?? '/reservation/orders' : redirect
|
||||
}
|
||||
return authStore.firstMenuPath ?? '/reservation/orders'
|
||||
}
|
||||
|
||||
function loginErrorMessage(error: unknown): string {
|
||||
if (error instanceof ApiError && isErrorRecord(error.details) && typeof error.details.message === 'string') {
|
||||
return error.details.message
|
||||
}
|
||||
return t('auth.loginFailed')
|
||||
}
|
||||
|
||||
function isErrorRecord(value: unknown): value is { message?: unknown } {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-page {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background:
|
||||
linear-gradient(135deg, rgb(9 31 68 / 84%), rgb(6 19 44 / 92%)),
|
||||
var(--th-color-navy-950);
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.login-panel {
|
||||
width: min(100%, 420px);
|
||||
display: grid;
|
||||
gap: 24px;
|
||||
border: 1px solid rgb(255 255 255 / 16%);
|
||||
border-radius: var(--th-radius-md);
|
||||
background: rgb(255 255 255 / 96%);
|
||||
box-shadow: var(--th-shadow-lg);
|
||||
padding: 28px;
|
||||
}
|
||||
|
||||
.login-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
color: var(--th-color-slate-900);
|
||||
}
|
||||
|
||||
.login-brand__mark {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(135deg, #0a7cff, #0057e5);
|
||||
}
|
||||
|
||||
.login-brand strong,
|
||||
.login-brand small {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.login-brand small {
|
||||
margin-top: 2px;
|
||||
color: var(--th-color-slate-500);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.login-panel h1,
|
||||
.login-panel p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.login-panel h1 {
|
||||
color: var(--th-color-slate-900);
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.login-panel header p {
|
||||
margin-top: 8px;
|
||||
color: var(--th-color-slate-500);
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.login-form label {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
color: var(--th-color-slate-700);
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.login-form input {
|
||||
min-height: 42px;
|
||||
border: 1px solid var(--th-color-slate-200);
|
||||
border-radius: var(--th-radius-sm);
|
||||
background: var(--th-color-white);
|
||||
color: var(--th-color-slate-900);
|
||||
font: inherit;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.login-form input:focus {
|
||||
border-color: var(--th-color-blue-600);
|
||||
box-shadow: 0 0 0 3px var(--th-color-blue-100);
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
.login-error {
|
||||
border-radius: var(--th-radius-sm);
|
||||
background: var(--th-color-danger-bg);
|
||||
color: var(--th-color-danger);
|
||||
font-size: 13px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.login-submit {
|
||||
display: inline-flex;
|
||||
min-height: 42px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
border: 1px solid var(--th-color-blue-600);
|
||||
border-radius: var(--th-radius-sm);
|
||||
background: var(--th-color-blue-600);
|
||||
color: var(--th-color-white);
|
||||
cursor: pointer;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.login-submit:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
</style>
|
||||
74
client/src/views/auth/UnauthorizedView.vue
Normal file
74
client/src/views/auth/UnauthorizedView.vue
Normal file
@@ -0,0 +1,74 @@
|
||||
<template>
|
||||
<section class="th-page unauthorized-view">
|
||||
<div class="th-section unauthorized-card">
|
||||
<i
|
||||
class="pi pi-lock"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<h1>{{ t('auth.unauthorizedTitle') }}</h1>
|
||||
<p>{{ t('auth.unauthorizedMessage') }}</p>
|
||||
<RouterLink
|
||||
class="primary-link"
|
||||
:to="authStore.firstMenuPath ?? '/reservation/orders'"
|
||||
>
|
||||
{{ t('auth.backHome') }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
|
||||
const { t } = useI18n()
|
||||
const authStore = useAuthStore()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.unauthorized-view {
|
||||
max-width: 760px;
|
||||
}
|
||||
|
||||
.unauthorized-card {
|
||||
min-height: 260px;
|
||||
display: grid;
|
||||
justify-items: start;
|
||||
align-content: center;
|
||||
gap: 12px;
|
||||
padding: 28px;
|
||||
}
|
||||
|
||||
.unauthorized-card i {
|
||||
color: var(--th-color-blue-600);
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.unauthorized-card h1,
|
||||
.unauthorized-card p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.unauthorized-card h1 {
|
||||
color: var(--th-color-slate-900);
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.unauthorized-card p {
|
||||
color: var(--th-color-slate-500);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.primary-link {
|
||||
display: inline-flex;
|
||||
min-height: 36px;
|
||||
align-items: center;
|
||||
border-radius: var(--th-radius-sm);
|
||||
background: var(--th-color-blue-600);
|
||||
color: var(--th-color-white);
|
||||
font-weight: 800;
|
||||
padding: 0 14px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user