vue前端框架搭建

Updated on in 程序人生 with 0 views and 0 comments

说明

跟着迪佬的vue系列文章,自己搭建了前端,特此记录一下,git项目地址

https://gitee.com/WylLoveX/bs-web.git

迪佬文章地址

前端新手Vue3+Vite+Ts+Pinia+Sass项目指北系列文章 —— 系列文章(目录)-CSDN博客

一、vite创建项目

vite官网地址

要求node>18,全局安装yarn

npm install -g yarn

创建项目

yarn create vite

image.png

启动测试

package.json的scripts属性中配置了启动命令

image.png

我们按照配置执行yarn dev就可以启动了

image.png

访问界面如下

image.png

二、集成element-plus

element-plus官网地址

2.1 安装element-plus

yarnadd element-plus

2.2 main.ts集成element-plus

在main.ts中添加内容,原来的配置暂时不动

import { createApp } from 'vue'
import './style.css'
//引入element-plus
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import App from './App.vue'

const app = createApp(App)
//引入element-plus
app.use(ElementPlus)
app.mount('#app')

三、自动导入组件配置

3.1 安装组件

安装依赖到开发环境

npm install -D unplugin-vue-components unplugin-auto-import 

3.2 配置自动导入组件

在vite.config.ts中配置导入的这两个组件

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

// 自动引入element-plus
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'

export default defineConfig({
  plugins: [
    vue(),
    // 自动引入element-plus
    AutoImport({
      resolvers: [ElementPlusResolver()],
    }),
    Components({
      resolvers: [ElementPlusResolver()],
    }),
  ],
})

四、vite配置plugins

4.1 安装插件

yarn add vite-plugin-svg-icons
yarn add vite-plugin-vue-setup-extend
yarn add vite-plugin-html
yarn add vite-plugin-top-level-await

4.2 添加vite.plugin.ts

在项目根目录添加vite.plugins.ts

import path from 'path'
import vue from '@vitejs/plugin-vue'
import { createSvgIconsPlugin } from 'vite-plugin-svg-icons'
import setupExtend from 'vite-plugin-vue-setup-extend'
import Components from 'unplugin-vue-components/vite'
import AutoImport from 'unplugin-auto-import/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
import { createHtmlPlugin } from 'vite-plugin-html'
import topLevelAwait from 'vite-plugin-top-level-await'

export default function createVitePlugins(viteEnv, isBuild = false) {
    const { VITE_GLOB_APP_TITLE } = viteEnv
    const vitePlugins = [
        vue(),
        setupExtend(),
        createSvgIconsPlugin({
            // 指定需要缓存的图标文件夹
            iconDirs: [path.resolve(process.cwd(), 'src/assets/icons/svg')],
            // 指定symbolId格式
            symbolId: 'icon-[dir]-[name]'
        }),
        Components({
            resolvers: [ElementPlusResolver()]
        }),
        AutoImport({
            // resolvers: [ElementPlusResolver()],
            // targets to transform
            include: [/\.[tj]sx?$/, /\.vue$/, /\.vue\?vue/, /\.md$/],
            // global imports to register
            imports: [
                // 插件预设支持导入的api
                'vue',
                'vue-router',
                'pinia'
                // 自定义导入的api
            ],

            // Generate corresponding .eslintrc-auto-import.json file.
            // eslint globals Docs - https://eslint.org/docs/user-guide/configuring/language-options#specifying-globals
            eslintrc: {
                enabled: false, // 默认false, true启用。生成一次就可以,避免每次工程启动都生成
                filepath: './.eslintrc-auto-import.json', // Default `./.eslintrc-auto-import.json`
                globalsPropValue: true // Default `true`, (true | false | 'readonly' | 'readable' | 'writable' | 'writeable')
            },

            // Filepath to generate corresponding .d.ts file.
            // Defaults to './auto-imports.d.ts' when `typescript` is installed locally.
            // Set `false` to disable.
            dts: './auto-imports.d.ts',

        }),
        createHtmlPlugin({
            minify: isBuild,
            inject: {
                data: {
                    title: VITE_GLOB_APP_TITLE
                }
            }
        }),
        topLevelAwait({
            // The export name of top-level await promise for each chunk module
            promiseExportName: '__tla',
            // The function to generate import names of top-level await promise in each chunk module
            promiseImportName: i => `__tla_${i}`
        })
    ]
    return vitePlugins
};

4.3 修改vite.config.ts

import path from 'path'
import { defineConfig, loadEnv } from 'vite'
import createVitePlugins from './vite.plugins'

const base_url = 'xxx'

// https://vitejs.dev/config/
export default defineConfig(({ mode, command }) => {
  const env = loadEnv(mode, process.cwd())
  return {
    plugins: createVitePlugins(env, command === 'build'),
    base: './',

    resolve: {
      alias: {
        '@': path.resolve(__dirname, 'src')
      },
      extensions: ['.mjs', '.js', '.ts', '.jsx', '.tsx', '.json', '.vue']
    },
    server: {
      host: "0.0.0.0",
      proxy: {
        '/xxx': {
          target: base_url,
          changeOrigin: true,
        }
      }
    }
  }
})


五、删除多余文件

项目初始化的时候,生成了一些测试文件

删除components目录下的HelloWorld.vue文件

删除src目录下的style.css文件,然后删除main.ts中对style.css文件的引用

import { createApp } from 'vue'
//引入element-plus
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import App from './App.vue'

const app = createApp(App)
//引入element-plus
app.use(ElementPlus)
app.mount('#app')

修改App.vue文件

<template>
  <div>
    <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>
  </div>
</template>

<script setup lang="ts">

</script>

<style scoped></style>

运行yarn dev,打开页面显示如下

image.png

六、引入sass

Sass官网地址

6.1 安装

yarn add sass

6.2 使用

在src文件下新增styles文件夹,新增 variables.module.scss文件

// base color
$blue: #324157;
$light-blue: #3a71a8;
$red: #c03639;
$pink: #e65d6e;
$green: #30b08f;
$tiffany: #4ab7bd;
$yellow: #fec171;
$panGreen: #30b08f;

// 默认菜单主题风格
$base-menu-color: #bfcbd9;
$base-menu-color-active: #f4f4f5;
$base-menu-background: #304156;
$base-logo-title-color: #fff;

$base-menu-light-color: #697280;
$base-menu-light-color-active: #697280;
$base-menu-light-background: #fff;
$base-logo-light-title-color: #001529;

$base-sub-menu-background: #1f2d3d;
$base-sub-menu-hover: #001528;

$base-sub-menu-light-background: #fff;
$base-sub-menu-light-active: #f2f3f5;
$base-sub-menu-light-hover: #f7f9fa;

$--color-primary: #409eff;
$--color-success: #67c23a;
$--color-warning: #e6a23c;
$--color-danger: #f56c6c;
$--color-info: #909399;

$base-sidebar-width: 260px;

新增 transition.scss文件

// global transition css

/* fade */
.fade-enter-active,
.fade-leave-active {
  transition: opacity 0.28s;
}

.fade-enter,
.fade-leave-active {
  opacity: 0;
}

/* fade-transform */
.fade-transform-leave-active,
.fade-transform-enter-active {
  transition: all 0.5s;
}

.fade-transform-enter {
  opacity: 0;
  transform: translateX(-30px);
}

.fade-transform-leave-to {
  opacity: 0;
  transform: translateX(30px);
}

/* breadcrumb transition */
.breadcrumb-enter-active,
.breadcrumb-leave-active {
  transition: all 0.5s;
}

.breadcrumb-enter,
.breadcrumb-leave-active {
  opacity: 0;
  transform: translateX(20px);
}

.breadcrumb-move {
  transition: all 0.5s;
}

.breadcrumb-leave-active {
  position: absolute;
}


新增 element.scss文件

#app {
  & .theme-dark .nest-menu .el-sub-menu > .el-sub-menu__title,
  & .theme-dark .el-sub-menu .el-menu-item {
    background-color: $base-sub-menu-background !important;

    &:hover {
      background-color: $base-sub-menu-hover !important;
    }
  }

  .el-button.is-text {
    padding-right: 0;
    padding-left: 0;
  }

  .el-table {
    margin: 10px 0;
  }

  .scp-table .el-table {
    font-size: 12px;

    --el-table-border: transparent;

    .el-table__inner-wrapper::before {
      display: none;
    }

    .el-table__header-wrapper {
      border-top: 1px solid #f2f3f5;

      th {
        height: 50px !important;
        font-size: 12px !important;
        color: #909399 !important;
        background: #fff !important;
        font-weight: normal !important;
      }
    }

    .el-table__body-wrapper {
      font-weight: bold;
      overflow: hidden;
      border: 1px solid #f2f3f5;
      border-bottom: 0;
      border-radius: 10px;
    }

    .el-table__cell {
      padding: 8px 0;

      .cell {
        /* height: auto; */
        line-height: 30px;
      }

      /* background: #f4f9fa; */
    }

    .el-table__row--striped .el-table__cell {
      /* background: #f2f3f5; */
    }

    .el-empty__description p {
      font-weight: normal;
      font-size: 12px;
    }
  }

  .el-dialog__body {
    padding-top: 15px;
    padding-bottom: 15px;
  }

  .el-input-number {
    width: 100%;

    .el-input__inner {
      text-align: left;
    }
  }

  .el-drawer__header {
    margin-bottom: 0;
  }
}


新增 index.scss 文件

@import './variables.module';
@import './transition';
@import './element';

:root {
  font-size: 14px;
  font-weight: 400;
  background-color: #fff;
}

html,
body,
#app {
  width: 100%;
  height: 100%;
}

a {
  text-decoration: none;
}

.flex {
  display: flex;
}

.flex-sb {
  display: flex;
  justify-content: space-between;
}

.flex-c {
  display: flex;
  justify-content: center;
}

.flex-sa {
  display: flex;
  justify-content: space-around;
}

.flex-end {
  display: flex;
  justify-content: flex-end;
}

.flex-align {
  display: flex;
  align-items: center;
}

.flex-wrap {
  display: flex;
  flex-wrap: wrap;
}

.flex-column {
  display: flex;
  flex-direction: column;
}

.flex-column-sb {
  display: flex;
  flex-direction: column;
  justify-content: space-between;
}

.flex-column-align-center {
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
}

.flex-align-sb {
  display: flex;
  justify-content: space-between;
  align-items: center;
}

.flex-align-sa {
  display: flex;
  justify-content: space-around;
  align-items: center;
}

.flex-column-sa {
  display: flex;
  flex-direction: column;
  justify-content: space-around;
}

.flex-align-ai {
  display: flex;
  align-items: center;
}

.flex-center {
  display: flex;
  justify-content: center;
  align-items: center;
}

.flex-sba {
  display: flex;
  justify-content: space-between;
  align-items: center;
}


6.3 安装公共样式

yarn add normalize.css

6.4 配置样式

配置main.ts

import { createApp } from 'vue'
import App from './App.vue'

//引入样式配置
import 'normalize.css'
import './styles/index.scss'
//引入element-plus
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'

const app = createApp(App)
//引入element-plus
app.use(ElementPlus)
app.mount('#app')

修改App.vue

<template>
  <div style="height: 100vh" class="flex-c flex-align">
    <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>
  </div>
</template>

<script setup lang="ts">

</script>

<style scoped></style>

启动后界面如下

image.png

七、路由

vue-router官网地址

7.1 安装

yarn add @types/vue-router

7.2 配置

src目录下新增router文件目录

新增router.ts文件

import { RouteConfig } from 'vue-router';

// 公共路由
export const constantRoutes: RouteConfig[] = [
    {
        path: '/',
        redirect: '/login'
    },
    {
        path: '/login',
        component: () => import('@/views/login/index.vue'),
        name: 'Login',
        meta: { title: 'login' }
    },
]

新增index.ts文件

import { createRouter, createWebHistory } from 'vue-router'
import { constantRoutes } from './route'

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

export default router

配置main.ts

import { createApp } from 'vue'
import App from './App.vue'

//引入样式配置
import 'normalize.css'
import './styles/index.scss'
//引入element-plus
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
//引入路由
import router from './router'



const app = createApp(App)
//引入element-plus
app.use(ElementPlus)
//引入路由
app.use(router)

app.mount('#app')

7.3 页面配置

在src目录下新增views/login目录,在这个目录下新增index.vue

<template>
  <div>
    login
  </div>
</template>

<script setup lang="ts">

</script>

<style lang="scss" scoped>

</style>

修改App.vue

<template>
  <router-view></router-view>
</template>

<script setup lang="ts">

</script>

<style scoped></style>

启动访问首页自动跳转到登录页面

image.png

八、页面布局

使用element-ui的布局,结合路由做一个登录页,简单判断token

8.1 cache设置

新增src/utils/cache目录,在下面新增index.ts

/**
 * window.localStorage 浏览器永久缓存
 * @method set 设置永久缓存
 * @method get 获取永久缓存
 * @method remove 移除永久缓存
 * @method clear 移除全部永久缓存
 */
export const Local = {
    // 设置永久缓存
    set(key: string, val: any) {
        window.localStorage.setItem(key, JSON.stringify(val));
    },

    // 获取永久缓存
    get(key: string) {
        let json: any = window.localStorage.getItem(key);
        return JSON.parse(json);
    },

    // 移除永久缓存
    remove(key: string) {
        window.localStorage.removeItem(key);
    },

    // 移除全部永久缓存
    clear() {
        window.localStorage.clear();
    },
};

/**
 * window.sessionStorage 浏览器临时缓存
 * @method set 设置临时缓存
 * @method get 获取临时缓存
 * @method remove 移除临时缓存
 * @method clear 移除全部临时缓存
 */
export const Session = {
    // 设置临时缓存
    set(key: string, val: any) {
        window.sessionStorage.setItem(key, JSON.stringify(val));
    },

    // 获取临时缓存
    get(key: string) {
        let json: any = window.sessionStorage.getItem(key);
        return JSON.parse(json);
    },

    // 移除临时缓存
    remove(key: string) {
        window.sessionStorage.removeItem(key);
    },

    // 移除全部临时缓存
    clear() {
        window.sessionStorage.clear();
    },
};


8.2 配置路由拦截token

修改src/router/index.ts文件

import { createRouter, createWebHistory } from 'vue-router'
import { ElMessage } from "element-plus";
import { constantRoutes } from './route'
import { Local } from '@/utils/cache'

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

/**
 * 全局前置路由守卫,每一次路由跳转前都进入这个 beforeEach 函数
 */
router.beforeEach((to, _from, next) => {
    if (to.path == '/login') {
        // 登录或者注册才可以往下进行
        next();
    } else {
        // 获取 token
        const token = Local.get('token');
        // token 不存在
        if (token === null || token === '') {
            ElMessage.error('登录失败,请先登录');
            next('/login');
        } else {
            next();
        }
    }
});

export default router

8.3 回车事件处理

新建src/utils/tools文件夹,新增index.ts

/**
 * @description 文档注册enter事件
 * @param {any} cb
 * @return {void}
 */
export const handleEnter = (cb: Function): void => {
    document.onkeydown = e => {
        const ev: KeyboardEventInit = window.event || e;
        if (ev.keyCode === 13) {
            cb();
        }
    };
};

// 其中 KeyboardEventInit 为内置,以下是代码截取
interface KeyboardEventInit extends EventModifierInit {
    /** @deprecated */
    charCode?: number;
    code?: string;
    isComposing?: boolean;
    key?: string;
    /** @deprecated */
    keyCode?: number;
    location?: number;
    repeat?: boolean;
}

8.4 登录页面配置

在src/assets目录下新增图片login-bg.png

loginbg.png

修改登录页面src/views/login/index.vue

<template>
  <div class="login-main" v-loading="loading" element-loading-text="Logging in...">
    <div class="login-form">
      <div class="logo flex-c flex-align">
        <!-- <img style="height: 50px;" src="../../assets/logo.png" alt="logo" /> -->
      </div>
      <el-form :model="loginForm" label-position="left" label-width="100px">
        <el-form-item label="Username:">
          <el-input v-model="loginForm.username" />
        </el-form-item>
        <el-form-item label="Password:">
          <el-input type="password" v-model="loginForm.password" />
        </el-form-item>
      </el-form>
      <div class="footer-btn flex-c">
        <button class="login-btn" @click="loginClick">login</button>
      </div>
    </div>
  </div>
</template>

<script lang="ts" setup>
import { useRouter } from "vue-router";
import { Local } from "@/utils/cache";
import { handleEnter } from "@/utils/tools";

const router = useRouter();

const loading = ref(false);

const loginForm = ref({
  username: "test",
  password: "1234",
});

const loginClick = () => {
  loading.value = true;
  const accessToken = Local.get("token");
  if (!accessToken) {
    Local.set("token", "abc");
    userLoginFunc();
  } else {
    userLoginFunc();
  }
};

const userLoginFunc = () => {
  loading.value = false;
  // userStore.SET_USER_INFO(res)
  router.push({ path: "/home" });
};

onMounted(() => {
  handleEnter(loginClick);
});
</script>

<style lang="scss" scoped>
.logo {
  margin-bottom: 20px;

  span {
    margin-left: 8px;
    font-size: 20px;
    color: #003574;
    font-weight: 700;
  }
}

.login-main {
  display: flex;
  justify-content: center;
  align-items: center;
  height: 100vh;
  background: url('../../assets/login-bg.png') no-repeat;
  background-size: 100% 100%;
}

.login-form {
  position: relative;
  top: -110px;
  background: #000;
  border-radius: 8px;

  ::v-deep(.el-form-item__label) {
    color: #fff;
  }
}

.login-btn {
  position: relative;
  z-index: 1;
  display: inline-flex;
  justify-content: center;
  align-items: center;
  overflow: hidden;
  width: 100%;
  height: 40px;
  font-size: 14px;
  font-family: alliance, mono, sans-serif;
  color: #fff;
  background: transparent;
  border-radius: 0;
  transition:
      background 0.3s ease-in-out,
      border-color 0.3s ease-in-out,
      color 0.3s ease-in-out;
  font-weight: 600;
  line-height: 40px;
  letter-spacing: 0.07em;
  text-transform: uppercase;
  font-feature-settings: "salt" on, "ss01" on, "ss02" on;
  transition-property: background, border-color, color;
  transition-duration: 0.3s, 0.3s, 0.3s;
  transition-timing-function: ease-in-out, ease-in-out, ease-in-out;
}

.login-btn::before {
  position: absolute;
  top: 1px;
  left: 1px;
  z-index: -1;
  display: block;
  width: calc(100% - 2px);
  height: calc(100% - 2px);
  background: #000;
  transition: background 0.3s ease-in-out;
  content: "";
  transform: translate3d(0, 0, 0);
}

.login-btn::after {
  position: absolute;
  top: 0;
  left: 0;
  z-index: -3;
  display: block;
  width: 100%;
  height: 100%;
  background:
      linear-gradient(
              269.16deg,
              #9867f0 -15.83%,
              #3bf0e4 -4.97%,
              #33ce43 15.69%,
              #b2f4b6 32.43%,
              #ffe580 50.09%,
              #ff7571 67.47%,
              #ff7270 84.13%,
              #ea5dad 105.13%,
              #c2a0fd 123.24%
      );
  background-position: 58% 50%;
  background-size: 500%;
  content: "";
  transform: translate3d(0, 0, 0);
  backface-visibility: hidden;
  animation: gradient-shift 30s ease infinite;
}

@keyframes gradient-shift {
  0% {
    background-position: 58% 50%;
  }

  25% {
    background-position: 100% 0%;
  }

  75% {
    background-position: 10% 50%;
  }

  100% {
    background-position: 58% 50%;
  }
}

.login-btn:hover::before {
  background: transparent;
}

.login-btn:hover {
  cursor: pointer;
  color: #000;
}
</style>


8.5 新增页面

8.5.1 layout页面

创建目录src/views/layout,在下面新建index.vue

<template>
  <div class="common-layout">
    <el-container>
      <el-header class="flex-c flex-align header"> Header </el-header>
      <el-container>
        <el-aside class="flex-c flex-align h-100 aside"> Aside </el-aside>
        <el-main>
          <router-view></router-view>
        </el-main>
      </el-container>
    </el-container>
  </div>
</template>

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

<style lang="scss" scoped>
.header {
  height: 6vh;
  background-color: #b3c0d1;
}

.aside {
  background-color: #d3dce6;
  width: 200px;
  height: 94vh;
}

.el-main {
  overflow: hidden;
  padding: 15px;
}
</style>


8.5.2 home页面

新增src/views/home目录,新增index.vue

<template>
  <div class="flex-c flex-align h-100">
    <el-button type="primary" @click="goRouter('/news')">go news</el-button>
    <el-button type="primary" @click="goRouter('/user')">go user</el-button>
  </div>
</template>

<script setup lang="ts">
import { useRouter } from 'vue-router'

const router = useRouter()

const goRouter = (path: string): void => {
  router.push(path)
}
</script>

8.5.3 news页面

创建src/views/news目录,新增index.vue

<template>
  <div>新闻</div>
</template>

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

8.5.4 user页面

创建src/views/user目录,新增index.vue页面

<template>
  <div>用户</div>
</template>

<script>
export default {
  name: "index"
}
</script>

<style scoped>

</style>

8.6 路由配置

创建src/router/modules目录

新增user.ts文件

import { RouteRecordRaw } from 'vue-router';

const Layout = () => import('@/views/layout/index.vue')

const user: RouteRecordRaw = {
    path: "/user",
    component: Layout,
    name: "User",
    meta: { title: "user" },
    redirect: "/user/index",
    children: [
        {
            path: "index",
            component: () => import("@/views/user/index.vue"),
            name: "UserIndex",
            meta: { title: "UserIndex" },
        },
    ],
};

export { user }


新增news.ts

import { RouteRecordRaw } from 'vue-router';

const Layout = () => import('@/views/layout/index.vue')

const user: RouteRecordRaw = {
    path: "/news",
    component: Layout,
    name: "News",
    meta: { title: "news" },
    redirect: "/news/index",
    children: [
        {
            path: "index",
            component: () => import("@/views/news/index.vue"),
            name: "NewsIndex",
            meta: { title: "NewsIndex" },
        },
    ],
};

export { user }


然后修改route.ts将这两个路由以及home页面引入

import type { RouteRecordRaw } from 'vue-router'

// 导入模块文件
const modulesFiles = import.meta.glob('./modules/*.ts', { eager: true })
// 获取模块路由
const modulesRoutes = Object.values(modulesFiles)

// 遍历模块路由,将路由记录添加到modules数组中
const modules: RouteRecordRaw[] = modulesRoutes.reduce(
    (routeArr: RouteRecordRaw[], routeItem: any) => {
        // 获取默认路由
        const defaultRoute: RouteRecordRaw[] = Object.values(routeItem)
        // 将默认路由添加到modules数组中
        routeArr.push(...defaultRoute)
        return routeArr
    },
    [],
) as RouteRecordRaw[]

const Layout = () => import('@/views/layout/index.vue')

// 公共路由
export const constantRoutes: RouteRecordRaw[] = [
    {
        path: '/',
        redirect: '/login',
    },
    {
        path: '/login',
        component: () => import('@/views/login/index.vue'),
        name: 'Login',
        meta: { title: 'login' },
    },
    {
        path: '/home',
        component: Layout,
        name: 'Home',
        meta: { title: 'home' },
        redirect: '/home/index',
        children: [
            {
                path: 'index',
                component: () => import('@/views/home/index.vue'),
                name: 'HomeIndex',
                meta: { title: 'homeIndex' },
            },
        ],
    },
    ...modules,
]

启动后登录跳转到如下页面,在localStorage中可以看到登录页设置的token缓存和home页面内容

image.png

点击按钮会跳转对应页面

image.png

image.png

九、Pinia

pinia官网地址

9.1 安装依赖

yarn add pinia

9.2 配置

在src目录下新增store文件夹

新增index.ts文件

import { createPinia } from 'pinia'

const store = createPinia()

export default store

在store目录下新增modules目录,在这个目录下创建user.ts

import { defineStore } from 'pinia'
import { Local } from '@/cache'

const useUserStore = defineStore('user', {
    state: () => ({
        userInfo: {
            id: '',
            username: '',
            nickname: '',
            roles: []
        } // 用户信息
    }),

    actions: {
        SET_USER_INFO(info: any) {
            this.userInfo = info
        },
        LOGOUT() {
            this.userInfo = {
                id: '',
                username: '',
                nickname: '',
                roles: []
            }
            Local.clear()
        }
    }
})

export default useUserStore

main.ts中新增配置

import { createApp } from 'vue'
import App from './App.vue'

//引入样式配置
import 'normalize.css'
import './styles/index.scss'
//引入element-plus
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
//引入路由
import router from './router'
//引入store
import store from './store'


const app = createApp(App)
//引入element-plus
app.use(ElementPlus)
//引入路由
app.use(router)
//引入store
app.use(store)
app.mount('#app')

9.3 测试

新增src/store/modules/user.ts文件

import { defineStore } from 'pinia'
import { Local } from '@/utils/cache'

const useUserStore = defineStore('user', {
    state: () => ({
        userInfo: {
            id: '',
            username: '',
            nickname: '',
            roles: []
        } // 用户信息
    }),

    actions: {
        SET_USER_INFO(info: any) {
            this.userInfo = info
        },
        LOGOUT() {
            this.userInfo = {
                id: '',
                username: '',
                nickname: '',
                roles: []
            }
            Local.clear()
        }
    }
})

export default useUserStore

修改src/views/login/index.vue

<template>
  <div class="login-main" v-loading="loading" element-loading-text="Logging in...">
    <div class="login-form">
      <div class="logo flex-c flex-align">
        <!-- <img style="height: 50px;" src="../../assets/logo.png" alt="logo" /> -->
      </div>
      <el-form :model="loginForm" label-position="left" label-width="100px">
        <el-form-item label="Username:">
          <el-input v-model="loginForm.username" />
        </el-form-item>
        <el-form-item label="Password:">
          <el-input type="password" v-model="loginForm.password" />
        </el-form-item>
      </el-form>
      <div class="footer-btn flex-c">
        <button class="login-btn" @click="loginClick">login</button>
      </div>
    </div>
  </div>
</template>

<script lang="ts" setup>
import { useRouter } from "vue-router";
import { Local } from "@/utils/cache";
import { handleEnter } from "@/utils/tools";
import useUserStore from "@/store/modules/user";
const userStore = useUserStore()

const router = useRouter();

const loading = ref(false);

const loginForm = ref({
  username: "test",
  password: "1234",
});

const loginClick = () => {
  loading.value = true;
  const accessToken = Local.get("token");
  if (!accessToken) {
    Local.set("token", "abc");
    userLoginFunc();
  } else {
    userLoginFunc();
  }
};

const userLoginFunc = () => {
  loading.value = false;
  // 新增
  userStore.SET_USER_INFO({
    id: 1,
    username: "test",
    nickname: "测试账号",
    roles: ["admin", "test"],
  });
  router.push({ path: "/home" });
};

onMounted(() => {
  handleEnter(loginClick);
});
</script>

<style lang="scss" scoped>
.logo {
  margin-bottom: 20px;

  span {
    margin-left: 8px;
    font-size: 20px;
    color: #003574;
    font-weight: 700;
  }
}

.login-main {
  display: flex;
  justify-content: center;
  align-items: center;
  height: 100vh;
  background: url('../../assets/login-bg.png') no-repeat;
  background-size: 100% 100%;
}

.login-form {
  position: relative;
  top: -110px;
  background: #000;
  border-radius: 8px;

  ::v-deep(.el-form-item__label) {
    color: #fff;
  }
}

.login-btn {
  position: relative;
  z-index: 1;
  display: inline-flex;
  justify-content: center;
  align-items: center;
  overflow: hidden;
  width: 100%;
  height: 40px;
  font-size: 14px;
  font-family: alliance, mono, sans-serif;
  color: #fff;
  background: transparent;
  border-radius: 0;
  transition:
      background 0.3s ease-in-out,
      border-color 0.3s ease-in-out,
      color 0.3s ease-in-out;
  font-weight: 600;
  line-height: 40px;
  letter-spacing: 0.07em;
  text-transform: uppercase;
  font-feature-settings: "salt" on, "ss01" on, "ss02" on;
  transition-property: background, border-color, color;
  transition-duration: 0.3s, 0.3s, 0.3s;
  transition-timing-function: ease-in-out, ease-in-out, ease-in-out;
}

.login-btn::before {
  position: absolute;
  top: 1px;
  left: 1px;
  z-index: -1;
  display: block;
  width: calc(100% - 2px);
  height: calc(100% - 2px);
  background: #000;
  transition: background 0.3s ease-in-out;
  content: "";
  transform: translate3d(0, 0, 0);
}

.login-btn::after {
  position: absolute;
  top: 0;
  left: 0;
  z-index: -3;
  display: block;
  width: 100%;
  height: 100%;
  background:
      linear-gradient(
              269.16deg,
              #9867f0 -15.83%,
              #3bf0e4 -4.97%,
              #33ce43 15.69%,
              #b2f4b6 32.43%,
              #ffe580 50.09%,
              #ff7571 67.47%,
              #ff7270 84.13%,
              #ea5dad 105.13%,
              #c2a0fd 123.24%
      );
  background-position: 58% 50%;
  background-size: 500%;
  content: "";
  transform: translate3d(0, 0, 0);
  backface-visibility: hidden;
  animation: gradient-shift 30s ease infinite;
}

@keyframes gradient-shift {
  0% {
    background-position: 58% 50%;
  }

  25% {
    background-position: 100% 0%;
  }

  75% {
    background-position: 10% 50%;
  }

  100% {
    background-position: 58% 50%;
  }
}

.login-btn:hover::before {
  background: transparent;
}

.login-btn:hover {
  cursor: pointer;
  color: #000;
}
</style>


登录成功后,看到localStorage中有数据

image.png

9.5 数据持久化

数据持久化可以帮助确保用户的数据在页面重新加载或用户会话结束后仍然保持完整性和可访问性

在引入依赖之前,先测试一下我们设置的userInfo的数据能否正常获取

修改src/views/home/index.vue

<template>
  <div class="flex-c flex-align h-100">
    <el-button type="primary" @click="goRouter('/news')">go news</el-button>
    <el-button type="primary" @click="goRouter('/user')">go user</el-button>
    <el-button type="primary" @click="getUserInfo()">get user info</el-button>
  </div>
</template>

<script setup lang="ts">
import { useRouter } from 'vue-router'
import useUserStore from "@/store/modules/user";
const userStore = useUserStore();
const router = useRouter()
const goRouter = (path: string): void => {
  router.push(path)
}
const getUserInfo = ():void=>{
  let userInfo = userStore.userInfo;
  console.log(userInfo)
}
</script>


刷新页面后点击获取userInfom,发现数据都为空

image.png

9.5.1 引入依赖

yarn add pinia-plugin-persistedstate

9.5.2 配置

修改src/store/index.ts

import { createPinia } from 'pinia'
import piniaPluginPersistedState from 'pinia-plugin-persistedstate'
const store = createPinia()
store.use(piniaPluginPersistedState)
export default store

修改src/store/modules/user.ts,配置持久化

import { defineStore } from 'pinia'
import { Local } from '@/utils/cache'

const useUserStore = defineStore('user', {
    state: () => ({
        userInfo: {
            id: '',
            username: '',
            nickname: '',
            roles: []
        } // 用户信息
    }),
    persist: true,//持久化配置
    actions: {
        SET_USER_INFO(info: any) {
            this.userInfo = info
        },
        LOGOUT() {
            this.userInfo = {
                id: '',
                username: '',
                nickname: '',
                roles: []
            }
            Local.clear()
        }
    }
})

export default useUserStore

9.5.3 测试

此时在home页面获取userInfo

image.png

十、axios请求

10.1 安装依赖

yarn add axios

10.2 封装配置

在src/utils/tools目录下新增user.ts

import useUserStore from '../../store/modules/user'
import { Local } from '@/utils/cache'

const userStore = useUserStore()

export function logout() {
  userStore.LOGOUT()
  setTimeout(() => {
    window.location.href = '/login'
  }, 500)
}

export function setToken(token: string) {
  Local.set('token', token)
}

export function getToken() {
  const token = Local.get('token')
  return token
}

新建src/utils/request目录

新增config.ts

const TEST_BASE_URL = '/mock'
const API_BASE_URL = '/api'
const AUTH_BASE_URL = '/auth'

const TIME_OUT = 30 * 1000

export {
    TEST_BASE_URL,
    API_BASE_URL,
    AUTH_BASE_URL,
    TIME_OUT
}

新增types.ts

import type { AxiosResponse, AxiosRequestConfig, InternalAxiosRequestConfig } from 'axios'

export interface BaseRequestInterceptors<T = AxiosResponse<any>> {
    requestInterceptor?: (config: InternalAxiosRequestConfig) => InternalAxiosRequestConfig
    requestInterceptorCatch?: (error: any) => any
    responseInterceptor?: (res: T) => T
    responseInterceptorCatch?: (error: any) => any
}

export interface BaseRequestConfig<T = AxiosResponse> extends AxiosRequestConfig {
    interceptors?: BaseRequestInterceptors<T>
    loading?: boolean | string
    noToken?: boolean
}

新增http.ts

import axios from "axios";
import type { AxiosInstance, InternalAxiosRequestConfig } from "axios";
import { BaseRequestConfig, BaseRequestInterceptors } from "./types";

import { ElLoading, ElMessage, ElMessageBox } from "element-plus";
import { LoadingInstance } from "element-plus/es/components/loading/src/loading";
import { getToken, logout } from "@/utils/tools/user";

const DEFAULT_LOADING = false;
let isReLogin = false;

class BaseRequest {
    // axios 实例
    instance: AxiosInstance;
    interceptors?: BaseRequestInterceptors;
    showLoading: boolean | string;
    loading?: LoadingInstance;

    constructor(config: BaseRequestConfig) {
        this.instance = axios.create(config);
        this.showLoading = config.loading ?? DEFAULT_LOADING;
        this.interceptors = config.interceptors;

        // 使用拦截器
        // 1. 从config中取出的拦截器是对应的实例的拦截器
        this.instance.interceptors.request.use(
            this.interceptors?.requestInterceptor,
            this.interceptors?.requestInterceptorCatch
        );
        this.instance.interceptors.response.use(
            this.interceptors?.responseInterceptor,
            this.interceptors?.responseInterceptorCatch
        );

        // 2.添加所有的实例都有的拦截器
        this.instance.interceptors.request.use(
            (config) => {
                if (config.params && config.params.responseType) {
                    config.responseType = config.params.responseType;
                    delete config.params.responseType;
                }
                if (
                    (config.method === "post" || config.method === "put") &&
                    config.params
                ) {
                    if (config.params.formData) {
                        config.data = config.params.formData;
                    } else {
                        config.data = { ...config.params };
                    }
                    delete config.params;
                }
                // console.log('所有的实例都有的拦截器: 请求成功拦截')
                if (this.showLoading) {
                    const loadingText =
                        typeof this.showLoading == "boolean" ? "加载中" : this.showLoading;
                    this.loading = ElLoading.service({
                        lock: true,
                        text: loadingText + "...",
                        background: "rgba(0, 0, 0, 0.5)",
                    });
                }
                return config;
            },
            (err) => {
                console.log("所有的实例都有的拦截器: 请求失败拦截");
                return err;
            }
        );

        this.instance.interceptors.response.use(
            (res) => {
                // 将loading移除
                this.loading?.close();
                if (res.status !== 200) return res;
                return res.data;
            },
            (err) => {
                // console.log('所有的实例都有的拦截器: 响应失败拦截')
                // 将loading移除
                this.loading?.close();

                // 例子: 判断不同的HttpErrorCode显示不同的错误信息
                switch (err.response.status) {
                    case 400:
                        ElMessage.error("请求错误(400)");
                        break;
                    case 401:
                        ElMessage.error("未授权,请重新登录(401)");
                        logout();
                        break;
                    case 403:
                        ElMessage.error("拒绝访问(403)");
                        break;
                    case 404:
                        ElMessage.error("请求出错(404)");
                        break;
                    case 408:
                        ElMessage.error("请求超时(408)");
                        break;
                    case 500:
                        ElMessage.error("服务器错误(500)");
                        break;
                    case 501:
                        ElMessage.error("服务未实现(501)");
                        break;
                    case 502:
                        ElMessage.error("网络错误(502)");
                        break;
                    case 503:
                        ElMessage.error("服务不可用(503)");
                        break;
                    case 504:
                        ElMessage.error("网络超时(504)");
                        break;
                    case 505:
                        ElMessage.error("HTTP版本不受支持(505)");
                        break;
                    default: {
                        ElMessage.error(`连接出错(${err.response.status})!`);
                    }
                }
                return err;
            }
        );
    }

    request<T = any>(config: BaseRequestConfig<T>): Promise<T> {
        return new Promise((resolve, reject) => {
            // 1.单个请求对请求config的处理
            if (config.interceptors?.requestInterceptor) {
                // 对config 进行转化
                config = config.interceptors.requestInterceptor(
                    config as InternalAxiosRequestConfig
                );
            }
            // 携带token的拦截
            if (!config.noToken && getToken()) {
                config.headers = {
                    Authorization: getToken(),
                };
            }
            // 2.判断是否需要显示loading
            if (config.loading) {
                this.showLoading = config.loading;
            }
            this.instance
                .request<any, T>(config)
                .then((res: any) => {
                    // 1.单个请求对数据的处理
                    if (config.interceptors?.responseInterceptor) {
                        res = config.interceptors.responseInterceptor(res);
                    }
                    // 2.将showLoading设置true, 这样不会影响下一个请求
                    this.showLoading = DEFAULT_LOADING;
                    if (res.status !== 200) {
                        if (config.responseType == "blob") {
                            resolve(res);
                            return;
                        }
                        let errMsg = res.message || "请求失败";
                        if (res.code == "501") errMsg = "数据中存在敏感词汇, 请修改!";
                        if (res.status == 401) {
                            if (!isReLogin) {
                                isReLogin = true;
                                // prettier-ignore
                                errMsg = res.message || '登录状态已过期,您可以继续留在该页面,或者重新登录'
                                ElMessageBox.confirm(errMsg, "系统提示", {
                                    confirmButtonText: "重新登录",
                                    cancelButtonText: "取消",
                                    type: "warning",
                                })
                                    .then(() => {
                                        isReLogin = false;
                                        logout();
                                    })
                                    .catch(() => {
                                        isReLogin = false;
                                    });
                            }
                            reject(res);
                            return;
                        }
                        ElMessage({
                            message: errMsg,
                            type: "error",
                        });
                        reject(res);
                    } else {
                        resolve(res);
                    }
                })
                .catch((err) => {
                    // 将showLoading设置true, 这样不会影响下一个请求
                    this.showLoading = DEFAULT_LOADING;
                    reject(err);
                    return err;
                });
        });
    }

    get<T = any>(config: BaseRequestConfig<T>): Promise<T> {
        return this.request<T>({ ...config, method: "GET" });
    }

    post<T = any>(config: BaseRequestConfig<T>): Promise<T> {
        return this.request<T>({ ...config, method: "POST" });
    }

    put<T = any>(config: BaseRequestConfig<T>): Promise<T> {
        return this.request<T>({ ...config, method: "PUT" });
    }

    delete<T = any>(config: BaseRequestConfig<T>): Promise<T> {
        return this.request<T>({ ...config, method: "DELETE" });
    }

    patch<T = any>(config: BaseRequestConfig<T>): Promise<T> {
        return this.request<T>({ ...config, method: "PATCH" });
    }
}

export default BaseRequest;

新增index.ts

import BaseRequest from './http'
import { TIME_OUT } from './config'

const testRequest = new BaseRequest({
    timeout: TIME_OUT,
    interceptors: {
        requestInterceptor: config => {
            return config
        },
        requestInterceptorCatch: err => {
            // console.log('请求失败的拦截')
            return err
        },
        responseInterceptor: res => {
            // console.log('响应成功的拦截')
            return res
        },
        responseInterceptorCatch: err => {
            const errRes = err.response
            if (errRes) {
                return {
                    status: errRes.status,
                    message: errRes.data.message
                }
            }
            return err
        }
    }
})

export default testRequest

十一、引入mock

11.1 安装依赖

yarn add mockjs -D
yarn add vite-plugin-mock@2.9.6 -D 

11.2 配置使用

在根目录创建mock文件夹,创建index.ts文件

import type { MockMethod } from 'vite-plugin-mock'

const mockItems: MockMethod[] = [
  {
    url: '/mock/login',
    method: 'post',
    response: () => {
      return {
        status: 200,
        data: {
          access_token: 'abc',
        },
        message: 'success',
      }
    },
  },
]

export default mockItems

修改vite.plugin.ts新增配置,这里根据common判断是否启用mock,执行yarn serve就会使用mock,所以createVitePlugins函数增加一个参数command

import path from 'path'
import vue from '@vitejs/plugin-vue'
import { createSvgIconsPlugin } from 'vite-plugin-svg-icons'
import setupExtend from 'vite-plugin-vue-setup-extend'
import Components from 'unplugin-vue-components/vite'
import AutoImport from 'unplugin-auto-import/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
import { createHtmlPlugin } from 'vite-plugin-html'
import topLevelAwait from 'vite-plugin-top-level-await'
import { viteMockServe } from "vite-plugin-mock";
export default function createVitePlugins(viteEnv, isBuild = false,command) {
    const { VITE_GLOB_APP_TITLE } = viteEnv
    const vitePlugins = [
        vue(),
        setupExtend(),
        viteMockServe({
            mockPath: "./mock",
            localEnabled: command === "serve"
        }),
        createSvgIconsPlugin({
            // 指定需要缓存的图标文件夹
            iconDirs: [path.resolve(process.cwd(), 'src/assets/icons/svg')],
            // 指定symbolId格式
            symbolId: 'icon-[dir]-[name]'
        }),
        Components({
            resolvers: [ElementPlusResolver()]
        }),
        AutoImport({
            // resolvers: [ElementPlusResolver()],
            // targets to transform
            include: [/\.[tj]sx?$/, /\.vue$/, /\.vue\?vue/, /\.md$/],
            // global imports to register
            imports: [
                // 插件预设支持导入的api
                'vue',
                'vue-router',
                'pinia'
                // 自定义导入的api
            ],

            // Generate corresponding .eslintrc-auto-import.json file.
            // eslint globals Docs - https://eslint.org/docs/user-guide/configuring/language-options#specifying-globals
            eslintrc: {
                enabled: false, // 默认false, true启用。生成一次就可以,避免每次工程启动都生成
                filepath: './.eslintrc-auto-import.json', // Default `./.eslintrc-auto-import.json`
                globalsPropValue: true // Default `true`, (true | false | 'readonly' | 'readable' | 'writable' | 'writeable')
            },

            // Filepath to generate corresponding .d.ts file.
            // Defaults to './auto-imports.d.ts' when `typescript` is installed locally.
            // Set `false` to disable.
            dts: './auto-imports.d.ts',

        }),
        createHtmlPlugin({
            minify: isBuild,
            inject: {
                data: {
                    title: VITE_GLOB_APP_TITLE
                }
            }
        }),
        topLevelAwait({
            // The export name of top-level await promise for each chunk module
            promiseExportName: '__tla',
            // The function to generate import names of top-level await promise in each chunk module
            promiseImportName: i => `__tla_${i}`
        })
    ]
    return vitePlugins
};

同时vite.config.ts中也要增加command参数

import path from 'path'
import { defineConfig, loadEnv } from 'vite'
import createVitePlugins from './vite.plugins'
// const base_url = 'xxx'

// https://vitejs.dev/config/
export default defineConfig(({ mode, command }) => {
  const env = loadEnv(mode, process.cwd())
  return {
    plugins: createVitePlugins(env, command === 'build',command),
    base: './',

    resolve: {
      alias: {
        '@': path.resolve(__dirname, 'src')
      },
      extensions: ['.mjs', '.js', '.ts', '.jsx', '.tsx', '.json', '.vue']
    },
    server: {
      host: "0.0.0.0",
      proxy: {
        // '/xxx': {
        //   target: base_url,
        //   changeOrigin: true,
        // }
      }
    }
  }
})


package.json中也要新增serve的启动配置,这里复制scripts的serve就可以了

{
  "name": "bs-web",
  "private": true,
  "version": "0.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "serve": "vite",
    "build": "vue-tsc && vite build",
    "preview": "vite preview"
  },
  "dependencies": {
    "@types/vue-router": "^2.0.0",
    "axios": "^1.6.2",
    "element-plus": "^2.4.3",
    "normalize.css": "^8.0.1",
    "pinia": "^2.1.7",
    "pinia-plugin-persistedstate": "^3.2.0",
    "sass": "^1.69.5",
    "vite-plugin-html": "^3.2.0",
    "vite-plugin-svg-icons": "^2.0.1",
    "vite-plugin-top-level-await": "^1.4.0",
    "vite-plugin-vue-setup-extend": "^0.4.0",
    "vue": "^3.3.11"
  },
  "devDependencies": {
    "@vitejs/plugin-vue": "^4.5.2",
    "mockjs": "^1.1.0",
    "typescript": "^5.2.2",
    "unplugin-auto-import": "^0.17.2",
    "unplugin-vue-components": "^0.26.0",
    "vite": "^5.0.8",
    "vite-plugin-mock": "2.9.6",
    "vue-tsc": "^1.8.25"
  }
}

11.3 测试

创建src/api/userApi.ts

import testRequest from '@/utils/request'
import { TEST_BASE_URL } from '@/utils/request/config'

// 登陆
export function userLogin(data = {}) {
  return testRequest.post({
    url: `${TEST_BASE_URL}/login`,
    data,
  })
}

修改src/views/login/index.vue

<template>
  <div class="login-main" v-loading="loading" element-loading-text="Logging in...">
    <div class="login-form">
      <div class="logo flex-c flex-align">
        <!-- <img style="height: 50px;" src="../../assets/logo.png" alt="logo" /> -->
      </div>
      <el-form :model="loginForm" label-position="left" label-width="100px">
        <el-form-item label="Username:">
          <el-input v-model="loginForm.username" />
        </el-form-item>
        <el-form-item label="Password:">
          <el-input type="password" v-model="loginForm.password" />
        </el-form-item>
      </el-form>
      <div class="footer-btn flex-c">
        <button class="login-btn" @click="loginClick">login</button>
      </div>
    </div>
  </div>
</template>

<script lang="ts" setup>
import { useRouter } from "vue-router";

import { handleEnter } from "@/utils/tools";
import useUserStore from "@/store/modules/user";

//引入请求
import testRequest from "@/utils/request";
import { TEST_BASE_URL } from "@/utils/request/config";

import { userLogin } from '@/api/userApi'
import { getToken, setToken } from '@/utils/tools/user'
import { ElMessage } from 'element-plus'
const userStore = useUserStore();

const router = useRouter();

const loading = ref(false);

const loginForm = ref({
  username: "test",
  password: "1234",
});

const loginClick = (data = {}) => {
  loading.value = true;
  const accessToken = getToken();
  if (!accessToken) {
    userLogin()
        .then((res) => {
          setToken(res.data.access_token);
        })
        .then(() => {
          userLoginFunc();
        })
        .catch((err) => {
          ElMessage.error(err);
          loading.value = false;
        });
  } else {
    userLoginFunc();
  }
};

const userLoginFunc = () => {
  loading.value = false;
  // 新增
  userStore.SET_USER_INFO({
    id: 1,
    username: "test",
    nickname: "测试账号",
    roles: ["admin", "test"],
  });
  router.push({ path: "/home" });
};

onMounted(() => {
  handleEnter(loginClick);
});
</script>

<style lang="scss" scoped>
.logo {
  margin-bottom: 20px;

  span {
    margin-left: 8px;
    font-size: 20px;
    color: #003574;
    font-weight: 700;
  }
}

.login-main {
  display: flex;
  justify-content: center;
  align-items: center;
  height: 100vh;
  background: url('../../assets/login-bg.png') no-repeat;
  background-size: 100% 100%;
}

.login-form {
  position: relative;
  top: -110px;
  background: #000;
  border-radius: 8px;

  ::v-deep(.el-form-item__label) {
    color: #fff;
  }
}

.login-btn {
  position: relative;
  z-index: 1;
  display: inline-flex;
  justify-content: center;
  align-items: center;
  overflow: hidden;
  width: 100%;
  height: 40px;
  font-size: 14px;
  font-family: alliance, mono, sans-serif;
  color: #fff;
  background: transparent;
  border-radius: 0;
  transition:
      background 0.3s ease-in-out,
      border-color 0.3s ease-in-out,
      color 0.3s ease-in-out;
  font-weight: 600;
  line-height: 40px;
  letter-spacing: 0.07em;
  text-transform: uppercase;
  font-feature-settings: "salt" on, "ss01" on, "ss02" on;
  transition-property: background, border-color, color;
  transition-duration: 0.3s, 0.3s, 0.3s;
  transition-timing-function: ease-in-out, ease-in-out, ease-in-out;
}

.login-btn::before {
  position: absolute;
  top: 1px;
  left: 1px;
  z-index: -1;
  display: block;
  width: calc(100% - 2px);
  height: calc(100% - 2px);
  background: #000;
  transition: background 0.3s ease-in-out;
  content: "";
  transform: translate3d(0, 0, 0);
}

.login-btn::after {
  position: absolute;
  top: 0;
  left: 0;
  z-index: -3;
  display: block;
  width: 100%;
  height: 100%;
  background:
      linear-gradient(
              269.16deg,
              #9867f0 -15.83%,
              #3bf0e4 -4.97%,
              #33ce43 15.69%,
              #b2f4b6 32.43%,
              #ffe580 50.09%,
              #ff7571 67.47%,
              #ff7270 84.13%,
              #ea5dad 105.13%,
              #c2a0fd 123.24%
      );
  background-position: 58% 50%;
  background-size: 500%;
  content: "";
  transform: translate3d(0, 0, 0);
  backface-visibility: hidden;
  animation: gradient-shift 30s ease infinite;
}

@keyframes gradient-shift {
  0% {
    background-position: 58% 50%;
  }

  25% {
    background-position: 100% 0%;
  }

  75% {
    background-position: 10% 50%;
  }

  100% {
    background-position: 58% 50%;
  }
}

.login-btn:hover::before {
  background: transparent;
}

.login-btn:hover {
  cursor: pointer;
  color: #000;
}
</style>


清理了缓存重新登录成功


标题:vue前端框架搭建
作者:wenyl
地址:http://www.wenyoulong.com/articles/2023/12/13/1702432295809.html