您现在的位置是:首页 >其他 >Vue 3 脚手架搭建网站首页其他

Vue 3 脚手架搭建

Dily_Su 2024-06-17 10:47:00
简介Vue 3 脚手架搭建

技术栈

  • vite
  • vue3
  • typescript
  • vue-router4
  • pinia
  • axios
  • eslint
  • prettier
  • sass

项目结构:

  • src : 项目代码

    • api :封装的网络请求

    • assets:静态文件

    • components:组件

    • layout:主体结构

    • model: 类型定义,严格遵守 ts 准则

    • router: 路由管理

    • stores: 全局变量管理

    • utils: 工具包

      -httpClient.ts : axios 二次封装

    • views:页面

      -App.vue : 系统入口页面

      -main.ts : 系统启动入口

      -setting.ts : 系统设置

  • types

    -route.d.ts : 定义路由类型

-.eslintrc.cjs :定义 eslint 规则

-.eslintrc-auto-import.json :unplugin-auto-import 插件自动生成的 eslint 类型规范

-.prettierrc.json :prettier 规则

-auto-imports.d.ts : unplugin-auto-import 插件自动生成,定义自动导入的方法

-components.d.ts :unplugin-vue-components 自动生成,定义自动导入的组件

-env.d.ts : 全局环境变量控制

-package.json : 包管理、依赖管理

-tsconfig.app.json :app 相关配置

-tsconfig.json :项目配置入口

-tsconfig.node.json : node 相关配置

-vite.config.ts : 项目内配置

一、环境准备

1.1 node.js 安装

node.js 官方下载地址

1.2 包管理工具安装:可选

  • npm:

    node.js 中自带,速度较慢

  • yarn:

# cmd 全局安装 yarn
npm install -g yarn 

二、创建项目

选择要创建项目的目录,打开cmd 执行以下命令

2.1 使用 npm

npm init vite@latest 
# npm 6.x
npm init vite@latest 项目名称 --template vue
# npm 7+, 需要额外的双横线:
npm init vite@latest 项目名称 -- --template vue

2.2 使用 yarn

# 方式一
yarn create vite
# 方式二
yarn create vite 项目名称 --template vue
# 方式三:该方式可直接配置需要的组件,可减少依赖版本冲突
npm init vue
# npm init vue 提示如下:Project name:<your-project-name>Add TypeScript?No / YesAdd JSX Support?No / YesAdd Vue Router for Single Page Application development?No / YesAdd Pinia for state management?No / YesAdd Vitest for Unit testing?No / YesAdd Cypress for both Unit and End-to-End testing?No / YesAdd ESLint for code quality?No / YesAdd Prettier for code formatting?No / Yes

Scaffolding project in ./<your-project-name>...
Done.

三、配置项目

3.1 安装初始依赖

# npm
npm install
# yarn
yarn init

3.2 增加依赖

yarn: yarn add 依赖名称

npm: npm install 依赖名称

# 异步请求包
yarn add axios
# scss 解析器,也可使用 less 等
yarn add sass -d
# 自动导包插件
yarn add unplugin-auto-import -d
yarn add unplugin-icons -d
yarn add unplugin-vue-components -d
# 页面加载时的进度条,后面路由守护时使用
yarn add nprogress 
yarn add @types/nprogress -D

package.json

{
"name": "crowd-funding-fronted",
"version": "0.0.0",
"private": true,
"scripts": {
 "dev": "vite",
 "build": "run-p type-check build-only",
 "preview": "vite preview",
 "build-only": "vite build",
 "type-check": "vue-tsc --noEmit",
 "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore",
 "format": "prettier --write src/"
},
"dependencies": {
 "axios": "^1.4.0",
 "element-plus": "^2.3.4",
 "pinia": "^2.0.35",
 "vue": "^3.2.47",
 "vue-router": "^4.1.6"
},
"devDependencies": {
 "@rushstack/eslint-patch": "^1.2.0",
 "@tsconfig/node18": "^2.0.0",
 "@types/jsdom": "^21.1.1",
 "@types/node": "^18.16.3",
 "@vitejs/plugin-vue": "^4.2.1",
 "@vue/eslint-config-prettier": "^7.1.0",
 "@vue/eslint-config-typescript": "^11.0.3",
 "@vue/tsconfig": "^0.3.2",
 "eslint": "^8.39.0",
 "eslint-plugin-vue": "^9.11.0",
 "jsdom": "^22.0.0",
 "npm-run-all": "^4.1.5",
 "prettier": "^2.8.8",
 "sass": "^1.62.1",
 "typescript": "~5.0.4",
 "unplugin-auto-import": "^0.15.3",
 "unplugin-icons": "^0.16.1",
 "unplugin-vue-components": "^0.24.1",
 "vite": "^4.3.4",
 "vue-tsc": "^1.6.4"
}
}

3.2 配置自动导包

// vite.config.ts
import { fileURLToPath, URL } from 'node:url'

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import Icons from 'unplugin-icons/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
import path from 'path'

export default defineConfig({
  base: '/',
  server: {
    open: true,
    // 修改端口号
    port: 8080, 
    proxy: {
        // 后端微服务时可在此处配置代理,解决测试环境跨域问题
    },
  },
  plugins: [
    vue(),
    AutoImport({
      imports: ['vue', 'vue-router', 'pinia'],
      resolvers: [ElementPlusResolver()],
      eslintrc: {
        enabled: true, // Default `false`
      },
    }),
    Components({
      resolvers: [ElementPlusResolver()],
      // 默认组件加载
      dirs: ['src/**/*.vue', 'src/**/*.ts', 'src/**/*.d.ts'],
      // 全局导入组件
      globs: [ 
        'src/layout/**/*.vue',
        'src/layout/**/*.ts',
        'src/layout/**/*.d.ts',
      ],
      extensions: ['vue', 'ts'],
    }),
    Icons({
      /* options */
    }),
  ],
  resolve: {
    alias: {
      '@': fileURLToPath(new URL('./src', import.meta.url)),
    },
  },
})

配置后会在根目录下自动生成以下文件:

  • components.d.ts

    组件库自动导入、自定义组件自动导入

  • auto-imports.d.ts

    vue 相关方法自动注入

  • .prettierrc

    prettier 规则

  • .eslintrc.cjs

    eslint 规则

  • .eslintrc-auto-import.json

    auto-imports 自动插件生成的 eslint 规则,需要在 eslint 中引入

3.3 配置 eslint 规则

// .eslintrc.cjs
require('@rushstack/eslint-patch/modern-module-resolution')

module.exports = {
  root: true,
  'extends': [
    'plugin:vue/vue3-essential',
    'eslint:recommended',
    '@vue/eslint-config-typescript',
    '@vue/eslint-config-prettier/skip-formatting',
    // 引入 auto-imports 自动生成的规则
    './.eslintrc-auto-import.json'  
  ],
  parserOptions: {
    ecmaVersion: 'latest'
  },
  rules: {
    'prettier/prettier': [
      'error',
      {
        arrowParens: 'always',
        bracketSameLine: true,
        bracketSpacing: true,
        embeddedLanguageFormatting: 'auto',
        htmlWhitespaceSensitivity: 'ignore',
        insertPragma: false,
        jsxSingleQuote: true,
        printWidth: 80,
        proseWrap: 'preserve',
        quoteProps: 'as-needed',
        requirePragma: false,
        semi: false,
        singleAttributePerLine: false,
        singleQuote: true,
        tabWidth: 2,
        trailingComma: 'es5',
        useTabs: false,
        vueIndentScriptAndStyle: false,
      },
    ],
      // 设置文件命名可以为单个单词 
      'vue/multi-word-component-names': 0
  },
}

3.4 .vue 文件导入报错解决

// env.d.ts
/// <reference types="vite/client" />
interface ImportMetaEnv {
  readonly VITE_APP_BASE_API: string
  readonly VITE_APP_DEV_USER: string
  readonly VITE_APP_DEV_PWD: string
  // more env variables...
}

interface ImportMeta {
  readonly env: ImportMetaEnv
}

declare module '*.vue' // .vue 文件引入报错解决

四、路由配置

该 layout 仅供参考,可按需求自行替换

src/router:

  • index.ts

    路由主要加载文件,可引入自定义路由文件

  • system.ts

    根据用户需求自定义路由文件,定义完成后在 index 中引入

  • routerGuard.ts
    路由守护

4.1 路由类型定义

// types/route.d.ts
// This can be directly added to any of your `.ts` files like `router.ts`
// It can also be added to a `.d.ts` file, in which case you will need to add an export
// to ensure it is treated as a module
export {}

import 'vue-router'

declare module 'vue-router' {
  interface RouteMeta {
    // 标题
    title: string
    // 图标
    icon?: string
    // 访问权限
    perms?: string[]
    // 是否在菜单中,默认在,设置为true则不在
    hidden?: boolean
    // 是否忽略子菜单全都被过滤掉导致的不显示,设置为true时忽略
    alwaysShow?: boolean
    // 是否在角色管理中不显示
    roleManageIgnore?: boolean
  }
}

4.2 index.ts 路由主入口

// src/router/index.ts
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
import system from '@/router/system' // 自定义路由文件

// 静态路由
export const commonRoutes: RouteRecordRaw[] = [
  {
    path: '/layout',
    name: 'Layout',
    component: () => import('@/layout/Home.vue'),
    meta: {
      title: '首页',
      hidden: true,
    },
    children: [
      {
        path: '/dashboard',
        name: 'dashboard',
        component: () => import('@/views/Dashboard.vue'),
        meta: {
          title: '首页',
          hidden: true,
        },
      },
    ],
  },
  {
    path: '/',
    redirect: '/login',
    meta: {
      title: '登录重定向',
      hidden: true,
    },
  },
  {
    path: '/login',
    name: 'login',
    component: () => import('@/components/login/Login.vue'),
    meta: {
      title: '登录',
      hidden: true,
    },
  },
  {
    path: '/404',
    name: '404',
    component: () => import('@/components/error/404.vue'),
    meta: {
      title: '404',
      hidden: true,
    },
  },
  {
    path: '/401',
    name: '401',
    component: () => import('@/components/error/401.vue'),
    meta: {
      title: '401',
      hidden: true,
    },
  },
]

// 将静态路由 和 自定义路由进行整合
export const allRoutes: RouteRecordRaw[] = [...commonRoutes, system]

const router = createRouter({
  history: createWebHistory(),
  routes: allRoutes,
})

export default router

4.3 system.ts 自定义路由

// src/router/system.ts
import type { RouteRecordRaw } from 'vue-router'
import Home from '@/layout/Home.vue'

// 该路由文件的最顶层路由
export const systemRoute: RouteRecordRaw = {
  path: '/system',
  name: 'system',
  meta: {
    title: '系统管理',
  },
  component: Home,
}

export const userRoute: RouteRecordRaw = {
  path: '/user',
  name: 'user',
  meta: {
    title: '用户管理',
  },
  component: () => import('@/views/system/user/user.vue'),
}
export const roleRoute: RouteRecordRaw = {
  path: '/role',
  name: 'role',
  meta: {
    title: '角色管理',
  },
  component: () => import('@/views/system/role/role.vue'),
}
export const logRoute: RouteRecordRaw = {
  path: '/log',
  name: 'log',
  meta: {
    title: '日志管理',
  },
  component: () => import('@/views/system/log/log.vue'),
}

export default <RouteRecordRaw>{
  ...systemRoute,
  children: [userRoute, roleRoute, logRoute],
}

4.4 routerGuard.ts 路由守护

// src/router/routerGuard.ts
import router from '@/router/index'
import NProgress from 'nprogress'
import 'nprogress/nprogress.css'
import { isNavigationFailure, NavigationFailureType } from 'vue-router'
import { ElMessage } from 'element-plus'
import getPageTitle from '@/utils/settingUtils'
import { authStore } from '@/stores/auth'
import { permissionStore } from '@/stores/permission'

// 白名单
const whiteList = ['/401', '/404']

// 路由守护前
router.beforeEach(async (to, from) => {
  NProgress.start()
  // 白名单直接 pass
  if (whiteList.includes(to.path)) {
    return true
  }
  return true

  // 登录检测
  const auth = authStore()
  try {
    const authenticated = await auth.isAuthenticated()
    if (to.name == 'Login') {
      if (authenticated) return { name: 'Home' }
    } else {
      if (!authenticated) return { name: 'Login' }
    }
  } catch (error) {
    return false
  }
  // 权限检查
  const permission = permissionStore()
  if (!checkPermission(permission.perms, to.meta.perms)) {
    return {
      name: 'Unauthorized',
    }
  }
})

// 路由守护结束后
router.afterEach(async (to, from, failure) => {
  NProgress.done()
  if (failure) {
    if (isNavigationFailure(failure, NavigationFailureType.aborted)) {
      ElMessage.error(failure.message)
    }
  } else {
    document.title = getPageTitle(to.meta.title)
  }
})

/**
 * 权限校验
 * @param hasPerms 用户已经有的权限
 * @param requirePerms 用户访问需要的权限
 */
function checkPermission(hasPerms: string[], requirePerms?: string[]) {
  if (requirePerms && requirePerms.length) {
    return hasPerms.some((perms) => requirePerms.includes(perms))
  }
  return true
}

五、常用类型定义

5.1 response.d.ts

// src/model/response.d.ts
// 父接口, 不返回对象
export interface BaseResponse {
  success: boolean
  code: number
  message?: string
}

// 用于返回一个对象
export interface ItemResponse<T> extends BaseResponse {
  item?: T
}

// 用于返回一个List
export interface ListResponse<T> extends BaseResponse {
  items: T[]
}

// 用于返回含分页的 list
export interface PageResponse<T> extends BaseResponse {
  pageNum: number
  pageSize: number
  total: number
  totalPages?: number
  items?: T[]
}

5.2 user.d.ts 自定义返回类型

// src/model/user.d.ts
// 用户信息,用于存放在全局变量
export interface UserInfo {
  username: string
  name: string
  phone?: string
  email?: string
}
// 登录的用户
export interface LoginUser {
  username: string
  password: string
}

// 注册用户
export interface RegisterUser {
  username: string
  password: string
  name: string
  sex: string
  email: string
  phone: string
  checkPassword: string
}

六、工具类

6.1 二次封装 axios

6.1.1 httpClient.ts

// src/utils/httpClient.ts
// 二次封装 axios
import axios, { type AxiosRequestConfig } from 'axios'
import type { BaseResponse } from '@/model/response'

const httpClient = axios.create({
  // 设置默认请求地址
  baseURL: import.meta.env.VITE_APP_BASE_API,
  // 设置超时时间
  timeout: import.meta.env.DEV ? 10000 : 10000,
  // 跨域时允许携带凭证
  withCredentials: true,
})

// 添加请求拦截器
httpClient.interceptors.request.use(
  (config) => {
    // 在发送请求之前做些什么
    return config
  },
  (error) => {
    // 对请求错误做些什么
    return Promise.reject(error)
  }
)

// 添加响应拦截器
httpClient.interceptors.response.use(
  (response) => {
    // 2xx 范围内的状态码都会触发该函数。
    // 对响应数据做点什么
    return response
  },
  (error) => {
    // 超出 2xx 范围的状态码都会触发该函数。
    // 对响应错误做点什么
    return Promise.reject(error)
  }
)

export function post(
  url: string,
  data?: any,
  config?: AxiosRequestConfig<any>
) {
  return httpClient.post<never, BaseResponse>(url, data, config)
}

export function put(url: string, data?: any, config?: AxiosRequestConfig<any>) {
  return httpClient.put<never, BaseResponse>(url, data, config)
}

export function get(url: string, config?: AxiosRequestConfig<any>) {
  return httpClient.get<never, BaseResponse>(url, config)
}

export function del(url: string, config?: AxiosRequestConfig<any>) {
  return httpClient.delete<never, BaseResponse>(url, config)
}

export default httpClient

6.1.2 httpClient 实例

import httpClient from '@/utils/httpClient'
import type { BaseResponse, ItemResponse } from '@/model/response'
import type {
  AuthedUser,
  LoginUser,
  RegisterUser,
  UserInfo,
} from '@/model/user'

export const getUserInfo: () => Promise<ItemResponse<UserInfo>> = () => {
  return httpClient.get('/api/gateway/user/info', {
    params: {},
  })
}

/**
 * 登录
 * @param user 用户
 * @returns {BaseResponse} 登录结果
 */
export const login: (user: LoginUser) => Promise<ItemResponse<AuthedUser>> = (
  user
) => {
  return httpClient.post('/api/gateway/login', null, {
    params: {
      username: user.username,
      password: user.password,
    },
  })
}

/**
 * @param user
 */
export const logout: (refreshToken: string) => Promise<BaseResponse> = (
  refreshToken
) => {
  return httpClient.get('/api/gateway/logout', {
    params: {
      refreshToken,
    },
  })
}

/**
 * 注册
 * @param user 用户
 * @returns {Promise<BaseResponse>} 注册结果
 */
export const register: (
  user: RegisterUser
) => Promise<ItemResponse<RegisterUser>> = (user) => {
  return httpClient.post('/api/gateway/user-service/user/registerUser', user)
}

export const refreshToken: (
  refreshToken: string
) => Promise<ItemResponse<AuthedUser>> = (refreshToken) => {
  return httpClient.get('/api/gateway/auth-service/auth/refreshToken', {
    params: {
      refreshToken,
    },
  })
}

6.2 浏览器本地存储

// src/utils/storageUtils.ts
const TokenKey = 'token'
const RefreshTokenKey = 'srid'
const pageSizeKey = 'pageSize'
const SidebarStatusKey = 'sidebarStatus'

export function getToken() {
  return localStorage.getItem(TokenKey)
}

export function setToken(token: string) {
  return localStorage.setItem(TokenKey, token)
}

export function removeToken() {
  return localStorage.removeItem(TokenKey)
}

export function getRefreshToken() {
  return localStorage.getItem(RefreshTokenKey)
}

export function setRefreshToken(token: string) {
  return localStorage.setItem(RefreshTokenKey, token)
}

export function removeRefreshToken() {
  return localStorage.removeItem(RefreshTokenKey)
}

export function getPageSize() {
  const pageSize = localStorage.getItem(pageSizeKey)
  if (pageSize) return Number(pageSize)
}

export function setPageSize(pageSize: number) {
  return localStorage.setItem(pageSizeKey, pageSize.toString())
}

export function removePageSize() {
  return localStorage.removeItem(pageSizeKey)
}

// 侧边栏状态(显示/隐藏)
export function getSidebarStatus() {
  return localStorage.getItem(SidebarStatusKey)
}

export function setSidebarStatus(sidebarStatus: string) {
  return localStorage.setItem(SidebarStatusKey, sidebarStatus)
}

6.3 系统设置工具

// src/utils/settingUtils.ts
import setting from '@/setting'
export default function getPageTitle(pageTitle?: string) {
  if (pageTitle) {
    return `${pageTitle} - ${setting.title}`
  }
  return `${setting.title}`
}

七、本地响应式存储

7.1 定义

7.1.1 user.ts

// src/stores/user.ts
// 用户相关
import type { LoginUser, UserInfo } from '@/model/user'
import { login } from '@/api/user' // api 中封装的登录请求

export const userStore = defineStore('user', () => {
   // 用户信息
  const userInfo = ref<UserInfo | null>(null)
  // 登录后获取用户信息
  async function loginUser(user: LoginUser) {
    try {
      const res = await login(user)
      const { success, code, item } = res
      if (success && code == 200) {
        userInfo.value = item!
      }
      return Promise.resolve(res)
    } catch (error: any) {
      return Promise.reject(error)
    }
  }
  // 对外暴漏的属性及方法
  return { userInfo, loginUser }
})

7.1.2 permission.ts

// src/stores/permission.ts
// 权限相关
import { allRoutes } from '@/router'
import type { RouteRecordRaw } from 'vue-router'

export const permissionStore = defineStore('permission', () => {
  const perms = ref<string[]>([])

  // 这个用于后面 layout 中根据用户权限生成菜单
  const authorizedRoutes = computed(() => {
    // 根据权限过滤路由
    const filterRoutes = filterAsyncRoutes(perms.value, allRoutes)
    return filterRoutes
  })

  function setPermission(newPermission: string[]) {
    perms.value = newPermission
  }

  function $reset() {
    perms.value = []
  }

  return { perms, $reset, setPermission, authorizedRoutes }
})

export function filterAsyncRoutes(
  perms: string[],
  routes: RouteRecordRaw[]
): RouteRecordRaw[] {
  const result = <RouteRecordRaw[]>[]
  routes.forEach((e) => {
    const route = <RouteRecordRaw>{ ...e }
    if (hasPermission(perms, route)) {
      if (route.children) {
        route.children = filterAsyncRoutes(perms, route.children)
      }
      result.push(route)
    }
  })
  return result
}

/**
 * Use meta.perms to determine if the current user has permission
 * @param perms
 * @param route
 */
function hasPermission(perms: string[], route: RouteRecordRaw) {
  if (route.meta && route.meta.perms) {
    // 如果该路由设置了权限,检查用户权限
    return perms.some((perm) => route.meta?.perms?.includes(perm))
  } else {
    // 如果该路由没有设置权限,则默认所有人可见
    return true
  }
}

7.1.3 auth.ts

import { defineStore } from 'pinia'
import { login, logout, refreshToken } from '@/api/user'

import {
  getToken,
  setToken,
  removeToken,
  getRefreshToken,
  setRefreshToken,
  removeRefreshToken,
} from '@/utils/storageUtils'
import { userStore } from './user'
import { permissionStore } from '@/stores/permission'
import type { LoginUser } from '@/model/user'

export const authStore = defineStore('auth', () => {
  // token
  const token = ref(getToken())
  // refreshToken
  const rtoken = ref(getRefreshToken())

  // 检查用户状态
  async function isAuthenticated(): Promise<boolean> {
    const user = userStore()
    // Token 不为空
    if (token.value) {
      if (user.userInfo == null) {
        try {
          await user.getInfo()
        } catch (error) {
          return Promise.reject(error)
        }
      }
      // 在这个位置token本来不为空,但是,
      // token被请求返回拦截后,请求toRefreshToken()后,如果被重置为空,
      // 这证明token已经刷新失败了,不能返回true,只有token还在才是一切正常
      if (token.value) {
        return Promise.resolve(true)
      }
    }
    // 如果 Token 为空
    return Promise.resolve(false)
  }

  // 重置Token
  function resetTokens() {
    removeToken()
    removeRefreshToken()
    token.value = null
    rtoken.value = null
  }

  // 登录接口
  async function toLogin(data: LoginUser) {
    try {
      const { username, password } = data
      const res = await login({
        username: username.trim(),
        password: password,
      })
      const { success, code } = res
      if (success && code === 200) {
        const { token: tokenValue, refreshToken: refreshTokenValue } = res.item!
        // token 存到浏览器
        setToken(tokenValue)
        setRefreshToken(refreshTokenValue)
        // 存到全局变量
        token.value = tokenValue
        rtoken.value = refreshTokenValue
      }
      return Promise.resolve()
    } catch (error: any) {
      return Promise.reject(error)
    }
  }

  // 退出登录
  async function toLogout() {
    try {
      if (rtoken.value == null) return Promise.resolve()
      const res = await logout(rtoken.value)
      if (res.success && res.code === 200) {
        // 推出登录后删除 本地存储的 token
        resetTokens()
        // 删除 本地用户信息
        userStore().$reset()
        // 删除 本地权限信息
        permissionStore().$reset()

        return Promise.resolve()
      } else {
        return Promise.reject(res)
      }
    } catch (error) {
      return Promise.reject(error)
    }
  }

  // 刷新token
  async function toRefreshToken() {
    const res = await refreshToken(rtoken.value ?? '')
    const { success, code } = res
    if (success && code === 200 && res.item !== undefined) {
      const { token: tokenValue, refreshToken: refreshTokenValue } = res.item
      setToken(tokenValue)
      setRefreshToken(refreshTokenValue)
      token.value = tokenValue
      rtoken.value = refreshTokenValue
      return Promise.resolve()
    } else {
      resetTokens()
      return Promise.reject(res)
    }
  }

  return {
    token,
    isAuthenticated,
    toLogin,
    toLogout,
    toRefreshToken,
  }
})

7.2 调用

  • 页面中使用
<!-- 该页面属于 layout 中 -->
<!-- src/layout/Header.vue -->
<template>
  <div class="header">
    <el-header>
      <Breadcrumb />
        <!-- 使用本地存储中的属性 -->
      <div>{{ userStore().userInfo?.name || '测试' }}</div>
    </el-header>
  </div>
</template>

<script setup lang="ts">
// 引入 userStore
import { userStore } from '@/stores/user'
</script>
  • ts 中使用
<!-- 该页面属于 layout 中 -->
<!-- src/layout/menu/menu.ts -->
import { isExternal } from '@/utils/validate'
import { allRoutes } from '@/router'
import type { RouteRecordRaw } from 'vue-router'
import { permissionStore } from '@/stores/permission'

export const useMenu = () => {
  const menus = computed(() => {
    // 使用本地存储获取用户权限 
    const routes: RouteRecordRaw[] = permissionStore().authorizedRoutes
    // 根据权限匹配路由
    console.log(routes)
    console.log(generateMenuFromRoute(routes))
    return generateMenuFromRoute(routes)
  })
  return { menus }
}

八、全局样式

8.1 样式重置

8.1.1 index.scss

// src/assets/style/index.scss
/* 全局样式重置 */
* {
  /* 初始化 */
  padding: 0;
  margin: 0;
}

html.body {
  width: 100%;
  height: 100vh;
}
#app {
  width: 100%;
  height: 100%;
  font-family: Avenir, Helvetica, Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-align: center;
  color: #2c3e50;
}

8.1.2 reset.scss

// src/assets/style/reset.scss
/**
 * src/assets/style/reset.scss
 * Eric Meyer's Reset CSS v2.0 (http://meyerweb.com/eric/tools/css/reset/)
 * http://cssreset.com
 */
html,
body,
div,
span,
applet,
object,
iframe,
h1,
h2,
h3,
h4,
h5,
h6,
p,
blockquote,
pre,
a,
abbr,
acronym,
address,
big,
cite,
code,
del,
dfn,
em,
img,
ins,
kbd,
q,
s,
samp,
small,
strike,
strong,
sub,
sup,
tt,
var,
b,
u,
i,
center,
dl,
dt,
dd,
ol,
ul,
li,
fieldset,
form,
label,
legend,
table,
caption,
tbody,
tfoot,
thead,
tr,
th,
td,
article,
aside,
canvas,
details,
embed,
figure,
figcaption,
footer,
header,
hgroup,
menu,
nav,
output,
ruby,
section,
summary,
time,
mark,
audio,
video {
  margin: 0;
  padding: 0;
  border: 0;
  font-size: 100%;
  font: inherit;
  font-weight: normal;
  vertical-align: baseline;
}
/* HTML5 display-role reset for older browsers */
article,
aside,
details,
figcaption,
figure,
footer,
header,
hgroup,
menu,
nav,
section {
  display: flex;
}
ol,
ul,
li {
  list-style: none;
}
blockquote,
q {
  quotes: none;
}
blockquote:before,
blockquote:after,
q:before,
q:after {
  content: '';
  content: none;
}
table {
  border-collapse: collapse;
  border-spacing: 0;
}

/* custom */
a {
  color: #7e8c8d;
  text-decoration: none;
  -webkit-backface-visibility: hidden;
}
::-webkit-scrollbar {
  width: 5px;
  height: 5px;
}
::-webkit-scrollbar-track-piece {
  background-color: rgba(0, 0, 0, 0.2);
  -webkit-border-radius: 6px;
}
::-webkit-scrollbar-thumb:vertical {
  height: 5px;
  background-color: rgba(125, 125, 125, 0.7);
  -webkit-border-radius: 6px;
}
::-webkit-scrollbar-thumb:horizontal {
  width: 5px;
  background-color: rgba(125, 125, 125, 0.7);
  -webkit-border-radius: 6px;
}
html,
body {
  font-family: 'Arial', 'Microsoft YaHei', '黑体', '宋体', '微软雅黑', sans-serif;
}
body {
  line-height: 1;
  -webkit-text-size-adjust: none;
  -webkit-tap-highlight-color: rgba(0, 0, 0, 0);
}
html {
  //overflow-y: scroll;
}

/*清除浮动*/
.clearfix:before,
.clearfix:after {
  content: ' ';
  display: inline-block;
  height: 0;
  clear: both;
  visibility: hidden;
}
.clearfix {
  *zoom: 1;
}

/*隐藏*/
.dn {
  display: none;
}

8.1.3 样式引入

  • main.ts
// 引入 index.scss
import '@/assets/style/index.scss' 

import { createApp } from 'vue'
import { createPinia } from 'pinia'

import App from './App.vue'
import router from './router'

const app = createApp(App)

app.use(createPinia())
app.use(router)
app.mount('#app')
  • App.vue
<script setup lang="ts">
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
</script>

<template>
  <el-config-provider :locale="zhCn" id="app">
    <router-view />
  </el-config-provider>
</template>

<style scoped lang="scss">
/* 引入 reset.scss */
@import '@/assets/style/reset'; 
</style>

8.2 样式常量

8.2.1 variables.scss

// src/styles/variables.scss
// base
$testWith: 200px;

8.2.2 常量使用

// src/styles/test.scss
// 导入 variables.scss
@import "variables.scss";
.test{
  width: $testWith; // 使用中定义的样式常量
}

九、layout

9.1 面包屑

<!-- src/layout/Breadcrumb.vue -->
<template>
  <div class="breadcrumb">
    <el-breadcrumb class="app-breadcrumb" separator=">">
      <transition-group name="breadcrumb">
        <el-breadcrumb-item
          v-for="(item, index) in route!.matched"
          :key="index">
          <span
            v-if="
              item.redirect === 'noRedirect' ||
              index == route!.matched.length - 1
            "
            class="no-redirect">
            {{ item.meta.title }}
          </span>
          <!-- 放开后可点击 -->
          <!-- <a v-else @click.prevent="handleLink(item)">{{ item.meta.title }}</a> -->
          <span v-else>{{ item.meta.title }}</span>
        </el-breadcrumb-item>
      </transition-group>
    </el-breadcrumb>
  </div>
</template>

<script lang="ts" setup>
import { useRoute } from 'vue-router'
const route = useRoute()
</script>

<style scoped lang="scss">
@import '@/styles/layout';
</style>

9.2 导航栏

<!-- src/layout/Header.vue -->
<template>
  <div class="header">
    <el-header>
       <!-- 面包屑 自动导入 -->
      <Breadcrumb />
      <div>{{ userStore().userInfo?.name || '测试' }}</div>
    </el-header>
  </div>
</template>

<script setup lang="ts">
import { userStore } from '@/stores/user'
</script>

<style lang="scss" scoped>
@import '@/styles/layout';
</style>

9.3 侧边栏

  • menu.ts
// src/layout/menu/menu.ts
// 将菜单中的逻辑封装,简化页面代码
import { isExternal } from '@/utils/validate'
import { allRoutes } from '@/router'
import type { RouteRecordRaw } from 'vue-router'
import { permissionStore } from '@/stores/permission'

export interface Menu {
   // 标题
   title: string
   // 图标
   icon?: string
   // 路径
   path: string
   // 子目录
   children?: Menu[]
   // 隐藏
   hidden?: boolean
   // 外链
   external?: boolean
}

// 出口方法,获取到 Menu 对象
export const useMenu = () => {
   const menus = computed(() => {
       // TODO 获取用户权限
       const routes: RouteRecordRaw[] = permissionStore().authorizedRoutes
       // 根据权限匹配路由
       console.log(routes)
       console.log(generateMenuFromRoute(routes))
       return generateMenuFromRoute(routes)
   })
   return { menus }
}

// 根据路由控制菜单显示
const generateMenuFromRoute = (
  routes: RouteRecordRaw[],
  parent?: Menu
): Menu[] => {
  const result: Menu[] = []
  routes.forEach((route) => {
    // 如果路由隐藏直接返回
    if (route.meta?.hidden) {
      return
    }
    // 将当前路由转换为 menu
    const menu: Menu = routeToMenu(route, parent?.path)
    if (route.children && route.children.length > 0) {
      menu.children = generateMenuFromRoute(route.children, menu)
    }
    result.push(menu)
  })

  return result
}

// 路由转化为 Menu 对象
const routeToMenu = (route: RouteRecordRaw, basePath?: string): Menu => {
  let path: string
  if (route.path.startsWith('/')) {
    // / 开头为 / 是绝对路径
    path = route.path
  } else {
    if (route.path == '') {
      // 空路由则返回父路由
      path = basePath ?? ''
    } else {
      // 没有 / 则为相对路径
      path = basePath + '/' + route.path
    }
  }
  return <Menu>{
    title: route.meta?.title ?? '',
    icon: route.meta?.icon,
    path: path,
    external: isExternal(route.path),
    hidden: route.meta?.hidden,
  }
}
  • SideMenuItem.vue
<!-- src/layout/menu/SideMenuItem.vue -->
 <!-- 递归生成菜单 -->
 <template>
   <div>
     <el-sub-menu v-if="item.children" :index="item.path">
       <template #title>
         {{ item.title }}
       </template>
       <side-menu-item
         v-for="menu in item.children"
         :key="menu.path"
         :item="menu" />
     </el-sub-menu>
     <el-menu-item v-else :index="item.path">
       <i :class="item.icon"></i>
       {{ item.title }}
     </el-menu-item>
   </div>
 </template>
 
 <script setup lang="ts">
 import type { Menu } from '@/layout/menu/menu'
 // 父传子属性
 const props = defineProps<{
   item: Menu
 }>()
 </script>
  • SideBar.vue
<!-- src/layout/menu/SideBar.vue -->
<!-- 侧边栏结构 -->
<template>
  <div class="sideBar">
    <div class="title">后台管理系统</div>
    <el-scrollbar>
      <el-menu text-color="#fff" background-color="#304156" router>
        <side-menu-item v-for="menu in menus" :key="menu.path" :item="menu" />
      </el-menu>
    </el-scrollbar>
  </div>
</template>

<script setup lang="ts">
import { useMenu } from '@/layout/menu/menu'
const { menus } = useMenu()
</script>

<style scoped lang="scss">
@import '@/styles/layout';
</style>

9.4 页脚

<!-- src/layout/Footer.vue -->
<template>
  <div class="footer">
    <el-card>Frontend 2022 dily</el-card>
  </div>
</template>

<script lang="ts" setup></script>

<style scoped lang="scss"></style>

9.5 主体结构

由 面包屑、侧边栏、导航栏、页脚搭建组合成

<!-- src/layout/Home.vue -->
<template>
  <div class="common-layout">
    <el-container>
      <el-aside>
        <side-bar />
      </el-aside>
      <el-container>
        <Header />
        <el-main>
          <div class="cont">
            <router-view v-slot="{ Component, route }">
              <transition name="fade-transform" mode="out-in">
                <keep-alive :include="cachedViews">
                  <component :is="Component" :key="route.path" />
                </keep-alive>
              </transition>
            </router-view>
          </div>
        </el-main>
        <el-footer>
          <Footer />
        </el-footer>
      </el-container>
    </el-container>
  </div>
</template>
<script setup lang="ts">
const cachedViews = ref<string[]>([])
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style lang="scss" scoped>
@import '@/styles/layout';
</style>

十、错误页面

错误页面 仅供参考,可自行替换

10.1 404页面

<!-- src/component/error/404.vue -->
<template>
  <div class="wscn-http404-container">
    <div class="wscn-http404">
      <div class="pic-404">
        <img
          class="pic-404__parent"
          src="@/assets/404_images/404.png"
          alt="404" />
        <img
          class="pic-404__child left"
          src="@/assets/404_images/404_cloud.png"
          alt="404" />
        <img
          class="pic-404__child mid"
          src="@/assets/404_images/404_cloud.png"
          alt="404" />
        <img
          class="pic-404__child right"
          src="@/assets/404_images/404_cloud.png"
          alt="404" />
      </div>
      <div class="bullshit">
        <div class="bullshit__oops">OOPS!</div>
        <div class="bullshit__info">
          All rights reserved
          <a
            style="color: #20a0ff"
            href="https://wallstreetcn.com"
            target="_blank">
            wallstreetcn
          </a>
        </div>
        <div class="bullshit__headline">{{ message }}</div>
        <div class="bullshit__info">
          Please check that the URL you entered is correct, or click the button
          below to return to the homepage.
        </div>
        <a href="" class="bullshit__return-home">Back to home</a>
      </div>
    </div>
  </div>
</template>

<script>
export default {
  name: 'Page_404',
  computed: {
    message() {
      return 'The webmaster said that you can not enter this page...'
    },
  },
}
</script>

<style lang="scss" scoped>
.wscn-http404-container {
  transform: translate(-50%, -50%);
  position: absolute;
  top: 40%;
  left: 50%;
}
.wscn-http404 {
  position: relative;
  width: 1200px;
  padding: 0 50px;
  overflow: hidden;
  .pic-404 {
    position: relative;
    float: left;
    width: 600px;
    overflow: hidden;
    &__parent {
      width: 100%;
    }
    &__child {
      position: absolute;
      &.left {
        width: 80px;
        top: 17px;
        left: 220px;
        opacity: 0;
        animation-name: cloudLeft;
        animation-duration: 2s;
        animation-timing-function: linear;
        animation-fill-mode: forwards;
        animation-delay: 1s;
      }
      &.mid {
        width: 46px;
        top: 10px;
        left: 420px;
        opacity: 0;
        animation-name: cloudMid;
        animation-duration: 2s;
        animation-timing-function: linear;
        animation-fill-mode: forwards;
        animation-delay: 1.2s;
      }
      &.right {
        width: 62px;
        top: 100px;
        left: 500px;
        opacity: 0;
        animation-name: cloudRight;
        animation-duration: 2s;
        animation-timing-function: linear;
        animation-fill-mode: forwards;
        animation-delay: 1s;
      }
      @keyframes cloudLeft {
        0% {
          top: 17px;
          left: 220px;
          opacity: 0;
        }
        20% {
          top: 33px;
          left: 188px;
          opacity: 1;
        }
        80% {
          top: 81px;
          left: 92px;
          opacity: 1;
        }
        100% {
          top: 97px;
          left: 60px;
          opacity: 0;
        }
      }
      @keyframes cloudMid {
        0% {
          top: 10px;
          left: 420px;
          opacity: 0;
        }
        20% {
          top: 40px;
          left: 360px;
          opacity: 1;
        }
        70% {
          top: 130px;
          left: 180px;
          opacity: 1;
        }
        100% {
          top: 160px;
          left: 120px;
          opacity: 0;
        }
      }
      @keyframes cloudRight {
        0% {
          top: 100px;
          left: 500px;
          opacity: 0;
        }
        20% {
          top: 120px;
          left: 460px;
          opacity: 1;
        }
        80% {
          top: 180px;
          left: 340px;
          opacity: 1;
        }
        100% {
          top: 200px;
          left: 300px;
          opacity: 0;
        }
      }
    }
  }
  .bullshit {
    position: relative;
    float: left;
    width: 300px;
    padding: 30px 0;
    overflow: hidden;
    &__oops {
      font-size: 32px;
      font-weight: bold;
      line-height: 40px;
      color: #1482f0;
      opacity: 0;
      margin-bottom: 20px;
      animation-name: slideUp;
      animation-duration: 0.5s;
      animation-fill-mode: forwards;
    }
    &__headline {
      font-size: 20px;
      line-height: 24px;
      color: #222;
      font-weight: bold;
      opacity: 0;
      margin-bottom: 10px;
      animation-name: slideUp;
      animation-duration: 0.5s;
      animation-delay: 0.1s;
      animation-fill-mode: forwards;
    }
    &__info {
      font-size: 13px;
      line-height: 21px;
      color: grey;
      opacity: 0;
      margin-bottom: 30px;
      animation-name: slideUp;
      animation-duration: 0.5s;
      animation-delay: 0.2s;
      animation-fill-mode: forwards;
    }
    &__return-home {
      display: block;
      float: left;
      width: 110px;
      height: 36px;
      background: #1482f0;
      border-radius: 100px;
      text-align: center;
      color: #ffffff;
      opacity: 0;
      font-size: 14px;
      line-height: 36px;
      cursor: pointer;
      animation-name: slideUp;
      animation-duration: 0.5s;
      animation-delay: 0.3s;
      animation-fill-mode: forwards;
    }
    @keyframes slideUp {
      0% {
        transform: translateY(60px);
        opacity: 0;
      }
      100% {
        transform: translateY(0);
        opacity: 1;
      }
    }
  }
}
</style>

  • src/assets/404_images
    404.png
    在这里插入图片描述

404_cloud.png

在这里插入图片描述

10.2 401页面

<!-- src/component/error/401.vue -->
<template>
  <div class="errPage-container">
    <el-button icon="el-icon-arrow-left" class="pan-back-btn" @click="back">
      返回
    </el-button>
    <el-row>
      <el-col :span="12">
        <h1 class="text-jumbo text-ginormous">Oops!</h1>
        gif来源
        <a href="https://zh.airbnb.com/" target="_blank">airbnb</a>
        页面
        <h2>你没有权限去该页面</h2>
        <h6>如有不满请联系你领导</h6>
        <ul class="list-unstyled">
          <li>或者你可以去:</li>
          <li class="link-type">
            <router-link to="/dashboard">回首页</router-link>
          </li>
          <li class="link-type">
            <a href="https://www.taobao.com/">随便看看</a>
          </li>
          <li>
            <a href="#" @click.prevent="dialogVisible = true">点我看图</a>
          </li>
        </ul>
      </el-col>
      <el-col :span="12">
        <img
          :src="errGif"
          width="313"
          height="428"
          alt="Girl has dropped her ice cream." />
      </el-col>
    </el-row>
    <el-dialog v-model:visible="dialogVisible" title="随便看">
      <img :src="ewizardClap" class="pan-img" />
    </el-dialog>
  </div>
</template>

<script>
import errGif from '@/assets/401_images/401.gif'

export default {
  name: 'Page_401',
  data() {
    return {
      errGif: errGif + '?' + +new Date(),
      ewizardClap:
        'https://wpimg.wallstcn.com/007ef517-bafd-4066-aae4-6883632d9646',
      dialogVisible: false,
    }
  },
  methods: {
    back() {
      if (this.$route.query.noGoBack) {
        this.$router.push({ path: '/dashboard' })
      } else {
        this.$router.go(-1)
      }
    },
  },
}
</script>

<style lang="scss" scoped>
.errPage-container {
  width: 800px;
  max-width: 100%;
  margin: 100px auto;
  .pan-back-btn {
    background: #008489;
    color: #fff;
    border: none !important;
  }
  .pan-gif {
    margin: 0 auto;
    display: block;
  }
  .pan-img {
    display: block;
    margin: 0 auto;
    width: 100%;
  }
  .text-jumbo {
    font-size: 60px;
    font-weight: 700;
    color: #484848;
  }
  .list-unstyled {
    font-size: 14px;
    li {
      padding-bottom: 5px;
    }
    a {
      color: #008489;
      text-decoration: none;
      &:hover {
        text-decoration: underline;
      }
    }
  }
}
</style>
  • src/assets/401_images

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-5HyaOxg3-1684399802012)(img/401.gif)]

十一、系统设置

11.1 setting.ts

// src/setting.ts
export default {
  title: '后台管理系统',

  /**
   * @type {boolean} true | false
   * @description Whether show the settings right-panel
   */
  showSettings: false,

  /**
   * @type {boolean} true | false
   * @description Whether need tagsView
   */
  tagsView: false,

  /**
   * @type {boolean} true | false
   * @description Whether fix the header
   */
  fixedHeader: true,

  /**
   * @type {boolean} true | false
   * @description Whether show the logo in sidebar
   */
  sidebarLogo: true,

  /**
   * @type {string | array} 'production' | ['production', 'development']
   * @description Need show err logs component.
   * The default is only used in the production env
   * If you want to also use it in dev, you can pass ['production', 'development']
   */

  pageSize: 10,
  sidebarCollapse: true,
  errorLog: 'production',
}

11.2 App.vue

<!-- src/App.vue -->
<script setup lang="ts">
    // 设置 element-plus 中使用中文
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
</script>

<template>
<!-- el-config-provider 配置使用中文 -->
  <el-config-provider :locale="zhCn" id="app">
    <router-view />
  </el-config-provider>
</template>

<style scoped lang="scss">
/** 重置样式 **/
@import '@/assets/style/reset';
</style>

11.3 main.ts

// src/main.ts
// 引入全局样式
import '@/assets/style/index.scss'

import { createApp } from 'vue'
import { createPinia } from 'pinia'

import App from './App.vue'
// 引入路由
import router from './router'
// 引入路由守护
import './router/routerGuard'

const app = createApp(App)
// 使用 pinia
app.use(createPinia())
// 使用 router
app.use(router)

app.mount('#app')
风语者!平时喜欢研究各种技术,目前在从事后端开发工作,热爱生活、热爱工作。