feat(auth): 实现登录注册功能和路由权限控制

- 新增 Login.vue 实现登录与注册界面,支持手机号、密码、验证码等验证
- 添加登录状态保持并在登录成功后设置 token
- 修改路由配置,新增 /login 路由,并调整默认班级页路由为 /class
- 移除 Header 组件中原有登录按钮,改为通过路由控制访问权限
- 实现路由前置守卫,根据 token 自动跳转登录页或班级页
- 添加验证码发送功能及倒计时禁用按钮逻辑
- 完善表单校验规则,区分登录和注册模式验证字段
This commit is contained in:
lbw
2025-12-29 14:45:44 +08:00
parent 2d76ed507e
commit 340bc5b5e3
4 changed files with 221 additions and 11 deletions

View File

@@ -34,12 +34,6 @@
</div>
</div>
</template>
<template v-else>
<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>
</template>
<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">
@@ -65,7 +59,7 @@
aria-current="page">Home</a>
</li>
<li>
<router-link to="/"
<router-link to="/class"
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>

View File

@@ -0,0 +1,198 @@
<template>
<div class="w-screen h-screen overflow-hidden flex">
<div class="flex-1 bg-black">
<el-image fit="cover" src="https://cube.elemecdn.com/6/94/4d3ea53c084bad6931a56d5158a48jpeg.jpeg"
style="width: 100%; height: 100%" />
</div>
<div class="w-[500px] z-10 bg-white dark:bg-gray-800 p-8">
<div class="mb-4 grid grid-cols-2 gap-2 justify-center pt-64">
<button type="button" @click="switchMode('login')" class="w-full px-3 py-1.5 rounded text-sm"
:class="mode === 'login' ? '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="mb-4">
<h1 class="text-2xl font-bold" v-if="mode === 'login'">登录</h1>
<h1 class="text-2xl font-bold" v-else>注册新用户</h1>
<p class="text-sm text-gray-500" v-if="mode === 'login'">请输入手机号和密码进行登录</p>
<p class="text-sm text-gray-500" v-else>请填写以下信息进行注册</p>
</div>
<el-form :model="form" :rules="rules" ref="formRef" class="space-y-4">
<el-form-item prop="phone">
<el-input v-model="form.phone" maxlength="11" placeholder="手机号" />
</el-form-item>
<template v-if="mode === 'login'">
<el-form-item prop="password">
<el-input v-model="form.password" type="password" maxlength="20" placeholder="密码" />
</el-form-item>
</template>
<template v-else>
<el-form-item prop="name">
<el-input v-model="form.name" maxlength="20" placeholder="姓名" />
</el-form-item>
<el-form-item prop="password">
<el-input v-model="form.password" type="password" maxlength="20" placeholder="设置密码" />
</el-form-item>
<el-form-item prop="password_repeat">
<el-input v-model="form.password_repeat" type="password" maxlength="20" placeholder="重复密码" />
</el-form-item>
<el-form-item prop="code">
<div class="flex items-center gap-2 w-full">
<el-input v-model="form.code" maxlength="6" placeholder="验证码" />
<el-button type="primary" @click="sendCode" :disabled="codeDisabled || !form.phone">
{{ codeBtnText }}
</el-button>
</div>
</el-form-item>
</template>
</el-form>
<div class="mt-6">
<el-button :type="mode === 'login' ? 'success' : 'warning'" plain class="w-full" :disabled="loading"
@click="userLogin">
<span v-if="!loading && mode === 'login'">立即登录</span>
<span v-if="loading && mode === 'login'">登录中...</span>
<span v-if="!loading && mode === 'register'">立即注册</span>
<span v-if="loading && mode === 'register'">处理中...</span>
</el-button>
</div>
</div>
<div class="absolute bottom-2 left-6 text-white drop-shadow">
<h2 class="text-xl">欢迎来到智慧英语</h2>
</div>
</div>
</template>
<script setup>
import { reactive, ref } from 'vue'
import router from '@/router'
import { ElMessage } from 'element-plus'
import { login, getVerificationCode } from '@/api/user'
import { setToken } from '@/composables/auth'
const loading = ref(false)
const formRef = ref()
const mode = ref('login')
const codeDisabled = ref(false)
const codeBtnText = ref('发送验证码')
let timer = null
const form = reactive({
phone: '',
name: '',
password: '',
password_repeat: '',
code: '',
})
const rules = {
phone: [
{ required: true, message: '请输入手机号', trigger: 'blur' },
{ pattern: /^1[3-9]\d{9}$/, message: '请输入合法的手机号', trigger: ['blur', 'change'] },
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, max: 20, message: '密码长度为6-20位', trigger: ['blur', 'change'] },
],
name: [
{
validator: (_r, v, cb) => {
if (mode.value !== 'register') return cb()
if (!v?.trim()) return cb(new Error('请输入姓名'))
cb()
}, trigger: ['blur', 'change']
},
],
password_repeat: [
{
validator: (_r, v, cb) => {
if (mode.value !== 'register') return cb()
if (!v) return cb(new Error('请再次输入密码'))
if (v !== form.password) return cb(new Error('两次密码不一致'))
cb()
}, trigger: ['blur', 'change']
},
],
code: [
{
validator: (_r, v, cb) => {
if (mode.value !== 'register') return cb()
if (!v?.trim()) return cb(new Error('请输入验证码'))
cb()
}, trigger: ['blur', 'change']
},
],
}
async function userLogin() {
if (loading.value) return
formRef.value?.validate(async (valid) => {
if (!valid) return
loading.value = true
try {
const payload = mode.value === 'login'
? {
phone: form.phone.trim(),
password: form.password.trim(),
}
: {
phone: form.phone.trim(),
name: form.name.trim(),
password: form.password.trim(),
code: form.code.trim(),
}
const res = await login(payload)
const data = res.data
if (data?.success) {
if (mode.value === 'login') {
try { setToken(data.data) } catch { }
ElMessage.success('登录成功')
router.push('/class')
} else {
ElMessage.success('注册成功')
mode.value = 'login'
}
} else {
ElMessage.error(data?.message || '登录失败')
}
} finally {
loading.value = false
}
})
}
function switchMode(m) {
mode.value = m
formRef.value?.clearValidate()
}
async function sendCode() {
if (!form.phone || codeDisabled.value) return
codeDisabled.value = true
try {
await getVerificationCode({ phone: form.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 {
codeDisabled.value = false
codeBtnText.value = '发送验证码'
}
}
</script>
<style scoped></style>

View File

@@ -2,12 +2,22 @@ import router from '@/router/index'
import { showMessage } from '@/composables/util'
import { showPageLoading, hidePageLoading } from '@/composables/util'
import { getToken } from '@/composables/auth'
// 全局路由前置守卫
router.beforeEach((to, from, next) => {
console.log('==> 全局路由前置守卫')
// 展示页面加载 Loading
showPageLoading()
const token = getToken()
if (!token && to.path !== '/login') {
next({ path: '/login' })
return
}
if (token && to.path === '/login') {
next({ path: '/class' })
return
}
next()
})

View File

@@ -5,14 +5,15 @@ import { createRouter, createWebHashHistory } from 'vue-router'
import Admid from '@/pages/admid/admid.vue'
import Student from '@/pages/student.vue'
import PlanTTS from '@/pages/PlanTTS.vue'
import Login from '@/pages/Login.vue'
// 统一在这里声明所有路由
const routes = [
{
path: '/', // 路由地址
component: Class, // 对应组件
meta: { // meta 信息
title: '班级' // 页面标题
path: '/class',
component: Class,
meta: {
title: '班级'
}
},
{
@@ -43,6 +44,13 @@ const routes = [
title: '管理员页面' // 页面标题
}
},
{
path: '/login',
component: Login,
meta: {
title: '登录'
}
},
{
path: '/plan/tts',
component: PlanTTS,