Vue 3 脚手架搭建
- 一、环境准备
- 1.1 node.js 安装
- 1.2 包管理工具安装:可选
- 二、创建项目
- 2.1 使用 npm
- 2.2 使用 yarn
- 三、配置项目
- 3.1 安装初始依赖
- 3.2 增加依赖
- 3.2 配置自动导包
- 3.3 配置 eslint 规则
- 3.4 .vue 文件导入报错解决
- 四、路由配置
- 4.1 路由类型定义
- 4.2 index.ts 路由主入口
- 4.3 system.ts 自定义路由
- 五、常用类型定义
- 5.1 response.d.ts
- 5.2 user.d.ts 自定义返回类型
- 六、二次封装 axios
- 6.1 httpClient.ts
- 6.2 httpClient 实例
- 七、本地响应式存储
- 7.1 定义
- 7.2 调用
- 八、全局样式
- 8.1 样式重置
- 8.1.1 index.scss
- 8.1.2 reset.scss
- 8.1.3 样式引入
- 8.2 样式常量
- 8.2.1 variables.scss
- 8.2.2 常量使用
- 九、layout
- 9.1 面包屑
- 9.2 导航栏
- 9.3 侧边栏
- 9.4 页脚
- 9.5 主体结构
- 十、错误页面
- 10.1 404页面
- 10.2 401页面
技术栈
- vite
- vue3
- typescript
- vue-router4
- pinia
- axios
- eslint
- prettier
项目结构:
src : 项目代码
api :封装的网络请求
assets:静态文件
components:组件
layout:主体结构
model: 类型定义,严格遵守 ts 准则
router: 路由管理
stores: 全局变量管理
utils: 工具包
-httpClient.ts : axios 二次封装
views:页面
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 / Yes
✔ Add JSX Support? … No / Yes
✔ Add Vue Router for Single Page Application development? … No / Yes
✔ Add Pinia for state management? … No / Yes
✔ Add Vitest for Unit testing? … No / Yes
✔ Add Cypress for both Unit and End-to-End testing? … No / Yes
✔ Add ESLint for code quality? … No / Yes
✔ Add 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
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 中引入
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],
}
五、常用类型定义
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 自定义返回类型
// 用户信息,用于存放在全局变量
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
}
六、二次封装 axios
6.1 httpClient.ts
// src/utils/httpClient.ts
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.2 httpClient 实例
// src/api/user.ts
import httpClient from '@/utils/httpClient'
// 以下类型在 src/model 中定义
import type { BaseResponse, ItemResponse } from '@/model/response'
import type { LoginUser, RegisterUser, UserInfo } from '@/model/user'
/**
* 登录
* @param user 用户
* @returns {BaseResponse} 登录结果
*/
export const login: (user: LoginUser) => Promise<ItemResponse<UserInfo>> = (
user
) => {
return httpClient.post('/api/gateway/login', null, {
params: {
username: user.username,
password: user.password,
},
})
}
/**
* @param user
*/
export const logout: () => Promise<BaseResponse> = () => {
return httpClient.get('/api/gateway/logout')
}
/**
* 注册
* @param user 用户
* @returns {Promise<BaseResponse>} 注册结果
*/
export const register: (
user: RegisterUser
) => Promise<ItemResponse<RegisterUser>> = (user) => {
return httpClient.post('/api/gateway/user-service/user/registerUser', user)
}
七、本地响应式存储
7.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 }
})
- 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.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
401.gif