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" "node": "^20.19.0 || >=22.12.0"
}, },
"scripts": { "scripts": {
"dev": "vite --host", "dev": "vite --host --mode development",
"build": "run-p type-check \"build-only {@}\" --", "build": "run-p type-check \"build-only {@}\" --",
"preview": "vite preview", "preview": "vite preview",
"build-only": "vite build", "build-only": "vite build",

View File

@ -1,13 +1,13 @@
<template> <template>
<header :class="{ login: hideLoading }"> <header :class="{ login: hideLoading }">
<t-avatar alt="Vue logo" class="logo" :image="avatar" /> <div class="login-box">
<transition name="slide-up" mode="out-in"> <div class="logo">
<span v-if="showTitle" class="title">你好{{ name }}</span> <img alt="Logo" :src="avatar" style="object-fit: cover"/>
</transition> </div>
<div class="login-info"> <div class="login-info">
<transition name="slide-up" mode="out-in"> <transition name="slide-up" mode="out-in">
<div v-if="!login"> <div v-if="!login">
<LoginProcess @login="handleLogin"/> <LoginProcess @login="handleLogin" />
</div> </div>
<div v-else-if="!weCome"> <div v-else-if="!weCome">
<transition name="slide-up" mode="out-in"> <transition name="slide-up" mode="out-in">
@ -16,8 +16,18 @@
</div> </div>
</transition> </transition>
</div> </div>
<div v-else></div>
</transition> </transition>
</div> </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> </header>
<div class="header-placeholder" /> <div class="header-placeholder" />
@ -33,8 +43,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { RouterView } from 'vue-router' import { RouterView } from 'vue-router'
import { ref, watch } from 'vue' 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 LoginProcess from '@/components/login/LoginProcess.vue'
import { RefreshIcon } from 'tdesign-icons-vue-next'
const login = ref(false) const login = ref(false)
const weCome = ref(false) const weCome = ref(false)
@ -48,7 +59,7 @@ if (!navigator.credentials) {
passkey.value = false passkey.value = false
} }
function handleLogin(){ function handleLogin() {
// navigator.credentials.create({ // navigator.credentials.create({
// publicKey: { // publicKey: {
// challenge: new Uint8Array(32), // challenge: new Uint8Array(32),
@ -107,28 +118,39 @@ header {
background: var(--td-mask-background); background: var(--td-mask-background);
box-shadow: var(--td-shadow-4); box-shadow: var(--td-shadow-4);
height: 100vh; 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; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; height: 100%;
padding: 2vh; width: 100%;
transition: all 0.5s ease-out;
.logo { .logo {
flex: 1; flex: 4;
display: flex; display: flex;
align-items: flex-end; align-items: flex-end;
margin: 0 calc(50vw - 62.5px); margin: 0 calc(50vw - 62.5px);
transition: all 0.3s ease-out; transition: all 0.3s ease-in-out;
position: relative; position: relative;
:deep(.t-avatar) { img {
background-color: transparent; width: 12vh;
height: 12vh;
object-fit: cover;
transition: all 0.3s ease-in-out;
border-radius: 50%;
} }
} }
.login-info { .login-info {
flex: 1; flex: 6;
margin: 1rem; margin: 1rem;
line-height: 2rem; line-height: 2rem;
height: 2rem; height: 2rem;
transition: all 0.3s ease-in-out;
.loading { .loading {
height: 2rem; height: 2rem;
width: 2rem; width: 2rem;
@ -148,16 +170,27 @@ header {
} }
.title { .title {
margin: 1rem; margin: 1rem;
flex: 98;
}
} }
&.login { &.login {
flex-direction: row;
justify-content: start;
height: 10vh; height: 10vh;
.login-box {
width: auto;
}
.logo { .logo {
flex: none; flex: 1;
margin: 0 0;
> img{
height: 6vh; height: 6vh;
width: 6vh; width: 6vh;
margin: 0 0; }
}
.title{
flex: 98;
}
.operating-area{
flex: 1;
} }
} }
+ .header-placeholder { + .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> <template>
<div> <div>
<t-form labelWidth="0">
<t-form-item>
<t-input v-model="username" placeholder="请输入用户名"></t-input> <t-input v-model="username" placeholder="请输入用户名"></t-input>
</t-form-item>
<t-form-item>
<t-input v-model="password" placeholder="请输入密码"></t-input> <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-link @click="handleRegister">没有账号去注册</t-link>
<t-button @click="handleLogin">登录</t-button> </t-space>
<t-button @click="handleUsePasskey">取消</t-button> </t-form>
</div> </div>
</template> </template>
<script setup lang="ts"> <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( const { username, password } = toRefs(
reactive({ reactive({
username: '', username: '',
password: '', password: '',
}), }),
) )
const emits = defineEmits(['complete', 'register'])
const loading = ref(false)
function handleLogin() { function handleLogin() {
loading.value = true
login({ user: username.value, password: password.value })
.then((x: any) => {
if (x.code === 200) {
emits('complete') 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() { 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')
}
})
})
})
// emits('complete')
} }
function handleRegister() { function handleRegister() {
emits('register') emits('register')
} }
</script> </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> <template>
<div> <div>
<transition name="slide-up"> <transition name="slide-up" mode="out-in">
<Login v-if="step === 'login'" @complete="handleLogin" @register="handleRegister"></Login> <Login v-if="step === 'login'" @complete="handleLogin" @register="handleToRegister"></Login>
<Register v-else-if="step === 'register'" @complete="handleLogin"></Register> <Register v-else-if="step === 'register'" @complete="handleLogin" @login="handleToLogin"></Register>
</transition> </transition>
</div> </div>
</template> </template>
@ -18,9 +18,12 @@ const step = ref('login')
function handleLogin() { function handleLogin() {
emits('login') emits('login')
} }
function handleRegister() { function handleToRegister() {
step.value = 'register' step.value = 'register'
} }
function handleToLogin() {
step.value = 'login'
}
</script> </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 './assets/main.css'
// import 'tdesign-mobile-vue/es/style/index.css'
import 'tdesign-vue-next/es/style/index.css' import 'tdesign-vue-next/es/style/index.css'
import { createApp } from 'vue' 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'; axios.defaults.headers['Content-Type'] = 'application/json;charset=utf-8';
const request = axios.create({ const request = axios.create({
timeout: 10000, timeout: 10000,
baseURL: import.meta.env.BASE_URL baseURL: import.meta.env.VITE_BASE_URL
}) })
request.interceptors.request.use((config: InternalAxiosRequestConfig) => { // request.interceptors.request.use((config: InternalAxiosRequestConfig) => {
//
return Promise.reject() // 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 export default request

View File

@ -28,4 +28,16 @@ export default defineConfig({
'@': fileURLToPath(new URL('./src', import.meta.url)) '@': 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/, '')
}
}
}
}) })