feat: frontend auth foundation (API client, login page, router guard)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
9730b9fd5e
commit
8d148937b1
2
.env
2
.env
@ -1,3 +1,5 @@
|
||||
VITE_APP_NAME=demo_default
|
||||
VITE_NUMBER_DEMO=3000
|
||||
VITE_BOOLEAN_DEMO=true
|
||||
|
||||
VITE_API_BASE_URL=http://localhost:3000/api/v1
|
||||
|
||||
77
src/api/client.ts
Normal file
77
src/api/client.ts
Normal file
@ -0,0 +1,77 @@
|
||||
import { clearTokens, getAccessToken, getRefreshToken, setTokens } from '@/utils/token';
|
||||
|
||||
const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:3000/api/v1';
|
||||
|
||||
interface ApiEnvelope<T = unknown> {
|
||||
code: number;
|
||||
message: string;
|
||||
data: T;
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
public code: number,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
async function rawRequest<T>(path: string, options: RequestInit = {}, token?: string): Promise<ApiEnvelope<T>> {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...(options.headers as Record<string, string> | undefined),
|
||||
};
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
|
||||
const res = await fetch(`${BASE_URL}${path}`, { ...options, headers });
|
||||
return (await res.json()) as ApiEnvelope<T>;
|
||||
}
|
||||
|
||||
let refreshing: Promise<boolean> | null = null;
|
||||
|
||||
async function tryRefresh(): Promise<boolean> {
|
||||
const refreshToken = getRefreshToken();
|
||||
if (!refreshToken) return false;
|
||||
try {
|
||||
const body = await rawRequest<{ accessToken: string; refreshToken: string }>('/auth/refresh', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ refreshToken }),
|
||||
});
|
||||
if (body.code === 0) {
|
||||
setTokens(body.data.accessToken, body.data.refreshToken);
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// 刷新失败,走清空逻辑
|
||||
}
|
||||
clearTokens();
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 统一请求:解析 {code,message,data},成功返回 data;401 自动刷新重试一次。 */
|
||||
export async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
const attempt = async (retry: boolean): Promise<T> => {
|
||||
const body = await rawRequest<T>(path, options, getAccessToken() ?? undefined);
|
||||
if (body.code === 0) return body.data;
|
||||
|
||||
if (body.code >= 40100 && body.code < 40200 && retry) {
|
||||
if (refreshing === null) {
|
||||
refreshing = tryRefresh().finally(() => {
|
||||
refreshing = null;
|
||||
});
|
||||
}
|
||||
const ok = await refreshing;
|
||||
if (ok) return attempt(false);
|
||||
}
|
||||
throw new ApiError(body.code, body.message);
|
||||
};
|
||||
return attempt(true);
|
||||
}
|
||||
|
||||
export const http = {
|
||||
get: <T>(path: string) => request<T>(path, { method: 'GET' }),
|
||||
post: <T>(path: string, data?: unknown) => request<T>(path, { method: 'POST', body: JSON.stringify(data ?? {}) }),
|
||||
patch: <T>(path: string, data?: unknown) => request<T>(path, { method: 'PATCH', body: JSON.stringify(data ?? {}) }),
|
||||
delete: <T>(path: string) => request<T>(path, { method: 'DELETE' }),
|
||||
};
|
||||
61
src/pages/login.vue
Normal file
61
src/pages/login.vue
Normal file
@ -0,0 +1,61 @@
|
||||
<route lang="yaml">
|
||||
meta:
|
||||
layout: false
|
||||
requiresAuth: false
|
||||
</route>
|
||||
|
||||
<template>
|
||||
<div class="login">
|
||||
<form @submit.prevent="onSubmit">
|
||||
<h1>登录</h1>
|
||||
<input v-model="tenantCode" placeholder="租户编码" />
|
||||
<input v-model="username" placeholder="用户名" />
|
||||
<input v-model="password" type="password" placeholder="密码" />
|
||||
<button type="submit">登录</button>
|
||||
<p v-if="error">{{ error }}</p>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useAuthStore } from '@/stores/modules/auth';
|
||||
|
||||
const router = useRouter();
|
||||
const auth = useAuthStore();
|
||||
|
||||
const tenantCode = ref('');
|
||||
const username = ref('');
|
||||
const password = ref('');
|
||||
const error = ref('');
|
||||
|
||||
async function onSubmit(): Promise<void> {
|
||||
error.value = '';
|
||||
try {
|
||||
await auth.login(tenantCode.value, username.value, password.value);
|
||||
router.push('/');
|
||||
} catch (e) {
|
||||
error.value = (e as Error).message;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.login {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
}
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
width: 280px;
|
||||
}
|
||||
input,
|
||||
button {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
@ -8,6 +8,7 @@ import type { App } from 'vue'
|
||||
import { routes } from 'vue-router/auto-routes'
|
||||
import { setupLayouts } from 'virtual:generated-layouts'
|
||||
import i18n, { loadLanguage } from '@/i18n'
|
||||
import { getAccessToken } from '@/utils/token'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
@ -41,7 +42,13 @@ async function loadRouteLocales(to: RouteLocationNormalized) {
|
||||
// 路由前置守卫
|
||||
router.beforeEach(async (to, from, next) => {
|
||||
await loadRouteLocales(to)
|
||||
next()
|
||||
// 未标记 requiresAuth: false 的路由默认需要登录
|
||||
const requiresAuth = to.meta.requiresAuth !== false
|
||||
if (requiresAuth && !getAccessToken()) {
|
||||
next('/login')
|
||||
} else {
|
||||
next()
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
|
||||
38
src/stores/modules/auth.ts
Normal file
38
src/stores/modules/auth.ts
Normal file
@ -0,0 +1,38 @@
|
||||
import { computed, ref } from 'vue';
|
||||
import { defineStore } from 'pinia';
|
||||
import { http } from '@/api/client';
|
||||
import { clearTokens, getAccessToken, getRefreshToken, setTokens } from '@/utils/token';
|
||||
|
||||
export const useAuthStore = defineStore('authStore', () => {
|
||||
const accessToken = ref<string | null>(getAccessToken());
|
||||
const refreshToken = ref<string | null>(getRefreshToken());
|
||||
|
||||
const isAuthenticated = computed(() => !!accessToken.value);
|
||||
|
||||
async function login(tenantCode: string, username: string, password: string): Promise<void> {
|
||||
const data = await http.post<{ accessToken: string; refreshToken: string }>('/auth/login', {
|
||||
tenantCode,
|
||||
username,
|
||||
password,
|
||||
});
|
||||
setTokens(data.accessToken, data.refreshToken);
|
||||
accessToken.value = data.accessToken;
|
||||
refreshToken.value = data.refreshToken;
|
||||
}
|
||||
|
||||
async function logout(): Promise<void> {
|
||||
const rt = refreshToken.value;
|
||||
if (rt) {
|
||||
try {
|
||||
await http.post('/auth/logout', { refreshToken: rt });
|
||||
} catch {
|
||||
// 忽略登出接口错误
|
||||
}
|
||||
}
|
||||
clearTokens();
|
||||
accessToken.value = null;
|
||||
refreshToken.value = null;
|
||||
}
|
||||
|
||||
return { accessToken, refreshToken, isAuthenticated, login, logout };
|
||||
});
|
||||
20
src/utils/token.ts
Normal file
20
src/utils/token.ts
Normal file
@ -0,0 +1,20 @@
|
||||
const ACCESS_KEY = 'sysmgr_access_token';
|
||||
const REFRESH_KEY = 'sysmgr_refresh_token';
|
||||
|
||||
export function getAccessToken(): string | null {
|
||||
return localStorage.getItem(ACCESS_KEY);
|
||||
}
|
||||
|
||||
export function getRefreshToken(): string | null {
|
||||
return localStorage.getItem(REFRESH_KEY);
|
||||
}
|
||||
|
||||
export function setTokens(access: string, refresh: string): void {
|
||||
localStorage.setItem(ACCESS_KEY, access);
|
||||
localStorage.setItem(REFRESH_KEY, refresh);
|
||||
}
|
||||
|
||||
export function clearTokens(): void {
|
||||
localStorage.removeItem(ACCESS_KEY);
|
||||
localStorage.removeItem(REFRESH_KEY);
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user