接入前端登录权限底座

This commit is contained in:
andy
2026-07-09 20:42:16 +08:00
parent c190d5e3a8
commit e49b2ccc1d
27 changed files with 1745 additions and 68 deletions

View 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>

View 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>