前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >Vue3+Element-plus前端学习笔记-巨长版

Vue3+Element-plus前端学习笔记-巨长版

作者头像
梦无矶小仔
发布2024-03-25 15:08:38
4150
发布2024-03-25 15:08:38
举报

Vue3编写的前端admin模版

「写好的代码」:Lvan826199/mwj-vue3-project: vue3-vite构建的一个前端模版 (github.com)

学习开源地址:https://github.com/Lvan826199/mwj-vue3-project

主要技术栈:「Vue3+Vite+Element-plus+Pinia+Axios+Js」

此项目用作学习,由于没有构建后端,部分项目数据写死在前端,动态路由部分有bug还没修,主要是一个简要的前端后台管理模版学习,记录一下,自己受益良多。

后面计划专注于一个项目架构进行学习,东学一下西学一下现在成傻子了。

Vue3+Vite项目搭建

Vue3+Vite,我本地的node和npm版本如下。

代码语言:javascript
复制
node v18.16.1
npm 9.5.1

vite官网文档:https://cn.vitejs.dev/guide/

项目搭建

通过附加的命令行选项直接指定项目名称和你想要使用的模板

代码语言:javascript
复制
# npm 7+, extra double-dash is needed:
npm create vite@latest mwj-vue3-project -- --template vue

输入命令后的显示如下:

代码语言:javascript
复制
D:\Y_WebProject>npm create vite@latest mwj-vue3-project -- --template vue
Need to install the following packages:
  create-vite@5.2.2
Ok to proceed? (y) y

Scaffolding project in D:\Y_WebProject\mwj-vue3-project...

Done. Now run:

  cd mwj-vue3-project
  npm install
  npm run dev

注:上面是以最新的vite版本安装,如果需要制定vite4安装,可以使用如下命令

代码语言:javascript
复制
npm create vite@4.3.0 mwj-vue3-project -- --template vue

安装依赖

命令行输入:

代码语言:javascript
复制
cd mwj-vue3-project
npm install
或
npm i

输入命令后的显示如下:

代码语言:javascript
复制
D:\Y_WebProject>cd mwj-vue3-project

D:\Y_WebProject\mwj-vue3-project>npm install

added 27 packages in 2m

运行项目

代码语言:javascript
复制
npm run dev

输入命令后的显示如下:

代码语言:javascript
复制
 VITE v5.1.6  ready in 403 ms

  ➜  Local:   http://localhost:5173/
  ➜  Network: use --host to expose
  ➜  press h + enter to show help

「浏览器展示」

在浏览器输入http://localhost:5173/,显示如下表示你构建成功了

VScode配置及代码配置

使用VSCode打开我们搭建好的项目,点击信任

目录展示

删除我们不需要的文件

  • 把asserts下的内容删除
  • 把HelloWorld.vue删除

VScode插件配置

vue插件:Vue - Official

简体中文插件:Chinese(Slimplified)

代码格式化

Prettier:代码格式化工具

  • https://github.com/prettier/prettier
  • https://prettier.io/

在VScode插件拓展中安装Prettier,点击安装即可

image

接着我们还需要再项目依赖中下载Prettier

代码语言:javascript
复制
npm install --save-dev --save-exact prettier
npm install eslint --save-dev

输入命令后的显示如下:

配置格式化文件

https://prettier.io/docs/en/configuration.html

可以在 https://prettier.io/playground/ 中测试效果,然后拷贝配置内容到自己的项目中

1、在项目根目录下新建.prettierrc.json文件,写入以下内容

代码语言:javascript
复制
{
  "arrowParens": "always",
  "bracketSameLine": true,
  "bracketSpacing": true,
  "semi": true,
  "singleQuote": true,
  "jsxSingleQuote": false,
  "quoteProps": "as-needed",
  "trailingComma": "all",
  "singleAttributePerLine": false,
  "htmlWhitespaceSensitivity": "css",
  "vueIndentScriptAndStyle": false,
  "proseWrap": "preserve",
  "insertPragma": false,
  "requirePragma": false,
  "useTabs": false,
  "embeddedLanguageFormatting": "auto",
  "tabWidth": 2,
  "printWidth": 200
}

2、在项目根目录下新建.prettierignore文件,该文件是忽略哪些文件夹下的内容不被格式化。

代码语言:javascript
复制
/dist/*
/node_modules/**
**/*.svg
/public/*

3、保存时自动格式化代码配置

打开vue文件,右键选择使用...格式化文档 - > 配置默认格式化方式 -> 选择对应的就可以

统一代码风格

需要安装插件EditorConfig for VS Code

image

.editorconfig 是一个配置文件,用于统一编辑器的格式化规则和代码风格。它可以帮助团队成员在不同的编辑器中编写代码时保持一致的格式。

.editorconfig 文件通常放置在项目根目录下,它使用简单的键值对格式来指定编辑器的规则,例如缩进大小、换行符类型、文件编码等。

在项目根目录下新建.editorconfig文件

代码语言:javascript
复制
# https://editorconfig.org
# 根目录配置,表示当前目录是编辑器配置的根目录
root = true

[*] # 对所有文件应用以下配置
charset = utf-8 # 使用 UTF-8 编码
indent_style = space # 使用空格进行缩进
indent_size = 2 # 每个缩进级别使用 2 个空格
end_of_line = lf # 使用 LF(Linux 和 macOS 的换行符)
insert_final_newline = true # 在文件末尾插入一行空白
trim_trailing_whitespace = true # 自动删除行末尾的空白字符

[*.md] # 对扩展名为 .md 的 Markdown 文件应用以下配置
insert_final_newline = false # 不在文件末尾插入一行空白
trim_trailing_whitespace = false # 不自动删除行末尾的空白字符

其他

ESLint

我自己的项目,不搞这么多规范,懒

参考文档:

https://github.com/eslint/eslint

  • vue3+vite项目配置ESlint
  • vue3+vite项目配置ESlint、pritter插件
  • 从项目规范(eslint + prettier)到自动化配置

Vite项目配置

  1. 配置路径别名
  2. 反向代理解决跨域问题
  3. 配置项目运行端口
  4. 环境变量
  5. 解决 import { ref , reactive ..... } from 'vue' 大量引入的问题
代码语言:javascript
复制
// 解决 `import { ref , reactive ..... } from 'vue'` 大量引入的问题
npm i -D unplugin-auto-import

// -D 参数表示将这个插件作为开发依赖(devDependency)安装

.env.dev 环境变量,在项目根目录下创建,写入如下内容

代码语言:javascript
复制
# 开发环境
NODE_ENV='dev'

# 为了防止意外地将一些环境变量泄漏到客户端,只有以 VITE_ 为前缀的变量才会暴露给经过 vite 处理的代码。
# js中通过`import.meta.env.VITE_APP_BASE_API`取值
VITE_APP_PORT = 5173
VITE_APP_BASE_API = '/dev-api'
VITE_APP_BASE_FILE_API = '/dev-api/web/api/system/file/upload'

# 后端服务地址
VITE_APP_SERVICE_API = 'http://localhost:8888'

package.json,在项目根目录下已经存在,我们只需要进行增加内容,这样可以通过上面的这个配置文件进行启动。

代码语言:javascript
复制
"scripts": {
    "dev": "vite --mode dev", // 使用.env.dev启动
    "prod": "vite --mode prod", // 使用.env.prod启动,需要自己在根目录新建,类似上面的.env.dev
    "build": "vite build --mode prod",
    "preview": "vite preview"
}
  • devDependencies: 里面的插件只用于开发环境,不用于生产环境
  • dependencies: 需要发布到生产环境的

启动项目遇到unplugin-auto-importreactivity-transform报错请先下载对应的包,下面代码里面写了。

代码语言:javascript
复制
npm i -D unplugin-auto-import
npm i -D @vue-macros/reactivity-transform

vite.config.js,在项目根目录下,修改为如下内容

代码语言:javascript
复制
import { defineConfig, loadEnv } from 'vite';
import vue from '@vitejs/plugin-vue';

import * as path from 'path';

// npm i -D unplugin-auto-import
import AutoImport from 'unplugin-auto-import/vite';

// npm i -D @vue-macros/reactivity-transform
import ReactivityTransform from '@vue-macros/reactivity-transform/vite';

// https://vitejs.dev/config/
export default defineConfig(({ mode }) => {
  // 获取`.env`环境配置文件
  const env = loadEnv(mode, process.cwd());

  return {
    plugins: [
      vue(),
      ReactivityTransform(), // 启用响应式语法糖 $ref ...
      // 解决 `import { ref , reactive ..... } from 'vue'` 大量引入的问题
      AutoImport({
        imports: ['vue', 'vue-router'],
      }),
    ],
    // 反向代理解决跨域问题
    server: {
      // host: 'localhost', // 只能本地访问
      host: '0.0.0.0', // 局域网别人也可访问
      port: Number(env.VITE_APP_PORT),
      // 运行时自动打开浏览器
      // open: true,
      proxy: {
        [env.VITE_APP_BASE_API]: {
          target: env.VITE_APP_SERVICE_API,
          changeOrigin: true,
          rewrite: (path) => path.replace(new RegExp('^' + env.VITE_APP_BASE_API), ''),
        },
      },
    },
    resolve: {
      // 配置路径别名
      alias: [
        // @代替src
        {
          find: '@',
          replacement: path.resolve('./src'),
        },
      ],
    },
    // 引入scss全局变量
    // css: {
    //   preprocessorOptions: {
    //     scss: {
    //       additionalData: `@import "@/styles/color.scss";@import "@/styles/theme.scss";`,
    //     },
    //   },
    // },
  };
});

自定义styles样式

tips: 可使用原子化CSS减轻代码量

一、安装

代码语言:javascript
复制
npm install sass --save-dev

二、scss使用循环实现动态样式

删除src根目录下的style.css样式文件,新建styles文件夹,新建以下样式文件。

具体参考开源地址下的styles文件夹

Vue Router路由

官方中文网站

https://router.vuejs.org/zh

一、安装

Vue Router 4 是专为 Vue 3 设计的,因此请确保你的项目使用的是 Vue 3。

代码语言:javascript
复制
npm install vue-router@4

二、入门配置

1、在src目录下新建router/index.js

代码语言:javascript
复制
import {createRouter, createWebHashHistory} from 'vue-router';

// 本地静态路由
export const constantRoutes = [
  {
    path: '/login',
    component: () => import('@/views/login/index.vue'),
    meta: {
      isParentView: true,
    },
  },
  {
    path: '/test',
    component: () => import('@/views/test/index.vue'),
  },
  {
    // path: '/404',
    path: '/:pathMatch(.*)*', // 防止浏览器刷新时路由未找到警告提示: vue-router.mjs:35 [Vue Router warn]: No match found for location with path "/xxx"
    component: () => import('@/views/error-page/404.vue'),
  },
];

// 创建路由
const router = createRouter({
  history: createWebHashHistory(),
  routes: constantRoutes,
});

export default router;

2、在src根目录下的main.js中新增router代码

代码语言:javascript
复制
import { createApp } from 'vue'
import App from './App.vue'
// 路由
import router from '@/router';
const app = createApp(App);
app.use(router);

// 注意,要先使用所需要的内容,自后在挂载到页面上,才能正常显示
// 这一行始终保持在最后一行就行
app.mount('#app')

3、在src根目录下新建views/error-page/404.vue

代码语言:javascript
复制
<template>
  <h1>404</h1>
</template>

4、在src根目录下新建views/test/index.vue

代码语言:javascript
复制
<template>
  <h1>hello-test</h1>
</template>

5、在src根目录下新建views/login/index.vue

代码语言:javascript
复制
<template>
  <h1>我是登录页面</h1>
</template>

6、修改src/App.vue

代码语言:javascript
复制
<template>
  <h1>当前路由信息:{{ $route }}</h1>

  <!--使用 router-link 组件进行导航 -->
  <!--通过传递 `to` 来指定链接 -->
  <ol>
    <li><router-link to="/">Go to Home</router-link></li>
    <li><router-link to="/test">Go to Test</router-link></li>
    <li><router-link to="/login">Go to login</router-link></li>
  </ol>
  <!-- 路由出口 -->
  <!-- 路由匹配到的组件将渲染在这里 -->
  <router-view></router-view>
</template>
  • js获取当前路由信息 proxy.$route
  • js跳转 proxy.$router.push({ path: '/' });

访问 http://localhost:5173/#/test

mixin混入

全局混入是一种在多个组件中共享相同逻辑的方式,可以将一些通用的方法、生命周期钩子等混入到所有页面和组件中,以简化代码的编写和维护。

等于抽取公共属性、方法...

新建src/utils/mixin.js

代码语言:javascript
复制
// 抽取公用的实例 - 操作成功与失败消息提醒内容等
export default {
    data() {
      return {
        sexList: [
          { name: '不想说', value: 0 },
          { name: '男', value: 1 },
          { name: '女', value: 2 },
        ],
      };
    },
    methods: {
      // 操作成功消息提醒内容
      submitOk(msg, cb) {
        console.log("点击成功");
      },
      // 操作失败消息提醒内容
      submitFail(msg) {
        console.log("点击失败");
      },
    },
  };

局部混入

代码语言:javascript
复制
<script>
import mixin from '@/utils/mixin.js';
export default {
    mixins: [mixin],
};
</script>


<script setup>
const { proxy } = getCurrentInstance();

async function submit() { 
    proxy.submitOk('保存成功');
}
</script>

全局混入

src/main.js新增代码

代码语言:javascript
复制
// 混入 -- 抽取公用的实例(操作成功与失败消息提醒内容等)
import mixin from '@/utils/mixin';
app.mixin(mixin);

完整代码

代码语言:javascript
复制
import { createApp } from 'vue'
import App from './App.vue'

const app = createApp(App);

// 路由
import router from '@/router';
app.use(router);

// 自定义样式
import '@/styles/index.scss';

// 混入 -- 抽取公用的实例(操作成功与失败消息提醒内容等)
import mixin from '@/utils/mixin';
app.mixin(mixin);

// 注意,要先使用所需要的内容,自后在挂载到页面上,才能正常显示
// 这一行始终保持在最后一行就行
app.mount('#app')

使用

修改views/test/index.vue

代码语言:javascript
复制
<template>
  <h1>{{ sexList }}</h1>
  <button @click="handleClick">click</button>
</template>

<script setup>
import { getCurrentInstance } from 'vue';
const { proxy } = getCurrentInstance();

async function handleClick() {
  proxy.submitOk('保存成功');
  proxy.submitFail('操作失败');
}
</script>

「效果展示」

image

全局过滤器

当后端返回的数据格式不是前端想要的时候,可以将返回的数据处理成自己需要的格式

src/utils/filters.js

代码语言:javascript
复制
export const filters = {
  // 获取性别值
  sexName: (sex) => {
    // 拿到mixin混入的属性值
    const { proxy } = getCurrentInstance();
    let result = proxy.sexList.find((obj) => obj.value == sex);
    return result ? result.name : '数据丢失';
  },
};

src/main.js

通过 app.config.globalProperties 来注册一个全局都能访问到的属性

代码语言:javascript
复制
// 全局过滤器
import { filters } from '@/utils/filters.js';
app.config.globalProperties.$filters = filters;

使用

views/test/index.vue加入

代码语言:javascript
复制
<h1>{{ $filters.sexName(1) }}</h1>

传入的是1,页面显示的是男,传入0,页面是不想说

集成Element Plus

https://element-plus.org/zh-CN/

一、安装

代码语言:javascript
复制
npm install element-plus --save
npm install @element-plus/icons-vue

二、配置

src/main.js

代码语言:javascript
复制
// element-plus
import ElementPlus from 'element-plus';
import 'element-plus/dist/index.css';
app.use(ElementPlus);
// 注册所有图标
import * as ElementPlusIconsVue from '@element-plus/icons-vue';
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
  app.component(key, component);
}

三、测试

依然去我们的src/views/test/index.vue

代码语言:javascript
复制
<template>
  <h1>hello</h1>
  <el-row class="mb-4">
    <el-button>Default</el-button>
    <el-button type="primary">Primary</el-button>
    <el-button type="success">Success</el-button>
    <el-button type="info">Info</el-button>
    <el-button type="warning">Warning</el-button>
    <el-button type="danger">Danger</el-button>
  </el-row>
  <el-icon :size="100" color="red">
    <Edit />
  </el-icon>
</template>

显示如下

修改src/utils/mixin.js文件内容,因为notify和message是依赖element-plus组件的,前面我们下载好了,这里就可以用了。

代码语言:javascript
复制
// 抽取公用的实例 - 操作成功与失败消息提醒内容等
export default {
    data() {
      return {
        sexList: [
          { name: '不想说', value: 0 },
          { name: '男', value: 1 },
          { name: '女', value: 2 },
        ],
      };
    },
    methods: {
      // 操作成功消息提醒内容
      submitOk(msg, cb) {
        this.$notify({
          title: '成功',
          message: msg || '操作成功!',
          type: 'success',
          duration: 2000,
          onClose: function () {
            cb && cb();
          },
        });
        console.log("点击成功");
      },
      // 操作失败消息提醒内容
      submitFail(msg) {
        this.$message({
          message: msg || '网络异常,请稍后重试!',
          type: 'error',
        });
        console.log("点击失败");
      },
    },
  };

这时候在测试就发现会有消息提示了。

Pinia

Pinia 是 Vue.js 应用程序的状态管理库,设计为 Vuex 的继任者。随着 Vue 3 的发布,Pinia 被推荐为 Vue 3 应用的官方状态管理解决方案。它提供了一种组织和管理前端应用状态的方式,特别是在复杂的单页应用(SPA)中。Pinia 以其简洁的 API、更好的 TypeScript 集成和轻量级的设计而受到社区的欢迎。

https://pinia.vuejs.org/zh/

一、安装

代码语言:javascript
复制
npm install pinia

二、配置

src/main.js

代码语言:javascript
复制
// pinia
import { createPinia } from 'pinia';
const pinia = createPinia();
app.use(pinia);
// store
import store from '@/store';
app.config.globalProperties.$store = store;

三、使用

新建src/store/index.js

store模块化

代码语言:javascript
复制
// 拿到modules下的所有文件
// const modulesFiles = import.meta.globEager('./modules/*.*');
const modulesFiles = import.meta.glob('./modules/*.*', {eager: true});
const modules = {};
for (const key in modulesFiles) {
  const moduleName = key.replace(/(.*\/)*([^.]+).*/gi, '$2');
  const value = modulesFiles[key];
  modules[moduleName] = value;
  console.log(modules);
}

export default modules;

新建src/store/modules/test.js

代码语言:javascript
复制
import { defineStore } from 'pinia';

export const useTestStore = defineStore('test', () => {
  const count = ref(0);
  function add() {
    count.value++;
  }
  return { count, add };
});

四、测试

之前我们都写在views/test/index.vue下了,但是里面有个proxy变量声明重复了会报错,要么重新改一个,要么就把之前的复制一份重写如下。

代码语言:javascript
复制
<template>
  <h1>{{ count }}</h1>
  <button @click="handleClick">click</button>
  <br />
  <h1>{{ $store.test.useTestStore().count }}</h1>
  <button @click="$store.test.useTestStore().add">click</button>
</template>

<script setup>
const { proxy } = getCurrentInstance();
let useTestStore = proxy.$store.test.useTestStore();
let { count } = toRefs(useTestStore); // 响应式
let { add } = useTestStore;

function handleClick() {
  add();
}
</script>

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

五、持久化存储

「pinia-plugin-persistedstate」

上面的配置浏览器一刷新数据就丢了,所以配置下持久化存储。

https://prazdevs.github.io/pinia-plugin-persistedstate/zh/

代码语言:javascript
复制
npm i pinia-plugin-persistedstate

src/main.js新增代码

代码语言:javascript
复制
// 持久化存储
import { createPersistedState } from 'pinia-plugin-persistedstate';
pinia.use(
  createPersistedState({
    auto: true, // 启用所有 Store 默认持久化
  }),
);

配置好后再去点击会保存在本地缓存中

「tips: pinia持久化的无法通过 window.localStorage.clear(); 一键清空数据」

代码语言:javascript
复制
window.localStorage.setItem('user2', 'hello');
// window.localStorage.removeItem('user2');

// tips: pinia持久化的无法通过这种方式清空数据,只能删除同样方式存储的值 eg: window.localStorage.setItem('user2', 'hello');
window.localStorage.clear();
window.sessionStorage.clear();

$reset() 清空数据

https://pinia.vuejs.org/zh/core-concepts/state.html#resetting-the-state

举个栗子:

代码语言:javascript
复制
import store from '@/store';

// 退出登录
function logout() {
  isLogin.value = false;
  // 清空当前store在pinia中持久化存储的数据
  this.$reset();
  
  // 其它store
  store.settings.useSettingsStore().$reset();
  
  // 最终真正清空storage数据
  window.localStorage.clear();
  window.sessionStorage.clear();
}

组合式api中直接使用 $reset() 会报如下错:

解决:

  1. 改用选项式api
  2. 重写 $reset 方法

src/main.js

代码语言:javascript
复制
// 重写 $reset 方法 => 解决组合式api中无法使用问题
pinia.use(({ store }) => {
  const initialState = JSON.parse(JSON.stringify(store.$state));
  store.$reset = () => {
    store.$patch(initialState);
  };
});
app.use(pinia);

axios和api封装

axios中文文档 http://www.axios-js.com/zh-cn/docs

一、安装

代码语言:javascript
复制
npm install axios

二、axios工具封装

src/utils/request.js

代码语言:javascript
复制
import axios from 'axios';
import { ElMessage, ElMessageBox } from 'element-plus';
import store from '@/store';
import { localStorage } from '@/utils/storage';

// 创建axios实例
const service = axios.create({
  baseURL: import.meta.env.VITE_APP_BASE_API,
  timeout: 50000, // 请求超时时间:50s
  headers: { 'Content-Type': 'application/json;charset=utf-8' },
});

// 请求拦截器
service.interceptors.request.use(
  (config) => {
    if (!config.headers) {
      throw new Error(`Expected 'config' and 'config.headers' not to be undefined`);
    }

    // const { isLogin, tokenObj } = toRefs(store.user.useUserStore());

    // if (isLogin.value) {
    //   // 授权认证
    //   config.headers[tokenObj.value.tokenName] = tokenObj.value.tokenValue;
    //   // 租户ID
    //   config.headers['TENANT_ID'] = '1';
    //   // 微信公众号appId
    //   config.headers['appId'] = localStorage.get('appId');
    // }
    return config;
  },
  (error) => {
    return Promise.reject(error);
  },
);

// 响应拦截器
service.interceptors.response.use(
  (response) => {
    const res = response.data;
    const { code, msg } = res;
    if (code === 200) {
      return res;
    } else {
      // token过期
      if (code === -1) {
        handleError();
      } else {
        ElMessage({
          message: msg || '系统出错',
          type: 'error',
          duration: 5 * 1000,
        });
      }
      return Promise.reject(new Error(msg || 'Error'));
    }
  },
  (error) => {
    console.log('请求异常:', error);
    const { msg } = error.response.data;
    // 未认证
    if (error.response.status === 401) {
      handleError();
    } else {
      ElMessage({
        message: '网络异常,请稍后再试!',
        type: 'error',
        duration: 5 * 1000,
      });
      return Promise.reject(new Error(msg || 'Error'));
    }
  },
);

// 统一处理请求响应异常
function handleError() {
  // const { isLogin, logout } = store.user.useUserStore();
  // if (isLogin) {
  //   ElMessageBox.confirm('您的登录账号已失效,请重新登录', {
  //     confirmButtonText: '再次登录',
  //     cancelButtonText: '取消',
  //     type: 'warning',
  //   }).then(() => {
  //     logout();
  //   });
  // }
}

// 导出实例
export default service;

src/utils/storage.js :浏览器永久存储、浏览器本地存储

代码语言:javascript
复制
/**
 * window.localStorage => 浏览器永久存储,用于长久保存整个网站的数据,保存的数据没有过期时间,直到手动去删除。
 */
export const localStorage = {
  set(key, val) {
    window.localStorage.setItem(key, JSON.stringify(val));
  },
  get(key) {
    const json = window.localStorage.getItem(key);
    return JSON.parse(json);
  },
  remove(key) {
    window.localStorage.removeItem(key);
  },
  clear() {
    window.localStorage.clear();
  },
};

/**
 * window.sessionStorage => 浏览器本地存储,数据保存在当前会话中,在关闭窗口或标签页之后将会删除这些数据。
 */
export const sessionStorage = {
  set(key, val) {
    window.sessionStorage.setItem(key, JSON.stringify(val));
  },
  get(key) {
    const json = window.sessionStorage.getItem(key);
    return JSON.parse(json);
  },
  remove(key) {
    window.sessionStorage.removeItem(key);
  },
  clear() {
    window.sessionStorage.clear();
  },
};

三、api封装

src/api/index.js

代码语言:javascript
复制
// 拿到所有api
// const modulesFiles = import.meta.globEager('./*/*.*'); // vite4.0写法
const modulesFiles = import.meta.glob('./*/*.*', {eager: true}); // vite5.0写法
const modules = {};
for (const key in modulesFiles) {
  const moduleName = key.replace(/(.*\/)*([^.]+).*/gi, '$2');
  const value = modulesFiles[key];
  if (value.default) {
    // 兼容js
    modules[moduleName] = value.default;
  } else {
    // 兼容ts
    modules[moduleName] = value;
  }
}
// console.log(666, modules);
export default modules;

src/main.js

代码语言:javascript
复制
// 配置全局api
import api from '@/api'
app.config.globalProperties.$api = api

四、测试

这个需要有后端接口

src/api/test/demo.js

代码语言:javascript
复制
import request from '@/utils/request';

export default {
  time() {
    return request({
      url: '/api/test/time',
      method: 'get',
    });
  },
};

页面

代码语言:javascript
复制
<template>
  <button @click="handleClick">click</button>
  <h1>{{ res }}</h1>
</template>

<script setup>
import { getCurrentInstance } from 'vue';
const { proxy } = getCurrentInstance();
let res = $ref(null);

async function handleClick() {
  res = await proxy.$api.demo.time();
}
</script>

全局组件

一、全局组件注册

src/components/index.js

代码语言:javascript
复制
// const modulesFiles = import.meta.globEager('./*/*.vue');
const modulesFiles = import.meta.glob('./*/*.vue',{eager: true});

const modules = {};
for (const key in modulesFiles) {
  const moduleName = key.replace(/(.*\/)*([^.]+).*/gi, '$2');
  const value = modulesFiles[key];
  modules[moduleName] = value.default;
}

// console.log(666, modules);
export default modules;

src/main.js

代码语言:javascript
复制
// 全局组件注册
import myComponent from '@/components/index';
Object.keys(myComponent).forEach((key) => {
  app.component(key, myComponent[key]);
});

二、组件示例

src/components/base/BaseNoData.vue

代码语言:javascript
复制
<template>
  <div>
    <slot>暂无数据</slot>
  </div>
</template>

引用,直接在app.vue的template组件中新增下面这个代码

代码语言:javascript
复制
<base-no-data />
<base-no-data>请先选择数据</base-no-data>

页面显示效果如下,如果<base-no-data>组件中间什么都没写,就会显示插槽的文字暂无数据

image

其它组件见src/components

登录页面

src/views/login/index.vue

代码语言:javascript
复制
<template>
  <base-wrapper class="bg-color-primary flex-center-center">
    <div class="flex-c-center-center bg-color-white" style="height: 400px; width: 500px; border-radius: 10px">
      <h1 class="font-size-lg">MwjVue3Platform</h1>
      <div class="m-t-20">
        <el-form ref="loginFormRef" :model="loginForm" :rules="loginRules">
          <el-form-item prop="username">
            <el-input v-model="loginForm.username" prefix-icon="User" placeholder="请输入账号" maxlength="30" />
          </el-form-item>
          <el-form-item prop="password">
            <el-input v-model="loginForm.password" prefix-icon="Lock" placeholder="请输入密码" show-password maxlength="30" />
          </el-form-item>
        </el-form>
        <div class="tips">
          <span>用户名: admin</span>
          <span class="m-l-20"> 密码: 123456</span>
        </div>
        <el-button type="primary" class="m-t-10 w-full" @click="handleLogin">登 录</el-button>
      </div>
    </div>
    <div class="copyright">
      <p>IF I WERE YOU</p>
    </div>
  </base-wrapper>
</template>

<script setup>
import { getCurrentInstance } from 'vue';
// 组件实例
const { proxy } = getCurrentInstance();
const { login } = proxy.$store.user.useUserStore();
const loginForm = $ref({});

const loginRules = {
  username: [{ required: true, trigger: 'change', message: '请输入账号' }],
  password: [{ required: true, trigger: 'change', validator: validatePassword }],
};

function validatePassword(rule, value, callback) {
  if (!value || value.length < 6) {
    callback(new Error('密码最少6位'));
  } else {
    callback();
  }
}

function handleLogin() {
  proxy.$refs.loginFormRef.validate((valid) => {
    if (valid) {
      login(loginForm).then(() => {
        console.log('登录成功');
        // 跳转到首页
        proxy.$router.push({ path: '/' });
        // let fullPath = proxy.$route.fullPath;
        // if (fullPath.startsWith('/login?redirect=')) {
        //   let lastPath = fullPath.replace('/login?redirect=', '');
        //   // 跳转到上次退出的页面
        //   proxy.$router.push({ path: lastPath });
        // } else {
        //   // 跳转到首页
        //   proxy.$router.push({ path: '/' });
        // }
      });
    }
  });
}
</script>

<style lang="scss" scoped>
.copyright {
  width: 100%;
  position: absolute;
  bottom: 0;
  font-size: 12px;
  text-align: center;
  color: #ccc;
}
</style>

src/store/modules/user.js

代码语言:javascript
复制
import { defineStore } from 'pinia';
import sysUserApi from '@/api/system/sys_user.js';
// 动态导入拿到所有页面 eg: {/src/views/test/index.vue: () => import("/src/views/test/index.vue")}
const views = import.meta.glob('@/views/**/**.vue');
import { useRoute, useRouter } from 'vue-router';
import store from '@/store';

export const useUserStore = defineStore('user', () => {
  const route = useRoute();
  const router = useRouter();
  let isLogin = ref(false);
  let tokenObj = ref({});
  let userObj = ref({});
  let routerMap = ref({}); // 全路径'/system/user' -> 路由信息

  // 登录
  async function login(loginObj) {
    console.log('loginObj', loginObj);
    console.log('isLogin.value', isLogin.value);
    if (isLogin.value) {
      return;
    }
    let result = await sysUserApi.login({
      username: loginObj.username.trim(),
      password: loginObj.password.trim(),
    });
    // isLogin.value = true;
    // tokenObj.value = result.data;
    // getUserInfo();
  }

  // 退出登录
  function logout() {
    // 清空pinia存储的数据
    this.$reset();

    store.settings.useSettingsStore().$reset();

    // window.localStorage.setItem('user2', 'hello');
    // window.localStorage.removeItem('user2');
    // tips: pinia持久化的无法通过这种方式清空数据,只能删除同样方式存储的值 eg: window.localStorage.setItem('user2', 'hello');
    window.localStorage.clear();
    window.sessionStorage.clear();

    // 跳转登录页
    router.push(`/login?redirect=${route.fullPath}`);
    // window.location.href = '/login';
    location.reload(); // 强制刷新页面
  }

  // 获取用户 & 权限数据
  async function getUserInfo() {
    let result = await sysUserApi.getUserPerm();
    userObj.value = result.data;

    // 初始化系统设置数据
    // store.system.useSystemStore().init();
  }

  const routerList = computed(() => {
    // 拿到后台的权限数据
    return generateRouterList({}, userObj.value.permissionTreeList);
  });

  // 生成侧边栏菜单 & 权限路由数据
  function generateRouterList(parentObj, permList) {
    let result = [];
    if (!permList || permList.length === 0) {
      return result;
    }

    for (let index = 0; index < permList.length; index++) {
      let permItem = permList[index];

      // 填充字段数据
      if (!permItem.meta) {
        permItem.meta = {};
      }
      if (!permItem.meta.isParentView) {
        permItem.meta.isParentView = false;
      }
      if (!permItem.meta.sort) {
        permItem.meta.sort = 10000;
      }

      let title = permItem.meta.title;
      if (title) {
        if (parentObj.meta) {
          // [子级]
          // 面包屑数据
          permItem.meta.breadcrumbItemList = parentObj.meta.breadcrumbItemList.concat([title]);
          // 全路径
          permItem.meta.fullPath = parentObj.meta.fullPath + '/' + permItem.path;
        } else {
          // [顶级]
          permItem.meta.breadcrumbItemList = [title];
          permItem.meta.fullPath = permItem.path;
        }
      }

      // 组件页面显示处理
      permItem.component = views[`/src/views/${permItem.component}.vue`];

      routerMap.value[permItem.meta.fullPath] = permItem;

      // 递归处理
      if (permItem.children.length > 0) {
        permItem.children = generateRouterList(permItem, permItem.children);
      }

      result.push(permItem);
    }

    // 从小到大 升序
    result = result.sort((a, b) => {
      return a.meta.sort - b.meta.sort;
    });
    return result;
  }

  return { isLogin, login, logout, tokenObj, userObj, getUserInfo, routerList, routerMap };
});

调试

App.vue代码如下

代码语言:javascript
复制
<template>
  <!-- <h1>当前路由信息:{{ $route }}</h1> -->
  <router-view></router-view>
</template>
<script setup></script>

动态路由权限

一、用户登录成功后将用户权限信息存储到store中

src/views/login/index.vue , 后端写了可以加上,我没加

代码语言:javascript
复制
function handleLogin() {
  proxy.$refs.loginFormRef.validate((valid) => {
    if (valid) {
      login(loginForm).then(() => {
        let fullPath = proxy.$route.fullPath;
        if (fullPath.startsWith('/login?redirect=')) {
          let lastPath = fullPath.replace('/login?redirect=', '');
          // 跳转到上次退出的页面
          proxy.$router.push({ path: lastPath });
        } else {
          // 跳转到首页
          proxy.$router.push({ path: '/' });
        }
      });
    }
  });
}

二、用户 store

src/store/modules/user.js

用于存储用户的登录信息(重要)

代码语言:javascript
复制
import { defineStore } from 'pinia';
import sysUserApi from '@/api/system/sys_user.js';
// 动态导入拿到所有页面 eg: {/src/views/test/index.vue: () => import("/src/views/test/index.vue")}
const views = import.meta.glob('@/views/**/**.vue');
import { useRoute, useRouter } from 'vue-router';
import store from '@/store';

export const useUserStore = defineStore('user', () => {
  const route = useRoute();
  const router = useRouter();
  let isLogin = ref(false);
  let tokenObj = ref({});
  let userObj = ref({});
  let routerMap = ref({}); // 全路径'/system/user' -> 路由信息

  // 登录
  async function login(loginObj) {
    if (isLogin.value) {
      return;
    }
    let result = await sysUserApi.login({
      username: loginObj.username.trim(),
      password: loginObj.password.trim(),
    });
    isLogin.value = true;
    tokenObj.value = result.data;
    getUserInfo();
  }

  // 退出登录
  function logout() {
    // 清空pinia存储的数据
    this.$reset();

    store.settings.useSettingsStore().$reset();

    // window.localStorage.setItem('user2', 'hello');
    // window.localStorage.removeItem('user2');
    // tips: pinia持久化的无法通过这种方式清空数据,只能删除同样方式存储的值 eg: window.localStorage.setItem('user2', 'hello');
    window.localStorage.clear();
    window.sessionStorage.clear();

    // 跳转登录页
    router.push(`/login?redirect=${route.fullPath}`);
    // window.location.href = '/login';
    location.reload(); // 强制刷新页面
  }

  // 获取用户 & 权限数据
  async function getUserInfo() {
    let result = await sysUserApi.getUserPerm();
    userObj.value = result.data;
  }

  const routerList = computed(() => {
    // 拿到后台的权限数据
    return generateRouterList({}, userObj.value.permissionTreeList);
  });

  // 生成侧边栏菜单 & 权限路由数据
  function generateRouterList(parentObj, permList) {
    let result = [];
    if (!permList || permList.length === 0) {
      return result;
    }

    for (let index = 0; index < permList.length; index++) {
      let permItem = permList[index];

      // 填充字段数据
      if (!permItem.meta) {
        permItem.meta = {};
      }
      if (!permItem.meta.isParentView) {
        permItem.meta.isParentView = false;
      }
      if (!permItem.meta.sort) {
        permItem.meta.sort = 10000;
      }

      let title = permItem.meta.title;
      if (title) {
        if (parentObj.meta) {
          // [子级]
          // 面包屑数据
          permItem.meta.breadcrumbItemList = parentObj.meta.breadcrumbItemList.concat([title]);
          // 全路径
          permItem.meta.fullPath = parentObj.meta.fullPath + '/' + permItem.path;
        } else {
          // [顶级]
          permItem.meta.breadcrumbItemList = [title];
          permItem.meta.fullPath = permItem.path;
        }
      }

      // 组件页面显示处理
      permItem.component = views[`/src/views/${permItem.component}.vue`];

      routerMap.value[permItem.meta.fullPath] = permItem;

      // 递归处理
      if (permItem.children.length > 0) {
        permItem.children = generateRouterList(permItem, permItem.children);
      }

      result.push(permItem);
    }

    // 从小到大 升序
    result = result.sort((a, b) => {
      return a.meta.sort - b.meta.sort;
    });
    return result;
  }

  return { isLogin, login, logout, tokenObj, userObj, getUserInfo, routerList, routerMap };
});

src/api/system/sys_user.js

代码语言:javascript
复制
import request from '@/utils/request';

const BASE_API = '/web/api/system/user';

export default {
  // 获取验证码
  getCaptcha() {
    return request({
      url: '/captcha?t=' + new Date().getTime().toString(),
      method: 'get',
    });
  },
  // 登录
  login(data) {
    return request({
      url: '/web/api/auth/login',
      method: 'post',
      data,
      // headers: {
      //   // 客户端信息Base64明文:web:123456
      //   Authorization: 'Basic d2ViOjEyMzQ1Ng==',
      // },
    });
  },
  // 注销
  logout() {
    return request({
      url: '/web/api/auth/logout',
      method: 'delete',
    });
  },
  // 获取用户权限
  getUserPerm() {
    return request({
      url: '/web/api/system/perm/getUserPerm',
      method: 'get',
      // params: { systemSource: 0 }
    });
  },
  listPage(query, headers) {
    return request({
      url: BASE_API + '/listPage',
      method: 'get',
      params: query,
      headers,
    });
  },
  add(data) {
    return request({
      url: BASE_API,
      method: 'post',
      data,
    });
  },
  update(data) {
    return request({
      url: BASE_API,
      method: 'put',
      data,
    });
  },
  delete(id) {
    return request({
      url: BASE_API,
      method: 'delete',
      params: { userId: id },
    });
  },
  updateStatus(id, status) {
    return request({
      url: BASE_API + '/updateStatus',
      method: 'post',
      data: { userId: id, status: status },
    });
  },
  resetPassword(data) {
    return request({
      url: BASE_API + '/resetPassword',
      method: 'get',
      params: data,
    });
  },
  getUserInfoById(userId) {
    return request({
      url: BASE_API + '',
      method: 'get',
      params: {
        userId: userId,
      },
    });
  },
  // 保存用户角色
  saveRoleIds(data) {
    return request({
      url: BASE_API + '/saveRoleIds',
      method: 'post',
      data: data,
    });
  },
  // 修改密码
  updatePassword(data) {
    return request({
      url: BASE_API + '/updatePassword',
      method: 'put',
      data: data,
    });
  },
};

由于没有后端服务,所以这里需要把该注释的代码屏蔽掉,比如接口的校验什么的。

三、刷新页面时获取路由权限

src/router/permission.js

router.hasRoute(to.name): 检查一个给定名称的路由是否存在 https://router.vuejs.org/zh/guide/advanced/navigation-guards.html : 全局前置守卫

代码语言:javascript
复制
import router from '@/router';
import store from '@/store';

// 进度条
import NProgress from 'nprogress'; // 导入 nprogress模块
import 'nprogress/nprogress.css'; // 导入样式
NProgress.configure({ showSpinner: true }); // 显示右上角螺旋加载提示

// 白名单路由
const whiteList = ['/login', '/test', '/test-layout'];
// 是否存在路由
let hasRouter = false;

/**
 * 全局前置守卫 https://router.vuejs.org/zh/guide/advanced/navigation-guards.html
 * next();放行 -- 其它的路由跳转在没放行之前都会走 router.beforeEach()
 */
router.beforeEach(async (to, from, next) => {
  NProgress.start(); // 开启进度条

  let useUserStore = store.user.useUserStore();
  const { getUserInfo, logout } = useUserStore;
  let { isLogin, routerList } = toRefs(useUserStore); // 响应式

  if (isLogin.value) {
    // 已经登录后的操作
    if (to.path === '/login') {
      next({ path: '/' }); // 跳转到首页
      // if (to.fullPath.startsWith('/login?redirect=')) {
      //   let lastPath = to.fullPath.replace('/login?redirect=', '');
      //   next({ path: lastPath }); // 跳转到上次退出的页面
      // } else {
      //   next({ path: '/' }); // 跳转到首页
      // }
    } else {
      try {
        if (hasRouter) {
          next(); // 放行
        } else {
          // // 请求接口数据,动态添加可访问路由表
          // await getUserInfo();
          // routerList.value.forEach((e) => router.addRoute(e)); // 路由添加进去之后不会及时更新,需要重新加载一次
          // // console.log('全部路由数据:', router.getRoutes());
          hasRouter = true;
          next({ ...to, replace: true });
        }
      } catch (error) {
        console.log('刷新页面时获取权限异常:', error);
        // ElMessage.error('错误:' + error || 'Has Error');
      }
    }
  } else {
    // 未登录
    if (whiteList.indexOf(to.path) !== -1) {
      next(); // 放行 -- 可以访问白名单页面(eg: 登录页面)
    } else {
      next(`/login?redirect=${to.path}`); // 无权限 & 白名单页面未配置  =》 跳转到登录页面
    }
  }
});

// 全局后置钩子
router.afterEach(() => {
  NProgress.done(); // 完成进度条
});

上面代码我把后端部分禁用了

src/main.js

代码语言:javascript
复制
// 动态路由权限
import '@/router/permission.js';

四、其它

NProgress 进度条

安装

代码语言:javascript
复制
npm install --save nprogress

使用

src/router/permission.js

代码语言:javascript
复制
// 进度条
import NProgress from 'nprogress'; // 导入 nprogress模块
import 'nprogress/nprogress.css'; // 导入样式
NProgress.configure({ showSpinner: true }); // 显示右上角螺旋加载提示

/**
 * 全局前置守卫 https://router.vuejs.org/zh/guide/advanced/navigation-guards.html
 */
router.beforeEach(async (to, from, next) => {
  NProgress.start(); // 开启进度条
});  

// 全局后置钩子
router.afterEach(() => {
  NProgress.done(); // 完成进度条
});

页面布局

开始CV

1、把layout全部复制过去

2、App.vue改成加载layout的代码

代码语言:javascript
复制
<template>
  <!-- 路由出口 -->
  <!-- 路由匹配到的组件将渲染在这里 -->
  <!-- <router-view /> -->
  <layout />
</template>
<script setup>
import layout from '@/layout/index.vue';
</script>

3、store/modules/settings.js复制过去

layout代码

「src/App.vue」

代码语言:javascript
复制
<template>
  <!-- 路由出口 -->
  <!-- 路由匹配到的组件将渲染在这里 -->
  <!-- <router-view /> -->
  <layout />
</template>
<script setup>
import layout from '@/layout/index.vue';
</script>

「src/layout/index.vue」

代码语言:javascript
复制
<template>
  <!-- <h1>{{ route.meta }}</h1> -->
  <div v-show="isLogin && !$route.meta.isParentView" class="flex h-full w-full">
    <!-- 侧边栏菜单 -->
    <sidebar v-if="isShowMenu" id="sidebar" class="w-200" />
    <div class="flex-1">
      <div id="top">
        <!-- 顶部导航栏 -->
        <navbar class="h-50" />
        <!-- Tabs标签页 -->
        <div :style="{ width: appMainWidth + 'px' }">
          <tabs-view />
        </div>
      </div>
      <!-- 主页面 -->
      <div :style="{ height: appMainHeight + 'px', width: appMainWidth + 'px' }">
        <app-main class="app-main p-10" />
      </div>
    </div>
  </div>
  <div v-if="!isLogin || (isLogin && $route.meta.isParentView)" class="h-full">
    <router-view />
  </div>
</template>

<script setup>
import sidebar from './components/sidebar.vue';
import navbar from './components/navbar.vue';
import appMain from './components/app-main.vue';
import tabsView from './components/tabs-view.vue';
const { proxy } = getCurrentInstance();
let { isLogin } = toRefs(proxy.$store.user.useUserStore());
let { isShowMenu } = toRefs(proxy.$store.settings.useSettingsStore());
let appMainWidth = ref(0);
let appMainHeight = ref(0);

onMounted(() => {
  // 窗口宽高变化时触发 -- tips:window.onresize只能在项目内触发1次
  window.onresize = function windowResize() {
    calWidthAndHeight();
  };
});

// 注册一个回调函数,在组件因为响应式状态变更而更新其 DOM 树之后调用。
onUpdated(() => {
  calWidthAndHeight();
});

watch(
  [isLogin, isShowMenu],
  (newValue) => {
    calWidthAndHeight();
  },
  { immediate: false, deep: true },
);

function calWidthAndHeight() {
  let sidebarW = document.getElementById('sidebar').offsetWidth;
  appMainWidth.value = window.innerWidth - sidebarW;

  let topH = document.getElementById('top').offsetHeight;
  appMainHeight.value = window.innerHeight - topH - 20; // 20 指 p-10
}
</script>
<style lang="scss" scoped>
.app-main {
  // height: calc(100vh - 50px); // 满屏 - navbar
}
</style>

「src/layout/components/app-main.vue」

代码语言:javascript
复制
<template>
  <el-scrollbar>
    <!-- 路由视图 -->
    <router-view />
  </el-scrollbar>
</template>
<script setup></script>
<style lang="scss" scoped></style>

「src/layout/components/navbar.vue」

代码语言:javascript
复制
<template>
  <!-- {{ route.meta }} -->
  <div class="app flex-between-center p-x-10">
    <div class="flex-center-center">
      <div class="m-r-10" style="cursor: pointer" @click="proxy.$store.settings.useSettingsStore().update">
        <el-icon :size="22">
          <component :is="proxy.$store.settings.useSettingsStore().isShowMenu ? 'Fold' : 'Expand'" />
        </el-icon>
      </div>

      <el-breadcrumb :separator-icon="ArrowRight">
        <el-breadcrumb-item :to="{ path: '/' }">home</el-breadcrumb-item>
        <el-breadcrumb-item v-for="item in route.meta.breadcrumbItemList" :key="item">
          <span class="text-color-grey">{{ item }}</span>
        </el-breadcrumb-item>
      </el-breadcrumb>
    </div>

    <wx-mp-account />

    <el-dropdown class="avatar-container right-menu-item hover-effect" trigger="click">
      <div class="flex-center-center">
        <el-avatar class="" :size="32" :src="userObj.avatarUrl" />
        <div class="flex-center-center">
          <span class="m-l-5"> {{ userObj.nickname }} </span>
          <el-icon :size="20" class="w-20">
            <ArrowDown />
          </el-icon>
        </div>
      </div>
      <template #dropdown>
        <el-dropdown-menu>
          <router-link to="/">
            <el-dropdown-item>首页</el-dropdown-item>
          </router-link>
          <router-link to="/system/personal-center">
            <el-dropdown-item>个人中心</el-dropdown-item>
          </router-link>
          <a target="_blank" href="https://gitee.com/zhengqingya">
            <el-dropdown-item>Gitee</el-dropdown-item>
          </a>
          <el-dropdown-item divided @click="logout">退出</el-dropdown-item>
        </el-dropdown-menu>
      </template>
    </el-dropdown>
  </div>
</template>
<script setup>
import { useRoute } from 'vue-router';
const route = useRoute();
import { ArrowRight } from '@element-plus/icons-vue';
import { getCurrentInstance, toRefs } from 'vue';
const { proxy } = getCurrentInstance();

let useUserStore = proxy.$store.user.useUserStore();
let { logout } = useUserStore;
let { userObj } = toRefs(useUserStore);
</script>
<style lang="scss" scoped>
.app {
  // -webkit-box-shadow: 0 1px 4px rgba(0, 0, 0, 0.2);
  box-shadow: 0 1px 5px rgba(0, 0, 0, 0.2);
}
</style>

src/layout/components/sidebar.vue

代码语言:javascript
复制
<template>
  <el-menu router :default-active="$route.meta.fullPath" :collapse="false" :unique-opened="false" @select="handleSelect">
    <el-scrollbar>
      <sidebar-item :router-list="routerList" />
    </el-scrollbar>
  </el-menu>
</template>

<script setup>
import sidebarItem from './sidebar-item.vue';
import { getCurrentInstance, toRefs } from 'vue';
const { proxy } = getCurrentInstance();
let { routerList, routerMap } = toRefs(proxy.$store.user.useUserStore());
let { activeTabs } = proxy.$store.settings.useSettingsStore();

/**
 * 选中菜单时触发
 * @param index 选中菜单项的 index  eg: /system/role (router 以 index 作为 path 进行路由跳转,或 router 属性直接跳转)
 * @param indexPath 选中菜单项的 index path eg: ['/system', '/system/role']
 * @param item 选中菜单项
 * @param routeResult vue-router 的返回值(如果 router 为 true)
 */
function handleSelect(index, indexPath, item, routeResult) {
  // console.log(index, indexPath, item, routeResult);
  // proxy.$router.push(index);
  activeTabs(routerMap.value[index]);
}
</script>

<style lang="scss" scoped>
.el-menu {
  box-shadow: 1px 0 5px rgba(0, 0, 0, 0.2);
}
</style>

src/layout/components/sidebar-item.vue

代码语言:javascript
复制
<template>
  <div v-for="item in routerList" :key="item.path">
    <!-- 一级菜单 -->
    <el-menu-item v-if="(item.meta.isShow && item.children.length === 0) || (item.children && item.children.length === 1 && !item.children[0].meta.isShow)" :index="item.meta.fullPath">
      <el-icon v-if="item.meta && item.meta.icon"><component :is="item.meta.icon" /></el-icon>
      <div v-else class="w-30"></div>
      <template #title>{{ item.meta.title }}</template>
    </el-menu-item>
    <!-- 二级菜单 -->
    <div v-else>
      <el-sub-menu v-if="item.meta.isShow" :index="item.meta.fullPath">
        <template #title>
          <el-icon v-if="item.meta && item.meta.icon"><component :is="item.meta.icon" /></el-icon>
          <div v-else class="w-30"></div>
          <span>{{ item.meta.title }}</span>
        </template>
        <!-- 递归 -->
        <sidebarItem :router-list="item.children" />
      </el-sub-menu>
    </div>
  </div>
</template>
<script setup>
defineProps({
  routerList: {
    type: Array,
    default: () => [],
  },
});
</script>
<style lang="scss" scoped></style>

src/layout/components/tabs-view.vue

代码语言:javascript
复制
<template>
  <div class="app">
    <el-scrollbar>
      <base-right-click class="flex">
        <div v-for="item in tabsList" :key="item" class="item m-3" :class="{ active: $route.meta.fullPath === item.meta.fullPath }" style="display: inline-block; white-space: nowrap">
          <div class="flex-between-center h-20" @click.right="handleRightClick(item, $event)">
            <router-link :to="item.meta.fullPath" @click="activeTabs(item)">
              <span class="m-r-3">{{ item.meta.title }}</span>
            </router-link>
            <el-icon v-if="item.meta.fullPath !== '/'" :size="10" @click="handleClose(item)"> <Close /> </el-icon>
          </div>
        </div>

        <template #right-show="{ isShow }">
          <div class="right-menu flex-column b-rd-5 bg-color-white">
            <div class="option" @click="handleCloseCurrent">
              <el-icon :size="10"> <Close /> </el-icon><span> 关闭当前</span>
            </div>
            <div class="option" @click="handleCloseAll">
              <el-icon :size="10"> <Close /> </el-icon><span> 关闭所有</span>
            </div>
          </div>
        </template>
      </base-right-click>
    </el-scrollbar>
  </div>
</template>
<script setup>
const { proxy } = getCurrentInstance();
let useSettingsStore = proxy.$store.settings.useSettingsStore();
let { tabsList } = toRefs(useSettingsStore);
let { activeTabs } = useSettingsStore;
let chooseItem = $ref(null);

// 保留首页
watch(
  tabsList,
  (newValue) => {
    if (newValue.length === 0) {
      tabsList.value.push({ meta: { title: '首页', fullPath: '/' } });
    }
  },
  { immediate: true, deep: true },
);

function handleClose(item) {
  tabsList.value.splice(tabsList.value.indexOf(item), 1);
}

function handleRightClick(item) {
  chooseItem = item;
}

function handleCloseCurrent() {
  handleClose(chooseItem);
}

function handleCloseAll() {
  tabsList.value = [];
}
</script>
<style lang="scss" scoped>
.app {
  position: relative;
  box-shadow: 0 1px 5px rgba(0, 0, 0, 0.2);
  .item {
    border: 1px solid #ebeef5;
    &.active {
      background: #00aaff;
    }
  }

  .right-menu {
    .option {
      text-align: center;
      padding: 5px 10px;
      cursor: pointer;
      &:hover {
        background: #eee;
      }
    }
  }
}

a {
  text-decoration: none; // 去掉下换线
  color: black; //文字颜色更改
}
</style>

src/store/modules/settings.js

代码语言:javascript
复制
import { defineStore } from 'pinia';

export const useSettingsStore = defineStore('settings', () => {
  let isShowMenu = ref(true); // 是否显示菜单
  let tabsList = ref([]); // Tabs标签页数据

  function update() {
    isShowMenu.value = !isShowMenu.value;
  }

  function getTabsList() {
    const unique = (arrs) => {
      const res = new Map();
      return arrs.filter((arr) => !res.has(arr.meta.fullPath) && res.set(arr.meta.fullPath, 1));
    };
    tabsList.value = unique(tabsList.value);
    return tabsList.value;
  }

  // 点击菜单/tabs标签页时触发
  function activeTabs(router) {
    // tabsList.value.forEach((e) => (e.meta.isActive = e.meta.fullPath === router.meta.fullPath));
    if (tabsList.value.filter((e) => e.meta.fullPath === router.meta.fullPath).length === 0) {
      // router.meta.isActive = true;
      tabsList.value.push(router);
    }
  }

  return { isShowMenu, update, tabsList, getTabsList, activeTabs };
});

其他知识点

$ref语法糖告别 .value

配置方法一

vue3.4版本之后废除

vite.config.js

代码语言:javascript
复制
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [
    vue({
      reactivityTransform: true, // 启用响应式语法糖 $ref $computed $toRef ...
    })
  ]
})

配置方法二

https://vue-macros.sxzz.moe/zh-CN/features/reactivity-transform.html

tips: store(pinia版) 中使用 $ref 无法正常持久化数据!!!

代码语言:javascript
复制
npm i -D @vue-macros/reactivity-transform

vite.config.js

代码语言:javascript
复制
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import ReactivityTransform from '@vue-macros/reactivity-transform/vite';

export default defineConfig({
  plugins: [
    vue(),
    ReactivityTransform(), // 启用响应式语法糖 $ref ...
  ]
})

解决ESLint警告: '$ref' is not defined.

.eslintrc.cjs

代码语言:javascript
复制
module.exports = {
  globals: { $ref: 'readonly', $computed: 'readonly', $shallowRef: 'readonly', $customRef: 'readonly', $toRef: 'readonly' },
};

测试

原本 .value 响应式

代码语言:javascript
复制
<template>
  <h1>{{ count }}</h1>
  <button @click="handleClick">click</button>
</template>

<script setup>
let count = ref(0);

function handleClick() {
  count.value++;
}
</script>

现在 $ref 去除 .value

代码语言:javascript
复制
<template>
  <h1>{{ count }}</h1>
  <button @click="handleClick">click</button>
</template>

<script setup>
let count = $ref(0);

function handleClick() {
  count++;
}
</script>

$ref 在以下情况无法直接使用

  • store pinia
  • watch 监听器

自定义样式

src/main.js

代码语言:javascript
复制
// 自定义样式
import '@/styles/index.scss';

src/styles/index.scss

代码语言:javascript
复制
@import './variable.scss';
@import './theme.scss';

body {
  background-color: #021b32;
}

深度选择器

vue3+scss中不要使用下面方式, 会有警告 [@vue/compiler-sfc] ::v-deep usage as a combinator has been deprecated. Use :deep(<inner-selector>) instead.

代码语言:javascript
复制
::v-deep .el-input__wrapper {
    background-color: #08c0ee8c;
}

改用

代码语言:javascript
复制
::v-deep(.el-input__wrapper) {
    background-color: #08c0ee8c;
}

报错:(intermediate value).globEager is not a function

vite版本5会出现这个问题,由 import.meta.globEager(参数) 改为 import.meta.glob(参数, {eager: true})可以解决,或者降级Vite为4.3.0

注册app

「这两段代码有区别吗」

代码语言:javascript
复制
import App from './App.vue'
createApp(App).mount('#app')

import App from './App.vue';
const app = createApp(App);

这两段代码都是用于创建和挂载 Vue 应用的,但它们在细节上有所不同。主要区别在于对 Vue 应用实例的操作方式。

第一段代码:

代码语言:javascript
复制
import App from './App.vue'
createApp(App).mount('#app')

这段代码直接在创建 Vue 应用实例后立即调用 .mount('#app') 方法来挂载应用到页面的某个元素上(通常是一个具有特定 ID 的元素)。这种方式简洁明了,适用于那些不需要对 Vue 应用实例进行额外配置或注册插件和组件的场景。

第二段代码:

代码语言:javascript
复制
import App from './App.vue';
const app = createApp(App);

这段代码首先创建了 Vue 应用实例,并将其赋值给变量 app。这样做的好处是可以在挂载(.mount('#app'))之前对 Vue 应用实例进行额外的操作,比如使用 .use() 方法注册插件、使用 .component() 方法全局注册组件、或者使用 .directive() 方法注册自定义指令等。这段代码没有展示挂载操作,但通常在进行了必要的配置后,你会看到类似 app.mount('#app') 的代码来完成挂载。

总结来说,第一段代码适用于简单场景,直接创建并挂载应用;第二段代码提供了更多的灵活性,允许在挂载应用之前对 Vue 实例进行配置或注册。选择哪种方式取决于你的具体需求。

npm命令

1、例如:react-router已经更新到4.x版本,想要下载2.x版本,可以通过下面命令

npm install --save-dev react-router@2.8.1

或 npm install --save react-router@2.8.1

2、--save -dev

--save:将保存配置信息到package.json。默认为dependencies节点中。

--dev:将保存配置信息devDependencies节点中。

因此:

--save:将保存配置信息到package.json的dependencies节点中。

--save-dev:将保存配置信息到package.json的devDependencies节点中。

dependencies:运行时的依赖,发布后,即生产环境下还需要用的模块

devDependencies:开发时的依赖。里面的模块是开发时用的,发布时用不到它。

3、删除模块

npm uninstall 模块

删除本地模块时你应该思考的问题:是否将在package.json上的相应依赖信息也消除?

npm uninstall 模块:删除模块,但不删除模块留在package.json中的对应信息

npm uninstall 模块--save 删除模块,同时删除模块留在package.json中dependencies下的对应信息

npm uninstall 模块 --save-dev 删除模块,同时删除模块留在package.json中devDependencies下的对应信息

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2024-03-18,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 梦无矶的测试开发之路 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • Vue3编写的前端admin模版
  • Vue3+Vite项目搭建
    • 项目搭建
      • 安装依赖
        • 运行项目
        • VScode配置及代码配置
          • VScode插件配置
            • 代码格式化
              • 配置格式化文件
                • 统一代码风格
                  • 其他
                  • Vite项目配置
                  • 自定义styles样式
                    • 一、安装
                      • 二、scss使用循环实现动态样式
                      • Vue Router路由
                        • 一、安装
                          • 二、入门配置
                          • mixin混入
                            • 新建src/utils/mixin.js
                              • 局部混入
                                • 全局混入
                                  • 使用
                              • 全局过滤器
                              • 集成Element Plus
                                • 一、安装
                                  • 二、配置
                                    • 三、测试
                                      • 一、安装
                                      • 二、配置
                                      • 三、使用
                                      • 四、测试
                                      • 五、持久化存储
                                  • Pinia
                                    • $reset() 清空数据
                                      • 一、安装
                                      • 二、axios工具封装
                                      • 三、api封装
                                      • 四、测试
                                      • 一、全局组件注册
                                      • 二、组件示例
                                  • axios和api封装
                                  • 全局组件
                                  • 登录页面
                                  • 动态路由权限
                                    • 一、用户登录成功后将用户权限信息存储到store中
                                      • 二、用户 store
                                        • 三、刷新页面时获取路由权限
                                          • 四、其它
                                            • NProgress 进度条
                                        • 页面布局
                                          • layout代码
                                          • 其他知识点
                                            • $ref语法糖告别 .value
                                              • 配置方法一
                                              • 配置方法二
                                              • 解决ESLint警告: '$ref' is not defined.
                                              • 测试
                                            • 自定义样式
                                              • 深度选择器
                                            • 报错:(intermediate value).globEager is not a function
                                              • 注册app
                                                • npm命令
                                                相关产品与服务
                                                验证码
                                                腾讯云新一代行为验证码(Captcha),基于十道安全栅栏, 为网页、App、小程序开发者打造立体、全面的人机验证。最大程度保护注册登录、活动秒杀、点赞发帖、数据保护等各大场景下业务安全的同时,提供更精细化的用户体验。
                                                领券
                                                问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档