feat: add login system backend

This commit is contained in:
2026-02-18 01:08:26 +08:00
parent dc1b192f47
commit df4fad2c5a
13 changed files with 383 additions and 100 deletions

View File

@@ -1 +1 @@
VITE_SERVER=http://localhost:8080 VITE_SERVER=http://localhost:48080

28
src/apis/system/oauth.ts Normal file
View File

@@ -0,0 +1,28 @@
import { httpWithCustomReturn } from "@/utils/http";
export const loginByEmailPasswordWithOauthApi = ({ email, password }) => {
return httpWithCustomReturn<{
code: number;
msg: string;
data: {
scope: string;
access_token: string;
refresh_token: string;
token_type: string;
expires_in: number;
};
}>({
url: "/admin-api/system/oauth2/token",
method: "POST",
params: {
grant_type: "password",
username: email,
password,
client_id: "rc",
client_secret: "rc",
},
headers: {
"tenant-id": 1,
},
});
};

26
src/apis/system/user.ts Normal file
View File

@@ -0,0 +1,26 @@
import { httpWithCustomReturn } from "@/utils/http";
export const getUserInfoApi = () => {
return httpWithCustomReturn<{
code: number;
msg: string;
data: {
id: number;
username: string;
nickname: string;
email: string;
mobile: string;
sex: number;
avatar: string;
loginIp: string;
loginDate: null;
createTime: number;
roles: object[];
dept: null;
posts: null;
};
}>({
url: "/admin-api/system/user/profile/get",
method: "GET",
});
};

View File

@@ -0,0 +1,17 @@
import { httpWithCustomReturn } from "@/utils/http";
/**
*
* @returns 直接返回图片二进制数据
*/
export const getAuthQrcodeApi = () => {
return httpWithCustomReturn<Blob>({
url: "https://api.qrtool.cn",
method: "get",
responseType: "blob",
params: {
text: "测试",
size: 600,
},
});
};

View File

@@ -1,19 +0,0 @@
import http from "../utils/http";
/**
* This is an example, please remove if not needed
*/
export const getUserList = () => {
return http({
url: "/user/list",
method: "get",
});
};
export const insertUser = (data: { account: string; passowrd: string }) => {
return http({
url: "/user/insert",
method: "post",
data,
});
};

View File

@@ -1,5 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { renderAuthDialog } from "@/hooks/dialog/renderAuthDialog"; import { renderAuthDialog } from "@/hooks/dialog/renderAuthDialog";
import { useUserStore } from "@/stores/UserStore";
// 用户 store
const userStore = useUserStore();
const dropdownVisible = ref(true); const dropdownVisible = ref(true);
@@ -9,19 +13,39 @@ const closeDropdown = () => {
dropdownVisible.value = true; dropdownVisible.value = true;
}, 300); }, 300);
}; };
// 退出登录
const handleLogout = () => {
userStore.clearUserInfo();
};
</script> </script>
<template> <template>
<div class="dropdown dropdown-hover dropdown-end"> <div class="dropdown dropdown-hover dropdown-end">
<div <div
tabindex="0" v-if="!userStore.checkUserLogin()"
role="button" role="button"
class="btn btn-circle btn-sm btn-ghost px-3 w-16" class="btn btn-circle btn-sm btn-ghost px-3 w-16"
@click="
renderAuthDialog('login');
closeDropdown();
"
> >
<span class="text-sm text-base-content/70">{{ $t("nav.login") }}</span> <span class="text-sm text-base-content/70">
{{ $t("nav.login") }}
</span>
</div> </div>
<div
v-else
role="button"
class="text-sm text-base-content/70 px-3 cursor-pointer"
>
{{ userStore.userInfo?.nickname || userStore.userInfo?.username }}
</div>
<!-- 未登录用户下拉框 -->
<ul <ul
v-show="dropdownVisible" v-if="dropdownVisible && !userStore.checkUserLogin()"
tabindex="-1" tabindex="-1"
class="dropdown-content z-1 w-100 p-2" class="dropdown-content z-1 w-100 p-2"
> >
@@ -53,6 +77,25 @@ const closeDropdown = () => {
</div> </div>
</ul> </ul>
<!-- 已登录用户下拉框 -->
<ul
v-if="dropdownVisible && userStore.checkUserLogin()"
tabindex="-1"
class="dropdown-content z-1 w-100 p-2"
>
<div class="card bg-base-200 w-96 mt-4 shadow-sm">
<div class="card-body">
<h2 class="card-title">退出登录</h2>
<p>切换您的账号</p>
<div class="card-actions justify-end">
<button class="btn btn-primary btn-sm" @click="handleLogout">
{{ $t("nav.logout") }}
</button>
</div>
</div>
</div>
</ul>
<!-- 登录弹窗 --> <!-- 登录弹窗 -->
<dialog id="my_modal" class="modal"> <dialog id="my_modal" class="modal">
<div class="modal-box"> <div class="modal-box">

View File

@@ -1,5 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { loginByEmailPasswordWithOauthApi } from "@/apis/system/oauth";
import { getUserInfoApi } from "@/apis/system/user";
import { getAuthQrcodeApi } from "@/apis/temp/auth-qrcode";
import { closeAuthDialog } from "@/hooks/dialog/renderAuthDialog"; import { closeAuthDialog } from "@/hooks/dialog/renderAuthDialog";
import { useUserStore } from "@/stores/UserStore";
import { AnimatePresence, motion } from "motion-v"; import { AnimatePresence, motion } from "motion-v";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
@@ -12,10 +16,12 @@ const props = defineProps({
}, },
}); });
const authFormType = ref<"login" | "register">(props.defaultAuthFormType); const authFormType = ref<"login" | "register" | "transition">(
props.defaultAuthFormType,
);
const changeAuthFormType = (type: "login" | "register") => { const changeAuthFormType = (type: "login" | "register") => {
authFormType.value = ""; authFormType.value = "transition";
setTimeout(() => { setTimeout(() => {
authFormType.value = type; authFormType.value = type;
}, 300); }, 300);
@@ -28,6 +34,49 @@ const closeAuthDialogWaitAnim = () => {
closeAuthDialog(); closeAuthDialog();
}, 500); }, 500);
}; };
// 登录方式
const loginType = ref<"password" | "phone">("password");
// 登录二维码
const authQrcode = ref("");
onMounted(async () => {
const qrImg = await getAuthQrcodeApi();
authQrcode.value = URL.createObjectURL(qrImg);
});
// 控制登录表单宽度
const loginFormWidth = computed(() => {
return authFormType.value === "login" ? "900px" : "550px";
});
// 登录表单数据绑定
const loginForm = reactive({
email: "",
password: "",
});
// 登录按钮点击事件
const clickLoginButtonEvent = async () => {
const { email, password } = loginForm;
if (!email || !password) {
return;
}
const res = await loginByEmailPasswordWithOauthApi({
email,
password,
});
if (res.code === 0) {
const store = useUserStore();
store.setAccessToken(res.data.access_token);
store.setRefreshToken(res.data.refresh_token);
// 进一步获取用户信息
const userInfoRes = await getUserInfoApi();
store.setUserInfo(userInfoRes.data);
closeAuthDialogWaitAnim();
}
};
</script> </script>
<template> <template>
@@ -38,7 +87,7 @@ const closeAuthDialogWaitAnim = () => {
v-show="isNotClose" v-show="isNotClose"
class="wrapper" class="wrapper"
:initial="{ opacity: 0, scale: 0 }" :initial="{ opacity: 0, scale: 0 }"
:animate="{ opacity: 1, scale: 1 }" :animate="{ opacity: 1, scale: 1, width: loginFormWidth }"
:transition="{ :transition="{
duration: 0.4, duration: 0.4,
scale: { type: 'spring', visualDuration: 0.4, bounce: 0.5 }, scale: { type: 'spring', visualDuration: 0.4, bounce: 0.5 },
@@ -77,50 +126,106 @@ const closeAuthDialogWaitAnim = () => {
opacity: 0, opacity: 0,
}" }"
> >
<h2> <div class="flex items-center gap-16">
{{ t("auth.login") }} <!-- 二维码登录区域 -->
</h2> <div class="flex flex-col items-center gap-4 w-[200px]">
<form action="#"> <div class="text-2xl select-none">扫描二维码登录</div>
<div class="input-box"> <img
<span class="icon"> v-if="authQrcode"
<span class="icon-[material-symbols--mail-outline]" /> :src="authQrcode"
</span> alt="登录二维码"
<input type="text" required /> class="h-48 w-48"
<label>{{ t("auth.email") }}</label> />
</div> <div v-if="!authQrcode" class="skeleton h-48 w-48" />
<div class="input-box"> <div class="text-sm text-gray-500 select-none">
<span class="icon"> 请打开手机微信 APP 扫码登录
<span class="icon-[material-symbols--lock-outline]" /> </div>
</span>
<input type="password" required />
<label>{{ t("auth.password") }}</label>
</div> </div>
<div class="remember-forgot"> <div class="h-48 w-[1px] bg-gray-300/70" />
<label class="label">
<input type="checkbox" class="checkbox checkbox-xs" /> <!-- 账号登录区域 -->
{{ t("auth.remember_me") }} <div>
</label> <div class="flex justify-center items-center gap-3">
<a class="label" href="#">{{ t("auth.forget_password") }}</a> <div
class="text-2xl cursor-pointer transition duration-300"
:class="{ 'text-gray-400': loginType === 'phone' }"
:transition="{
duration: 0.3,
}"
@click="loginType = 'password'"
>
{{ t("auth.login_password") }}
</div>
<div class="h-8 w-[1px] bg-gray-300/70" />
<!-- 注册暂未开放 -->
<div class="tooltip">
<div class="tooltip-content">
<div class="font-black">暂未开放</div>
</div>
<div
class="text-2xl cursor-pointer transition duration-300"
:class="{ 'text-gray-400': loginType === 'password' }"
:transition="{
duration: 0.3,
}"
>
{{ t("auth.login_phone") }}
</div>
</div>
</div>
<!-- 表单区域 -->
<form action="#">
<div class="input-box">
<span class="icon">
<span class="icon-[material-symbols--mail-outline]" />
</span>
<input v-model="loginForm.email" type="text" required />
<label>{{ t("auth.email") }}</label>
</div>
<div class="input-box">
<span class="icon">
<span class="icon-[material-symbols--lock-outline]" />
</span>
<input
v-model="loginForm.password"
type="password"
required
/>
<label>{{ t("auth.password") }}</label>
</div>
<div class="remember-forgot">
<label class="label">
<input type="checkbox" class="checkbox checkbox-xs" />
{{ t("auth.remember_me") }}
</label>
<a class="label" href="#">{{
t("auth.forget_password")
}}</a>
</div>
<button
class="btn bg-primary text-primary-content"
@click="clickLoginButtonEvent"
>
{{ t("auth.login") }}
</button>
<div
class="login-register"
@click="changeAuthFormType('register')"
>
<p>
{{ t("auth.no_account") }}
<a class="register-link cursor-pointer">
{{ t("auth.register") }}
</a>
</p>
</div>
</form>
</div> </div>
<button </div>
type="submit"
class="btn bg-primary text-primary-content"
>
{{ t("auth.login") }}
</button>
<div
class="login-register"
@click="changeAuthFormType('register')"
>
<p>
{{ t("auth.no_account") }}
<a class="register-link cursor-pointer">
{{ t("auth.register") }}
</a>
</p>
</div>
</form>
</motion.div> </motion.div>
</AnimatePresence> </AnimatePresence>
@@ -146,7 +251,11 @@ const closeAuthDialogWaitAnim = () => {
opacity: 0, opacity: 0,
}" }"
> >
<h2>{{ t("auth.register") }}</h2> <div class="flex justify-center items-center gap-3">
<div class="text-2xl cursor-pointer">
{{ t("auth.register") }}
</div>
</div>
<form action="#"> <form action="#">
<div class="input-box"> <div class="input-box">
<span class="icon"> <span class="icon">
@@ -197,19 +306,9 @@ const closeAuthDialogWaitAnim = () => {
</template> </template>
<style scoped> <style scoped>
@import url("https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;500;600;700;800;900&display=swap");
* {
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: "Poppins", sans-serif;
}
.wrapper { .wrapper {
height: 430px;
position: relative; position: relative;
width: 400px;
height: 440px;
background: transparent; background: transparent;
border: 2px solid rgba(255, 255, 255, 0.5); border: 2px solid rgba(255, 255, 255, 0.5);
border-radius: 20px; border-radius: 20px;
@@ -222,8 +321,7 @@ const closeAuthDialogWaitAnim = () => {
} }
.wrapper .form-box { .wrapper .form-box {
width: 100%; padding: 40px 80px;
padding: 40px;
} }
.wrapper .icon-close { .wrapper .icon-close {
@@ -241,14 +339,9 @@ const closeAuthDialogWaitAnim = () => {
z-index: 1; z-index: 1;
} }
.form-box h2 {
font-size: 2em;
text-align: center;
}
.input-box { .input-box {
position: relative; position: relative;
width: 100%; width: 350px;
height: 50px; height: 50px;
border-bottom: 2px solid; border-bottom: 2px solid;
margin: 30px 0; margin: 30px 0;
@@ -314,8 +407,6 @@ const closeAuthDialogWaitAnim = () => {
outline: none; outline: none;
border-radius: 6px; border-radius: 6px;
cursor: pointer; cursor: pointer;
font-size: 1em;
font-weight: 500;
} }
.login-register { .login-register {

View File

@@ -8,9 +8,12 @@ export const en_USMessages: messagesInterface = {
locale: "Locale", locale: "Locale",
login: "Login", login: "Login",
register: "Register", register: "Register",
logout: "Logout",
}, },
auth: { auth: {
login: "Login", login: "Login",
login_password: "Password Login",
login_phone: "Phone Login",
register: "Register", register: "Register",
email: "Email", email: "Email",
password: "Password", password: "Password",
@@ -19,5 +22,6 @@ export const en_USMessages: messagesInterface = {
no_account: "Don't have an account?", no_account: "Don't have an account?",
agree_terms: "Agree to the Terms & Conditions", agree_terms: "Agree to the Terms & Conditions",
have_account: "Already have an account?", have_account: "Already have an account?",
logout: "Logout",
}, },
}; };

View File

@@ -10,9 +10,12 @@ export interface messagesInterface {
locale: string; locale: string;
login: string; login: string;
register: string; register: string;
logout: string;
}; };
auth: { auth: {
login: string; login: string;
login_password: string;
login_phone: string;
register: string; register: string;
email: string; email: string;
password: string; password: string;
@@ -21,6 +24,7 @@ export interface messagesInterface {
no_account: string; no_account: string;
agree_terms: string; agree_terms: string;
have_account: string; have_account: string;
logout: string;
}; };
} }

View File

@@ -8,16 +8,20 @@ export const zh_CNMessages: messagesInterface = {
locale: "语言", locale: "语言",
login: "登录", login: "登录",
register: "注册", register: "注册",
logout: "退出登录",
}, },
auth: { auth: {
login: "登录", login: "登录",
login_password: "密码登录",
login_phone: "短信登录",
register: "注册", register: "注册",
email: "邮箱", email: "邮箱",
password: "密码", password: "密码",
remember_me: "记住我", remember_me: "记住我",
forget_password: "忘记密码", forget_password: "忘记密码",
no_account: "没有账号?", no_account: "没有账号?",
agree_terms: "同意条款和条件", agree_terms: "同意《用户协议》相关条款和条件",
have_account: "已经有账号?", have_account: "已经有账号?",
logout: "退出登录",
}, },
}; };

View File

@@ -1,18 +1,89 @@
import { ref } from "vue"; import { ref } from "vue";
import { defineStore } from "pinia"; import { defineStore } from "pinia";
export interface UserInfo {
id: number;
username: string;
nickname: string;
email: string;
mobile: string;
sex: number;
avatar: string;
loginIp: string;
loginDate: null;
createTime: number;
roles: object[];
dept: null;
posts: null;
}
export const useUserStore = defineStore( export const useUserStore = defineStore(
"user", "user",
() => { () => {
const token = ref(""); const accessToken = ref("");
const refreshToken = ref("");
const setToken = (newToken: string) => { const setAccessToken = (newToken: string) => {
token.value = newToken; accessToken.value = newToken;
};
const setRefreshToken = (newToken: string) => {
refreshToken.value = newToken;
};
const userInfo = ref<UserInfo>({
id: 0,
username: "",
nickname: "",
email: "",
mobile: "",
sex: 0,
avatar: "",
loginIp: "",
loginDate: null,
createTime: 0,
roles: [],
dept: null,
posts: null,
});
const setUserInfo = (newInfo: UserInfo) => {
userInfo.value = newInfo;
};
const checkUserLogin = () => {
return accessToken.value !== "";
};
const clearUserInfo = () => {
accessToken.value = "";
refreshToken.value = "";
userInfo.value = {
id: 0,
username: "",
nickname: "",
email: "",
mobile: "",
sex: 0,
avatar: "",
loginIp: "",
loginDate: null,
createTime: 0,
roles: [],
dept: null,
posts: null,
};
}; };
return { return {
token, accessToken,
setToken, setAccessToken,
refreshToken,
setRefreshToken,
userInfo,
setUserInfo,
checkUserLogin,
clearUserInfo,
}; };
}, },
{ {

View File

@@ -7,6 +7,12 @@
themes: all; themes: all;
}; };
@plugin "daisyui/theme" {
name: "light";
--color-primary: blue;
--color-secondary: teal;
}
/* Custom Scrollbar Styles */ /* Custom Scrollbar Styles */
::-webkit-scrollbar { ::-webkit-scrollbar {
width: 8px; width: 8px;

View File

@@ -5,22 +5,30 @@ const instance = axios.create({
baseURL: import.meta.env.VITE_SERVER, baseURL: import.meta.env.VITE_SERVER,
}); });
// 所有请求自动加入 token
instance.interceptors.request.use((config) => { instance.interceptors.request.use((config) => {
const store = useUserStore(); const store = useUserStore();
if (!store.token) { if (!store.accessToken) {
return config; return config;
} }
const token = store.token; const token = store.accessToken;
if (!token) { if (!token) {
return config; return config;
} }
config.headers["token"] = token; config.headers["Authorization"] = `Bearer ${token}`;
return config; return config;
}); });
const http = async <T>(config: AxiosRequestConfig): Promise<Result<T>> => { export const http = async <T>(
config: AxiosRequestConfig,
): Promise<Result<T>> => {
const { data } = await instance.request<Result<T>>(config); const { data } = await instance.request<Result<T>>(config);
return data; return data;
}; };
export default http; export const httpWithCustomReturn = async <T>(
config: AxiosRequestConfig,
): Promise<T> => {
const { data } = await instance.request<T>(config);
return data;
};