diff --git a/.env b/.env index a09625a..12a4e78 100644 --- a/.env +++ b/.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 diff --git a/src/api/client.ts b/src/api/client.ts new file mode 100644 index 0000000..9e736ba --- /dev/null +++ b/src/api/client.ts @@ -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 { + code: number; + message: string; + data: T; +} + +export class ApiError extends Error { + constructor( + public code: number, + message: string, + ) { + super(message); + } +} + +async function rawRequest(path: string, options: RequestInit = {}, token?: string): Promise> { + const headers: Record = { + 'Content-Type': 'application/json', + ...(options.headers as Record | undefined), + }; + if (token) headers.Authorization = `Bearer ${token}`; + + const res = await fetch(`${BASE_URL}${path}`, { ...options, headers }); + return (await res.json()) as ApiEnvelope; +} + +let refreshing: Promise | null = null; + +async function tryRefresh(): Promise { + 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(path: string, options: RequestInit = {}): Promise { + const attempt = async (retry: boolean): Promise => { + const body = await rawRequest(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: (path: string) => request(path, { method: 'GET' }), + post: (path: string, data?: unknown) => request(path, { method: 'POST', body: JSON.stringify(data ?? {}) }), + patch: (path: string, data?: unknown) => request(path, { method: 'PATCH', body: JSON.stringify(data ?? {}) }), + delete: (path: string) => request(path, { method: 'DELETE' }), +}; diff --git a/src/pages/login.vue b/src/pages/login.vue new file mode 100644 index 0000000..c4b05a9 --- /dev/null +++ b/src/pages/login.vue @@ -0,0 +1,61 @@ + +meta: + layout: false + requiresAuth: false + + + + + + + diff --git a/src/router/index.ts b/src/router/index.ts index e92b860..40b2030 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -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() + } }) /** diff --git a/src/stores/modules/auth.ts b/src/stores/modules/auth.ts new file mode 100644 index 0000000..84a3c19 --- /dev/null +++ b/src/stores/modules/auth.ts @@ -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(getAccessToken()); + const refreshToken = ref(getRefreshToken()); + + const isAuthenticated = computed(() => !!accessToken.value); + + async function login(tenantCode: string, username: string, password: string): Promise { + 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 { + 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 }; +}); diff --git a/src/utils/token.ts b/src/utils/token.ts new file mode 100644 index 0000000..b206547 --- /dev/null +++ b/src/utils/token.ts @@ -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); +}