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:
liuyun 2026-10-10 21:13:55 +08:00
parent 9730b9fd5e
commit 8d148937b1
6 changed files with 206 additions and 1 deletions

2
.env
View File

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

View File

@ -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()
}
})
/**

View 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
View 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);
}