阶段 10:前端登录、权限与布局
合辑 :FastAPI × Vue3 JoyFit 系统阶段开发计划 | 端 :前端 | 阶段 :10 / 12 | 前置阶段 :阶段 09:前端基础工程构建
大家好,我是小马不起床。
目录
- 一、阶段目标
- 二、本阶段开发顺序
- 三、本阶段接口依赖
- 四、本阶段文件清单
- [五、步骤 1:创建认证 API 封装](#五、步骤 1:创建认证 API 封装)
- [六、步骤 2:创建认证状态 Store](#六、步骤 2:创建认证状态 Store)
- [七、步骤 3:创建路由元信息类型声明](#七、步骤 3:创建路由元信息类型声明)
- [八、步骤 4:修改 Axios 请求封装](#八、步骤 4:修改 Axios 请求封装)
- [九、步骤 5:修改路由配置和守卫](#九、步骤 5:修改路由配置和守卫)
- [十、步骤 6:创建登录页面](#十、步骤 6:创建登录页面)
- [十一、步骤 7:修改基础布局](#十一、步骤 7:修改基础布局)
- [十二、步骤 8:创建占位页面](#十二、步骤 8:创建占位页面)
- [十三、步骤 9:启动验证](#十三、步骤 9:启动验证)
- 十四、本阶段验收清单
- 十五、常见问题
- 十六、进入下一阶段的条件
- 十七、问题记录与修复方案
说明:尖括号 <> 内容为占位符,使用时按实际环境替换;本阶段代码均为完整实现,无占位符。
一、阶段目标
本阶段目标是在前端基础工程已经完成的基础上,实现登录页、令牌(Token)本地持久化(Persistence)、用户状态、路由守卫(Navigation Guard)、权限菜单和基础后台布局。
完成本阶段后,前端应具备以下能力:
- 用户可以通过
/login登录。 - 登录成功后保存后端返回的 Token。
- 刷新页面后可以根据 Token 恢复当前用户信息。
- 未登录访问后台页面会跳转到登录页。
- 已登录访问登录页会跳转回首页。
- 布局侧边栏可以根据后端菜单数据展示菜单。
- 没有真实页面的菜单先进入占位页面,后续阶段再替换为业务页面。
- 退出登录后清空本地 Token 和用户状态。
二、本阶段开发顺序
text
1. 创建认证 API 封装
2. 创建认证状态 Store
3. 创建路由元信息类型声明
4. 修改 Axios 响应拦截
5. 修改路由配置并增加路由守卫
6. 创建登录页面
7. 修改基础布局,接入用户信息和动态菜单
8. 创建占位页面
9. 启动前端并验证登录流程
三、本阶段接口依赖
本阶段依赖后端已经完成以下接口:
| 方法 | 路径 | 说明 |
|---|---|---|
| POST | /api/auth/login |
JSON 登录 |
| GET | /api/auth/me |
当前用户 |
| GET | /api/auth/permissions |
当前用户权限 |
| GET | /api/auth/menus |
当前用户菜单 |
前端 Axios 的 baseURL 已配置为 /api,实际代码中请求路径为:
text
/auth/login
/auth/me
/auth/permissions
/auth/menus
四、本阶段文件清单
| 序号 | 相对路径 | 类型 | 说明 |
|---|---|---|---|
| 1 | frontend/src/api/auth.ts |
新增 | 登录认证接口封装 |
| 2 | frontend/src/stores/auth.ts |
新增 | 登录状态、用户信息、权限、菜单 |
| 3 | frontend/src/router/route-meta.d.ts |
新增 | Vue Router 元信息类型声明 |
| 4 | frontend/src/api/http.ts |
修改 | 401 处理和 Token 请求头 |
| 5 | frontend/src/router/index.ts |
修改 | 登录路由、后台路由、路由守卫 |
| 6 | frontend/src/views/LoginView.vue |
新增 | 登录页面 |
| 7 | frontend/src/layouts/BasicLayout.vue |
修改 | 用户信息、退出登录、动态菜单 |
| 8 | frontend/src/views/PlaceholderView.vue |
新增 | 后续业务页面占位 |
五、步骤 1:创建认证 API 封装
相对路径
text
frontend/src/api/auth.ts
完整代码
typescript
import { request } from './http'
export interface LoginPayload {
username: string
password: string
}
export interface UserInfo {
user_id: number
role_id: number
username: string
real_name: string | null
phone: string | null
status: number | null
}
export interface LoginResult {
access_token: string
token_type: string
user: UserInfo
permissions: string[]
}
export interface MenuItem {
permission_code: string
permission_name: string
menu_path: string
}
export function loginApi(data: LoginPayload) {
return request<LoginResult>({
url: '/auth/login',
method: 'post',
data,
})
}
export function getMeApi() {
return request<UserInfo>({
url: '/auth/me',
method: 'get',
})
}
export function getPermissionsApi() {
return request<string[]>({
url: '/auth/permissions',
method: 'get',
})
}
export function getMenusApi() {
return request<MenuItem[]>({
url: '/auth/menus',
method: 'get',
})
}
注意事项
loginApi对接后端/api/auth/login。getMenusApi返回后端sys_permission.menu_path对应的菜单。- 前端菜单路径需要和后端种子权限中的
menu_path保持一致。
六、步骤 2:创建认证状态 Store
相对路径
text
frontend/src/stores/auth.ts
完整代码
typescript
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'
import {
getMeApi,
getMenusApi,
getPermissionsApi,
loginApi,
type LoginPayload,
type MenuItem,
type UserInfo,
} from '../api/auth'
const TOKEN_KEY = 'joyfit_token'
export const useAuthStore = defineStore('auth', () => {
const token = ref(localStorage.getItem(TOKEN_KEY) || '')
const user = ref<UserInfo | null>(null)
const permissions = ref<string[]>([])
const menus = ref<MenuItem[]>([])
const isLoggedIn = computed(() => Boolean(token.value))
const displayName = computed(() => user.value?.real_name || user.value?.username || '管理员')
function setToken(value: string) {
token.value = value
localStorage.setItem(TOKEN_KEY, value)
}
function clearAuth() {
token.value = ''
user.value = null
permissions.value = []
menus.value = []
localStorage.removeItem(TOKEN_KEY)
}
async function login(payload: LoginPayload) {
const result = await loginApi(payload)
setToken(result.access_token)
user.value = result.user
permissions.value = result.permissions
menus.value = await getMenusApi()
return result
}
async function loadProfile() {
if (!token.value) {
return
}
try {
const [currentUser, permissionList, menuList] = await Promise.all([
getMeApi(),
getPermissionsApi(),
getMenusApi(),
])
user.value = currentUser
permissions.value = permissionList
menus.value = menuList
} catch (error) {
clearAuth()
throw error
}
}
function hasPermission(permissionCode: string) {
return permissions.value.includes(permissionCode)
}
function logout() {
clearAuth()
}
return {
token,
user,
permissions,
menus,
isLoggedIn,
displayName,
login,
loadProfile,
hasPermission,
logout,
clearAuth,
}
})
注意事项
- Token 持久化存储在
localStorage.joyfit_token。 - 刷新页面后,Store 会先从
localStorage恢复 Token。 - 路由守卫会调用
loadProfile补齐用户、权限、菜单。
七、步骤 3:创建路由元信息类型声明
相对路径
text
frontend/src/router/route-meta.d.ts
完整代码
typescript
import 'vue-router'
export {}
declare module 'vue-router' {
interface RouteMeta {
title?: string
requiresAuth?: boolean
guestOnly?: boolean
permission?: string
}
}
注意事项
requiresAuth表示该路由必须登录。guestOnly表示该路由只允许未登录用户访问,例如登录页。permission表示该路由需要某个后端权限代码。
八、步骤 4:修改 Axios 请求封装
相对路径
text
frontend/src/api/http.ts
完整代码
typescript
import axios, {
type AxiosError,
type AxiosInstance,
type AxiosRequestConfig,
type InternalAxiosRequestConfig,
} from 'axios'
import { ElMessage } from 'element-plus'
import type { ApiResponse } from './types'
const TOKEN_KEY = 'joyfit_token'
const http: AxiosInstance = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || '/api',
timeout: 10000,
})
http.interceptors.request.use((config: InternalAxiosRequestConfig) => {
const token = localStorage.getItem(TOKEN_KEY)
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
http.interceptors.response.use(
(response) => {
const body = response.data as ApiResponse
if (body && typeof body.code === 'number' && body.code !== 200) {
ElMessage.error(body.message || '请求失败')
return Promise.reject(body)
}
return response
},
(error: AxiosError<ApiResponse>) => {
const status = error.response?.status
const message = error.response?.data?.message || error.message || '网络请求失败'
if (status === 401) {
localStorage.removeItem(TOKEN_KEY)
if (window.location.pathname !== '/login') {
const redirect = `${window.location.pathname}${window.location.search}`
window.location.href = `/login?redirect=${encodeURIComponent(redirect)}`
}
} else {
ElMessage.error(message)
}
return Promise.reject(error)
},
)
export async function request<T = unknown>(config: AxiosRequestConfig): Promise<T> {
const response = await http.request<ApiResponse<T>>(config)
return response.data.data
}
export default http
注意事项
- 收到 401 时清除 Token 并跳转登录页。
- 业务错误
code !== 200会统一提示。 - 请求层未直接导入 Pinia Store,避免请求层与状态层产生循环依赖。
九、步骤 5:修改路由配置和守卫
相对路径
text
frontend/src/router/index.ts
完整代码
typescript
import { ElMessage } from 'element-plus'
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
import { useAuthStore } from '../stores/auth'
const routes: RouteRecordRaw[] = [
{
path: '/login',
name: 'login',
component: () => import('../views/LoginView.vue'),
meta: {
title: '登录',
guestOnly: true,
},
},
{
path: '/',
component: () => import('../layouts/BasicLayout.vue'),
meta: {
requiresAuth: true,
},
children: [
{
path: '',
name: 'home',
component: () => import('../views/HomeView.vue'),
meta: {
title: '工作台',
requiresAuth: true,
},
},
{
path: 'users',
name: 'users',
component: () => import('../views/PlaceholderView.vue'),
meta: {
title: '用户管理',
requiresAuth: true,
permission: 'user:list',
},
},
{
path: 'stores',
name: 'stores',
component: () => import('../views/PlaceholderView.vue'),
meta: {
title: '门店管理',
requiresAuth: true,
permission: 'store:list',
},
},
{
path: 'courses',
name: 'courses',
component: () => import('../views/PlaceholderView.vue'),
meta: {
title: '课程管理',
requiresAuth: true,
permission: 'course:list',
},
},
{
path: 'actions',
name: 'actions',
component: () => import('../views/PlaceholderView.vue'),
meta: {
title: '动作库',
requiresAuth: true,
permission: 'action:list',
},
},
{
path: 'content/events',
name: 'content-events',
component: () => import('../views/PlaceholderView.vue'),
meta: {
title: '活动管理',
requiresAuth: true,
permission: 'event:list',
},
},
{
path: 'content/slogans',
name: 'content-slogans',
component: () => import('../views/PlaceholderView.vue'),
meta: {
title: 'Slogan 管理',
requiresAuth: true,
permission: 'slogan:list',
},
},
{
path: 'system/roles',
name: 'system-roles',
component: () => import('../views/PlaceholderView.vue'),
meta: {
title: '角色管理',
requiresAuth: true,
permission: 'role:list',
},
},
{
path: 'system/permissions',
name: 'system-permissions',
component: () => import('../views/PlaceholderView.vue'),
meta: {
title: '权限管理',
requiresAuth: true,
permission: 'permission:list',
},
},
],
},
{
path: '/:pathMatch(.*)*',
name: 'not-found',
component: () => import('../views/NotFoundView.vue'),
meta: {
title: '页面不存在',
},
},
]
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes,
})
router.beforeEach(async (to) => {
const authStore = useAuthStore()
if (to.meta.guestOnly && authStore.isLoggedIn) {
return { name: 'home' }
}
if (to.meta.requiresAuth && !authStore.isLoggedIn) {
return {
name: 'login',
query: {
redirect: to.fullPath,
},
}
}
if (to.meta.requiresAuth && authStore.isLoggedIn && !authStore.user) {
try {
await authStore.loadProfile()
} catch (error) {
return {
name: 'login',
query: {
redirect: to.fullPath,
},
}
}
}
if (to.meta.permission && !authStore.hasPermission(to.meta.permission)) {
ElMessage.error('没有访问权限')
return { name: 'home' }
}
return true
})
router.afterEach((to) => {
const title = to.meta.title ? `${String(to.meta.title)} - JoyFit` : 'JoyFit'
document.title = title
})
export default router
注意事项
/login无需登录。/和后台菜单都需要登录。- 菜单路径和后端
menu_path保持一致,例如/users、/courses、/content/events。 - 真实业务页面开发完成前,统一进入
PlaceholderView.vue。
十、步骤 6:创建登录页面
相对路径
text
frontend/src/views/LoginView.vue
完整代码
vue
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '../stores/auth'
const router = useRouter()
const route = useRoute()
const authStore = useAuthStore()
const formRef = ref<FormInstance>()
const loading = ref(false)
const form = reactive({
username: 'admin',
password: '123456',
})
const rules: FormRules = {
username: [
{
required: true,
message: '请输入用户名',
trigger: 'blur',
},
],
password: [
{
required: true,
message: '请输入密码',
trigger: 'blur',
},
],
}
async function submitLogin() {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) {
return
}
loading.value = true
try {
await authStore.login(form)
ElMessage.success('登录成功')
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/'
await router.replace(redirect)
} finally {
loading.value = false
}
}
</script>
<template>
<main class="login-page">
<section class="login-brand">
<p class="eyebrow">JoyFit Admin</p>
<h1>训练、门店、课程与活动的一体化管理后台</h1>
<p class="summary">
前端将从这里接入后端 JWT 登录、RBAC 权限、动态菜单和各业务模块页面。
</p>
</section>
<section class="login-panel">
<div class="panel-heading">
<h2>登录系统</h2>
<p>使用后端初始化的管理员账号进入管理端。</p>
</div>
<el-form ref="formRef" :model="form" :rules="rules" label-position="top">
<el-form-item label="用户名" prop="username">
<el-input v-model="form.username" autocomplete="username" />
</el-form-item>
<el-form-item label="密码" prop="password">
<el-input
v-model="form.password"
autocomplete="current-password"
show-password
type="password"
@keyup.enter="submitLogin"
/>
</el-form-item>
<el-button class="login-button" type="primary" :loading="loading" @click="submitLogin">
登录
</el-button>
</el-form>
</section>
</main>
</template>
<style scoped>
.login-page {
display: grid;
min-height: 100vh;
grid-template-columns: minmax(0, 1fr) 420px;
gap: 48px;
align-items: center;
background:
linear-gradient(rgba(17, 24, 39, 0.78), rgba(17, 24, 39, 0.72)),
url("https://images.unsplash.com/photo-1534438327276-14e5300c3a48?auto=format&fit=crop&w=1800&q=80");
background-position: center;
background-size: cover;
padding: 48px;
}
.login-brand {
max-width: 760px;
color: #ffffff;
}
.eyebrow {
margin: 0 0 14px;
color: #a7f3d0;
font-size: 14px;
font-weight: 700;
}
.login-brand h1 {
margin: 0;
max-width: 720px;
font-size: 44px;
line-height: 1.15;
}
.summary {
max-width: 620px;
margin: 18px 0 0;
color: #e5e7eb;
font-size: 16px;
line-height: 1.8;
}
.login-panel {
border-radius: 8px;
background: #ffffff;
padding: 30px;
box-shadow: 0 20px 50px rgba(15, 23, 42, 0.22);
}
.panel-heading {
margin-bottom: 22px;
}
.panel-heading h2 {
margin: 0;
color: #111827;
font-size: 24px;
}
.panel-heading p {
margin: 8px 0 0;
color: #6b7280;
font-size: 14px;
}
.login-button {
width: 100%;
margin-top: 8px;
}
@media (max-width: 900px) {
.login-page {
grid-template-columns: 1fr;
gap: 24px;
padding: 24px;
}
.login-brand h1 {
font-size: 32px;
}
.login-panel {
max-width: 480px;
width: 100%;
}
}
</style>
注意事项
- 默认填入
admin和123456,便于开发测试;正式上线前必须移除默认凭证。 - 登录成功后会跳转到原访问地址,没有 redirect 时跳转首页。
- 登录背景使用在线图片;离线环境下图片不显示,不影响登录功能。
十一、步骤 7:修改基础布局
相对路径
text
frontend/src/layouts/BasicLayout.vue
完整代码
vue
<script setup lang="ts">
import { computed } from 'vue'
import { SwitchButton } from '@element-plus/icons-vue'
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '../stores/auth'
const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
const fallbackMenus = [
{
permission_code: 'home',
permission_name: '工作台',
menu_path: '/',
},
]
const menus = computed(() => {
return authStore.menus.length > 0 ? authStore.menus : fallbackMenus
})
function isActive(path: string) {
if (path === '/') {
return route.path === '/'
}
return route.path.startsWith(path)
}
async function logout() {
authStore.logout()
await router.replace('/login')
}
</script>
<template>
<div class="app-shell">
<aside class="sidebar">
<div class="brand">
<span class="brand-mark">J</span>
<span class="brand-name">JoyFit</span>
</div>
<nav class="nav-list">
<RouterLink
v-for="item in menus"
:key="item.permission_code"
class="nav-item"
:class="{ active: isActive(item.menu_path) }"
:to="item.menu_path"
>
{{ item.permission_name }}
</RouterLink>
</nav>
</aside>
<section class="workspace">
<header class="topbar">
<div>
<p class="eyebrow">FastAPI + Vue3</p>
<h1>{{ route.meta.title || 'JoyFit 管理端' }}</h1>
</div>
<div class="user-area">
<span class="user-name">{{ authStore.displayName }}</span>
<el-tooltip content="退出登录" placement="bottom">
<el-button :icon="SwitchButton" circle @click="logout" />
</el-tooltip>
</div>
</header>
<main class="content-area">
<RouterView />
</main>
</section>
</div>
</template>
<style scoped>
.app-shell {
display: grid;
min-height: 100vh;
grid-template-columns: 240px minmax(0, 1fr);
background: #f6f7fb;
}
.sidebar {
display: flex;
flex-direction: column;
gap: 28px;
border-right: 1px solid #e5e7eb;
background: #ffffff;
padding: 24px 18px;
}
.brand {
display: flex;
align-items: center;
gap: 10px;
min-height: 40px;
}
.brand-mark {
display: inline-flex;
width: 36px;
height: 36px;
align-items: center;
justify-content: center;
border-radius: 8px;
background: #111827;
color: #ffffff;
font-size: 18px;
font-weight: 800;
}
.brand-name {
color: #111827;
font-size: 18px;
font-weight: 800;
}
.nav-list {
display: grid;
gap: 8px;
overflow-y: auto;
}
.nav-item {
display: flex;
min-height: 40px;
align-items: center;
border-radius: 8px;
padding: 0 12px;
color: #4b5563;
font-size: 14px;
font-weight: 600;
}
.nav-item.active,
.nav-item.router-link-active {
background: #eef2ff;
color: #3730a3;
}
.workspace {
min-width: 0;
}
.topbar {
display: flex;
min-height: 88px;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid #e5e7eb;
background: #ffffff;
padding: 0 28px;
}
.eyebrow {
margin: 0;
color: #6b7280;
font-size: 13px;
font-weight: 600;
}
.topbar h1 {
margin: 4px 0 0;
color: #111827;
font-size: 22px;
line-height: 1.3;
}
.user-area {
display: inline-flex;
align-items: center;
gap: 12px;
}
.user-name {
max-width: 160px;
overflow: hidden;
color: #374151;
font-size: 14px;
font-weight: 700;
text-overflow: ellipsis;
white-space: nowrap;
}
.content-area {
padding: 28px;
}
@media (max-width: 760px) {
.app-shell {
grid-template-columns: 1fr;
}
.sidebar {
gap: 14px;
border-right: 0;
border-bottom: 1px solid #e5e7eb;
padding: 16px;
}
.topbar {
align-items: flex-start;
flex-direction: column;
gap: 10px;
padding: 18px 16px;
}
.content-area {
padding: 18px 16px;
}
}
</style>
注意事项
- 菜单来自后端
/api/auth/menus。 - 菜单未加载时,默认显示工作台。
- 退出登录后清空 Token 并返回登录页。
十二、步骤 8:创建占位页面
相对路径
text
frontend/src/views/PlaceholderView.vue
完整代码
vue
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
const title = computed(() => String(route.meta.title || '功能页面'))
</script>
<template>
<section class="placeholder">
<div class="placeholder-panel">
<p class="eyebrow">Coming Next</p>
<h2>{{ title }}</h2>
<p>该模块路由和权限已经接入,具体业务页面会在后续阶段实现。</p>
</div>
</section>
</template>
<style scoped>
.placeholder {
display: grid;
min-height: 320px;
place-items: center;
}
.placeholder-panel {
width: min(100%, 560px);
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #ffffff;
padding: 28px;
text-align: center;
}
.eyebrow {
margin: 0 0 10px;
color: #0891b2;
font-size: 13px;
font-weight: 800;
}
.placeholder-panel h2 {
margin: 0;
color: #111827;
font-size: 24px;
}
.placeholder-panel p {
margin: 12px 0 0;
color: #6b7280;
font-size: 14px;
line-height: 1.7;
}
</style>
注意事项
- 用户、门店、课程、动作、活动等业务页面完成后,会逐步替换该占位页面。
- 占位页可用于验证菜单和权限是否正确。
十三、步骤 9:启动验证
启动后端
bash
cd backend
source .venv/bin/activate
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
启动前端
bash
cd frontend
npm run dev
验证流程
- 访问
http://127.0.0.1:5173。 - 未登录应自动跳转到
/login。 - 使用
admin / 123456登录。 - 登录成功后进入工作台。
- 侧边栏显示后端返回的菜单。
- 点击菜单进入占位页面。
- 点击右上角退出按钮,返回登录页。
- 刷新页面后,如果 Token 有效,应保持登录状态。
十四、本阶段验收清单
- 登录页:
/login可访问。 - 登录接口:可调用
/api/auth/login。 - Token 保存:登录后
localStorage.joyfit_token存在。 - 用户恢复:刷新页面后可调用
/api/auth/me。 - 权限恢复:刷新页面后可调用
/api/auth/permissions。 - 菜单恢复:刷新页面后可调用
/api/auth/menus。 - 路由守卫:未登录访问
/跳转/login。 - 退出登录:清空 Token 并回到登录页。
- 菜单点击:菜单可进入对应占位页面。
- 构建检查:
npm run build成功。
十五、常见问题
1. 登录返回 401
原因:
用户名或密码不正确,或者后端管理员账号没有初始化成功。
修复:
- 确认后端启动时
AUTO_SEED_DATA=true。 - 使用默认账号
admin / 123456。 - 如果修改过管理员密码,使用修改后的密码登录。
2. 登录成功后菜单为空
原因:
当前角色没有带 menu_path 的权限,或者 /api/auth/menus 返回空数组。
修复:
- 使用管理员账号登录。
- 检查
sys_permission.menu_path是否有值。 - 检查
sys_role_permission是否绑定了对应权限。
3. 刷新页面后又回到登录页
原因:
Token 无效、过期,或者后端 /api/auth/me 返回 401。
修复:
重新登录,并确认后端服务没有重置 JWT_SECRET_KEY。
4. 点击菜单进入 404
原因:
后端 menu_path 和前端路由路径不一致。
修复:
- 检查后端
DEFAULT_PERMISSIONS中的menu_path。 - 检查前端
router/index.ts中是否配置了同路径路由。
5. npm run build 提示找不到 .vue
原因:
缺少 src/vite-env.d.ts。
修复:
按第九阶段的问题记录补齐 frontend/src/vite-env.d.ts。
十六、进入下一阶段的条件
满足以下条件后,进入下一阶段:阶段 11:前端后台管理页面。
必须满足:
- 登录流程正常。
- 刷新页面可以恢复登录状态。
- 动态菜单正常显示。
- 退出登录正常。
- 路由守卫正常。
npm run build成功。
十七、问题记录与修复方案
本阶段实践中遇到问题时,需要完整记录报错原文,便于检索与定位,并按以下流程处理:
- 判断错误属于登录接口、Token、Pinia、路由守卫(Navigation Guard)、动态菜单、Axios 拦截器还是布局页面问题。
- 将修复办法追加到本文件底部。
问题追加格式
text
### 问题 N:问题标题
#### 报错现象
按下方模板记录报错信息。
#### 原因分析
这里说明为什么会报错。
#### 修复办法
这里给出具体修复步骤。
#### 需要修改的文件
- 相对路径:xxx
- 修改说明:xxx
- 完整代码或补丁:xxx