feat(auth): 实现用户登录和注册功能
- 新增登录和注册接口调用 - 添加通行密钥认证支持 - 实现登录表单和注册表单界面 - 添加状态管理和错误处理 - 更新环境变量配置和代理设置 - 优化登录流程动画和样式布局
This commit is contained in:
parent
0c7aaa848d
commit
846c8ce882
2
.env
2
.env
@ -1 +1 @@
|
||||
BASE_URL=https://animo.alina-dace.info/api
|
||||
VITE_BASE_URL=https://animo.alina-dace.info/api
|
||||
|
||||
@ -1 +1 @@
|
||||
BASE_URL=http://localhost:8080/
|
||||
VITE_BASE_URL=http://localhost:5173/api
|
||||
|
||||
@ -7,7 +7,7 @@
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite --host",
|
||||
"dev": "vite --host --mode development",
|
||||
"build": "run-p type-check \"build-only {@}\" --",
|
||||
"preview": "vite preview",
|
||||
"build-only": "vite build",
|
||||
|
||||
149
src/App.vue
149
src/App.vue
@ -1,23 +1,33 @@
|
||||
<template>
|
||||
<header :class="{ login: hideLoading }">
|
||||
<t-avatar alt="Vue logo" class="logo" :image="avatar" />
|
||||
<div class="login-box">
|
||||
<div class="logo">
|
||||
<img alt="Logo" :src="avatar" style="object-fit: cover"/>
|
||||
</div>
|
||||
<div class="login-info">
|
||||
<transition name="slide-up" mode="out-in">
|
||||
<div v-if="!login">
|
||||
<LoginProcess @login="handleLogin" />
|
||||
</div>
|
||||
<div v-else-if="!weCome">
|
||||
<transition name="slide-up" mode="out-in">
|
||||
<div v-if="login">
|
||||
<div class="loading" />
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
<div v-else></div>
|
||||
</transition>
|
||||
</div>
|
||||
</div>
|
||||
<transition name="slide-up" mode="out-in">
|
||||
<span v-if="showTitle" class="title">你好,{{ name }}</span>
|
||||
</transition>
|
||||
<div class="login-info">
|
||||
<transition name="slide-up" mode="out-in">
|
||||
<div v-if="!login">
|
||||
<LoginProcess @login="handleLogin"/>
|
||||
</div>
|
||||
<div v-else-if="!weCome">
|
||||
<transition name="slide-up" mode="out-in">
|
||||
<div v-if="login">
|
||||
<div class="loading" />
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
<transition name="slide-up" mode="out-in">
|
||||
<div v-if="showTitle" class="operating-area">
|
||||
<refresh-icon :fill-color='"transparent"' :stroke-color='"currentColor"' :stroke-width="2"/>
|
||||
</div>
|
||||
</transition>
|
||||
</header>
|
||||
<div class="header-placeholder" />
|
||||
|
||||
@ -33,8 +43,9 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterView } from 'vue-router'
|
||||
import { ref, watch } from 'vue'
|
||||
import avatar from '@/assets/logo.svg'
|
||||
import avatar from '@/assets/avater.jpg'
|
||||
import LoginProcess from '@/components/login/LoginProcess.vue'
|
||||
import { RefreshIcon } from 'tdesign-icons-vue-next'
|
||||
|
||||
const login = ref(false)
|
||||
const weCome = ref(false)
|
||||
@ -48,7 +59,7 @@ if (!navigator.credentials) {
|
||||
passkey.value = false
|
||||
}
|
||||
|
||||
function handleLogin(){
|
||||
function handleLogin() {
|
||||
// navigator.credentials.create({
|
||||
// publicKey: {
|
||||
// challenge: new Uint8Array(32),
|
||||
@ -76,7 +87,7 @@ function handleLogin(){
|
||||
// }).then(resp => {
|
||||
// login.value = true
|
||||
// })
|
||||
login.value = true
|
||||
login.value = true
|
||||
}
|
||||
|
||||
// 登录动画流程
|
||||
@ -108,56 +119,78 @@ header {
|
||||
box-shadow: var(--td-shadow-4);
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
justify-content: start;
|
||||
padding: 2vh;
|
||||
transition: all 0.5s ease-out;
|
||||
.logo {
|
||||
flex: 1;
|
||||
transition: all 0.5s ease-in-out;
|
||||
.login-box{
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
margin: 0 calc(50vw - 62.5px);
|
||||
transition: all 0.3s ease-out;
|
||||
position: relative;
|
||||
:deep(.t-avatar) {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
.login-info {
|
||||
flex: 1;
|
||||
margin: 1rem;
|
||||
line-height: 2rem;
|
||||
height: 2rem;
|
||||
.loading {
|
||||
height: 2rem;
|
||||
width: 2rem;
|
||||
border: var(--td-brand-color) 2px solid;
|
||||
border-top-color: transparent;
|
||||
border-radius: 50%;
|
||||
@keyframes circle {
|
||||
0% {
|
||||
transform: rotate(0);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
.logo {
|
||||
flex: 4;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
margin: 0 calc(50vw - 62.5px);
|
||||
transition: all 0.3s ease-in-out;
|
||||
position: relative;
|
||||
img {
|
||||
width: 12vh;
|
||||
height: 12vh;
|
||||
object-fit: cover;
|
||||
transition: all 0.3s ease-in-out;
|
||||
border-radius: 50%;
|
||||
}
|
||||
animation: circle 1s linear infinite;
|
||||
}
|
||||
}
|
||||
.title {
|
||||
margin: 1rem;
|
||||
.login-info {
|
||||
flex: 6;
|
||||
margin: 1rem;
|
||||
line-height: 2rem;
|
||||
height: 2rem;
|
||||
transition: all 0.3s ease-in-out;
|
||||
.loading {
|
||||
height: 2rem;
|
||||
width: 2rem;
|
||||
border: var(--td-brand-color) 2px solid;
|
||||
border-top-color: transparent;
|
||||
border-radius: 50%;
|
||||
@keyframes circle {
|
||||
0% {
|
||||
transform: rotate(0);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
animation: circle 1s linear infinite;
|
||||
}
|
||||
}
|
||||
.title {
|
||||
margin: 1rem;
|
||||
flex: 98;
|
||||
}
|
||||
}
|
||||
&.login {
|
||||
flex-direction: row;
|
||||
justify-content: start;
|
||||
height: 10vh;
|
||||
.login-box {
|
||||
width: auto;
|
||||
}
|
||||
.logo {
|
||||
flex: none;
|
||||
height: 6vh;
|
||||
width: 6vh;
|
||||
flex: 1;
|
||||
margin: 0 0;
|
||||
> img{
|
||||
height: 6vh;
|
||||
width: 6vh;
|
||||
}
|
||||
}
|
||||
.title{
|
||||
flex: 98;
|
||||
}
|
||||
.operating-area{
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
+ .header-placeholder {
|
||||
|
||||
43
src/api/auth.ts
Normal file
43
src/api/auth.ts
Normal file
@ -0,0 +1,43 @@
|
||||
import request from '@/request/axios.ts'
|
||||
|
||||
export function login(data: any) {
|
||||
return request({
|
||||
url: '/auth/login',
|
||||
method: 'post',
|
||||
data: data
|
||||
})
|
||||
}
|
||||
export function register(data: any) {
|
||||
return request({
|
||||
url: '/auth/register',
|
||||
method: 'post',
|
||||
data: data
|
||||
})
|
||||
}
|
||||
|
||||
export function registration_options(){
|
||||
return request({
|
||||
url: '/passkey/registration/options',
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
export function registration(credential: any){
|
||||
return request({
|
||||
url: '/passkey/registration',
|
||||
method: 'post',
|
||||
data: credential
|
||||
})
|
||||
}
|
||||
export function assertion_options(){
|
||||
return request({
|
||||
url: '/passkey/assertion/options',
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
export function assertion(credential: any){
|
||||
return request({
|
||||
url: '/passkey/assertion',
|
||||
method: 'post',
|
||||
data: credential
|
||||
})
|
||||
}
|
||||
BIN
src/assets/avater.jpg
Normal file
BIN
src/assets/avater.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 178 KiB |
@ -1,32 +1,109 @@
|
||||
<template>
|
||||
<div>
|
||||
<t-input v-model="username" placeholder="请输入用户名"></t-input>
|
||||
<t-input v-model="password" placeholder="请输入密码"></t-input>
|
||||
<t-link @click="handleRegister">没有账号?去注册</t-link>
|
||||
<t-button @click="handleLogin">登录</t-button>
|
||||
<t-button @click="handleUsePasskey">取消</t-button>
|
||||
<t-form labelWidth="0">
|
||||
<t-form-item>
|
||||
<t-input v-model="username" placeholder="请输入用户名"></t-input>
|
||||
</t-form-item>
|
||||
<t-form-item>
|
||||
<t-input v-model="password" placeholder="请输入密码"></t-input>
|
||||
</t-form-item>
|
||||
<t-space class="login-button-box" direction="vertical" size="small">
|
||||
<t-button class="login-button" theme="primary" :loading="loading" @click="handleLogin"
|
||||
>登录</t-button
|
||||
>
|
||||
<t-button
|
||||
class="login-button"
|
||||
theme="default"
|
||||
:disabled="!passkey"
|
||||
@click="handleUsePasskey"
|
||||
>使用通行密钥</t-button
|
||||
>
|
||||
<t-link @click="handleRegister">没有账号?去注册</t-link>
|
||||
</t-space>
|
||||
</t-form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, toRefs } from 'vue'
|
||||
import { reactive, ref, toRefs } from 'vue'
|
||||
import { assertion, assertion_options, login } from '@/api/auth.ts'
|
||||
|
||||
const emits = defineEmits(['complete', 'register'])
|
||||
const { username, password } = toRefs(
|
||||
reactive({
|
||||
username: '',
|
||||
password: '',
|
||||
}),
|
||||
)
|
||||
const emits = defineEmits(['complete', 'register'])
|
||||
|
||||
const loading = ref(false)
|
||||
function handleLogin() {
|
||||
emits('complete')
|
||||
loading.value = true
|
||||
login({ user: username.value, password: password.value })
|
||||
.then((x: any) => {
|
||||
if (x.code === 200) {
|
||||
emits('complete')
|
||||
}
|
||||
})
|
||||
.catch((e: any) => {
|
||||
console.error(e)
|
||||
})
|
||||
.finally(() => {
|
||||
loading.value = false
|
||||
})
|
||||
}
|
||||
|
||||
const passkey = ref(true)
|
||||
|
||||
if (!navigator.credentials) {
|
||||
passkey.value = false
|
||||
}
|
||||
function base64ToArrayBuffer(base64: string): ArrayBuffer {
|
||||
// 处理base64url格式:替换字符并添加填充
|
||||
let normalizedBase64 = base64.replace(/-/g, '+').replace(/_/g, '/')
|
||||
// 添加必要的填充
|
||||
while (normalizedBase64.length % 4 !== 0) {
|
||||
normalizedBase64 += '='
|
||||
}
|
||||
|
||||
try {
|
||||
const binaryString = atob(normalizedBase64)
|
||||
const bytes = new Uint8Array(binaryString.length)
|
||||
for (let i = 0; i < binaryString.length; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i)
|
||||
}
|
||||
return bytes.buffer
|
||||
} catch (e) {
|
||||
console.error('Base64 decoding failed:', e, 'Input was:', base64)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
function handleUsePasskey() {
|
||||
emits('complete')
|
||||
assertion_options().then((x: any) => {
|
||||
const parse = JSON.parse(x.data)
|
||||
parse.publicKey.challenge = base64ToArrayBuffer(parse.publicKey?.challenge)
|
||||
console.log(parse)
|
||||
navigator.credentials.get(parse).then((x: any) => {
|
||||
assertion(x).then((x: any) => {
|
||||
if (x.code === 200) {
|
||||
emits('complete')
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
// emits('complete')
|
||||
}
|
||||
function handleRegister() {
|
||||
emits('register')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
<style scoped lang="scss">
|
||||
.login-button-box {
|
||||
width: 100%;
|
||||
.login-button {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<div>
|
||||
<transition name="slide-up">
|
||||
<Login v-if="step === 'login'" @complete="handleLogin" @register="handleRegister"></Login>
|
||||
<Register v-else-if="step === 'register'" @complete="handleLogin"></Register>
|
||||
<transition name="slide-up" mode="out-in">
|
||||
<Login v-if="step === 'login'" @complete="handleLogin" @register="handleToRegister"></Login>
|
||||
<Register v-else-if="step === 'register'" @complete="handleLogin" @login="handleToLogin"></Register>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
@ -18,9 +18,12 @@ const step = ref('login')
|
||||
function handleLogin() {
|
||||
emits('login')
|
||||
}
|
||||
function handleRegister() {
|
||||
function handleToRegister() {
|
||||
step.value = 'register'
|
||||
}
|
||||
function handleToLogin() {
|
||||
step.value = 'login'
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
@ -1,5 +1,57 @@
|
||||
<template></template>
|
||||
<template>
|
||||
<div>
|
||||
<t-form labelWidth="0">
|
||||
<t-form-item>
|
||||
<t-input v-model="username" placeholder="请输入用户名"></t-input>
|
||||
</t-form-item>
|
||||
<t-form-item>
|
||||
<t-input v-model="password" placeholder="请输入密码"></t-input>
|
||||
</t-form-item>
|
||||
<t-space class="login-button-box" direction="vertical" size="small">
|
||||
<t-button class="login-button" theme="primary" :loading="loading" @click="handleRegister"
|
||||
>注册</t-button
|
||||
>
|
||||
<t-link @click="handleLogin">已有帐号?去登陆</t-link>
|
||||
</t-space>
|
||||
</t-form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts"></script>
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, toRefs } from 'vue'
|
||||
import { register } from '@/api/auth.ts'
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
const { username, password } = toRefs(
|
||||
reactive({
|
||||
username: '',
|
||||
password: '',
|
||||
}),
|
||||
)
|
||||
const loading = ref(false)
|
||||
const emits = defineEmits(['complete', 'login'])
|
||||
|
||||
function handleRegister() {
|
||||
loading.value = true
|
||||
register({ user: username.value, password: password.value }).then((x: any) => {
|
||||
if (x.code === 200) {
|
||||
emits('complete')
|
||||
}
|
||||
}).finally(() => {
|
||||
loading.value = false
|
||||
})
|
||||
}
|
||||
|
||||
function handleLogin() {
|
||||
emits('login')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.login-button-box {
|
||||
width: 100%;
|
||||
.login-button {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
90
src/enums/RespEnum.ts
Normal file
90
src/enums/RespEnum.ts
Normal file
@ -0,0 +1,90 @@
|
||||
export enum HttpStatus {
|
||||
/**
|
||||
* 操作成功
|
||||
*/
|
||||
SUCCESS = 200,
|
||||
/**
|
||||
* 对象创建成功
|
||||
*/
|
||||
CREATED = 201,
|
||||
/**
|
||||
* 请求已经被接受
|
||||
*/
|
||||
ACCEPTED = 202,
|
||||
/**
|
||||
* 操作已经执行成功,但是没有返回数据
|
||||
*/
|
||||
NO_CONTENT = 204,
|
||||
/**
|
||||
* 资源已经被移除
|
||||
*/
|
||||
MOVED_PERM = 301,
|
||||
/**
|
||||
* 重定向
|
||||
*/
|
||||
SEE_OTHER = 303,
|
||||
/**
|
||||
* 资源没有被修改
|
||||
*/
|
||||
NOT_MODIFIED = 304,
|
||||
/**
|
||||
* 参数列表错误(缺少,格式不匹配)
|
||||
*/
|
||||
PARAM_ERROR = 400,
|
||||
/**
|
||||
* 未授权
|
||||
*/
|
||||
UNAUTHORIZED = 401,
|
||||
/**
|
||||
* 访问受限,授权过期
|
||||
*/
|
||||
FORBIDDEN = 403,
|
||||
/**
|
||||
* 资源,服务未找到
|
||||
*/
|
||||
NOT_FOUND = 404,
|
||||
/**
|
||||
* 不允许的http方法
|
||||
*/
|
||||
BAD_METHOD = 405,
|
||||
/**
|
||||
* 资源冲突,或者资源被锁
|
||||
*/
|
||||
CONFLICT = 409,
|
||||
/**
|
||||
* 不支持的数据,媒体类型
|
||||
*/
|
||||
UNSUPPORTED_TYPE = 415,
|
||||
/**
|
||||
* 系统内部错误
|
||||
*/
|
||||
SERVER_ERROR = 500,
|
||||
/**
|
||||
* 接口未实现
|
||||
*/
|
||||
NOT_IMPLEMENTED = 501,
|
||||
/**
|
||||
* 服务不可用,过载或者维护
|
||||
*/
|
||||
BAD_GATEWAY = 502,
|
||||
/**
|
||||
* 网关超时
|
||||
*/
|
||||
GATEWAY_TIMEOUT = 504,
|
||||
/**
|
||||
* 未知错误
|
||||
*/
|
||||
UNKNOWN_ERROR = 520,
|
||||
/**
|
||||
* 服务未知错误
|
||||
*/
|
||||
SERVICE_ERROR = 521,
|
||||
/**
|
||||
* 数据库未知错误
|
||||
*/
|
||||
DATABASE_ERROR = 522,
|
||||
/**
|
||||
* 系统警告消息
|
||||
*/
|
||||
WARN = 601
|
||||
}
|
||||
@ -1,4 +1,5 @@
|
||||
import './assets/main.css'
|
||||
// import 'tdesign-mobile-vue/es/style/index.css'
|
||||
import 'tdesign-vue-next/es/style/index.css'
|
||||
|
||||
import { createApp } from 'vue'
|
||||
|
||||
@ -1,15 +1,47 @@
|
||||
import axios, { type InternalAxiosRequestConfig } from 'axios'
|
||||
import axios, { type AxiosResponse, type InternalAxiosRequestConfig } from 'axios'
|
||||
import { HttpStatus } from '@/enums/RespEnum.ts'
|
||||
import { MessagePlugin } from 'tdesign-vue-next'
|
||||
|
||||
const errorCode: any = {
|
||||
'401': '认证失败,无法访问系统资源',
|
||||
'403': '当前操作没有权限',
|
||||
'404': '访问资源不存在',
|
||||
default: '系统未知错误,请反馈给管理员'
|
||||
};
|
||||
|
||||
|
||||
axios.defaults.headers['Content-Type'] = 'application/json;charset=utf-8';
|
||||
|
||||
const request = axios.create({
|
||||
timeout: 10000,
|
||||
baseURL: import.meta.env.BASE_URL
|
||||
baseURL: import.meta.env.VITE_BASE_URL
|
||||
})
|
||||
|
||||
request.interceptors.request.use((config: InternalAxiosRequestConfig) => {
|
||||
|
||||
return Promise.reject()
|
||||
// request.interceptors.request.use((config: InternalAxiosRequestConfig) => {
|
||||
//
|
||||
// return Promise.reject()
|
||||
// })
|
||||
request.interceptors.response.use((res: AxiosResponse) => {
|
||||
// 未设置状态码则默认成功状态
|
||||
const code = res.data.code || HttpStatus.SUCCESS;
|
||||
// 获取错误信息
|
||||
const msg = errorCode[code] || res.data.message || errorCode['default'];
|
||||
// 二进制数据则直接返回
|
||||
if (res.request.responseType === 'blob' || res.request.responseType === 'arraybuffer') {
|
||||
return res.data;
|
||||
}
|
||||
if (code === HttpStatus.SERVER_ERROR) {
|
||||
MessagePlugin.error(msg);
|
||||
return Promise.reject(new Error(msg));
|
||||
} else if (code === HttpStatus.WARN) {
|
||||
MessagePlugin.warning(msg);
|
||||
return Promise.reject(new Error(msg));
|
||||
} else if (code !== HttpStatus.SUCCESS) {
|
||||
MessagePlugin.error(msg);
|
||||
return Promise.reject('error');
|
||||
} else {
|
||||
return Promise.resolve(res.data);
|
||||
}
|
||||
})
|
||||
|
||||
export default request
|
||||
|
||||
@ -28,4 +28,16 @@ export default defineConfig({
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||
},
|
||||
},
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
cors: true,
|
||||
open: true,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://127.0.0.1:8080',
|
||||
changeOrigin: true,
|
||||
rewrite: (path: string) => path.replace(/^\/api/, '')
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
Loading…
Reference in New Issue
Block a user