实现效果
根据后端模型定义 Ts 接口
Apifox 有方便导出 Ts
types/entity.d.ts (declare 项目内无需导入)
declare interface BaseEntity {
/**
* 创建时间
*/
createAt: Date;
/**
* 创建人
*/
createBy: number;
/**
* 逻辑删除 `0` 未删除 `null` 删除
*/
deleted: boolean;
/**
* 备注
*/
remark: string;
/**
* 是否启用 `0` 关闭 `1` 开启
*/
status: boolean;
/**
* 最后更新时间
*/
updateAt: Date;
/**
* 最后更新人
*/
updateBy: number;
/**
* 乐观锁
*/
version: number;
[property: string]: any;
}
declare interface UserEntity extends BaseEntity {
/**
* 地址
*/
address: string;
/**
* 地址码
*/
addressCode: string;
/**
* 头像路径
*/
avatarUrl: string;
/**
* 用户邮箱
*/
email: string;
/**
* 性别
*/
gender: Gender;
/**
* ipv4 数字地址
*/
ip: number;
/**
* 昵称
*/
nickName: string;
/**
* 手机号码
*/
phoneNumber: string;
/**
* 用户主键
*/
userId: number;
/**
* 用户名
*/
username: string;
}
/**
* RoleEntity,角色表
*/
declare interface RoleEntity {
/**
* ipv4 数字地址
*/
ip: number;
/**
* 权限列表
*/
perEntities: PerEntity[];
/**
* 角色编码
*/
roleCode: string;
/**
* 角色主键
*/
roleId: number;
/**
* 角色名称
*/
roleName: string;
}
/**
* PerEntity,权限表
*/
declare interface PerEntity {
/**
* ipv4 数字地址
*/
ip: number;
/**
* 权限标识
*/
perCode: string;
/**
* 权限主键
*/
perId: number;
/**
* 权限名称
*/
perName: string;
/**
* 资源 url
*/
resourceUrl: string;
}
types/dto/user.ts
export interface UserAccountLoginDto {
/**
* 验证码
*/
code: string;
/**
* 用户登录密码
*/
password: string;
/**
* 记住我
*/
rememberMe: boolean;
/**
* 用户登录名称
*/
username: string;
[property: string]: any;
}
/**
* LoginDetails,数据
*/
export interface LoginDetails {
/**
* 访问token
*/
accessToken: string;
/**
* 权限符号列表
*/
authorities: string[];
/**
* ipv4 数字地址
*/
ip: string;
/**
* 权限符列
*/
permissions: string[];
/**
* 刷新token次数
*/
refreshCount: number;
/**
* 刷新token
*/
refreshToken: string;
roles: RoleEntity[];
user: UserEntity;
/**
* 用户代理
*/
userAgent: string;
userId: number;
username: string;
}
/**
* 邮件登录
*/
export interface UserEmailLoginDto {
/**
* 验证码
*/
code: string;
/**
* 用户登录邮件
*/
email: string;
/**
* 记住我
*/
rememberMe?: boolean;
}
type/vo/captcha.ts
export interface ImageCaptchaVo {
base64Data: string;
ip: string;
token: string;
[property: string]: any;
}
接口函数
- useHttpGet,useHttpPost... 为 nuxt3 useFetch 二次封装函数
api/captcha.ts
import type { ImageCaptchaVo } from '~/types/vo/captcha';
/**
* 获取图片验证码
*/
export const getImageCaptchaApi = () => {
return useHttpGet<ImageCaptchaVo>({
url: '/captcha/img',
});
};
/**
* 发送邮箱验证码
*
* @param email 邮箱
* @return 过期秒数
*/
export const postEmailCaptchaApi = (email: string) => {
return useHttpPost<number>({
url: '/captcha/login',
params: {
email,
},
});
};
api/user.ts
import type {
LoginDetails,
UserAccountLoginDto,
UserEmailLoginDto,
} from '~/types/dto/user';
/**
* 获取用户信息
*/
export const getUserInfoApi = () => {
return useHttpGet<UserEntity>({
url: '/my/info',
});
};
/**
* 账号密码登录
*
* @param body body
*/
export const postAccountLoginApi = (body: UserAccountLoginDto) => {
return useHttpPost<LoginDetails>({
url: '/user/login',
body,
});
};
/**
* 邮箱验证码登录
*
* @param body 邮件登录 DTO
*/
export const postEmailCaptchaLoginApi = (body: UserEmailLoginDto) => {
return useHttpPost<LoginDetails>({
url: '/user/mail-login',
body,
});
};
请求前的拦截
nuxt3 useFetch 二次封装函数
import type { UseFetchOptions } from 'nuxt/app';
import { useUserStore } from '~/composables/useUserStore';
//这里是 env 变量, 根据不同开发环境配置多个环境变量
const BASE_URL = import.meta.env.VITE_APP_SERVICE_API;
const SERVICE_TIMEOUT = import.meta.env.VITE_APP_SERVICE_TIMEOUT;
export interface RequestParams<T = unknown> extends UseFetchOptions<T> {
url: string;
}
export type RequestMethodParams<T> = RequestParams<T>;
const useHttp = <T = unknown>({ url, ...options }: RequestParams<T>) => {
return new Promise<T>((resolve, reject) => {
useFetch(url, {
...options,
baseURL: BASE_URL,
timeout: SERVICE_TIMEOUT,
onRequest: ({ options }) => {
const userStore = useUserStore();
// 基于 bearer jwt
if (userStore.accessToken) {
const accessToken = userStore.accessToken;
options.headers = {
...options.headers,
Authorization: `Bearer ${accessToken}`,
};
}
},
onResponse: ({ response }) => {
const commonEntity: CommonEntity<T> = response._data;
const { code, message, data } = commonEntity || {};
// 简单判定
if (code === 200) {
resolve(data);
return;
}
reject(new Error(message));
},
onRequestError: ({ error }) => {
Message.error({
id: 'network-error',
content: '网络异常,请检查当前网络是否正常',
});
reject(error);
},
onResponseError: ({ error }) => {
Message.error({
id: 'request-error',
content: '请求失败',
});
reject(error);
},
});
});
};
const useHttpGet = <T>(options: RequestMethodParams<T>) => {
return useHttp<T>({
...options,
method: 'GET',
});
};
const useHttpPost = <T>(options: RequestMethodParams<T>) => {
return useHttp<T>({
...options,
method: 'POST',
});
};
const useHttpPut = <T>(options: RequestMethodParams<T>) => {
return useHttp<T>({
...options,
method: 'PUT',
});
};
const useHttpDelete = <T>(options: RequestMethodParams<T>) => {
return useHttp<T>({
...options,
method: 'DELETE',
});
};
export { useHttp, useHttpGet, useHttpPost, useHttpPut, useHttpDelete };
页面编写
- vueuse
- unocss
- pinia
- arco-design
- nuxt3
登录页面主内容[pages/login/index.vue]
<script lang="ts" setup>
import AccountLogin from './components/AccountLogin.vue';
import EmailLogin from './components/EmailLogin.vue';
import LoginSide from './components/LoginSide.vue';
definePageMeta({
layout: 'single',
});
enum LoginMode {
ACCOUNT,
EMAIL,
}
const status = reactive<{
rememberMe: boolean;
accountLoginRef: InstanceType<typeof AccountLogin> | null;
loginMode: LoginMode;
}>({
rememberMe: true,
accountLoginRef: null,
loginMode: LoginMode.ACCOUNT,
});
const { rememberMe, accountLoginRef } = toRefs(status);
// 点击登录
const onLogin = () => {
accountLoginRef.value?.onLogin();
};
// 验证码加载中
const captchaLoading = computed(() => {
return accountLoginRef.value?.captchaLoading || false;
});
const { state: loginMode, next } = useCycleList([
LoginMode.ACCOUNT,
LoginMode.EMAIL,
]);
</script>
<template>
<div class="login-container">
<a-card :bordered="false" class="pl-8 pr-12 py-2 shadow-md rounded-3xl!">
<div class="flex justify-center">
<LoginSide />
<div class="login-main">
<div class="flex justify-between items-center">
<LogoBanner />
<a-button @click="next()">
<template #icon>
<i class="i-line-md-linkedin" />
</template>
</a-button>
</div>
<a-divider class="mt-5! mb-0!" />
<!-- main -->
<div class="py-5">
<AccountLogin
v-if="loginMode === LoginMode.ACCOUNT"
ref="accountLoginRef"
:remember-me="rememberMe"
/>
<EmailLogin
v-else-if="loginMode === LoginMode.EMAIL"
ref="emailLoginRef"
:remember-me="rememberMe"
/>
<a-empty v-else>未开发的登录方式</a-empty>
</div>
<!-- main -->
<div class="flex w-full justify-between">
<a-space>
<a-button
:shape="'circle'"
:size="'mini'"
:type="rememberMe ? 'primary' : 'secondary'"
@click="rememberMe = !rememberMe"
>
<i class="i-streamline-target-solid" />
</a-button>
<span>记住我</span>
</a-space>
<a-link>
<span>忘记密码</span>
<template #icon>
<i class="i-line-md-chevron-small-double-left" />
</template>
</a-link>
</div>
<div class="pt-3 pb-4" />
<div class="flex w-full justify-between">
<a-space>
<ThemeButton />
<a-button :shape="'circle'">
<template #icon>
<i
class="i-streamline-ai-settings-spark hover:i-streamline-ai-settings-spark-solid"
/>
</template>
</a-button>
</a-space>
<a-space>
<a-button :shape="'round'" :size="'medium'">
注册用户
<template #icon>
<i class="i-streamline-fingerprint-2" />
</template>
</a-button>
<a-button
v-if="loginMode === LoginMode.ACCOUNT"
:loading="captchaLoading"
:shape="'round'"
:size="'medium'"
type="primary"
@click="onLogin"
>
登录用户
<template #icon>
<i
class="i-streamline-interface-login-dial-pad-finger-password-dial-pad-dot-finger"
/>
</template>
</a-button>
</a-space>
</div>
</div>
</div>
</a-card>
</div>
</template>
<style scoped>
.login-container {
height: 100%;
display: flex;
justify-content: center;
align-items: center;
.login-main {
float: left;
width: 450px;
}
}
</style>
登录页面侧边内容[pages/login/components/LoginSide.vue]
<script lang="ts" setup>
const userStore = useUserStore();
const { user, isLogin } = storeToRefs(userStore);
/**
* 清空所有缓存
*/
const onClearCache = () => {
localStorage.clear();
sessionStorage.clear();
Message.success('缓存已清空');
};
</script>
<template>
<div class="pr-12">
<a-space
:align="'center'"
:direction="'vertical'"
:size="'large'"
class="pt-4"
>
<a-space :align="'center'" :direction="'vertical'">
<a-avatar
:image-url="user.avatarUrl || 'avatar.jpg'"
:shape="'square'"
:size="86"
>
{{ user.username }}
</a-avatar>
<a-typography-text>
<a-link v-if="isLogin" :status="'success'" @click="navigateTo('/')">
{{ user.username }}
</a-link>
</a-typography-text>
</a-space>
<a-space :direction="'vertical'" :size="'medium'">
<a-button :size="'small'" @click="userStore.loadAnonymous">
<span>访客登录</span>
<template #icon>
<i class="i-streamline-cyborg-2" />
</template>
</a-button>
<a-button :size="'small'" @click="onClearCache">
<span>清空缓存</span>
<template #icon>
<i class="i-streamline-emergency-exit" />
</template>
</a-button>
</a-space>
<a-image
:preview="false"
:src="'memphis-pointe.png'"
height="130"
width="130"
/>
</a-space>
</div>
</template>
<style scoped></style>
账号密码登录方式[pages/login/components/AccountLogin.vue]
<script lang="ts" setup>
import Form from '@arco-design/web-vue/es/form';
import type { UserAccountLoginDto } from '~/types/dto/user';
import { formRules } from '~/pages/rules';
import { getImageCaptchaApi } from '~/api/captcha';
import { useRequest, useUserStore } from '#imports';
import { postAccountLoginApi } from '~/api/user';
const userStore = useUserStore();
const props = withDefaults(
defineProps<{
rememberMe: boolean;
}>(),
{
rememberMe: false,
}
);
// 监听同步 isRememberMe
watch(
() => props.rememberMe,
(isRememberMe) => {
accountForm.value.rememberMe = isRememberMe;
}
);
// 表单数据
const accountForm = ref<UserAccountLoginDto>({
username: 'admin123',
password: 'admin123',
rememberMe: false,
code: '',
});
const formRef = ref<InstanceType<typeof Form>>();
// 计时器状态
const imageCaptchaCountdownStart = ref(false);
// 倒计时 毫秒值
const countdown = ref(0);
// 图像验证码窗口是否显示
const imageCaptchaVisible = ref(false);
// 请求 图形验证码接口
const {
data: imageCaptchaData,
run: imageCaptchaRun,
refresh: imageCaptchaRefresh,
loading: captchaLoading,
} = useRequest(getImageCaptchaApi, {
onSuccess: () => {
console.log('image captcha success');
// 显示图像验证码窗口
imageCaptchaVisible.value = true;
// 启动计时器
countdown.value = Date.now() + 60000 * 3;
imageCaptchaCountdownStart.value = true;
// 清空旧验证码
accountForm.value.code = '';
},
});
// 请求 登录接口
const { run: accountLoginRun, loading: loginLoading } = useRequest(
postAccountLoginApi,
{
debounceInterval: 300,
onSuccess: (loginDetails) => {
// 登录成功, 关闭图像验证码窗口
imageCaptchaVisible.value = false;
// 存储登录信息
userStore.loadUserDetails(loginDetails);
},
onError: ({ message }) => {
// 刷新验证码
imageCaptchaRefresh();
// 清空旧验证码
accountForm.value.code = '';
// 提示消息
Message.error(message);
},
}
);
/**
* 输入验证码完成时,触发登录
*
*/
const onVerificationCodeFinish = () => {
accountLoginRun({ ...unref(accountForm) });
};
/**
* 点击登录触发
*/
const onLogin = async () => {
const validate = await formRef.value?.validate();
if (!validate) {
imageCaptchaRun();
}
};
defineExpose({
/**
* 点击登录触发
*/
onLogin,
/**
* 图形验证码是否加载中
*/
captchaLoading,
/**
* 登录中
*/
loginLoading,
});
</script>
<template>
<!-- 图像验证码弹框 -->
<a-modal
:closable="true"
:footer="false"
:hide-title="true"
:simple="true"
:visible="imageCaptchaVisible"
modal-class="mt-0! pt-0!"
@ok="imageCaptchaVisible = false"
>
<a-typography>
<div class="w-full flex justify-between items-center mt-4 mb-2">
<h5 class="m-0! text-lg text-orange-4">
<span>图形验证码</span>
</h5>
<a-countdown
:start="imageCaptchaCountdownStart"
:value="countdown"
:value-style="{ fontSize: '1.4em' }"
format="mm:ss"
@finish="imageCaptchaVisible = false"
/>
</div>
<a-typography-text :type="'secondary'">
图形验证码由五位字符组成,验证码时效为
<a-tag>3分钟</a-tag>
,输入验证后即可登录
</a-typography-text>
</a-typography>
<a-divider />
<a-space
:align="'center'"
:direction="'vertical'"
:size="'large'"
class="overflow-hidden"
fill
>
<a-spin :loading="captchaLoading">
<a-image
:src="imageCaptchaData?.base64Data"
:width="200"
class="pt-2 pb-4"
/>
</a-spin>
<a-verification-code
v-model="accountForm.code"
:length="5"
style="width: 240px"
@finish="onVerificationCodeFinish"
/>
<a-space fill>
<a-button :loading="captchaLoading" @click="imageCaptchaRefresh">
刷新
</a-button>
<a-button @click="imageCaptchaVisible = false">取消</a-button>
</a-space>
</a-space>
</a-modal>
<!-- 账号登录表单 -->
<a-form
ref="formRef"
:layout="'vertical'"
:model="accountForm"
:rules="formRules"
class="login-form"
>
<a-form-item
class="w-[440px]!"
field="username"
hide-asterisk
validate-trigger="input"
>
<a-input
v-model="accountForm.username"
:max-length="30"
placeholder="请输入登录账号"
>
<template #prefix>
<i class="i-streamline-user-profile-focus pr-1" />
</template>
</a-input>
</a-form-item>
<a-form-item
class="w-[440px]!"
field="password"
hide-asterisk
validate-trigger="input"
>
<a-input-password
v-model="accountForm.password"
:max-length="30"
placeholder="请输入登录密码"
>
<template #prefix>
<i
class="i-streamline-interface-user-lock-actions-lock-geometric-human-person-single-up-user pr-1"
/>
</template>
</a-input-password>
</a-form-item>
</a-form>
</template>
<style scoped></style>
邮件验证码登录方式[pages/login/components/EmailLogin.vue]
<script lang="ts" setup>
import Form from '@arco-design/web-vue/es/form';
import { formRules } from '~/pages/rules';
import { postEmailCaptchaApi } from '~/api/captcha';
import { useRequest, useUserStore } from '#imports';
import { postEmailCaptchaLoginApi } from '~/api/user';
const userStore = useUserStore();
const props = withDefaults(
defineProps<{
rememberMe: boolean;
}>(),
{
rememberMe: false,
}
);
const loginForm = ref({
email: '577393000@qq.com',
rememberMe: true,
code: '',
});
// 监听同步 isRememberMe
watch(
() => props.rememberMe,
(isRememberMe) => {
loginForm.value.rememberMe = isRememberMe;
}
);
const formRef = ref<InstanceType<typeof Form>>();
// 开始倒计时
const countdownStart = ref(false);
// 毫秒倒计时
const countdown = ref(0);
// 验证窗口显示
const captchaVisible = ref(false);
const { run: emailLoginRun } = useRequest(postEmailCaptchaLoginApi, {
debounceInterval: 300,
onSuccess: (loginDetails) => {
// 登录成功, 关闭图像验证码窗口
captchaVisible.value = false;
// 存储登录信息
userStore.loadUserDetails(loginDetails);
},
onError: ({ message }) => {
// 清空旧验证码
loginForm.value.code = '';
// 提示消息
Message.error(message);
},
});
/**
* 邮件验证码请求
*/
const { run: emailCaptchaRun, loading: emailCaptchaLoading } = useRequest(
postEmailCaptchaApi,
{
onSuccess: (expiredSeconds = 0) => {
// 启动验证码计时器
countdown.value = Date.now() + 1000 * expiredSeconds;
countdownStart.value = true;
// 显示验证码窗口
captchaVisible.value = true;
// 提示消息
const form = unref(loginForm);
Message.success(`邮件验证码已发送成功, 请到 ${form.email} 邮箱中查看`);
},
onError: ({ message }) => {
// 发送邮件失败
formRef.value?.setFields({
email: {
status: 'error',
message,
},
});
},
}
);
/**
* 发送邮件验证码
*/
const onEmailCaptcha = async () => {
const emailValid = await formRef.value?.validateField('email');
if (!emailValid) {
const email = loginForm.value.email;
console.log(`邮件验证成功 ${email}`);
emailCaptchaRun(email);
}
};
/**
* 完成邮件验证码登录
*/
const emailCaptchaFinish = () => {
// 触发邮件登录
emailLoginRun({ ...unref(loginForm) });
};
</script>
<template>
<a-form
ref="formRef"
:layout="'vertical'"
:model="loginForm"
:rules="formRules"
class="login-form"
>
<a-form-item field="email" hide-asterisk>
<a-space :size="'large'">
<EmailInput v-model="loginForm.email" style="width: 340px" />
<a-button
:disabled="captchaVisible"
:loading="emailCaptchaLoading"
type="primary"
@click="onEmailCaptcha"
>
<span>验证码</span>
<template #icon>
<i
class="dark:i-streamline-send-email i-streamline-send-email-solid"
/>
</template>
</a-button>
</a-space>
</a-form-item>
<a-form-item>
<a-space v-if="captchaVisible" :direction="'vertical'" :size="'medium'">
<a-space :size="40" class="justify-between" fill>
<span class="text-lg">验证码</span>
<span class="text-md">验证码时效</span>
</a-space>
<a-space :size="40" class="justify-between" fill>
<a-verification-code
v-model="loginForm.code"
:length="5"
style="width: 340px"
@finish="emailCaptchaFinish"
/>
<a-countdown
:start="countdownStart"
:value="countdown"
format="mm:ss"
@finish="captchaVisible = false"
/>
</a-space>
</a-space>
</a-form-item>
</a-form>
</template>