前端接入登录鉴权与会话状态

This commit is contained in:
2026-09-20 02:38:24 +08:00
parent 2858d3c0f7
commit 2d7f4a63fe
11 files changed
+324 -59

No files matched your search

+49
View File
@@ -0,0 +1,49 @@
import { request } from './http'
export interface AuthUserGroup {
id: number
name: string
description: string
is_system: boolean
}
export interface AuthUser {
id: number
username: string
email: string
nickname: string
avatar: string
status: number
groups: AuthUserGroup[]
}
export interface LoginPayload {
account: string
password: string
}
export interface LoginResponse {
token: string
expires_at: string
user: AuthUser
}
export interface RegisterPayload {
username: string
email: string
password: string
}
export function login(payload: LoginPayload): Promise<LoginResponse> {
return request<LoginResponse>('/auth/login', {
method: 'POST',
body: JSON.stringify(payload),
})
}
export function register(payload: RegisterPayload): Promise<AuthUser> {
return request<AuthUser>('/auth/register', {
method: 'POST',
body: JSON.stringify(payload),
})
}
+25 -4
View File
@@ -8,17 +8,38 @@ export class ApiError extends Error {
}
}
let authToken: string | null = null
let unauthorizedHandler: (() => void) | null = null
export function setAuthToken(token: string | null) {
authToken = token
}
export function setUnauthorizedHandler(handler: (() => void) | null) {
unauthorizedHandler = handler
}
export async function request<T>(path: string, init?: RequestInit): Promise<T> {
const headers = new Headers(init?.headers)
if (init?.body && !headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json')
}
if (authToken) {
headers.set('Authorization', `Bearer ${authToken}`)
}
let response: Response
try {
response = await fetch(`/api${path}`, {
headers: { 'Content-Type': 'application/json' },
...init,
})
response = await fetch(`/api${path}`, { ...init, headers })
} catch {
throw new ApiError(0, 'network error')
}
const isAuthPath = path.startsWith('/auth/')
if (response.status === 401 && !isAuthPath) {
unauthorizedHandler?.()
}
if (!response.ok) {
let message = response.statusText
try {
-23
View File
@@ -1,23 +0,0 @@
import { request } from './http'
export interface RegisterPayload {
username: string
email: string
password: string
}
export interface User {
id: number
username: string
email: string
nickname: string
avatar: string
status: number
}
export function registerUser(payload: RegisterPayload): Promise<User> {
return request<User>('/auth/register', {
method: 'POST',
body: JSON.stringify(payload),
})
}
+72 -15
View File
@@ -1,11 +1,34 @@
<script setup lang="ts">
import { ref } from 'vue'
import { onMounted, onUnmounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { RouterLink } from 'vue-router'
import { RouterLink, useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const keyword = ref('')
const menuOpen = ref(false)
const menuRef = ref<HTMLElement | null>(null)
function onDocumentClick(event: MouseEvent) {
if (menuRef.value && !menuRef.value.contains(event.target as Node)) {
menuOpen.value = false
}
}
onMounted(() => document.addEventListener('click', onDocumentClick))
onUnmounted(() => document.removeEventListener('click', onDocumentClick))
function logout() {
auth.logout()
menuOpen.value = false
if (route.name !== 'home') {
router.push({ name: 'home' })
}
}
</script>
<template>
@@ -49,21 +72,55 @@ const keyword = ref('')
</button>
</div>
<RouterLink
:to="{ name: 'register' }"
class="flex h-9 items-center rounded-full border border-primary px-4 text-sm text-primary transition-colors hover:bg-primary/10"
>
{{ t('header.register') }}
</RouterLink>
<template v-if="auth.isAuthenticated">
<div ref="menuRef" class="relative">
<button
type="button"
:aria-label="t('header.userMenu')"
:aria-expanded="menuOpen"
class="flex items-center gap-2 rounded-full py-1 pl-1 pr-3 text-sm transition-colors hover:bg-page"
@click="menuOpen = !menuOpen"
>
<img
v-if="auth.user?.avatar"
:src="auth.user.avatar"
alt=""
class="h-8 w-8 rounded-full object-cover"
/>
<span v-else class="h-8 w-8 shrink-0 rounded-full bg-line" />
<span class="max-w-28 truncate text-content-1">{{ auth.displayName }}</span>
</button>
<RouterLink
:to="{ name: 'login' }"
class="flex h-9 items-center rounded-full bg-primary px-4 text-sm font-medium text-on-primary transition-colors hover:bg-primary-hover"
>
{{ t('header.login') }}
</RouterLink>
<div
v-if="menuOpen"
class="absolute right-0 top-full mt-2 w-36 overflow-hidden rounded-xl border border-line bg-surface py-1 shadow-lg"
>
<button
type="button"
class="block w-full px-4 py-2 text-left text-sm text-content-2 transition-colors hover:bg-page hover:text-primary"
@click="logout"
>
{{ t('header.logout') }}
</button>
</div>
</div>
</template>
<div class="h-9 w-9 shrink-0 rounded-full bg-line" :aria-label="t('header.avatar')" />
<template v-else>
<RouterLink
:to="{ name: 'register' }"
class="flex h-9 items-center rounded-full border border-primary px-4 text-sm text-primary transition-colors hover:bg-primary/10"
>
{{ t('header.register') }}
</RouterLink>
<RouterLink
:to="{ name: 'login' }"
class="flex h-9 items-center rounded-full bg-primary px-4 text-sm font-medium text-on-primary transition-colors hover:bg-primary-hover"
>
{{ t('header.login') }}
</RouterLink>
</template>
</div>
</div>
</header>
+5 -1
View File
@@ -9,7 +9,8 @@ const enUS: MessageSchema = {
search: 'Search',
register: 'Sign up',
login: 'Log in',
avatar: 'Avatar placeholder',
userMenu: 'User menu',
logout: 'Log out',
},
home: {
recommend: 'Recommended',
@@ -87,6 +88,9 @@ const enUS: MessageSchema = {
invalid: 'The submitted information is invalid, please check and retry',
duplicate: 'Username or email already exists',
network: 'Network error, please try again later',
invalidCredentials: 'Incorrect account or password',
accountDisabled: 'Account disabled',
sessionExpired: 'Session expired, please log in again',
},
},
}
+5 -1
View File
@@ -9,7 +9,8 @@ const jaJP: MessageSchema = {
search: '検索',
register: '新規登録',
login: 'ログイン',
avatar: 'アバターのプレースホルダー',
userMenu: 'ユーザーメニュー',
logout: 'ログアウト',
},
home: {
recommend: 'おすすめ',
@@ -87,6 +88,9 @@ const jaJP: MessageSchema = {
invalid: '入力内容が無効です。確認してもう一度お試しください',
duplicate: 'ユーザー名またはメールアドレスは既に存在します',
network: 'ネットワークエラーが発生しました。後でもう一度お試しください',
invalidCredentials: 'アカウントまたはパスワードが正しくありません',
accountDisabled: 'アカウントが無効化されています',
sessionExpired: 'ログインの有効期限が切れました。もう一度ログインしてください',
},
},
}
+5 -1
View File
@@ -7,7 +7,8 @@ const zhCN = {
search: '搜索',
register: '注册',
login: '登录',
avatar: '头像占位',
userMenu: '用户菜单',
logout: '退出登录',
},
home: {
recommend: '推荐',
@@ -85,6 +86,9 @@ const zhCN = {
invalid: '提交的信息无效,请检查后重试',
duplicate: '用户名或邮箱已存在',
network: '网络异常,请稍后重试',
invalidCredentials: '账号或密码错误',
accountDisabled: '账号已被禁用',
sessionExpired: '登录已过期,请重新登录',
},
},
}
+16 -1
View File
@@ -6,11 +6,26 @@ import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import { i18n } from './i18n'
import { setUnauthorizedHandler } from './api/http'
import { useAuthStore } from './stores/auth'
const app = createApp(App)
const pinia = createPinia()
app.use(createPinia())
app.use(pinia)
app.use(router)
app.use(i18n)
const auth = useAuthStore()
auth.restore()
setUnauthorizedHandler(() => {
const wasAuthenticated = auth.isAuthenticated
auth.logout()
const current = router.currentRoute.value
if (wasAuthenticated && current.name !== 'login') {
router.push({ name: 'login', query: { redirect: current.fullPath, expired: '1' } })
}
})
app.mount('#app')
+102
View File
@@ -0,0 +1,102 @@
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'
import {
login as loginRequest,
register as registerRequest,
type AuthUser,
type LoginResponse,
type RegisterPayload,
} from '@/api/auth'
import { setAuthToken } from '@/api/http'
const TOKEN_KEY = 'rill-token'
const USER_KEY = 'rill-user'
const EXPIRES_KEY = 'rill-token-expires'
function readStoredUser(): AuthUser | null {
const raw = localStorage.getItem(USER_KEY)
if (!raw) {
return null
}
try {
return JSON.parse(raw) as AuthUser
} catch {
return null
}
}
export const useAuthStore = defineStore('auth', () => {
const token = ref<string | null>(localStorage.getItem(TOKEN_KEY))
const user = ref<AuthUser | null>(readStoredUser())
const expiresAt = ref<string | null>(localStorage.getItem(EXPIRES_KEY))
const isExpired = computed(() => {
if (!expiresAt.value) {
return true
}
const time = Date.parse(expiresAt.value)
return Number.isNaN(time) || time <= Date.now()
})
const isAuthenticated = computed(() => Boolean(token.value && user.value) && !isExpired.value)
const displayName = computed(() => user.value?.nickname || user.value?.username || '')
const isAdmin = computed(() =>
Boolean(user.value?.groups?.some((group) => group.name === 'admin')),
)
function clear() {
token.value = null
user.value = null
expiresAt.value = null
localStorage.removeItem(TOKEN_KEY)
localStorage.removeItem(USER_KEY)
localStorage.removeItem(EXPIRES_KEY)
setAuthToken(null)
}
function applySession(data: LoginResponse) {
token.value = data.token
user.value = data.user
expiresAt.value = data.expires_at
localStorage.setItem(TOKEN_KEY, data.token)
localStorage.setItem(USER_KEY, JSON.stringify(data.user))
localStorage.setItem(EXPIRES_KEY, data.expires_at)
setAuthToken(data.token)
}
async function login(account: string, password: string): Promise<AuthUser> {
const data = await loginRequest({ account, password })
applySession(data)
return data.user
}
async function register(payload: RegisterPayload): Promise<AuthUser> {
return registerRequest(payload)
}
function logout() {
clear()
}
function restore(): boolean {
if (token.value && user.value && !isExpired.value) {
setAuthToken(token.value)
return true
}
clear()
return false
}
return {
token,
user,
expiresAt,
isAuthenticated,
displayName,
isAdmin,
login,
register,
logout,
restore,
}
})
+42 -11
View File
@@ -1,11 +1,13 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import { z } from 'zod'
import { ApiError } from '@/api/http'
import AuthShell from '@/components/auth/AuthShell.vue'
import { useAuthStore } from '@/stores/auth'
interface LoginForm {
account: string
@@ -14,6 +16,8 @@ interface LoginForm {
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const validationSchema = computed(() =>
toTypedSchema(
@@ -24,7 +28,7 @@ const validationSchema = computed(() =>
),
)
const { defineField, handleSubmit, errors } = useForm<LoginForm>({
const { defineField, handleSubmit, errors, isSubmitting } = useForm<LoginForm>({
validationSchema,
initialValues: {
account: '',
@@ -35,11 +39,27 @@ const { defineField, handleSubmit, errors } = useForm<LoginForm>({
const [account, accountProps] = defineField('account')
const [password, passwordProps] = defineField('password')
const registered = computed(() => route.query.registered === '1')
const pendingNotice = ref(false)
const sessionExpired = computed(() => route.query.expired === '1')
const justRegistered = computed(() => route.query.registered === '1')
const submitError = ref('')
const onSubmit = handleSubmit(() => {
pendingNotice.value = true
const onSubmit = handleSubmit(async (values) => {
submitError.value = ''
try {
await auth.login(values.account, values.password)
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : null
await router.push(redirect ?? { name: 'home' })
} catch (error) {
if (!(error instanceof ApiError) || error.status === 0) {
submitError.value = t('auth.errors.network')
} else if (error.status === 401) {
submitError.value = t('auth.errors.invalidCredentials')
} else if (error.status === 403) {
submitError.value = t('auth.errors.accountDisabled')
} else {
submitError.value = t('auth.errors.network')
}
}
})
</script>
@@ -49,7 +69,14 @@ const onSubmit = handleSubmit(() => {
<p class="mt-1 text-sm text-content-3">{{ t('auth.loginSubtitle') }}</p>
<p
v-if="registered"
v-if="sessionExpired"
class="mt-4 rounded-lg bg-primary/10 px-3 py-2 text-xs text-primary"
>
{{ t('auth.errors.sessionExpired') }}
</p>
<p
v-else-if="justRegistered"
class="mt-4 rounded-lg bg-primary/10 px-3 py-2 text-xs text-primary"
>
{{ t('auth.registerSuccess') }}
@@ -94,15 +121,19 @@ const onSubmit = handleSubmit(() => {
</p>
</div>
<p v-if="pendingNotice" class="rounded-lg bg-primary/10 px-3 py-2 text-xs text-primary">
{{ t('auth.loginPending') }}
<p
v-if="submitError"
class="rounded-lg bg-red-500/10 px-3 py-2 text-xs text-red-500 dark:text-red-400"
>
{{ submitError }}
</p>
<button
type="submit"
class="h-10 w-full rounded-full bg-primary text-sm font-medium text-on-primary transition-colors hover:bg-primary-hover"
:disabled="isSubmitting"
class="h-10 w-full rounded-full bg-primary text-sm font-medium text-on-primary transition-colors hover:bg-primary-hover disabled:cursor-not-allowed disabled:opacity-60"
>
{{ t('auth.login') }}
{{ isSubmitting ? t('common.submitting') : t('auth.login') }}
</button>
</form>
+3 -2
View File
@@ -6,8 +6,8 @@ import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import { z } from 'zod'
import { ApiError } from '@/api/http'
import { registerUser } from '@/api/users'
import AuthShell from '@/components/auth/AuthShell.vue'
import { useAuthStore } from '@/stores/auth'
interface RegisterForm {
username: string
@@ -18,6 +18,7 @@ interface RegisterForm {
const { t } = useI18n()
const router = useRouter()
const auth = useAuthStore()
const validationSchema = computed(() =>
toTypedSchema(
@@ -67,7 +68,7 @@ const submitError = ref('')
const onSubmit = handleSubmit(async (values) => {
submitError.value = ''
try {
await registerUser({
await auth.register({
username: values.username,
email: values.email,
password: values.password,