
C端登录注册功能

项目结构

发送验证码
由于我们前面已经讲了很多后端代码相关内容了,相信大家学到这里的时候应该对代码很熟悉了,就不做详细讲解,看代码直接懂就好了
Controller
import com.bite.common.core.controller.BaseController;
import com.bite.common.core.domain.R;
import com.bite.friend.model.DTO.UserDTO;
import com.bite.friend.service.IUserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/user")
public class UserController extends BaseController {
@Autowired
private IUserService userService;
// /user/sendCode
@PostMapping("sendCode")
public R<Void> sendCode(@RequestBody UserDTO userDTO) {
return toR(userService.sendCode(userDTO)) ;
}
}
DTO
@Getter
@Setter
public class UserDTO {
private String phone;
private String code;
}
Service
我们这里首先对传过来的手机号进行验证验证,看是否符合一个手机号的格式,然后生成一个随机验证码,至于发送验证码,我们需要用到阿里云短信服务(但是,我们发现,阿里云的短信服务居然不允许个人使用了)

import cn.hutool.core.util.RandomUtil;
import com.bite.common.core.enums.ResultCode;
import com.bite.common.security.EXCEPTION.ServiceException;
import com.bite.friend.model.DTO.UserDTO;
import com.bite.friend.service.IUserService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@Service
@Slf4j
public class UserServiceImpl implements IUserService {
@Override
public int sendCode(UserDTO userDTO) {
if (!checkPhone(userDTO.getPhone())) {
throw new ServiceException(ResultCode.FAILED_USER_PHONE);
}
String phoneCodeKey = getPhoneCodeKey(userDTO.getPhone());
Long expire = redisService.getExpire(phoneCodeKey, TimeUnit.SECONDS);
if (expire != null && (phoneCodeExpiration * 60 - expire) < 60 ){
throw new ServiceException(ResultCode.FAILED_FREQUENT);
}
//每天的验证码获取次数有一个限制 50次 第二天 计数清0 重新开始计数 计数 怎么存 存在哪
//操作这个次数数据频繁 、 不需要存储、 记录的次数 有有效时间的(当天有效) redis String key:c:t:手机号
//获取已经请求的次数 和50 进行比较 如果大于限制抛出异常。如果不大于限制,正常执行后续逻辑,并且将获取计数 + 1
String codeTimeKey = getCodeTimeKey(userDTO.getPhone());
Long sendTimes = redisService.getCacheObject(codeTimeKey, Long.class);
if (sendTimes != null && sendTimes >= sendLimit) {
throw new ServiceException(ResultCode.FAILED_TIME_LIMIT);
}
String code=RandomUtil.randomNumbers(6);
System.out.println("密码:"+code);
redisService.setCacheObject(phoneCodeKey, code, phoneCodeExpiration, TimeUnit.MINUTES);
redisService.increment(codeTimeKey);
if (sendTimes == null) { //说明是当天第一次发起获取验证码的请求
long seconds = ChronoUnit.SECONDS.between(LocalDateTime.now(),
LocalDateTime.now().plusDays(1).withHour(0).withMinute(0).withSecond(0).withNano(0));
redisService.expire(codeTimeKey, seconds, TimeUnit.SECONDS);
}
return 1;
}
}
我们这里需要保证几点:
1.检查这个手机号是否为正确的
2.获得当前验证码还剩多少秒,如果一分钟内有5次以上,则抛出异常
3.查看一天内发送了多少次,如果超过一定限度则抛出异常,每次发送后++;
mapper
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.bite.friend.model.User;
public interface UserMapper extends BaseMapper<User> {
}
User
package com.bite.friend.model;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.bite.common.core.domain.BaseEntity;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
@TableName("tb_user")
public class User extends BaseEntity {
@JsonSerialize(using = ToStringSerializer.class)
@TableId(value = "USER_ID", type = IdType.ASSIGN_ID)
private Long userId;
private String nickName;
private String headImage;
private Integer sex;
private String phone;
private String code;
private String email;
private String wechat;
private String schoolName;
private String majorName;
private String introduce;
private Integer status;
}
配置文件bootstrap.yml
server:
port: 9202
# Spring
spring:
application:
# 应用名称
name: oj-friend
profiles:
# 环境配置
active: local
cloud:
nacos:
discovery:
namespace: 78015f37-f9d0-4c31-8970-cc017a0923ff
server-addr: http://localhost:8848
config:
namespace: 78015f37-f9d0-4c31-8970-cc017a0923ff
server-addr: http://localhost:8848
file-extension: yaml
nacos配置文件

除此之外我们还需要配置gateway的白名单以及路由

server:
port: 19090
spring:
data:
redis:
host: localhost
password: 123456
cloud:
gateway:
routes:
# 管理模块
- id: oj-system
uri: lb://oj-system
predicates:
- Path=/system/**
filters:
- StripPrefix=1
- id: oj-friend
uri: lb://oj-friend # 假设friend服务的注册名是friend-service
predicates:
- Path=/friend/** # 匹配以/friend开头的请求
filters:
- StripPrefix=1
security:
ignore:
whites:
- /system/sysUser/login
- /friend/user/sendCode
- /friend/user/code/login
- /friend/user/test
jwt:
secret: zxsksjdjoss
登录注册接口
登录流程设计
-
点击登录按钮后,系统会执行以下验证和操作流程
-
验证码校验阶段: 从Redis缓存中获取当前手机号对应的验证码,与用户输入的验证码进行比对。若验证失败则立即终止流程
-
用户验证阶段: 通过手机号查询数据库用户表。若用户存在则识别为老用户,不存在则进入新用户注册流程
-
老用户处理: 生成新的访问令牌(Token),将完整的用户信息存入Redis缓存,设置合理的过期时间
-
新用户处理: 自动完成新用户注册,为手机号创建基础用户记录并保存到数据库。随后执行与老用户相同的令牌生成和缓存操作
@Override
public String codeLogin(String phone, String code) {
checkCode(phone, code);
User user = userMapper.selectOne(new LambdaQueryWrapper<User>().eq(User::getPhone, phone));
if (user == null) { //新用户
//注册逻辑
user = new User();
user.setPhone(phone);
user.setStatus(UserStatus.Normal.getValue());
user.setCreateBy(Contants.SYSTEM_USER_ID);
userMapper.insert(user);
}
return tokenService.createToken(user.getUserId(), secret, UserIdentity.ORDINARY.getCode(), user.getNickName(), user.getHeadImage());}
其余方法
方法1: 验证码校对
方法2: 获得手机号::密码key
方法3:获得密码::时间key
方法4:校验手机号格式是否正确
private void checkCode(String phone, String code) {
String phoneCodeKey = getPhoneCodeKey(phone);
String cacheCode = redisService.getCacheObject(phoneCodeKey, String.class);
if (StrUtil.isEmpty(cacheCode)) {
throw new ServiceException(ResultCode.FAILED_INVALID_CODE);
}
if (!cacheCode.equals(code)) {
throw new ServiceException(ResultCode.FAILED_ERROR_CODE);
}
//验证码比对成功
redisService.deleteObject(phoneCodeKey);
}
private String getPhoneCodeKey(String phone) {
return CacheConstants.PHONE_CODE_KEY + phone;
}
private String getCodeTimeKey(String phone) {
return CacheConstants.CODE_TIME_KEY + phone;
}
public static boolean checkPhone(String phone) {
Pattern regex = Pattern.compile("^1[2|3|4|5|6|7|8|9][0-9]\\d{8}$");
Matcher m = regex.matcher(phone);
return m.matches();
}
退出登录
点击退出按钮时,系统会先检查token是否为空。若token存在且包含预设前缀,则先去除前缀,再执行token删除操作。具体流程为:解析token获取对应的redis键值,然后在redis中删除该键值。
@DeleteMapping("/logout")
public R<Void> logout(@RequestHeader(HttpConstants.AUTHENTICATION) String token) {
return toR(userService.logout(token));
}
@Override
public boolean logout(String token) {
if (StrUtil.isNotEmpty(token) && token.startsWith(HttpConstants.PREFIX)) {
token = token.replaceFirst(HttpConstants.PREFIX, StrUtil.EMPTY);
}
return tokenService.deleteLoginUser(token, secret);
}


获得信息
从请求头中提取token并解析后,若未能从Redis获取到对应的LoginUser信息,则返回错误响应;若成功获取,则将LoginUser中的相关数据映射到返回给前端的VO对象中。
@GetMapping("/info")
public R<LoginUserVO> info(@RequestHeader(HttpConstants.AUTHENTICATION) String token) {
return userService.info(token);
}
@Override
public R<LoginUserVO> info(String token) {
if (StrUtil.isNotEmpty(token) && token.startsWith(HttpConstants.PREFIX)) {
token = token.replaceFirst(HttpConstants.PREFIX, StrUtil.EMPTY);
}
LoginUser loginUser = tokenService.getLoginUser(token, secret);
System.out.println(loginUser);
if (loginUser == null) {
return R.fail();
}
LoginUserVO loginUserVO = new LoginUserVO();
loginUserVO.setNickName(loginUser.getNickName());
loginUserVO.setHeadImage(loginUser.getHeadImage());
System.out.println(loginUserVO);
return R.ok(loginUserVO);
}
前端代码
Login.vue
<template>
<div class="login-page">
<div class="orange"> </div>
<div class="blue"></div>
<div class="blue small"></div>
<div class="login-box">
<div class="logo-box">
<img src="@/assets/logo.png">
<div>
<div class="sys-name">比特OJ</div>
<div class="sys-sub-name">帮助100万大学生就业</div>
</div>
</div>
<div class="form-box-title">
<span>验证码登录</span>
</div>
<div class="form-box">
<div class="form-item">
<img src="@/assets/images/shouji.png">
<el-input v-model="mobileForm.phone" type="text" placeholder="请输入手机号" />
</div>
<div class="form-item">
<img src="@/assets/images/yanzhengma.png">
<el-input style="width:134px" v-model="mobileForm.code" type="text" placeholder="请输入验证码" />
<div class="code-btn-box" @click="getCode" :disabled="isCodeBtnDisabled">
<span>{{ txt }}</span>
</div>
</div>
<div class="submit-box" @click="loginFun">
登录/注册
</div>
</div>
<div class="gray-bot">
<p>注册或点击登录代表您同意 <span>服务条款</span> 和 <span>隐私协议</span></p>
</div>
</div>
</div>
</template>
<script setup>
import { ref, reactive } from 'vue'
import { setToken } from '@/utils/cookie'
import { sendCodeService, codeLoginService } from '@/api/user'
import router from '@/router'
// 验证码登录表单
let mobileForm = reactive({
phone: '',
code: ''
})
let txt = ref('获取验证码')
let timer = null
// 新增:控制按钮是否可点击的状态
let isCodeBtnDisabled = ref(false)
async function getCode() {
// 点击后立即禁用按钮
isCodeBtnDisabled.value = true
await sendCodeService(mobileForm)
txt.value = '59s'
let num = 59
timer = setInterval(() => {
num--
if (num < 1) {
txt.value = '重新获取验证码'
clearInterval(timer)
// 倒计时结束,启用按钮
isCodeBtnDisabled.value = false
} else {
txt.value = num + 's'
}
}, 1000)
}
async function loginFun() {
const loginRef = await codeLoginService(mobileForm)
setToken(loginRef.data)
router.push('/c-oj/home')
}
</script>
<style lang="scss" scoped>
.login-page {
width: 100vw;
height: 100vh;
position: relative;
margin-top: -60px;
margin-left: -20px;
overflow: hidden;
.login-box {
width: 600px;
height: 604px;
background: #FFFFFF;
box-shadow: 0px 0px 6px 0px rgba(0, 0, 0, 0.1);
border-radius: 10px;
opacity: 0.9;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 2;
padding: 0 72px;
padding-top: 50px;
overflow: hidden;
.logo-box {
display: flex;
align-items: center;
&.refister-logo {
margin-bottom: 56px;
}
img {
width: 68px;
height: 68px;
margin-right: 16px;
}
.sys-name {
height: 33px;
font-family: PingFangSC, PingFang SC;
font-weight: 600;
font-size: 24px;
color: #222222;
line-height: 33px;
margin-bottom: 13px;
}
.sys-sub-name {
height: 22px;
font-family: PingFangSC, PingFang SC;
font-weight: 400;
font-size: 16px;
color: #222222;
line-height: 22px;
}
}
.form-box-title {
height: 116px;
display: flex;
align-items: center;
span {
font-family: PingFangSC, PingFang SC;
font-weight: 400;
font-size: 24px;
color: #000000;
line-height: 33px;
display: block;
height: 33px;
margin-right: 40px;
position: relative;
letter-spacing: 1px;
cursor: pointer;
&.active {
font-weight: bold;
&::before {
position: absolute;
content: '';
bottom: -13px;
left: 0;
width: 100%;
height: 5px;
background: #32C5FF;
border-radius: 10px;
}
}
}
}
.gray-bot {
position: absolute;
left: 0;
text-align: center;
margin-top: 56px;
width: 100%;
height: 50px;
background: #FAFAFA;
font-family: PingFangSC, PingFang SC;
font-weight: 400;
font-size: 14px;
color: #666666;
line-height: 50px;
p {
margin: 0;
}
span {
color: #32C5FF;
cursor: pointer;
}
}
:deep(.form-box) {
.submit-box {
margin-top: 90px;
width: 456px;
height: 48px;
background: #96E1FE;
border-radius: 8px;
cursor: pointer;
display: flex;
justify-content: center;
align-items: center;
font-family: PingFangSC, PingFang SC;
font-weight: 600;
font-size: 16px;
color: #FFFFFF;
letter-spacing: 1px;
&.refister-submit {
margin-top: 72px;
}
&:hover {
background: #32C5FF;
}
}
.form-item {
display: flex;
align-items: center;
width: 456px;
height: 48px;
background: #F8F8F8;
border-radius: 8px;
margin-bottom: 30px;
position: relative;
.code-btn-box {
position: absolute;
right: 0;
width: 151px;
height: 48px;
background: #32C5FF;
border-radius: 8px;
top: 0;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
span {
font-family: PingFangSC, PingFang SC;
font-weight: 400;
font-size: 16px;
color: #FFFFFF;
}
}
.error-tip {
position: absolute;
width: 140px;
text-align: right;
padding-right: 12px;
height: 20px;
font-family: PingFangSC, PingFang SC;
font-weight: 400;
font-size: 14px;
color: #FD4C40;
line-height: 20px;
right: 0;
&.bottom {
right: 157px;
}
}
.el-input {
width: 380px;
font-family: PingFangSC, PingFang SC;
font-weight: 400;
font-size: 16px;
color: #222222;
}
.el-input__wrapper {
border: none;
box-shadow: none;
background: transparent;
width: 230px;
padding-left: 0;
}
img {
width: 24px;
height: 24px;
margin: 0 18px;
}
}
}
}
&::after {
position: absolute;
top: 0;
left: 0;
height: 100vh;
bottom: 0;
right: 0;
background: rgba(255, 255, 255, .8);
z-index: 1;
content: '';
}
.orange {
background: #F0714A;
width: 498px;
height: 498px;
border-radius: 50%;
background: #F0714A;
opacity: 0.67;
filter: blur(50px);
left: 14.2%;
top: 41%;
position: absolute;
}
.blue {
width: 334px;
height: 334px;
background: #32C5FF;
opacity: 0.67;
filter: blur(50px);
left: 14.2%;
top: 42%;
position: absolute;
top: 16.3%;
left: 80.7%;
&.small {
width: 186px;
height: 186px;
top: 8.2%;
left: 58.2%;
}
}
}
</style>
Template和style部分的代码我们不做多余介绍,我们介绍一些js部分代码

Home.vue
<template>
<div class="oj-main-layout">
<div class="oj-main-layout-header">
<div class="oj-main-layout-nav">
<Navbar></Navbar>
</div>
</div>
<div>
<img src="@/assets/images/log-banner.png" class="banner-img">
</div>
</div>
</template>
<script setup>
import Navbar from '@/components/Navbar.vue'
</script>
<style lang="scss" scoped>
.el-main {
padding: 0;
}
.oj-main-layout {
padding-top: 20px;
.banner-img {
max-width: 1520px;
margin: 0 auto;
border-radius: 16px;
width: "100%"
}
.oj-main-layout-header {
height: 60px;
position: absolute;
width: 100%;
background: #fff;
left: 0;
top: 0;
z-index: 3;
overflow: hidden;
}
.oj-main-layout-nav {
max-width: 1520px;
min-width: 100%;
margin: 0 auto;
height: 60px;
background: #fff;
}
.oj-ship-banner {
max-width: 1520px;
min-width: 1520;
margin: 0 auto;
width: 100%;
height: 100%;
height: 350px;
color: #ffffff;
background: url("@/assets/index_bg.png") left top no-repeat;
background-size: cover;
overflow: hidden;
}
}
</style>

Navbar.vue
<template>
<div class="oj-navbar">
<div class="oj-navbar-menus">
<img class="oj-navbar-logo" src="@/assets/logo.png" />
<el-menu class="oj-navbar-menu" mode="horizontal">
<el-menu-item>题库</el-menu-item>
<el-menu-item>竞赛</el-menu-item>
</el-menu>
</div>
<div class="oj-navbar-users">
<img v-if="isLogin" class="oj-message" @click="goMessage" src="@/assets/message/message.png" />
<el-dropdown v-if="isLogin">
<div class="oj-navbar-name">
<img class="oj-head-image" v-if="isLogin" :src="userInfo.headImage" />
<span>{{ userInfo.nickName }}</span>
</div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="goUserInfo">
<div class="oj-navabar-item">
<span>个⼈中⼼</span>
</div>
</el-dropdown-item>
<el-dropdown-item @click="goMyExam">
<div class="oj-navabar-item">
<span>我的⽐赛</span>
</div>
</el-dropdown-item>
<el-dropdown-item>
<div class="oj-navabar-item">
<span @click="handleLogout">退出登录</span>
</div>
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<span class="oj-navbar-login-btn" v-if="!isLogin" @click="goLogin">登录</span>
</div>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import router from '@/router';
import { getToken, removeToken } from '@/utils/cookie'
import { logoutService, getUserInfoService } from '@/api/user'
import { ElMessageBox } from 'element-plus'; // 补充可能遗漏的导入
const userInfo = reactive({
nickName: '',
headImage: ''
})
// 当前登录状态
const isLogin = ref(false)
// 检查登录状态
async function checkLogin() {
if (getToken()) {
// 实际项目中应调用接口验证token有效性
const userInfoRes = await getUserInfoService()
console.log(userInfoRes)
userInfoRes.data.headImage = `/${userInfoRes.data.headImage}`
Object.assign(userInfo, userInfoRes.data)
console.log(userInfo)
isLogin.value = true
}
}
checkLogin()
// 退出登录
async function handleLogout() {
await ElMessageBox.confirm(
'确认退出',
'温馨提⽰',
{
confirmButtonText: '确认',
cancelButtonText: '退出',
type: 'warning',
}
)
await logoutService()
removeToken()
isLogin.value = false
}
// 跳转到登录页
function goLogin() {
router.push('/c-oj/login')
}
// 以下方法需要补充实现
function goMessage() {
// 消息页面跳转逻辑
}
function goUserInfo() {
// 个人中心跳转逻辑
}
function goMyExam() {
// 我的比赛跳转逻辑
}
</script>
<style lang="scss" scoped>
.oj-navbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 20px;
box-sizing: border-box;
max-width: 1520px;
margin: 0 auto;
.oj-navbar-menus {
display: flex;
align-items: center;
height: 50px;
.el-menu-item {
font-family: PingFangSC, PingFang SC;
font-weight: 400;
font-size: 20px;
color: #222222;
line-height: 28px;
text-align: center;
width: 42px;
text-align: left;
margin-right: 25px;
}
}
.oj-navbar-logo {
width: 38px;
height: 38px;
background: #32C5FF;
border-radius: 8px;
cursor: pointer;
object-fit: contain;
margin-right: 59px;
}
.oj-navbar-menu {
width: 600px;
border: none;
.el-menu-item {
font-size: 16px;
font-weight: 500;
background-color: transparent !important;
transition: none;
border: none;
line-height: 60px;
}
}
.oj-navbar-users {
display: flex;
align-items: center;
}
.oj-navbar-login-btn {
line-height: 60px;
display: inline-block;
font-family: PingFangSC, PingFang SC;
font-weight: 400;
font-size: 18px;
color: #222222;
text-align: center;
cursor: pointer;
.line {
display: inline-block;
width: 25px;
}
}
.oj-message {
cursor: pointer;
margin-top: 15px;
}
.oj-head-image {
width: 30px;
height: 30px;
border-radius: 30px;
margin-right: 10px;
}
.oj-navbar-name {
cursor: pointer;
margin-top: 15px;
font-weight: 400;
color: #000;
margin-left: 15px;
font-size: 20px;
width: 100px;
display: flex;
align-items: center;
}
.oj-navabar-item {
display: flex;
align-items: center;
justify-content: center;
padding: 0 32px;
}
}
</style>

request.js
和之前学的一样,不做多余解释
import axios from 'axios'
import { getToken, removeToken } from './cookie'
import router from '@/router';
//不同的功能,通过axios请求的是不同接口的地址
//127.0.0.1:19090
const service = axios.create({
baseURL:"/dev-api",
timeout:5000,
})
axios.defaults.headers["Content-Type"] = "application/json;charset=utf-8";
//请求拦截器
service.interceptors.request.use(
(config) => {
if (getToken()) {
config.headers["Authorization"] = "Bearer " + getToken();
}
return config;
},
(error) => {
console.log(error)
Promise.reject(error);
}
);
//响应拦截器
service.interceptors.response.use(
(res) => { //res : 响应数据
// 未设置状态码则默认成功状态
const code = res.data.code;
const msg = res.data.msg;
if (code === 3001) {
ElMessage.error(msg);
removeToken()
return Promise.reject(new Error(msg));
} else if (code !== 1000) {
ElMessage.error(msg);
return Promise.reject(new Error(msg));
} else {
return Promise.resolve(res.data);
}
},
(error) => {
return Promise.reject(error);
}
);
export default service
cookie.js
和之前系统用户那里一样,不做多余解释
import Cookies from "js-cookie";
const TokenKey = "Oj-c-Token";
export function getToken() {
return Cookies.get(TokenKey);
}
export function setToken(token) {
return Cookies.set(TokenKey, token);
}
export function removeToken() {
return Cookies.remove(TokenKey);
}
index.js
路由功能,不做多余解释
import { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: "/",
redirect: '/c-oj/home',
},
{
path: "/c-oj/home",
name: "home",
component: () => import("@/views/Home.vue"),
},
{
path: "/c-oj/login",
name: "login",
component: () => import("@/views/Login.vue"),
},
{
path: "//c-oj/test",
name: "test",
component: () => import("@/views/Test.vue"),
}
],
})
export default router
vite.config.js
配置功能,不多余解释
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
import vue from '@vitejs/plugin-vue'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
vue(),
AutoImport({
resolvers: [ElementPlusResolver()],
}),
Components({
resolvers: [ElementPlusResolver()],
}),
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
},
server: {
proxy: {
"/dev-api": {
target: "http://127.0.0.1:19090/friend",
rewrite: (p) => p.replace(/^\/dev-api/, ""),
},
},
},
})
效果展示


