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

@@ -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>