feat(auth): 实现用户登录和注册功能

- 新增登录和注册接口调用
- 添加通行密钥认证支持
- 实现登录表单和注册表单界面
- 添加状态管理和错误处理
- 更新环境变量配置和代理设置
- 优化登录流程动画和样式布局
This commit is contained in:
Grand-cocoa 2025-11-10 19:08:02 +08:00
parent 0c7aaa848d
commit 846c8ce882
13 changed files with 426 additions and 83 deletions

2
.env
View File

@ -1 +1 @@
BASE_URL=https://animo.alina-dace.info/api
VITE_BASE_URL=https://animo.alina-dace.info/api

View File

@ -1 +1 @@
BASE_URL=http://localhost:8080/
VITE_BASE_URL=http://localhost:5173/api

View File

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

View File

@ -1,13 +1,13 @@
<template>
<header :class="{ login: hideLoading }">
<t-avatar alt="Vue logo" class="logo" :image="avatar" />
<transition name="slide-up" mode="out-in">
<span v-if="showTitle" class="title">你好{{ name }}</span>
</transition>
<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"/>
<LoginProcess @login="handleLogin" />
</div>
<div v-else-if="!weCome">
<transition name="slide-up" mode="out-in">
@ -16,8 +16,18 @@
</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>
<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),
@ -107,28 +118,39 @@ header {
background: var(--td-mask-background);
box-shadow: var(--td-shadow-4);
height: 100vh;
display: flex;
flex-direction: row;
align-items: center;
justify-content: start;
padding: 2vh;
transition: all 0.5s ease-in-out;
.login-box{
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2vh;
transition: all 0.5s ease-out;
height: 100%;
width: 100%;
.logo {
flex: 1;
flex: 4;
display: flex;
align-items: flex-end;
margin: 0 calc(50vw - 62.5px);
transition: all 0.3s ease-out;
transition: all 0.3s ease-in-out;
position: relative;
:deep(.t-avatar) {
background-color: transparent;
img {
width: 12vh;
height: 12vh;
object-fit: cover;
transition: all 0.3s ease-in-out;
border-radius: 50%;
}
}
.login-info {
flex: 1;
flex: 6;
margin: 1rem;
line-height: 2rem;
height: 2rem;
transition: all 0.3s ease-in-out;
.loading {
height: 2rem;
width: 2rem;
@ -148,16 +170,27 @@ header {
}
.title {
margin: 1rem;
flex: 98;
}
}
&.login {
flex-direction: row;
justify-content: start;
height: 10vh;
.login-box {
width: auto;
}
.logo {
flex: none;
flex: 1;
margin: 0 0;
> img{
height: 6vh;
width: 6vh;
margin: 0 0;
}
}
.title{
flex: 98;
}
.operating-area{
flex: 1;
}
}
+ .header-placeholder {

43
src/api/auth.ts Normal file
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

View File

@ -1,32 +1,109 @@
<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="handleLogin"
>登录</t-button
>
<t-button
class="login-button"
theme="default"
:disabled="!passkey"
@click="handleUsePasskey"
>使用通行密钥</t-button
>
<t-link @click="handleRegister">没有账号去注册</t-link>
<t-button @click="handleLogin">登录</t-button>
<t-button @click="handleUsePasskey">取消</t-button>
</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() {
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() {
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>

View File

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

View File

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

View File

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

View File

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

View File

@ -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/, '')
}
}
}
})