feat(auth): 实现基于阿里云短信验证码的登录注册功能

- 新增阿里云短信发送客户端配置及属性绑定类
- 集成阿里云短信服务实现验证码发送功能
- 基于 Sa-Token 完成登录状态管理和 token 生成
- 实现手机号验证码登录、密码登录及验证码注册支持
- 添加密码加密 Bean,使用 BCrypt 保障密码安全
- 新增 Redis 缓存验证码,实现验证码有效期和校验
- Vue 前端新增登录弹窗组件,支持三种登录模式切换
- 统一 Axios 请求添加 Token 请求头及响应错误提示
- 更新配置文件,加入 Sa-Token 相关配置项
- 调整后端数据库实体生成配置,新增用户表映射
- 添加前端依赖包 @vueuse/integrations 和 universal-cookie
- 新增前端 Cookie 操作逻辑,用于 Token 的存取管理
- 优化 Header 组件,增加 Login 按钮触发登录弹窗
This commit is contained in:
lbw
2025-12-22 17:13:04 +08:00
parent 515bd8fae2
commit f4498e5676
24 changed files with 951 additions and 21 deletions

View File

@@ -105,6 +105,55 @@
<artifactId>tess4j</artifactId>
</dependency>
<!-- 阿里云短信发送 -->
<dependency>
<groupId>com.aliyun</groupId>
<artifactId>dysmsapi20170525</artifactId>
</dependency>
<!-- Sa-Token 权限认证在线文档https://sa-token.cc -->
<dependency>
<groupId>cn.dev33</groupId>
<artifactId>sa-token-spring-boot3-starter</artifactId>
</dependency>
<dependency>
<groupId>cn.dev33</groupId>
<artifactId>sa-token-redis-template</artifactId>
</dependency>
<!-- 密码加密 -->
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-crypto</artifactId>
</dependency>
<dependency>
<groupId>org.apache.tomcat.embed</groupId>
<artifactId>tomcat-embed-core</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
</dependency>
<dependency>
<groupId>commons-codec</groupId>
<artifactId>commons-codec</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
</dependency>
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
</dependency>
</dependencies>
<build>

View File

@@ -0,0 +1,13 @@
package com.yinlihupo.enlish.service.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
@ConfigurationProperties(prefix = "aliyun")
@Component
@Data
public class AliyunAccessKeyProperties {
private String accessKeyId;
private String accessKeySecret;
}

View File

@@ -0,0 +1,36 @@
package com.yinlihupo.enlish.service.config;
import com.aliyun.dysmsapi20170525.Client;
import com.aliyun.teaopenapi.models.Config;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
@Slf4j
public class AliyunSmsClientConfig {
@Resource
private AliyunAccessKeyProperties aliyunAccessKeyProperties;
@Bean
public Client smsClient() {
try {
Config config = new Config()
// 必填
.setAccessKeyId(aliyunAccessKeyProperties.getAccessKeyId())
// 必填
.setAccessKeySecret(aliyunAccessKeyProperties.getAccessKeySecret());
// Endpoint 请参考 https://api.aliyun.com/product/Dysmsapi
config.endpoint = "dysmsapi.aliyuncs.com";
return new Client(config);
} catch (Exception e) {
log.error("初始化阿里云短信发送客户端错误: ", e);
return null;
}
}
}

View File

@@ -0,0 +1,19 @@
package com.yinlihupo.enlish.service.config;
import org.springframework.context.annotation.Bean;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component;
@Component
public class PasswordEncoderConfig {
@Bean
public PasswordEncoder passwordEncoder() {
// BCrypt 是一种安全且适合密码存储的哈希算法,它在进行哈希时会自动加入“盐”,增加密码的安全性。
return new BCryptPasswordEncoder();
}
}

View File

@@ -0,0 +1,10 @@
package com.yinlihupo.enlish.service.constant;
public class UserRedisConstants {
public static final String USER_LOGIN_CODE = "user:login:code:";
public static String buildUserLoginCode(String phone) {
return USER_LOGIN_CODE + phone;
}
}

View File

@@ -0,0 +1,48 @@
package com.yinlihupo.enlish.service.controller;
import cn.dev33.satoken.stp.StpUtil;
import com.yinlihupo.enlish.service.model.vo.login.LoginReqVO;
import com.yinlihupo.enlish.service.model.vo.login.VerificationCodeReqVO;
import com.yinlihupo.enlish.service.service.LoginService;
import com.yinlihupo.framework.biz.operationlog.aspect.ApiOperationLog;
import com.yinlihupo.framework.common.response.Response;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
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;
@RequestMapping("/login/")
@RestController
@Slf4j
public class LoginController {
@Resource
private LoginService loginService;
@PostMapping("login")
@ApiOperationLog(description = "登录")
public Response<String> login(@RequestBody LoginReqVO loginReqVO) {
try {
loginService.login(loginReqVO.getPhone(), loginReqVO.getName(), loginReqVO.getPassword(), loginReqVO.getCode());
return Response.success(StpUtil.getTokenInfo().getTokenValue());
} catch (Exception e) {
log.error("注册或登录失败 {}", e.getMessage());
return Response.fail("注册或登录失败 " + e.getMessage());
}
}
@PostMapping("sendVerificationCode")
@ApiOperationLog(description = "发送验证码")
public Response<Void> sendVerificationCode(@RequestBody VerificationCodeReqVO verificationCodeReqVO) {
try {
loginService.sendVerificationCode(verificationCodeReqVO.getPhone());
return Response.success();
} catch (Exception e) {
log.error("发送验证码失败 {}", e.getMessage());
return Response.fail("发送验证码失败 " + e.getMessage());
}
}
}

View File

@@ -0,0 +1,29 @@
package com.yinlihupo.enlish.service.domain.dataobject;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@AllArgsConstructor
@NoArgsConstructor
@Data
@Builder
public class UserDO {
private Long id;
private String password;
private String name;
private String openid;
private String phone;
private String email;
private Integer status;
private Integer isDeleted;
}

View File

@@ -0,0 +1,10 @@
package com.yinlihupo.enlish.service.domain.mapper;
import com.yinlihupo.enlish.service.domain.dataobject.UserDO;
public interface UserDOMapper {
UserDO selectByPhone(String phone);
void insert(UserDO userDO);
}

View File

@@ -0,0 +1,18 @@
package com.yinlihupo.enlish.service.model.vo.login;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@AllArgsConstructor
@NoArgsConstructor
@Data
@Builder
public class LoginReqVO {
private String phone;
private String name;
private String password;
private String code;
}

View File

@@ -0,0 +1,15 @@
package com.yinlihupo.enlish.service.model.vo.login;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@AllArgsConstructor
@NoArgsConstructor
@Data
@Builder
public class VerificationCodeReqVO {
private String phone;
}

View File

@@ -0,0 +1,8 @@
package com.yinlihupo.enlish.service.service;
public interface LoginService {
void login(String phone, String name, String reqPassword, String reqCode);
void sendVerificationCode(String phone);
}

View File

@@ -0,0 +1,108 @@
package com.yinlihupo.enlish.service.service.login;
import cn.dev33.satoken.stp.StpUtil;
import cn.hutool.core.util.RandomUtil;
import com.aliyun.dysmsapi20170525.Client;
import com.aliyun.dysmsapi20170525.models.SendSmsRequest;
import com.aliyun.dysmsapi20170525.models.SendSmsResponse;
import com.aliyun.teautil.models.RuntimeOptions;
import com.yinlihupo.enlish.service.constant.UserRedisConstants;
import com.yinlihupo.enlish.service.domain.dataobject.UserDO;
import com.yinlihupo.enlish.service.domain.mapper.UserDOMapper;
import com.yinlihupo.enlish.service.service.LoginService;
import com.yinlihupo.framework.common.util.JsonUtils;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import java.time.Duration;
@Service
@Slf4j
public class LoginServiceImpl implements LoginService {
@Resource
private UserDOMapper userDOMapper;
@Resource
private PasswordEncoder passwordEncoder;
@Resource
private StringRedisTemplate stringRedisTemplate;
@Resource
private Client client;
@Override
public void login(String phone, String name, String reqPassword, String reqCode) {
UserDO userDO = userDOMapper.selectByPhone(phone);
log.info("userDO:{}", userDO);
String code = stringRedisTemplate.opsForValue().get(UserRedisConstants.buildUserLoginCode(phone));
if (userDO == null) {
if (code == null || !code.equals(reqCode)) {
throw new RuntimeException("验证码错误");
}
userDO = UserDO.builder()
.phone(phone)
.name(name)
.password(passwordEncoder.encode(reqPassword))
.build();
userDOMapper.insert(userDO);
StpUtil.login(userDO.getId());
return;
}
if (code != null && code.equals(reqCode)) {
StpUtil.login(userDO.getId());
return;
}
if (reqPassword != null && passwordEncoder.matches(reqPassword, userDO.getPassword())) {
StpUtil.login(userDO.getId());
throw new RuntimeException("密码错误");
}
throw new RuntimeException("登录错误");
}
@Override
public void sendVerificationCode(String phone) {
String code = RandomUtil.randomNumbers(6);
stringRedisTemplate.opsForValue().set(UserRedisConstants.buildUserLoginCode(phone), code, Duration.ofSeconds(60));
String signName = "短信测试";
String templateCode = "SMS_154950909";
String templateParam = String.format("{\"code\":\"%s\"}", code);
try {
sendMessage(phone, signName, templateCode, templateParam);
} catch (Exception e) {
log.error("==> 短信发送失败, phone: {}, signName: {}, templateCode: {}, templateParam: {}", phone, signName, templateCode, templateParam);
throw new RuntimeException(e);
}
}
private void initUserRole(UserDO userDO) {
// todo
}
/**
* 发送短信
*/
public void sendMessage(String signName, String templateCode, String phone, String templateParam) throws Exception {
SendSmsRequest sendSmsRequest = new SendSmsRequest()
.setSignName(signName)
.setTemplateCode(templateCode)
.setPhoneNumbers(phone)
.setTemplateParam(templateParam);
RuntimeOptions runtime = new RuntimeOptions();
log.info("==> 开始短信发送, phone: {}, signName: {}, templateCode: {}, templateParam: {}", phone, signName, templateCode, templateParam);
// 发送短信
SendSmsResponse response = client.sendSmsWithOptions(sendSmsRequest, runtime);
log.info("==> 短信发送成功, response: {}", JsonUtils.toJsonString(response));
}
}

View File

@@ -35,3 +35,7 @@ tmp:
ai:
key: app-loC6IrJpj4cS54MAYp73QtGl
url: https://chat.cosonggle.com/v1/chat-messages
aliyun:
accessKeyId:
accessKeySecret:

View File

@@ -8,3 +8,22 @@ spring:
mybatis:
# MyBatis xml 配置文件路径
mapper-locations: classpath:/mapper/**/*.xml
############## Sa-Token 配置 (文档: https://sa-token.cc) ##############
sa-token:
# token 名称(同时也是 cookie 名称)
token-name: Authorization
# token 前缀
token-prefix: Bearer
# token 风格默认可取值uuid、simple-uuid、random-32、random-64、random-128、tik
token-style: random-128
# token 有效期(单位:秒) 默认30天-1 代表永久有效
timeout: 2592000
# token 最低活跃频率(单位:秒),如果 token 超过此时间没有访问系统就会被冻结,默认-1 代表不限制,永不冻结
active-timeout: -1
# 是否允许同一账号多地同时登录 (为 true 时允许一起登录, 为 false 时新登录挤掉旧登录)
is-concurrent: true
# 在多人登录同一账号时,是否共用一个 token (为 true 时所有登录共用一个 token, 为 false 时每次登录新建一个 token
is-share: true
# 是否输出操作日志
is-log: true

View File

@@ -45,7 +45,7 @@
targetProject="src/main/java"/>
<!-- 需要生成的表-实体类 -->
<table tableName="student_lesson_plans" domainObjectName="StudentLessonPlansDO"
<table tableName="user" domainObjectName="UserDO"
enableCountByExample="false"
enableUpdateByExample="false"
enableDeleteByExample="false"

View File

@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.yinlihupo.enlish.service.domain.mapper.UserDOMapper">
<resultMap id="BaseResultMap" type="com.yinlihupo.enlish.service.domain.dataobject.UserDO">
<id column="id" jdbcType="BIGINT" property="id" />
<result column="password" jdbcType="VARCHAR" property="password" />
<result column="name" jdbcType="VARCHAR" property="name" />
<result column="openid" jdbcType="VARCHAR" property="openid" />
<result column="phone" jdbcType="VARCHAR" property="phone" />
<result column="email" jdbcType="VARCHAR" property="email" />
<result column="status" jdbcType="INTEGER" property="status" />
<result column="is_deleted" jdbcType="INTEGER" property="isDeleted" />
</resultMap>
<insert id="insert" useGeneratedKeys="true" keyProperty="id">
insert into user (password, phone, name)
values (#{password}, #{phone}, #{name})
</insert>
<select id="selectByPhone" resultMap="BaseResultMap">
select *
from user
where phone = #{phone}
and is_deleted = 0
</select>
</mapper>

View File

@@ -8,12 +8,14 @@
"name": "enlish-vue",
"version": "0.0.0",
"dependencies": {
"@vueuse/integrations": "^14.1.0",
"axios": "^1.13.2",
"echarts": "^6.0.0",
"element-plus": "^2.12.0",
"flowbite": "^1.8.1",
"nprogress": "^0.2.0",
"pinia": "^3.0.4",
"universal-cookie": "^8.0.1",
"vue": "^3.5.24",
"vue-router": "^4.6.4"
},
@@ -1200,6 +1202,116 @@
}
}
},
"node_modules/@vueuse/integrations": {
"version": "14.1.0",
"resolved": "https://registry.npmjs.org/@vueuse/integrations/-/integrations-14.1.0.tgz",
"integrity": "sha512-eNQPdisnO9SvdydTIXnTE7c29yOsJBD/xkwEyQLdhDC/LKbqrFpXHb3uS//7NcIrQO3fWVuvMGp8dbK6mNEMCA==",
"license": "MIT",
"dependencies": {
"@vueuse/core": "14.1.0",
"@vueuse/shared": "14.1.0"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"async-validator": "^4",
"axios": "^1",
"change-case": "^5",
"drauu": "^0.4",
"focus-trap": "^7",
"fuse.js": "^7",
"idb-keyval": "^6",
"jwt-decode": "^4",
"nprogress": "^0.2",
"qrcode": "^1.5",
"sortablejs": "^1",
"universal-cookie": "^7 || ^8",
"vue": "^3.5.0"
},
"peerDependenciesMeta": {
"async-validator": {
"optional": true
},
"axios": {
"optional": true
},
"change-case": {
"optional": true
},
"drauu": {
"optional": true
},
"focus-trap": {
"optional": true
},
"fuse.js": {
"optional": true
},
"idb-keyval": {
"optional": true
},
"jwt-decode": {
"optional": true
},
"nprogress": {
"optional": true
},
"qrcode": {
"optional": true
},
"sortablejs": {
"optional": true
},
"universal-cookie": {
"optional": true
}
}
},
"node_modules/@vueuse/integrations/node_modules/@types/web-bluetooth": {
"version": "0.0.21",
"resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz",
"integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==",
"license": "MIT"
},
"node_modules/@vueuse/integrations/node_modules/@vueuse/core": {
"version": "14.1.0",
"resolved": "https://registry.npmjs.org/@vueuse/core/-/core-14.1.0.tgz",
"integrity": "sha512-rgBinKs07hAYyPF834mDTigH7BtPqvZ3Pryuzt1SD/lg5wEcWqvwzXXYGEDb2/cP0Sj5zSvHl3WkmMELr5kfWw==",
"license": "MIT",
"dependencies": {
"@types/web-bluetooth": "^0.0.21",
"@vueuse/metadata": "14.1.0",
"@vueuse/shared": "14.1.0"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"vue": "^3.5.0"
}
},
"node_modules/@vueuse/integrations/node_modules/@vueuse/metadata": {
"version": "14.1.0",
"resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-14.1.0.tgz",
"integrity": "sha512-7hK4g015rWn2PhKcZ99NyT+ZD9sbwm7SGvp7k+k+rKGWnLjS/oQozoIZzWfCewSUeBmnJkIb+CNr7Zc/EyRnnA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/@vueuse/integrations/node_modules/@vueuse/shared": {
"version": "14.1.0",
"resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-14.1.0.tgz",
"integrity": "sha512-EcKxtYvn6gx1F8z9J5/rsg3+lTQnvOruQd8fUecW99DCK04BkWD7z5KQ/wTAx+DazyoEE9dJt/zV8OIEQbM6kw==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"vue": "^3.5.0"
}
},
"node_modules/@vueuse/metadata": {
"version": "9.13.0",
"resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-9.13.0.tgz",
@@ -1305,7 +1417,8 @@
"version": "4.2.5",
"resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz",
"integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/asynckit": {
"version": "0.4.0",
@@ -1356,6 +1469,7 @@
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz",
"integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==",
"license": "MIT",
"peer": true,
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.4",
@@ -1553,6 +1667,19 @@
"dev": true,
"license": "MIT"
},
"node_modules/cookie": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/copy-anything": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.0.5.tgz",
@@ -2445,7 +2572,8 @@
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/nprogress/-/nprogress-0.2.0.tgz",
"integrity": "sha512-I19aIingLgR1fmhftnbWWO3dXc0hSxqHQHQb3H8m+K3TnEn/iSeTZZOyvKXWqQESMwuUVnatlCnZdLBZZt2VSA==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/object-assign": {
"version": "4.1.1",
@@ -3148,6 +3276,16 @@
"@types/estree": "^1.0.0"
}
},
"node_modules/universal-cookie": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/universal-cookie/-/universal-cookie-8.0.1.tgz",
"integrity": "sha512-B6ks9FLLnP1UbPPcveOidfvB9pHjP+wekP2uRYB9YDfKVpvcjKgy1W5Zj+cEXJ9KTPnqOKGfVDQBmn8/YCQfRg==",
"license": "MIT",
"peer": true,
"dependencies": {
"cookie": "^1.0.2"
}
},
"node_modules/unplugin": {
"version": "2.3.11",
"resolved": "https://registry.npmjs.org/unplugin/-/unplugin-2.3.11.tgz",

View File

@@ -9,12 +9,14 @@
"preview": "vite preview"
},
"dependencies": {
"@vueuse/integrations": "^14.1.0",
"axios": "^1.13.2",
"echarts": "^6.0.0",
"element-plus": "^2.12.0",
"flowbite": "^1.8.1",
"nprogress": "^0.2.0",
"pinia": "^3.0.4",
"universal-cookie": "^8.0.1",
"vue": "^3.5.24",
"vue-router": "^4.6.4"
},

View File

@@ -0,0 +1,9 @@
import axios from "@/axios";
export function login(data) {
return axios.post("/login/login", data)
}
export function getVerificationCode(data) {
return axios.post("/login/sendVerificationCode", data)
}

View File

@@ -1,4 +1,6 @@
import axios from "axios";
import { getToken } from "@/composables/auth";
import { showMessage } from "@/composables/util.js";
// 创建 Axios 实例
const instance = axios.create({
@@ -6,5 +8,34 @@ const instance = axios.create({
timeout: 7000, // 请求超时时间
})
// 添加请求拦截器
instance.interceptors.request.use(function (config) {
const token = getToken()
console.log('统一添加请求头中的 Token:' + token)
// 当 token 不为空时
if (token) {
// 添加请求头, key 为 Authorizationvalue 值的前缀为 'Bearer '
config.headers['Authorization'] = 'Bearer ' + token
}
return config;
}, function (error) {
// 对请求错误做些什么
return Promise.reject(error)
});
// 添加响应拦截器
instance.interceptors.response.use(function (response) {
// 2xx 范围内的状态码都会触发该函数。
// 对响应数据做点什么
return response
}, function (error) {
// 若后台有错误提示就用提示文字,默认提示为 '请求失败'
let errorMsg = error.response.data.message || '请求失败'
// 弹错误提示
showMessage(errorMsg, 'error')
return Promise.reject(error)
})
// 暴露出去
export default instance;

View File

@@ -0,0 +1,20 @@
import { useCookies } from '@vueuse/integrations/useCookies'
// 存储在 Cookie 中的 Token 的 key
const TOKEN_KEY = 'Authorization'
const cookie = useCookies()
// 获取 Token 值
export function getToken() {
return cookie.get(TOKEN_KEY)
}
// 设置 Token 到 Cookie 中
export function setToken(token, expires = 2592000) {
return cookie.set(TOKEN_KEY, token, { expires })
}
// 删除 Token
export function removeToken() {
return cookie.remove(TOKEN_KEY)
}

View File

@@ -7,12 +7,14 @@
<span class="self-center text-xl font-semibold whitespace-nowrap dark:text-white">Flowbite</span>
</a>
<div class="flex items-center lg:order-2">
<a href="#" @click.prevent="showLogin = true"
class="text-gray-800 dark:text-white hover:bg-gray-50 focus:ring-4 focus:ring-gray-300 font-medium rounded-lg text-sm px-4 lg:px-5 py-2 lg:py-2.5 mr-2 dark:hover:bg-gray-700 focus:outline-none dark:focus:ring-gray-800">
Login
</a>
<a href="#"
class="text-gray-800 dark:text-white hover:bg-gray-50 focus:ring-4 focus:ring-gray-300 font-medium rounded-lg text-sm px-4 lg:px-5 py-2 lg:py-2.5 mr-2 dark:hover:bg-gray-700 focus:outline-none dark:focus:ring-gray-800">Log
in</a>
<a href="#"
class="text-white bg-primary-700 hover:bg-primary-800 focus:ring-4 focus:ring-primary-300 font-medium rounded-lg text-sm px-4 lg:px-5 py-2 lg:py-2.5 mr-2 dark:bg-primary-600 dark:hover:bg-primary-700 focus:outline-none dark:focus:ring-primary-800">Get
started</a>
class="text-white bg-primary-700 hover:bg-primary-800 focus:ring-4 focus:ring-primary-300 font-medium rounded-lg text-sm px-4 lg:px-5 py-2 lg:py-2.5 mr-2 dark:bg-primary-600 dark:hover:bg-primary-700 focus:outline-none dark:focus:ring-primary-800">
Get started
</a>
<button data-collapse-toggle="mobile-menu-2" type="button"
class="inline-flex items-center p-2 ml-1 text-sm text-gray-500 rounded-lg lg:hidden hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-gray-200 dark:text-gray-400 dark:hover:bg-gray-700 dark:focus:ring-gray-600"
aria-controls="mobile-menu-2" aria-expanded="false">
@@ -38,22 +40,19 @@
aria-current="page">Home</a>
</li>
<li>
<router-link
to="/"
<router-link to="/"
class="block py-2 pr-4 pl-3 text-gray-700 border-b border-gray-100 hover:bg-gray-50 lg:hover:bg-transparent lg:border-0 lg:hover:text-primary-700 lg:p-0 dark:text-gray-400 lg:dark:hover:text-white dark:hover:bg-gray-700 dark:hover:text-white lg:dark:hover:bg-transparent dark:border-gray-700">
班级
</router-link>
</li>
<li>
<router-link
to="/learningplan"
<router-link to="/learningplan"
class="block py-2 pr-4 pl-3 text-gray-700 border-b border-gray-100 hover:bg-gray-50 lg:hover:bg-transparent lg:border-0 lg:hover:text-primary-700 lg:p-0 dark:text-gray-400 lg:dark:hover:text-white dark:hover:bg-gray-700 dark:hover:text-white lg:dark:hover:bg-transparent dark:border-gray-700">
学案
</router-link>
</li>
<li>
<router-link
to="/uploadpng"
<router-link to="/uploadpng"
class="block py-2 pr-4 pl-3 text-gray-700 border-b border-gray-100 hover:bg-gray-50 lg:hover:bg-transparent lg:border-0 lg:hover:text-primary-700 lg:p-0 dark:text-gray-400 lg:dark:hover:text-white dark:hover:bg-gray-700 dark:hover:text-white lg:dark:hover:bg-transparent dark:border-gray-700">
上传图片
</router-link>
@@ -62,8 +61,12 @@
</div>
</div>
</nav>
<LoginDialog v-model="showLogin" />
</header>
</template>
<script setup>
import { ref } from 'vue'
import LoginDialog from '@/layouts/components/LoginDialog.vue'
const showLogin = ref(false)
</script>

View File

@@ -0,0 +1,288 @@
<template>
<transition name="fade">
<div v-if="visible" class="fixed inset-0 z-50 flex items-center justify-center bg-gray-900 bg-opacity-50 p-4"
@click.self="close">
<div class="relative w-full max-w-md p-8 border border-gray-100 shadow-xl rounded-xl bg-white">
<button @click="close" aria-label="关闭"
class="absolute top-3 right-3 text-gray-500 hover:text-gray-700 focus:outline-none"></button>
<div class="text-center px-2">
<div class="mt-2 grid grid-cols-3 gap-2">
<button type="button" @click="switchMode('password')" class="w-full px-3 py-1.5 rounded text-sm"
:class="mode === 'password' ? 'bg-blue-500 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'">
账号密码登录
</button>
<button type="button" @click="switchMode('code')" class="w-full px-3 py-1.5 rounded text-sm"
:class="mode === 'code' ? 'bg-blue-500 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'">
验证码登录
</button>
<button type="button" @click="switchMode('register')" class="w-full px-3 py-1.5 rounded text-sm"
:class="mode === 'register' ? 'bg-blue-500 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'">
验证码注册
</button>
</div>
<div class="mt-6 min-h-[300px]">
<transition name="fade-slide" mode="out-in">
<div v-if="mode === 'password'" key="password">
<div class="space-y-4">
<div>
<input type="text" v-model="form.phone" maxlength="20" placeholder="手机号"
class="appearance-none bg-transparent border border-gray-300 rounded-lg w-full px-3 py-2 text-gray-700 leading-tight focus:outline-none focus:border-blue-500">
</div>
<div>
<input type="password" v-model="form.password" maxlength="20" placeholder="密码"
class="appearance-none bg-transparent border border-gray-300 rounded-lg w-full px-3 py-2 text-gray-700 leading-tight focus:outline-none focus:border-blue-500">
</div>
</div>
<div class="mt-6">
<button @click="userLogin"
class="w-full bg-green-500 hover:bg-green-600 text-white font-medium py-2.5 px-4 rounded-lg focus:outline-none focus:shadow-outline disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="loading || !canSubmit">
<span v-if="!loading">立即登录</span>
<span v-else class="inline-flex items-center justify-center gap-2">
<svg class="w-4 h-4 animate-spin" xmlns="http://www.w3.org/2000/svg"
fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor"
stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor"
d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"></path>
</svg>
登录中...
</span>
</button>
</div>
</div>
<div v-else-if="mode === 'code'" key="code">
<div class="space-y-4">
<div>
<input type="text" v-model="form.phone" maxlength="20" placeholder="手机号"
class="appearance-none bg-transparent border border-gray-300 rounded-lg w-full px-3 py-2 text-gray-700 leading-tight focus:outline-none focus:border-blue-500">
</div>
<div>
<div class="flex items-center gap-2">
<input type="text" v-model="form.code" maxlength="6" placeholder="验证码"
class="appearance-none bg-transparent border border-gray-300 rounded-lg w-full px-3 py-2 text-gray-700 leading-tight focus:outline-none focus:border-blue-500">
<button type="button" @click="sendCode"
:disabled="codeDisabled || !form.phone"
class="ml-auto bg-blue-500 hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed text-white text-sm px-3 py-2 rounded focus:outline-none focus:shadow-outline whitespace-nowrap flex-shrink-0 inline-flex items-center justify-center min-w-[100px]">
{{ codeBtnText }}
</button>
</div>
</div>
</div>
<div class="mt-6">
<button @click="userLogin"
class="w-full bg-green-500 hover:bg-green-600 text-white font-medium py-2.5 px-4 rounded-lg focus:outline-none focus:shadow-outline disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="loading || !canSubmit">
<span v-if="!loading">立即登录</span>
<span v-else class="inline-flex items-center justify-center gap-2">
<svg class="w-4 h-4 animate-spin" xmlns="http://www.w3.org/2000/svg"
fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor"
stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor"
d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"></path>
</svg>
处理中...
</span>
</button>
</div>
</div>
<div v-else key="register">
<div class="space-y-4">
<div>
<input type="text" v-model="form.phone" maxlength="20" placeholder="手机号"
class="appearance-none bg-transparent border border-gray-300 rounded-lg w-full px-3 py-2 text-gray-700 leading-tight focus:outline-none focus:border-blue-500">
</div>
<div>
<input type="text" v-model="form.name" maxlength="20" placeholder="姓名"
class="appearance-none bg-transparent border border-gray-300 rounded-lg w-full px-3 py-2 text-gray-700 leading-tight focus:outline-none focus:border-blue-500">
</div>
<div>
<input type="password" v-model="form.password" maxlength="20" placeholder="设置密码"
class="appearance-none bg-transparent border border-gray-300 rounded-lg w-full px-3 py-2 text-gray-700 leading-tight focus:outline-none focus:border-blue-500">
</div>
<div>
<div class="flex items-center gap-2">
<input type="text" v-model="form.code" maxlength="6" placeholder="验证码"
class="appearance-none bg-transparent border border-gray-300 rounded-lg w-full px-3 py-2 text-gray-700 leading-tight focus:outline-none focus:border-blue-500">
<button type="button" @click="sendCode"
:disabled="codeDisabled || !form.phone"
class="ml-auto bg-blue-500 hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed text-white text-sm px-3 py-2 rounded focus:outline-none focus:shadow-outline whitespace-nowrap flex-shrink-0 inline-flex items-center justify-center min-w-[100px]">
{{ codeBtnText }}
</button>
</div>
</div>
</div>
<div class="mt-6">
<button @click="userLogin"
class="w-full bg-blue-500 hover:bg-blue-600 text-white font-medium py-2.5 px-4 rounded-lg focus:outline-none focus:shadow-outline disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="loading || !canSubmit">
<span v-if="!loading">验证码注册</span>
<span v-else class="inline-flex items-center justify-center gap-2">
<svg class="w-4 h-4 animate-spin" xmlns="http://www.w3.org/2000/svg"
fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor"
stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor"
d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"></path>
</svg>
处理中...
</span>
</button>
</div>
</div>
</transition>
</div>
</div>
</div>
</div>
</transition>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { login, getVerificationCode,} from '@/api/user'
import { setToken } from '../../composables/auth'
const props = defineProps({
modelValue: { type: Boolean, default: false }
})
const emit = defineEmits(['update:modelValue', 'success'])
const visible = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
})
const loading = ref(false)
const mode = ref('password')
const form = ref({
phone: '',
name: '',
password: '',
code: ''
})
const codeDisabled = ref(false)
const codeBtnText = ref('发送验证码')
let timer = null
const canSubmit = computed(() => {
if (mode.value === 'password') {
return form.value.phone.trim() && form.value.password.trim()
}
if (mode.value === 'code') {
return form.value.phone.trim() && form.value.code.trim()
}
return form.value.phone.trim() && form.value.name.trim() && form.value.password.trim() && form.value.code.trim()
})
function switchMode(m) {
mode.value = m
}
function close() {
emit('update:modelValue', false)
}
async function sendCode() {
if (!form.value.phone || codeDisabled.value) return
codeDisabled.value = true
try {
await getVerificationCode({ phone: form.value.phone.trim() })
ElMessage.success('验证码已发送')
let count = 60
codeBtnText.value = `${count}s`
timer = setInterval(() => {
count -= 1
if (count <= 0) {
clearInterval(timer)
timer = null
codeDisabled.value = false
codeBtnText.value = '发送验证码'
} else {
codeBtnText.value = `${count}s`
}
}, 1000)
} catch (e) {
codeDisabled.value = false
codeBtnText.value = '发送验证码'
}
}
async function userLogin() {
if (!canSubmit.value || loading.value) return
loading.value = true
try {
if (mode.value === 'register') {
const res = await login({
phone: form.value.phone.trim(),
name: form.value.name.trim(),
password: form.value.password.trim(),
code: form.value.code.trim()
})
const data = res.data
if (data?.success) {
ElMessage.success('注册成功')
emit('success', res)
emit('update:modelValue', false)
} else {
ElMessage.error(data?.message || '注册失败')
}
return
}
const res = await login({
phone: form.value.phone.trim(),
name: mode.value === 'password' ? '' : form.value.name.trim(),
password: mode.value === 'password' ? form.value.password : '',
code: mode.value === 'code' ? form.value.code.trim() : ''
})
const data = res.data
if (data?.success) {
try { setToken(data.data) } catch { }
ElMessage.success('登录成功')
emit('success', res)
emit('update:modelValue', false)
} else {
ElMessage.error(data?.message || '登录失败')
}
} finally {
loading.value = false
}
}
watch(
() => props.modelValue,
(v) => {
if (v) {
form.value = { phone: '', name: '', password: '', code: '' }
mode.value = 'password'
} else {
if (timer) {
clearInterval(timer)
timer = null
codeDisabled.value = false
codeBtnText.value = '发送验证码'
}
}
}
)
</script>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity .2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.fade-slide-enter-active,
.fade-slide-leave-active {
transition: all .2s ease;
}
.fade-slide-enter-from,
.fade-slide-leave-to {
opacity: 0;
transform: translateY(6px);
}
</style>

36
pom.xml
View File

@@ -40,7 +40,7 @@
<jackson.version>2.16.1</jackson.version>
<mysql-connector-java.version>8.0.29</mysql-connector-java.version>
<druid.version>1.2.23</druid.version>
<sa-token.version>1.38.0</sa-token.version>
<sa-token>1.44.0</sa-token>
<guava.version>33.0.0-jre</guava.version>
<hutool.version>5.8.26</hutool.version>
<commons-lang3.version>3.12.0</commons-lang3.version>
@@ -209,14 +209,14 @@
<dependency>
<groupId>cn.dev33</groupId>
<artifactId>sa-token-spring-boot3-starter</artifactId>
<version>${sa-token.version}</version>
<version>${sa-token}</version>
</dependency>
<!-- Sa-Token 整合 Redis (使用 jackson 序列化方式) -->
<!-- Sa-Token 整合 RedisTemplate -->
<dependency>
<groupId>cn.dev33</groupId>
<artifactId>sa-token-redis-jackson</artifactId>
<version>${sa-token.version}</version>
<artifactId>sa-token-redis-template</artifactId>
<version>${sa-token}</version>
</dependency>
<!-- 相关工具类 -->
@@ -248,6 +248,32 @@
<artifactId>activation</artifactId>
<version>${activation.version}</version>
</dependency>
<!-- 阿里云短信发送 -->
<dependency>
<groupId>com.aliyun</groupId>
<artifactId>dysmsapi20170525</artifactId>
<version>${dysmsapi.version}</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>4.4.0</version>
</dependency>
<dependency>
<groupId>commons-codec</groupId>
<artifactId>commons-codec</artifactId>
<version>1.15</version>
</dependency>
<!-- no more than 2.3.3-->
<dependency>
<groupId>org.glassfish.jaxb</groupId>