从零开始使用 vite + vue3 + pinia + naiveui 搭建简单后台管理系统

构建工具使用vue3推荐的vite;状态管理使用pinia,该库的作者也是vuex的核心成员;UI组件库使用尤大推荐的naiveui。欢迎各位大神指导。话不多说,直接开撸。

一、初始化vite模板

国内使用最好将yarn切换到淘宝源:
yarn config set registry https://registry.npmmirror.com/
yarn create @vitejs/app
之后会让你输入项目名称,选择框架等。这里我们输入名称为jianshu,框架选择vue,回车
然后进入项目中,输入yarn回车,安装依赖
cd jianshu && yarn
安装完成之后,使用yarn dev命令启动开发服务。
打开localhost:3000地址,可以看到vite默认的欢迎页面。

二、安装插件

  • axios:网络请求;
  • sass:css预处理;
  • js-md5:登录密码使用md5加密,如果不需要则可以不用安装;
  • pinia:状态管理;
  • moment:时间格式化;
  • naive-ui:UI组件库;
  • vfonts:naiveui的字体库;
  • @vicons/antd:图标库,可自行选择;
  • vue-router:路由;
  • unplugin-auto-import:自动导入composition api;
  • unplugin-vue-components:自动注册组件;
yarn add axios sass js-md5 pinia moment naive-ui vfonts @vicons/antd vue-router unplugin-auto-import unplugin-vue-components

全部安装完成后,删除components下默认的页面和assets下的logo.png,然后进行项目的配置

三、项目配置

1.配置unplugin-auto-import、unplugin-vue-components、naive-ui:

打开vite.config.js文件,引入组件

import AutoImport from 'unplugin-auto-import/vite';
import Components from 'unplugin-vue-components/vite';
import { NaiveUiResolver } from 'unplugin-vue-components/resolvers';

然后在plugins内添加配置

export default defineConfig({
  plugins: [
    vue(),
    AutoImport({
      imports: ['vue']
    }),
    Components({
      resolvers: [NaiveUiResolver()]
    })
  ]
});

这里naiveui使用的是按需自动引入,具体可参考官方文档:按需引入(Tree Shaking) - Naive UI
笔者添加了一些打包的配置,不需要可以忽略。配置完成后的样子:

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 { NaiveUiResolver } from 'unplugin-vue-components/resolvers';

// 获取当前时间戳
const timeStamp = new Date().getTime();

export default defineConfig({
  plugins: [
    vue(),
    AutoImport({
      imports: ['vue']
    }),
    Components({
      resolvers: [NaiveUiResolver()]
    })
  ],
  // 开发时候的端口号,默认为3000
  server: {
    port: 3001
  },
  // 打包配置
  build: {
    // 打包文件夹名称
    outDir: 'dist',
    // 打包后去掉console语句
    terserOptions: {
      compress: {
        drop_console: true
      }
    },
    compress: {
      drop_console: true,
      drop_debugger: true
    },
    // 打包后的文件名
    rollupOptions: {
      output: {
        entryFileNames: `assets/[name].${timeStamp}.js`,
        chunkFileNames: `assets/[name].${timeStamp}.js`,
        assetFileNames: `assets/[name].${timeStamp}.[ext]`
      }
    }
  }
});

2.配置pinia:

在src目录下新建plugins文件夹,然后新建pinia.js:

import { createPinia } from 'pinia';

const pinia = createPinia();

export default pinia;

到src目录下新建store目录,然后新建user.js:

import { defineStore } from 'pinia';

export const useUserStore = defineStore('user', {
  state: () => ({
    userInfo: JSON.parse(localStorage.getItem('userInfo')),
    token: localStorage.getItem('token')
  }),
  actions: {
    LoginIn(data) {
      this.token = data.token;
      this.userInfo = data;
      localStorage.setItem('userInfo', JSON.stringify(data));
      localStorage.setItem('token', data.token);
      localStorage.setItem('expire', data.expire);
    },
    LoginOut() {
      localStorage.removeItem('userInfo');
      localStorage.removeItem('token');
      localStorage.removeItem('expire');
      location.href = '/';
    }
  }
});

pinia和vuex的用法不一样,具体可以参考官方文档:Home | Pinia

3.配置axios:

在plugins下新建axios.js文件:

import axios from 'axios';
import pinia from './pinia';
import { useUserStore } from '../store/user';

const user = useUserStore(pinia);

// api
const apiHost = ''; // 接口地址

let http = axios.create({
  // 当前环境为线上时,自动使用production环境变量里的VITE_API_BASEURL线上接口地址
  baseURL: import.meta.env.MODE === 'production' ? import.meta.env.VITE_API_BASEURL : apiHost,
  timeout: 60000
});

// 参数配置
let needLoading = true, // 是否需要loading
  needErrorNotify = true, // 是否需要错误提示
  requestCount = 0; // 请求数量,用于loading控制,避免多个接口并发时重复loading

// loading操作
const ShowLoading = () => {
  if (requestCount === 0) {
    $uiMsg.loading('加载中');
  }
  requestCount++;
};
const HideLoading = () => {
  requestCount--;
  if (requestCount <= 0) {
    requestCount = 0;
    $uiMsg.removeMessage();
  }
};

http.interceptors.request.use(config => {
  // 切换组件时,取消还未完成的请求
  config.cancelToken = new axios.CancelToken(cancel => {
    window.__axiosPromiseArr.push({
      url: config.url,
      cancel
    });
  });
  if (needLoading) {
    ShowLoading();
  }
  if (user.token) {
    config.headers.Authorization = 'Bearer ' + user.token;
  }
  config.headers.post['Content-Type'] = 'application/json;charset=UTF-8;';
  return config;
});
http.interceptors.response.use(
  response => {
    if (needLoading) {
      HideLoading();
    }
    // 换token时报错,退出登录
    if (response.config.url.indexOf('/token') !== -1 && response.data.code !== 200) {
      $uiMsg.error('登录已过期,请重新登录', () => {
        user.LoginOut();
      });
      return;
    }
    return response.data;
  },
  error => {
    HideLoading();
    if (error.response && error.response.status === 401) {
      // token过期访问刷新token的接口
      return http
        .post('/token', JSON.stringify(user.token))
        .then(res => {
          if (res.code !== 200) {
            $uiMsg.error(res.msg);
            return;
          }
          // 更新pinia里的数据
          user.LoginIn(res.body);
          // 刷新token后重新请求之前的接口
          const config = error.response.config;
          config.headers.Authorization = 'Bearer ' + res.body.token;
          return http(config);
        })
        .catch(error => {
          $uiMsg.error(error.msg);
        });
    } else {
      $uiMsg.error('网络错误,请稍后再试');
      return Promise.reject(error);
    }
  }
);

const get = (url, params, _needLoading = true, _needErrorNotify = true) => {
  needLoading = _needLoading;
  needErrorNotify = _needErrorNotify;
  return new Promise((resolve, reject) => {
    let _timestamp = new Date().getTime();
    http
      .get(url, { params })
      .then(res => {
        if (new Date().getTime() - _timestamp < 200) {
          setTimeout(() => {
            if (res?.code === 200) {
              resolve(res);
            } else {
              needErrorNotify && res && res.msg && $uiMsg.error(res.msg);
              reject(res);
            }
          }, 200);
        } else {
          if (res?.code === 200) {
            resolve(res);
          } else {
            needErrorNotify && res && res.msg && $uiMsg.error(res.msg);
            reject(res);
          }
        }
      })
      .catch(error => {
        reject(error);
      });
  });
};

const post = (url, params, _needLoading = true, _needErrorNotify = true) => {
  needLoading = _needLoading;
  needErrorNotify = _needErrorNotify;
  return new Promise((resolve, reject) => {
    let _timestamp = new Date().getTime();
    http
      .post(url, params)
      .then(res => {
        if (new Date().getTime() - _timestamp < 200) {
          setTimeout(() => {
            if (res?.code === 200) {
              resolve(res);
            } else {
              needErrorNotify && res && res.msg && $uiMsg.error(res.msg);
              reject(res);
            }
          }, 200);
        } else {
          if (res?.code === 200) {
            resolve(res);
          } else {
            needErrorNotify && res && res.msg && $uiMsg.error(res.msg);
            reject(res);
          }
        }
      })
      .catch(error => {
        reject(error);
      });
  });
};

export { get, post };

这里使用了环境变量配置,在src同级目录下新建.env.production文件:

NODE_ENV = production

# 线上接口请求地址
VITE_API_BASEURL = ''

项目打包后会自动使用production环境变量里的VITE_API_BASEURL

3.配置vue-router:

在plugins下新建router.js:

import { createRouter, createWebHistory } from 'vue-router';
import pinia from './pinia';
import { useUserStore } from '../store/user';

const user = useUserStore(pinia);

// 不需要权限的页面
const constantRoutes = [
  {
    // 登录
    path: '/login',
    name: 'login',
    component: () => import('../views/login/index.vue')
  },
  {
    // 404
    path: '/:pathMatch(.*)',
    name: 'notFound',
    component: () => import('../views/error/notFound.vue')
  },
  {
    // 无权限
    path: '/noPermission',
    name: 'noPermission',
    component: () => import('../views/error/noPermission.vue')
  }
];

const asyncRoutes = {
  path: '/',
  name: 'main',
  component: () => import('../views/mainPage.vue'),
  children: [
    {
      // 首页
      path: '/',
      name: 'home',
      component: () => import('../views/home/index.vue')
    },
    {
      // 用户管理
      path: '/settingUser',
      name: 'settingUser',
      component: () => import('../views/setting/user.vue')
    }
  ]
};

const router = createRouter({
  history: createWebHistory('/'),
  routes: constantRoutes
});

router.addRoute(asyncRoutes);

router.beforeEach((to, from, next) => {
  // 切换router时,取消pending中的请求
  if (window.__axiosPromiseArr) {
    window.__axiosPromiseArr.forEach((ele, ind) => {
      ele.cancel();
      delete window.__axiosPromiseArr[ind];
    });
  }
  // token过期时间
  if (localStorage.getItem('expires') && (new Date().getTime() - localStorage.getItem('expires')) / 1000 > 1) {
    $uiMsg.error('登录失效,请重新登录', () => {
      localStorage.removeItem('userInfon');
      localStorage.removeItem('token');
      localStorage.removeItem('expires');
      location.href = '/login';
    });
    return;
  }
  // 登录判断
  if (user.token) {
    if (to.path === '/login') {
      next({ path: '/' });
    } else {
      // 权限判断
      next();
    }
  } else {
    if (to.path === '/login') {
      next();
    } else {
      next({ name: 'login' });
    }
  }
});

// 跳转完成后,将滚动条位置重置
router.afterEach(to => {
  window.scrollTo(0, 0);
});

export default router;

router中引入了一些默认页面,我们来创建一些简单的页面组件:
/src/views/error/notFound.vue

  
6 Created with Sketch.

很抱歉,您访问的页面不存在

返回首页

/src/views/error/noPermission.vue






/src/views/mainPage.vue






mainPage中引入了两个组件,下面公共组件的时候会讲到。
/src/views/home/index.vue






/src/views/login/index.vue




可以看到axios和router中都使用了pinia,需要注意的是,这里不能像组件中直接引入,需要将pinia配置单独成一个js,然后使用的时候引入。

组件中的写法:

import { useUserStore } from '../store/user';

const user = useUserStore();

axios和router中的写法:

import pinia from './pinia';
import { useUserStore } from '../store/user';

const user = useUserStore(pinia);

4.main.js配置

引入我们刚刚配置好的插件,挂载全局变量,/style/index.css这里是使用了reset.css设置了浏览器的默认样式
index.css

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;
}

/* HTML5 display-role reset for older browsers */
article,
aside,
details,
figcaption,
figure,
footer,
header,
hgroup,
menu,
nav,
section {
  display: block;
}

body {
  line-height: 1;
}

ol,
ul {
  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;
}

main.js

import { createApp } from 'vue';
import App from './App.vue';
import './style/index.css';
import { uiCopy, uiDownloadImg } from './utils';
import { get, post } from './plugins/axios';
import moment from 'moment';
import pinia from './plugins/pinia';
import router from './plugins/router';

const app = createApp(App);

app.use(pinia);
app.use(router);

if (import.meta.env.MODE === 'production') {
  app.config.devtools = false;
} else {
  app.config.devtools = true;
}

// 请求队列
window.__axiosPromiseArr = [];

// 挂载全局变量
app.config.globalProperties.$get = get;
app.config.globalProperties.$post = post;
app.config.globalProperties.$moment = moment;
app.config.globalProperties.$uiCopy = uiCopy;
app.config.globalProperties.$uiDownloadImg = uiDownloadImg;

app.mount('#app');

utils内是常用的一些工具函数,具体可以看下面。不需要则可以忽略

5.其他配置

常用的工具函数

src目录下新建utils文件夹,创建index.js

// 公共复制剪贴板
const uiCopy = str => {
  let copyDom = document.createElement('div');
  copyDom.innerText = str;
  copyDom.style.position = 'absolute';
  copyDom.style.top = '0px';
  copyDom.style.right = '-9999px';
  document.body.appendChild(copyDom);
  //创建选中范围
  let range = document.createRange();
  range.selectNode(copyDom);
  //移除剪切板中内容
  window.getSelection().removeAllRanges();
  //添加新的内容到剪切板
  window.getSelection().addRange(range);
  //复制
  let successful = document.execCommand('copy');
  copyDom.parentNode.removeChild(copyDom);
  try {
    uiMsg(successful ? '复制成功!' : '复制失败,请手动复制内容', null, successful ? 'success' : 'error');
  } catch (err) {}
};

// 下载base64格式的图片
const uiDownloadImg = (data, fileName) => {
  var link = document.createElement('a');
  link.href = data;
  link.download = fileName;
  link.click();
};

export { uiCopy, uiDownloadImg };
全局的消息提示和naiveui的主题配置

naiveui的message组件需要在n-message-provider 内部并且使用 useMessage 去获取 API,具体配置如下:
在components中新建provider文件夹,然后新建index.vue:




这里笔者配置了菜单的颜色和默认语言为中文。如果不需要颜色配置则可以忽略Menu。下面来配置全局的消息提示:
在index.vue同级新建Message.vue文件:




配置完成后需要到App.vue组件中引入:






这里配置好后,就可以使用$uiMsg.success('成功')来调用消息提示的组件了;有loading,success,error,info,warning这几种类型,可以根据需要增减。
笔者在这里还加入了全局的公共css样式,不需要可以去掉。

6.头部和左侧菜单组件

做完以上的配置之后项目大体结构已经搭建完毕,下面我们来编写公共的头部和左侧菜单组件。
在components文件夹下新建layout文件夹,新建Header.vue和Menu.vue。

公共头部:






公共左侧菜单:





最后我们的架子就搭建完毕了,完整项目地址:gitee
后面会加入公共表格组件和公共图片上传组件。

你可能感兴趣的:(从零开始使用 vite + vue3 + pinia + naiveui 搭建简单后台管理系统)