Compare commits

...

2 Commits

Author SHA1 Message Date
liuyun
7738abbbfd chore: migrate lockfile to pnpm lockfileVersion 9
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-10 21:14:18 +08:00
liuyun
8d148937b1 feat: frontend auth foundation (API client, login page, router guard)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-10 21:13:55 +08:00
7 changed files with 364 additions and 1 deletions

2
.env
View File

@ -1,3 +1,5 @@
VITE_APP_NAME=demo_default VITE_APP_NAME=demo_default
VITE_NUMBER_DEMO=3000 VITE_NUMBER_DEMO=3000
VITE_BOOLEAN_DEMO=true VITE_BOOLEAN_DEMO=true
VITE_API_BASE_URL=http://localhost:3000/api/v1

158
pnpm-lock.yaml generated
View File

@ -1,3 +1,161 @@
---
lockfileVersion: '9.0'
importers:
.:
configDependencies: {}
packageManagerDependencies:
pnpm:
specifier: 12.10.1
version: 12.10.1
packages:
'@pnpm/exe.android-arm64@12.10.1':
resolution: {integrity: sha512-CVTlHPwNvadQCJD9bGSMzWIhFX8QpjtqC2c27dhdnn/9iKzEjZ3qL62o3gHpKbIuo6qn4WxTKRoaokgNdReBig==}
cpu: [arm64]
os: [android]
'@pnpm/exe.android-x64@12.10.1':
resolution: {integrity: sha512-NFiYLIaodY7da0XIebwcU3JoP7IK/lzF7zgKSSP+9H+QcFnQ1GDfBkuBKCYJzZ+Xu0WH54neIPLBBCaOjaaauA==}
cpu: [x64]
os: [android]
'@pnpm/exe.darwin-arm64@12.10.1':
resolution: {integrity: sha512-0O8tdQ8ijlPJudXspLMTcitMkdBEz6uUflazL5uQE0WMs85oQIp0U+5hfEktMFB64QqEhjqlvvujTwbzdr9oMg==}
cpu: [arm64]
os: [darwin]
'@pnpm/exe.darwin-x64@12.10.1':
resolution: {integrity: sha512-ud+1/uZOKEo8P/AsPynOJc/LNwfI0j6WwCElibj71rU9l4nIBnsSn+wWTIPZPkZ4BBSH5+82azSnAARMyJ1lNA==}
cpu: [x64]
os: [darwin]
'@pnpm/exe.freebsd-x64@12.10.1':
resolution: {integrity: sha512-mj9PE5rf8uAfQQ2K66HSATMSut+lNiB4eSqFd5+azFVCMnxNSy/32xpZQJT21PhnN3Y4qbK+iKHe5nWAxSuRhw==}
cpu: [x64]
os: [freebsd]
'@pnpm/exe.linux-arm64-musl@12.10.1':
resolution: {integrity: sha512-xba+00wZJ8Um6rBJJjWl88uJj0tydB8dtomvjlCLvi0uL32vpa9CRP8Vv26FIXvbiZ1bo2RCzYt/eChxJVV52w==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@pnpm/exe.linux-arm64@12.10.1':
resolution: {integrity: sha512-oBdMp69ScC9UnniG4qZAQVr2fBDSXjm4yk/72vkEVyrlwS23yMxcRQl7Qdo3sgXPAygY6TWnPRKm4rXzjoyW7w==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@pnpm/exe.linux-ppc64@12.10.1':
resolution: {integrity: sha512-Yyb3wu09h3c7mLme270uHFiVaAIUHC411Ru9LIU2BKUIstrGlXa9OqqVWoXaRgYRdFdOFknkwG7GNn6ygwLbnw==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@pnpm/exe.linux-riscv64@12.10.1':
resolution: {integrity: sha512-PdkeUFFkybknnGkWP0H22eMpIiS4OZXL5BcQzF4lh+vmrW3IaC2HOkbANKgot/PKO0Kl77pzA/6PKFxgbgfXIQ==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@pnpm/exe.linux-s390x@12.10.1':
resolution: {integrity: sha512-1okKuHKJwJPAbofDxfhZWAUxEWtee4ZSf7Pah8kcvocaJkf2WxFJ5KVTiNIvDml5GLidDQsgf7Pcf/WHbCak7Q==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@pnpm/exe.linux-x64-musl@12.10.1':
resolution: {integrity: sha512-e6IqzVNkPa7srFKJKnY23uWJ+qz0LaRWiTUo/sHfFXRRMRzB5uNOr6qWWip/jqhdGwU91n9J7LrEYZ6e0tRczw==}
cpu: [x64]
os: [linux]
libc: [musl]
'@pnpm/exe.linux-x64@12.10.1':
resolution: {integrity: sha512-ZByV0DRgWzGaDSjT5ddRBASdwz+r/qPMYh7o6RGmPQjtRGNFbSl94xY2DcAM8mqCOZJUUDtAXQ+wpBXcdpKlkg==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@pnpm/exe.win32-arm64@12.10.1':
resolution: {integrity: sha512-I7QZHcjioG7orFeCRUFUHByJ1G0zFPnAlaGGyrUvb6MGkv5oeNwNPl7t+Gp+7zHfnMcgLGJ1a+Wfim0IDw3lOw==}
cpu: [arm64]
os: [win32]
'@pnpm/exe.win32-x64@12.10.1':
resolution: {integrity: sha512-4WJFdp58EKlCj0zLobrXU0vpszSZIr4X/3y/uNbAQqGW0zE+VdruZB0gFc3L2kzsUzVwTYbaCn+XEhnCYSAlTg==}
cpu: [x64]
os: [win32]
pnpm@12.10.1:
resolution: {integrity: sha512-ukCjfrGjcNYP6oxM8dNkwTvMzvUY6YrSvO6VT1OdONo3RY5YJho/pV4GUjRQ6g//+fSXsTW+QfLBXBZCusrObA==}
engines: {node: '>=18.*'}
hasBin: true
snapshots:
'@pnpm/exe.android-arm64@12.10.1':
optional: true
'@pnpm/exe.android-x64@12.10.1':
optional: true
'@pnpm/exe.darwin-arm64@12.10.1':
optional: true
'@pnpm/exe.darwin-x64@12.10.1':
optional: true
'@pnpm/exe.freebsd-x64@12.10.1':
optional: true
'@pnpm/exe.linux-arm64-musl@12.10.1':
optional: true
'@pnpm/exe.linux-arm64@12.10.1':
optional: true
'@pnpm/exe.linux-ppc64@12.10.1':
optional: true
'@pnpm/exe.linux-riscv64@12.10.1':
optional: true
'@pnpm/exe.linux-s390x@12.10.1':
optional: true
'@pnpm/exe.linux-x64-musl@12.10.1':
optional: true
'@pnpm/exe.linux-x64@12.10.1':
optional: true
'@pnpm/exe.win32-arm64@12.10.1':
optional: true
'@pnpm/exe.win32-x64@12.10.1':
optional: true
pnpm@12.10.1:
optionalDependencies:
'@pnpm/exe.android-arm64': 12.10.1
'@pnpm/exe.android-x64': 12.10.1
'@pnpm/exe.darwin-arm64': 12.10.1
'@pnpm/exe.darwin-x64': 12.10.1
'@pnpm/exe.freebsd-x64': 12.10.1
'@pnpm/exe.linux-arm64': 12.10.1
'@pnpm/exe.linux-arm64-musl': 12.10.1
'@pnpm/exe.linux-ppc64': 12.10.1
'@pnpm/exe.linux-riscv64': 12.10.1
'@pnpm/exe.linux-s390x': 12.10.1
'@pnpm/exe.linux-x64': 12.10.1
'@pnpm/exe.linux-x64-musl': 12.10.1
'@pnpm/exe.win32-arm64': 12.10.1
'@pnpm/exe.win32-x64': 12.10.1
---
lockfileVersion: '9.0' lockfileVersion: '9.0'
settings: settings:

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 { routes } from 'vue-router/auto-routes'
import { setupLayouts } from 'virtual:generated-layouts' import { setupLayouts } from 'virtual:generated-layouts'
import i18n, { loadLanguage } from '@/i18n' import i18n, { loadLanguage } from '@/i18n'
import { getAccessToken } from '@/utils/token'
const router = createRouter({ const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL), history: createWebHistory(import.meta.env.BASE_URL),
@ -41,7 +42,13 @@ async function loadRouteLocales(to: RouteLocationNormalized) {
// 路由前置守卫 // 路由前置守卫
router.beforeEach(async (to, from, next) => { router.beforeEach(async (to, from, next) => {
await loadRouteLocales(to) 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);
}