零基础玩转 FastAPI_10:前端登录、权限与布局

阶段 10:前端登录、权限与布局

合辑 :FastAPI × Vue3 JoyFit 系统阶段开发计划 | 端 :前端 | 阶段 :10 / 12 | 前置阶段 :阶段 09:前端基础工程构建

大家好,我是小马不起床。

目录

  1. 一、阶段目标
  2. 二、本阶段开发顺序
  3. 三、本阶段接口依赖
  4. 四、本阶段文件清单
  5. [五、步骤 1:创建认证 API 封装](#五、步骤 1:创建认证 API 封装)
  6. [六、步骤 2:创建认证状态 Store](#六、步骤 2:创建认证状态 Store)
  7. [七、步骤 3:创建路由元信息类型声明](#七、步骤 3:创建路由元信息类型声明)
  8. [八、步骤 4:修改 Axios 请求封装](#八、步骤 4:修改 Axios 请求封装)
  9. [九、步骤 5:修改路由配置和守卫](#九、步骤 5:修改路由配置和守卫)
  10. [十、步骤 6:创建登录页面](#十、步骤 6:创建登录页面)
  11. [十一、步骤 7:修改基础布局](#十一、步骤 7:修改基础布局)
  12. [十二、步骤 8:创建占位页面](#十二、步骤 8:创建占位页面)
  13. [十三、步骤 9:启动验证](#十三、步骤 9:启动验证)
  14. 十四、本阶段验收清单
  15. 十五、常见问题
  16. 十六、进入下一阶段的条件
  17. 十七、问题记录与修复方案

说明:尖括号 <> 内容为占位符,使用时按实际环境替换;本阶段代码均为完整实现,无占位符。

一、阶段目标

本阶段目标是在前端基础工程已经完成的基础上,实现登录页、令牌(Token)本地持久化(Persistence)、用户状态、路由守卫(Navigation Guard)、权限菜单和基础后台布局。

完成本阶段后,前端应具备以下能力:

  1. 用户可以通过 /login 登录。
  2. 登录成功后保存后端返回的 Token。
  3. 刷新页面后可以根据 Token 恢复当前用户信息。
  4. 未登录访问后台页面会跳转到登录页。
  5. 已登录访问登录页会跳转回首页。
  6. 布局侧边栏可以根据后端菜单数据展示菜单。
  7. 没有真实页面的菜单先进入占位页面,后续阶段再替换为业务页面。
  8. 退出登录后清空本地 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',
  })
}

注意事项

  1. loginApi 对接后端 /api/auth/login。
  2. getMenusApi 返回后端 sys_permission.menu_path 对应的菜单。
  3. 前端菜单路径需要和后端种子权限中的 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,
  }
})

注意事项

  1. Token 持久化存储在 localStorage.joyfit_token。
  2. 刷新页面后,Store 会先从 localStorage 恢复 Token。
  3. 路由守卫会调用 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
  }
}

注意事项

  1. requiresAuth 表示该路由必须登录。
  2. guestOnly 表示该路由只允许未登录用户访问,例如登录页。
  3. 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

注意事项

  1. 收到 401 时清除 Token 并跳转登录页。
  2. 业务错误 code !== 200 会统一提示。
  3. 请求层未直接导入 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

注意事项

  1. /login 无需登录。
  2. / 和后台菜单都需要登录。
  3. 菜单路径和后端 menu_path 保持一致,例如 /users、/courses、/content/events。
  4. 真实业务页面开发完成前,统一进入 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>

注意事项

  1. 默认填入 admin 和 123456,便于开发测试;正式上线前必须移除默认凭证。
  2. 登录成功后会跳转到原访问地址,没有 redirect 时跳转首页。
  3. 登录背景使用在线图片;离线环境下图片不显示,不影响登录功能。

十一、步骤 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>

注意事项

  1. 菜单来自后端 /api/auth/menus。
  2. 菜单未加载时,默认显示工作台。
  3. 退出登录后清空 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>

注意事项

  1. 用户、门店、课程、动作、活动等业务页面完成后,会逐步替换该占位页面。
  2. 占位页可用于验证菜单和权限是否正确。

十三、步骤 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

验证流程

  1. 访问 http://127.0.0.1:5173。
  2. 未登录应自动跳转到 /login。
  3. 使用 admin / 123456 登录。
  4. 登录成功后进入工作台。
  5. 侧边栏显示后端返回的菜单。
  6. 点击菜单进入占位页面。
  7. 点击右上角退出按钮,返回登录页。
  8. 刷新页面后,如果 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

原因:

用户名或密码不正确,或者后端管理员账号没有初始化成功。

修复:

  1. 确认后端启动时 AUTO_SEED_DATA=true。
  2. 使用默认账号 admin / 123456。
  3. 如果修改过管理员密码,使用修改后的密码登录。

2. 登录成功后菜单为空

原因:

当前角色没有带 menu_path 的权限,或者 /api/auth/menus 返回空数组。

修复:

  1. 使用管理员账号登录。
  2. 检查 sys_permission.menu_path 是否有值。
  3. 检查 sys_role_permission 是否绑定了对应权限。

3. 刷新页面后又回到登录页

原因:

Token 无效、过期,或者后端 /api/auth/me 返回 401。

修复:

重新登录,并确认后端服务没有重置 JWT_SECRET_KEY。

4. 点击菜单进入 404

原因:

后端 menu_path 和前端路由路径不一致。

修复:

  1. 检查后端 DEFAULT_PERMISSIONS 中的 menu_path。
  2. 检查前端 router/index.ts 中是否配置了同路径路由。

5. npm run build 提示找不到 .vue

原因:

缺少 src/vite-env.d.ts。

修复:

按第九阶段的问题记录补齐 frontend/src/vite-env.d.ts。

十六、进入下一阶段的条件

满足以下条件后,进入下一阶段:阶段 11:前端后台管理页面。

必须满足:

  1. 登录流程正常。
  2. 刷新页面可以恢复登录状态。
  3. 动态菜单正常显示。
  4. 退出登录正常。
  5. 路由守卫正常。
  6. npm run build 成功。

十七、问题记录与修复方案

本阶段实践中遇到问题时,需要完整记录报错原文,便于检索与定位,并按以下流程处理:

  1. 判断错误属于登录接口、Token、Pinia、路由守卫(Navigation Guard)、动态菜单、Axios 拦截器还是布局页面问题。
  2. 将修复办法追加到本文件底部。

问题追加格式

text 复制代码
### 问题 N:问题标题

#### 报错现象

按下方模板记录报错信息。

#### 原因分析

这里说明为什么会报错。

#### 修复办法

这里给出具体修复步骤。

#### 需要修改的文件

- 相对路径:xxx
- 修改说明:xxx
- 完整代码或补丁:xxx
相关推荐
程序员Sunday1 小时前
JavaScript 深拷贝和浅拷贝有什么区别?structuredClone 能替代 JSON 拷贝吗?
前端·javascript
ShineWinsu1 小时前
对于CSS:选择器的解析
前端·css·面试·html·web·选择器·网页
m0_587383001 小时前
碰碰好评卡功能落地实战 从需求梳理到上线部署全流程实操指南
java·linux·服务器·前端·javascript·vue.js·人工智能
Pu_Nine_91 小时前
Vue‑Flow 从零入门实战|AI‑Agent 拓扑图开发
前端·javascript·vue.js
keyipatience1 小时前
CSS 学习(1):语法、引入方式、选择器、字体与背景
前端·css·html
程序人生8882 小时前
本地向量底座怎么搭?DocConverter Web 的 BGE 嵌入模型、ChromaDB 与外部 Milvus 接入
前端·milvus
星恒随风2 小时前
Linux进程信号(一):从Ctrl+C到kill、raise、alarm,深入理解信号的概念与产生原理
linux·c语言·笔记·学习·状态模式
恋猫de小郭2 小时前
Flutter Android 改动:APK 不再允许通过 Intent 动态修改 Engine 参数
android·前端·flutter
ck0135792 小时前
视频转 GIF 的基本流程与文件大小优化
前端·测试工具·html·音视频·视频