使用 Vue3 + Pinia + Ant Design Vue3 搭建后台管理系统

Vue3 & Ant Design Vue3基础


nodejs版本要求:node-v18.16.0-x64
nodejs基础配置

npm -v
node -v

npm config set prefix "D:\software\nodejs\node_global"
npm config set cache "D:\software\nodejs\node_cache"

npm config get registry
npm config set registry https://registry.npm.taobao.org

安装Vue3

npm install @vue/cli -g
vue --version

#npm install @vue/cli@5.0.8 -g	安装指定版本
#npm uninstall @vue/cli -g

使用Vue创建前端项目

npm create vue@latest
√ Project name: ...web
√ Add TypeScript? ... No 
√ Add JSX Support? ... No 
√ Add Vue Router for Single Page Application development? ...  Yes
√ Add Pinia for state management? ...  Yes
√ Add Vitest for Unit Testing? ... No 
√ Add an End-to-End Testing Solution? » No
√ Add ESLint for code quality? ... Yes
√ Add Prettier for code formatting? ... Yes

启动前端项目

cd web
npm install
npm run dev

浏览器访问:http://localhost:5173
修改端口号,修改配置 vite.config.js

export default defineConfig({
  server: {
    port: 9000
  },
  plugins: [
    vue(),
  ],
  resolve: {
    alias: {
      '@': fileURLToPath(new URL('./src', import.meta.url))
    }
  }
})

再次访问:http://localhost:9000/


https://antdv.com/components/overview-cn
Ant Design Vue官方文档

安装Ant Design Vue

npm install ant-design-vue --save
npm install --save @ant-design/icons-vue

#自动按需引入组件
npm install unplugin-vue-components -D

修改配置文件vite.config.js

import {fileURLToPath, URL} from 'node:url'

import {defineConfig} from 'vite'
import vue from '@vitejs/plugin-vue'
import Components from 'unplugin-vue-components/vite';
import {AntDesignVueResolver} from 'unplugin-vue-components/resolvers';

// https://vitejs.dev/config/
export default defineConfig({
    server: {
        port: 9000
    },
    plugins: [
        vue(),
        Components({
            resolvers: [
                AntDesignVueResolver({
                    importStyle: false, // css in js
                }),
            ],
        }),
    ],
    resolve: {
        alias: {
            '@': fileURLToPath(new URL('./src', import.meta.url))
        }
    }
})

修改main.js

import './assets/main.css'

import { createApp } from 'vue'
import { createPinia } from 'pinia'
import Antd from 'ant-design-vue';

import App from './App.vue'
import router from './router'
import 'ant-design-vue/dist/reset.css';

const app = createApp(App)

app.use(createPinia())
app.use(router)
app.use(Antd)

app.mount('#app')

添加一个测试页面

<script setup>
import {ZoomOutOutlined} from "@ant-design/icons-vue";
</script>

<template>
  <div class="about">
    <h1>This is an about page</h1>
    <a-button>测试</a-button><br>
    <ZoomOutOutlined />
  </div>
</template>

<style>
</style>

Antd栅格把页面平均分成24份

<template>
  <a-row>
    <a-col :span="24">col</a-col>
  </a-row>
  <a-row>
    <a-col :span="12">col-12</a-col>
    <a-col :span="12">col-12</a-col>
  </a-row>
  <a-row>
    <a-col :span="8">col-8</a-col>
    <a-col :span="8">col-8</a-col>
    <a-col :span="8">col-8</a-col>
  </a-row>
  <a-row>
    <a-col :span="6">col-6</a-col>
    <a-col :span="6">col-6</a-col>
    <a-col :span="6">col-6</a-col>
    <a-col :span="6">col-6</a-col>
  </a-row>
</template>

使用Pinia管理用户状态

刷新页面,Pinia中的数据会丢失,使用Pinia插件做数据持久化

npm install --save zipson
npm install --save pinia-plugin-persistedstate

修改main.js

import './assets/main.css'

import { createApp } from 'vue'
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
import Antd from 'ant-design-vue';

import App from './App.vue'
import router from './router'
import 'ant-design-vue/dist/reset.css';

const app = createApp(App)
const pinia = createPinia()
pinia.use(piniaPluginPersistedstate);//pinia数据持久化

app.use(pinia)
app.use(router)
app.use(Antd)

app.mount('#app')

使用Pinia保存用户状态,添加文件src/stores/user.js

import {reactive} from 'vue'
import {defineStore} from 'pinia'
import {stringify, parse} from 'zipson'

const MEMBER = "MEMBER"
export const useUserStore = defineStore('user', () => {
    const userInfo = reactive({
        id: '',
        mobile: '',
        token: ''
    })

    function setUserInfo({id, mobile, token}) {
        userInfo.id = id
        userInfo.mobile = mobile
        userInfo.token = token
    }

    function clearUserInfo() {
        userInfo.id = ''
        userInfo.mobile = ''
        userInfo.token = ''
    }

    return {userInfo, setUserInfo, clearUserInfo}
}, {
    persist: {
        key: MEMBER,
        storage: sessionStorage,
        // paths: ['count'],
        serializer: {
            deserialize: parse,
            serialize: stringify
        },
        beforeRestore: (ctx) => {
            console.log(`about to restore '${ctx.store.$id}'`)
        },
        afterRestore: (ctx) => {
            console.log(`just restored '${ctx.store.$id}'`)
        },
        debug: true,
    }
})

ite多环境配置

https://vitejs.cn/vite3-cn/guide/env-and-mode.html#env-variables
官方配置文档

在根目录创建文件 .env.development

NODE_ENV=development
#自定义变量需要以VITE_开头
VITE_APP_BASE_URL=http://localhost:8000

生产环境 .env.production

NODE_ENV=production
VITE_APP_BASE_URL=http://train.intmall.com

使用环境变量

axios.defaults.baseURL = import.meta.env.VITE_APP_BASE_URL;
console.log(process.env.NODE_ENV)
console.log(import.meta.env.VITE_APP_BASE_URL)

封装网络请求工具类Axios

npm install axios --save

封装网络请求工具类 src/utils/request.js

import axios from 'axios'
import {notification} from 'ant-design-vue';
import {useUserStore} from '@/stores/user';
import router from '@/router'

const {userInfo, clearUserInfo} = useUserStore()
export const serverUrl = import.meta.env.VITE_APP_BASE_URL

const service = axios.create({
    baseURL: serverUrl,
    timeout: 5000
})

// Add a request interceptor 全局请求拦截
service.interceptors.request.use(
    function (config) {
        // Do something before request is sent
        const token = userInfo.token
        if (token) {
            config.headers['token'] = token
        }
        // 此处还可以设置token
        return config
    },
    function (error) {
        // Do something with request error
        return Promise.reject(error)
    }
)

// Add a response interceptor 全局相应拦截
service.interceptors.response.use(
    function (response) {
        // Any status code that lie within the range of 2xx cause this function to trigger
        // Do something with response data

        // 如果是固定的数据返回模式,此处可以做继续完整的封装
        const resData = response.data || {}
        if (resData.success) {
            return resData
        }
        notification.error({description: resData.message});
        return Promise.reject(resData.message)
    },
    function (error) {
        // Any status codes that falls outside the range of 2xx cause this function to trigger
        // Do something with response error

        // 此处需要对返回的状态码或者异常信息作统一处理
        console.log('error', error)
        const response = error.response;
        const status = response.status;
        if (status === 401) {
            // 判断状态码是401 跳转到登录页
            console.log("未登录或登录超时,跳到登录页");
            clearUserInfo()
            notification.error({description: "未登录或登录超时"});
            router.push('/login')
        }
        return Promise.reject(error)
    }
)

export const get = (url, params) => {
    return service.get(url, {
        params
    })
}

export const post = (url, data) => service.post(url, data)

export const put = (url, data) => service.put(url, data)

export const del = (url, data) => service.delete(url)

遇到的问题:
useRouter失效,router无法跳转页面
https://blog.csdn.net/qq_57700056/article/details/133530562

后台接口调用示例: src/api/userApi.js

import { get, post, put, del } from "../utils/request";

// 用户登录
export async function login(data) {
    return post('/member/member/login', data)
}

export async function sendCode(data) {
    return post('/member/member/sendCode', data)
}

export async function getUserCount() {
    return get('/member/member/count')
}

export async function savePassenger(data) {
    return post('/member/passenger/save', data)
}

export async function queryPassengerList(data) {
    return post('/member/passenger/queryList', data)
}

export async function deletePassenger(id) {
    return del(`/member/passenger/delete/${id}`)
}

// 导出 userApi 方法
export default {
    login,
    sendCode,
    getUserCount,
    savePassenger,
    deletePassenger,
    queryPassengerList
}


前端页面路由配置

增加路由防卫,判断要跳转的页面是否需要登录
src/router/index.js
由于router挂载比pinia要早,守卫在在使用pinia时,pinia还没有挂载,把pinia写在守卫里面即可解决问题

import {createRouter, createWebHistory} from 'vue-router'
import {notification} from 'ant-design-vue';
import {useUserStore} from '@/stores/user';

const router = createRouter({
    history: createWebHistory(import.meta.env.BASE_URL),
    routes: [
        {
            path: '/',
            name: 'main',
            component: () => import('../views/MainView.vue'),
            children: [
                {
                    path: '/welcome',
                    name: 'welcome',
                    component: () => import('../views/main/WelcomeView.vue')
                }, {
                    path: '/passenger',
                    name: 'passenger',
                    component: () => import('../views/main/PassengerView.vue')
                }
            ]
        },
        {
            path: '/login',
            name: 'login',
            component: () => import('../views/LoginView.vue'),
            meta: {
                noToken: true
            }
        }, {
            path: '',
            redirect: '/welcome'
        }
    ]
})

// 路由登录拦截
router.beforeEach((to, from, next) => {
    // 要不要对meta.noToken属性做监控拦截
    if (to.matched.some(function (item) {
        console.log(item, "是否不需要登录校验:", item.meta.noToken || false);
        return !item.meta.noToken
    })) {
        const {userInfo} = useUserStore()
        console.log("页面登录校验开始:", userInfo);
        if (!userInfo.token) {
            console.log("用户未登录或登录超时!");
            notification.error({description: "未登录或登录超时"});
            next('/login');
        } else {
            next();
        }
    } else {
        next();
    }
});

export default router


登录页面

<script setup>
import {reactive} from 'vue';
import {useRouter} from 'vue-router'
import {CodepenCircleOutlined} from "@ant-design/icons-vue";
import {notification} from 'ant-design-vue';
import userApi from '../api/userApi';
import { useUserStore } from '@/stores/user';
const { setUserInfo } = useUserStore()

const router = useRouter();

const loginForm = reactive({
  mobile: '',
  code: '',
});
const onFinish = async (value) => {
  // 执行登录逻辑
  const respData = await userApi.login(value);
  const data = respData.data
  setUserInfo(data)
  console.log('Success:', value, data);
  notification.success({ description: '登录成功!' });
  router.push("/welcome");
};
const onFinishFailed = errorInfo => {
  console.log('Failed:', errorInfo);
};

const sendCode = async () => {
  await userApi.sendCode({
    mobile: loginForm.mobile
  })
  notification.success({ description: '发送验证码成功!' });
  loginForm.code = "8888";
}
</script>

<template>
  <a-row class="login">
    <a-col :span="8" :offset="8" class="login-main">
      <h1 style="text-align: center">
        <CodepenCircleOutlined/>&nbsp;模拟12306售票系统
      </h1>
      <a-form
          :model="loginForm"
          name="basic"
          autocomplete="off"
          @finish="onFinish"
          @finishFailed="onFinishFailed"
      >
        <a-form-item
            label=""
            name="mobile"
            :rules="[{ required: true, message: '请输入手机号!' }]"
        >
          <a-input v-model:value="loginForm.mobile" placeholder="手机号"/>
        </a-form-item>

        <a-form-item
            label=""
            name="code"
            :rules="[{ required: true, message: '请输入验证码!' }]"
        >
          <a-input v-model:value="loginForm.code">
            <template #addonAfter>
              <a @click="sendCode">获取验证码</a>
            </template>
          </a-input>
        </a-form-item>

        <a-form-item :wrapper-col="{ offset: 8, span: 16 }">
          <a-button type="primary" html-type="submit">登录</a-button>
        </a-form-item>
      </a-form>
    </a-col>
  </a-row>

</template>

<style scoped>
.login-main h1 {
  font-size: 25px;
  font-weight: bold;
}

.login-main {
  margin-top: 100px;
  padding: 30px 30px 20px;
  border: 2px solid grey;
  border-radius: 10px;
  background-color: #fcfcfc;
}
</style>

退出登录

<script setup>
import {ref} from "vue";
import {useUserStore} from '@/stores/user';

const {userInfo, clearUserInfo} = useUserStore()

const selectedKeys1 = ref(['2']);
</script>

<template>
  <a-layout-header class="header">
    <div class="logo"/>
    <div style="float: right; color: white;">
      您好:{{userInfo.mobile}} &nbsp;&nbsp;
      <router-link to="/login" @click.native="clearUserInfo()" style="color: white;">
        退出登录
      </router-link>
    </div>
    <a-menu
        v-model:selectedKeys="selectedKeys1"
        theme="dark"
        mode="horizontal"
        :style="{ lineHeight: '64px' }"
    >
      <a-menu-item key="1">nav 1</a-menu-item>
      <a-menu-item key="2">nav 2</a-menu-item>
      <a-menu-item key="3">nav 3abc</a-menu-item>
    </a-menu>
  </a-layout-header>
</template>

页面增删改查操作

<script setup>
import {ref, reactive} from 'vue';
import {notification} from "ant-design-vue";
import {cloneDeep} from 'lodash-es';
import userApi from '@/api/userApi';
import {PASSENGER_TYPE_ARRAY} from '@/assets/js/enums'

const visible = ref(false);
const loading = ref(false);

let passenger = ref({
  id: undefined,
  memberId: undefined,
  name: undefined,
  idCard: undefined,
  type: undefined,
  createTime: undefined,
  updateTime: undefined,
});

const passengers = ref([]);
const pagination = reactive({
  total: 0,
  current: 1,
  pageSize: 2
})

const columns = [
  {
    title: '姓名',
    dataIndex: 'name',
    key: 'name',
  },
  {
    title: '身份证',
    dataIndex: 'idCard',
    key: 'idCard',
  },
  {
    title: '旅客类型',
    dataIndex: 'type',
    key: 'type',
  },
  {
    title: '操作',
    dataIndex: 'operation'
  },
]
const handleQuery = (param) => {
  if (!param) {
    param = {
      "page": 1,
      "size": pagination.pageSize
    }
  }
  loading.value = true;
  userApi.queryPassengerList({
    "page": param.page,
    "limit": param.size
  }).then(res => {
    console.log('res', res)
    loading.value = false;
    passengers.value = res.data;
    pagination.total = res.count;
    pagination.current = res.page
  })
}

const handleTableChange = (pagination) => {
  handleQuery({
    page: pagination.current,
    size: pagination.pageSize
  })
}

handleQuery()

const onAdd = () => {
  passenger.value = {};
  visible.value = true;
}

const onEdit = (record) => {
  console.log("record", record)
  passenger.value = cloneDeep(record);
  visible.value = true;
}

const onDelete = (record) => {
  console.log("delete record", record)
  userApi.deletePassenger(record.id).then(() => {
    notification.success({description: "删除成功!"});
    handleQuery({
      page: pagination.current,
      size: pagination.pageSize,
    });
  })
}

const handleOk = () => {
  userApi.savePassenger(passenger.value).then(resp => {
    notification.success({description: "保存成功!"});
    visible.value = false;
    handleQuery({
      page: pagination.current,
      size: pagination.pageSize
    })
  })
}
</script>

<template>
  <p>
    <a-space>
      <a-button type="primary" @click="handleQuery()">刷新</a-button>
      <a-button type="primary" @click="onAdd">新增</a-button>
    </a-space>
  </p>
  <a-table :dataSource="passengers" :columns="columns" :pagination="pagination" @change="handleTableChange"
           :loading="loading">
    <template #bodyCell="{ column, text, record }">
      <template v-if="column.dataIndex === 'operation'">
        <a-space>
          <a @click="onEdit(record)">编辑</a>
          <a-popconfirm
              title="删除后不可恢复,确认删除?"
              ok-text="确认" cancel-text="取消"
              @confirm="onDelete(record)"
          >
            <a style="color: red">删除</a>
          </a-popconfirm>
        </a-space>
      </template>
      <template v-else-if="column.dataIndex === 'type'">
        <span v-for="item in PASSENGER_TYPE_ARRAY" :key="item.code">
          <span v-if="item.code === record.type">
            {{item.desc}}
          </span>
        </span>
      </template>
    </template>
  </a-table>
  <a-modal v-model:open="visible" title="乘车人" @ok="handleOk"
           ok-text="确认" cancel-text="取消">
    <a-form
        :model="passenger" :label-col="{span: 4}" :wrapper-col="{ span: 20 }"
    >
      <a-form-item label="姓名">
        <a-input v-model:value="passenger.name"/>
      </a-form-item>
      <a-form-item label="身份证">
        <a-input v-model:value="passenger.idCard"/>
      </a-form-item>
      <a-form-item label="旅客类型">
        <a-select v-model:value="passenger.type">
          <a-select-option v-for="item in PASSENGER_TYPE_ARRAY" :key="item.code" :value="item.code">
            {{ item.desc }}
          </a-select-option>
        </a-select>
      </a-form-item>
    </a-form>
  </a-modal>

</template>

前端跨域问题

前后端分离项目,前端在请求后台接口时会出现跨域问题
这个后端项目使用到了gateway,在配置文件中加入:

# 允许请求来源(老版本叫allowedOrigin)
spring.cloud.gateway.globalcors.cors-configurations.[/**].allowedOriginPatterns=*
# 允许携带的头信息
spring.cloud.gateway.globalcors.cors-configurations.[/**].allowedHeaders=*
# 允许的请求方式
spring.cloud.gateway.globalcors.cors-configurations.[/**].allowedMethods=*
# 是否允许携带cookie
spring.cloud.gateway.globalcors.cors-configurations.[/**].allowCredentials=true
# 跨域检测的有效期,会发起一个OPTION请求
spring.cloud.gateway.globalcors.cors-configurations.[/**].maxAge=3600


后端分页查询方法

#CommonPageParam.java
@Data
public class CommonPageParam {
    @NotNull(message = "页码不能为空")
    private Integer page;
    @NotNull(message = "每页数量不能为空")
    @Max(value = 100, message = "分页条数不能超过100")
    private Integer limit;
}


#PassengerQueryReq.java
@Data
public class PassengerQueryReq extends CommonPageParam {
    private Long memberId;
}


#CommonPageResp.java
@Data
@NoArgsConstructor
public class CommonPageResp<T> {
    /** 默认每页的条数 */
    public static final int PAGE_SIZE_DEFAULT = 10;
    /**
     * 业务上的成功或失败
     */
    private boolean success = true;

    /**
     * 返回信息
     */
    private String message;

    /**
     * 返回泛型数据,自定义类型
     */
    private List<T> data;

    /**
     * 总数
     */
    private Long count;

    /**
     * 页码
     */
    private Integer page;
    /**
     * 每页数量
     */
    private Integer limit;

    public Integer getPage() {
        if (page == null || page < 1) {
            return 1;
        }
        return page;
    }

    public Integer getLimit() {
        if (limit == null) {
            return PAGE_SIZE_DEFAULT;
        }
        return limit;
    }

    public static <T> CommonPageResp<T> SUCCESS(String message, List<T> data, PageInfo pageInfo) {
        return new CommonPageResp<>(true, message, data, pageInfo.getTotal(), pageInfo.getPageNum(), pageInfo.getPageSize());
    }

    public CommonPageResp(boolean success, String message, List<T> data, Long count, Integer page, Integer limit) {
        this.success = success;
        this.message = message;
        this.data = data;
        this.count = count;
        this.page = page;
        this.limit = limit;
    }
}


#PassengerService.java
@Service
@Slf4j
public class PassengerService {
    @Resource
    private PassengerMapper passengerMapper;

    public CommonPageResp<PassengerQueryResp> queryList(PassengerQueryReq req) {
        PassengerExample passengerExample = new PassengerExample();
        passengerExample.setOrderByClause("id desc");
        PassengerExample.Criteria criteria = passengerExample.createCriteria();
        if (ObjectUtil.isNotNull(req.getMemberId())) {
            criteria.andMemberIdEqualTo(req.getMemberId());
        }

        PageHelper.startPage(req.getPage(), req.getLimit());
        List<Passenger> passengerList = passengerMapper.selectByExample(passengerExample);
        PageInfo<Passenger> pageInfo = new PageInfo<>(passengerList);

        List<PassengerQueryResp> list = BeanUtil.copyToList(passengerList, PassengerQueryResp.class);
        return CommonPageResp.SUCCESS("", list, pageInfo);
    }
}

使用线程本地变量存储用户信息

拦截器从token中获取用户信息

@Slf4j
@Component
public class MemberInterceptor implements HandlerInterceptor {
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        log.info("MemberInterceptor开始");
        //获取header的token参数
        String token = request.getHeader("token");
        if (StrUtil.isNotBlank(token)) {
            log.info("获取会员登录token:{}", token);
            JSONObject loginMember = JwtUtil.getJSONObject(token);
            log.info("当前登录会员:{}", loginMember);
            MemberLoginResp member = JSONUtil.toBean(loginMember, MemberLoginResp.class);
            LoginMemberContext.setMember(member);
        }
        log.info("MemberInterceptor结束");
        return true;
    }
}

配置开启拦截器

@Configuration
public class SpringMvcConfig implements WebMvcConfigurer {

    @Resource
    private MemberInterceptor memberInterceptor;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        // 路径不要包含context-path
        registry.addInterceptor(memberInterceptor)
                .addPathPatterns("/**")
                .excludePathPatterns(
                        "/hello",
                        "/member/sendCode",
                        "/member/login"
                );
    }
}

设置用户到本地线程

import com.intmall.train.common.domain.resp.MemberLoginResp;
import lombok.extern.slf4j.Slf4j;

@Slf4j
public class LoginMemberContext {
    private static ThreadLocal<MemberLoginResp> member = new ThreadLocal<>();

    public static MemberLoginResp getMember() {
        return member.get();
    }

    public static void setMember(MemberLoginResp member) {
        LoginMemberContext.member.set(member);
    }

    public static Long getId() {
        try {
            return member.get().getId();
        } catch (Exception e) {
            log.error("获取登录会员信息异常", e);
            throw e;
        }
    }
}

源码地址

完整代码参考:
https://gitee.com/galen.zhang/train

  • 0
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

GalenZhang888

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值