基于 Vue 3、Naive UI 和 TypeScript 实现的可切换租户登录系统页面,UI 设计美观简约,背景带粒子特效
javascript
<script setup lang="ts">
import {computed, onMounted, onUnmounted, reactive, ref} from 'vue';
import type {FormRules, SelectOption} from 'naive-ui';
import {useMessage} from 'naive-ui';
import CryptoJS from 'crypto-js';
import {fetchCaptchaCode, fetchTenantList} from '@/service/api';
import {useAuthStore} from '@/store/modules/auth';
import {localStg} from '@/utils/storage';
import {decryptWithAes, encryptWithAes} from '@/utils/crypto';
import LogoImg from '@/assets/imgs/logo2.svg';
import {useLoading} from '~/packages/hooks';
import {useI18n} from 'vue-i18n';
// ==================== 类型定义 ====================
/** 记住密码存储的数据结构 */
interface RememberData {
tenantId: string | null;
username: string;
password: string;
isAdminTenantId: boolean;
}
const { t } = useI18n();
const message = useMessage();
const authStore = useAuthStore();
/** AES 加密密钥,用于加密本地存储的记住密码数据 */
const aesKey = CryptoJS.enc.Utf8.parse(import.meta.env.VITE_REMEMBER_ME_AES_KEY || 'pC4aO6cD2uU7hA0bK6iD4vE1mV8sU8xG');
/** localStorage 中记住密码数据的存储键名 */
const REMEMBER_KEY = 'loginRember';
const model: Api.Auth.PwdLoginForm = reactive({
tenantId: null,
username: '',
password: '',
code: '',
uuid: ''
});
const tenantEnabled = ref<boolean>(false);
const captchaEnabled = ref<boolean>(false);
const { loading: tenantLoading, startLoading: startTenantLoading, endLoading: endTenantLoading } = useLoading();
const { loading: codeLoading, startLoading: startCodeLoading, endLoading: endCodeLoading } = useLoading();
const tenantOption = ref<SelectOption[]>([]);
const formRef = ref<InstanceType<typeof NForm> | null>(null);
const loading = ref(false);
const rememberMe = ref(false);
const captchaCode = ref('');
const isAdminTenantId = ref<boolean>(false);
/** 切换管理员租户 / 业务租户模式,并恢复对应租户的记住密码数据 */
function handleChangeTen() {
isAdminTenantId.value = !isAdminTenantId.value;
if (isAdminTenantId.value) {
// 切换到管理员租户
model.tenantId = '000000';
} else {
// 切换到业务租户
model.tenantId = tenantOption.value.length > 0 ? tenantOption.value[0].value : null;
}
}
const rules = computed<FormRules>(() => ({
tenantId: tenantEnabled.value
? {
required: true,
message: t('page.login.login5.rules.tenantId'),
trigger: ['blur', 'change']
}
: {},
username: {
required: true,
message: t('page.login.login5.rules.username'),
trigger: ['blur', 'change']
},
password: {
required: true,
message: t('page.login.login5.rules.password'),
trigger: ['blur', 'change']
},
code: captchaEnabled.value
? {
required: true,
message: t('page.login.login5.rules.code'),
trigger: ['blur', 'change']
}
: {}
}));
/** 处理登录提交 */
async function handleLogin() {
// 1. 表单校验
try {
await formRef.value?.validate();
} catch {
return;
}
// 2. 调用登录接口
try {
await authStore.login(model);
// 3. 登录成功后处理「记住密码」
if (rememberMe.value) {
const rememberData: RememberData = {
tenantId: model.tenantId,
username: model.username,
password: model.password,
isAdminTenantId: isAdminTenantId.value
};
localStg.set(REMEMBER_KEY, encryptWithAes(JSON.stringify(rememberData), aesKey));
} else {
localStg.remove(REMEMBER_KEY);
}
} catch {
// 登录失败由 authStore 内部统一处理提示,此处仅做容错
}
}
/** 从 localStorage 恢复记住的登录信息 */
function handleLoginRemember() {
const loginRemember = localStg.get(REMEMBER_KEY);
if (!loginRemember) return;
try {
const savedData: RememberData = JSON.parse(decryptWithAes(loginRemember, aesKey));
rememberMe.value = true;
// 逐字段赋值,避免 Object.assign 将 isAdminTenantId 混入 model 响应式对象
model.tenantId = savedData.tenantId;
model.username = savedData.username;
model.password = savedData.password;
isAdminTenantId.value = savedData.isAdminTenantId ?? false;
} catch {
// 解密或解析失败时清除损坏的缓存
localStg.remove(REMEMBER_KEY);
}
}
const codeUrl = ref<string>();
async function handleFetchCaptchaCode() {
startCodeLoading();
const { data, error } = await fetchCaptchaCode();
if (!error) {
captchaEnabled.value = data.captchaEnabled;
if (data.captchaEnabled) {
model.uuid = data.uuid;
codeUrl.value = `data:image/gif;base64,${data.img}`;
}
}
endCodeLoading();
}
/** 获取租户列表,仅在未设置 tenantId 时设置默认值(防止覆盖 remember me 恢复的值) */
async function handleFetchTenantList() {
startTenantLoading();
const { data, error } = await fetchTenantList();
if (error) return;
tenantEnabled.value = data.tenantEnabled;
if (data.tenantEnabled) {
tenantOption.value = data.voList
.filter(tenant => tenant.tenantId !== '000000') // 过滤掉管理员租户
.map(tenant => ({
label: tenant.companyName,
value: tenant.tenantId
}));
// 仅当 model.tenantId 未被设置时才赋默认值,避免覆盖 handleLoginRemember 恢复的租户 ID
if (!model.tenantId) {
model.tenantId = tenantOption.value.length > 0 ? tenantOption.value[0].value : null;
}
}
endTenantLoading();
}
onMounted(() => {
handleLoginRemember();
handleFetchCaptchaCode();
handleFetchTenantList();
window.addEventListener('keyup', e => {
if (e.key === 'Enter') {
// handleLogin();
}
});
});
onUnmounted(() => {});
</script>
<template>
<div class="login-container">
<!-- 背景几何装饰 -->
<div class="geo-decoration geo-ring-top-left" aria-hidden="true"></div>
<div class="geo-decoration geo-ring-bottom-right" aria-hidden="true"></div>
<div class="geo-decoration geo-diamond" aria-hidden="true"></div>
<div class="geo-decoration geo-triangle" aria-hidden="true"></div>
<div class="geo-decoration geo-lines" aria-hidden="true"></div>
<div class="login-box">
<div class="login-left">
<div class="brand-wrapper">
<div class="logo-icon">
<NImage :src="LogoImg" :preview-disabled="true"></NImage>
</div>
<h1 class="system-name">{{ $t('page.login.login5.systemName') }}</h1>
<p class="system-slogan">{{ $t('page.login.login5.systemSlogan') }}</p>
<div class="brand-decoration">
<span class="deco-line"></span>
<span class="deco-dot"></span>
<span class="deco-line"></span>
</div>
<div class="brand-features">
<span>{{ $t('page.login.login5.brandFeature1') }}</span>
<span>{{ $t('page.login.login5.brandFeature2') }}</span>
<span>{{ $t('page.login.login5.brandFeature3') }}</span>
</div>
</div>
</div>
<div class="login-right">
<!-- 面板角落装饰 -->
<div class="corner-decoration corner-top-right" aria-hidden="true"></div>
<div class="corner-decoration corner-bottom-left" aria-hidden="true"></div>
<div class="login-card">
<div class="card-header">
<h2>{{ $t('page.login.login5.welcomeTitle') }}</h2>
<p>{{ $t('page.login.login5.welcomeSubtitle') }}</p>
</div>
<NForm
ref="formRef"
:model="model"
:rules="rules"
label-placement="left"
label-width="0"
size="large"
class="login-form"
>
<NFormItem v-if="tenantEnabled && !isAdminTenantId" path="tenantId">
<NSelect
v-model:value="model.tenantId"
:placeholder="$t('page.login.login5.tenantPlaceholder')"
:options="tenantOption"
clearable
class="form-input"
:style="{ height: '48px' }"
/>
</NFormItem>
<NFormItem path="username">
<NInput
v-model:value="model.username"
:placeholder="$t('page.login.common.userNamePlaceholder')"
clearable
class="form-input"
:style="{ height: '48px' }"
>
<template #prefix>
<span style="color: #8c8c8c; font-size: 18px">👤</span>
</template>
</NInput>
</NFormItem>
<NFormItem path="password">
<NInput
v-model:value="model.password"
type="password"
:placeholder="$t('page.login.common.passwordPlaceholder')"
show-password-on="click"
clearable @keyup.enter="handleLogin"
class="form-input"
:style="{ height: '48px' }"
>
<template #prefix>
<span style="color: #8c8c8c; font-size: 18px">🔒</span>
</template>
</NInput>
</NFormItem>
<NFormItem v-if="captchaEnabled" path="code">
<div class="captcha-wrapper">
<NInput
v-model:value="model.code"
:placeholder="$t('page.login.common.codePlaceholder')"
clearable
class="captcha-input"
:style="{ height: '48px' }"
/>
<div class="captcha-image" @click="handleFetchCaptchaCode">
<NImage :src="codeUrl" :preview-disabled="true"></NImage>
</div>
<NButton
type="primary"
tertiary
size="small"
class="captcha-refresh-btn"
:title="$t('page.login.login5.refreshCaptcha')"
@click="handleFetchCaptchaCode"
>
<template #icon>
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2">
<path d="M1 4v6h6" stroke-linecap="round" stroke-linejoin="round" />
<path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</template>
</NButton>
</div>
</NFormItem>
<NFormItem>
<NButton
type="primary"
size="large"
block
:loading="authStore.loginLoading"
class="login-btn"
@click="handleLogin"
>
{{ $t('common.login') }}
</NButton>
</NFormItem>
<div class="form-footer">
<NCheckbox v-model:checked="rememberMe">{{ $t('page.login.pwdLogin.rememberMe') }}</NCheckbox>
</div>
</NForm>
<div style="margin-top: 16px">
<NDivider>
<span class="divider-text" @click="handleChangeTen">
{{ isAdminTenantId ? $t('page.login.login5.businessTenantLogin') : $t('page.login.login5.adminBackend') }}
</span>
</NDivider>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.login-container {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
width: 100vw;
background: #f5f9ff;
padding: 20px;
box-sizing: border-box;
position: relative;
overflow: hidden;
background-image: radial-gradient(circle at 2px 2px, rgba(45, 124, 246, 0.1) 2px, transparent 0);
background-size: 40px 40px;
}
.login-container::before {
content: '';
position: absolute;
top: -30%;
right: -20%;
width: 60%;
height: 80%;
background: radial-gradient(ellipse, rgba(45, 124, 246, 0.06) 0%, transparent 70%);
border-radius: 50%;
pointer-events: none;
}
.login-container::after {
content: '';
position: absolute;
bottom: -30%;
left: -20%;
width: 50%;
height: 70%;
background: radial-gradient(ellipse, rgba(45, 124, 246, 0.04) 0%, transparent 70%);
border-radius: 50%;
pointer-events: none;
}
/* 几何装饰基础样式 */
.geo-decoration {
position: absolute;
pointer-events: none;
z-index: 0;
}
/* 左上角圆环装饰 */
.geo-ring-top-left {
top: 8%;
left: 6%;
width: 100px;
height: 100px;
border: 2px solid rgba(45, 124, 246, 0.08);
border-radius: 50%;
animation: geo-rotate-slow 20s linear infinite;
}
.geo-ring-top-left::before {
content: '';
position: absolute;
top: 15px;
left: 15px;
right: 15px;
bottom: 15px;
border: 1px solid rgba(103, 85, 255, 0.06);
border-radius: 50%;
}
/* 右下角圆环装饰 */
.geo-ring-bottom-right {
bottom: 10%;
right: 8%;
width: 70px;
height: 70px;
border: 2px solid rgba(103, 85, 255, 0.07);
border-radius: 50%;
animation: geo-rotate-slow 25s linear infinite reverse;
}
.geo-ring-bottom-right::after {
content: '';
position: absolute;
top: 20px;
left: 20px;
right: 20px;
bottom: 20px;
border: 1px solid rgba(45, 124, 246, 0.05);
border-radius: 50%;
}
/* 菱形装饰 */
.geo-diamond {
top: 20%;
right: 15%;
width: 30px;
height: 30px;
border: 2px solid rgba(45, 124, 246, 0.08);
transform: rotate(45deg);
animation: geo-float 6s ease-in-out infinite;
}
/* 三角形装饰 */
.geo-triangle {
bottom: 25%;
left: 10%;
width: 0;
height: 0;
border-left: 18px solid transparent;
border-right: 18px solid transparent;
border-bottom: 30px solid rgba(103, 85, 255, 0.06);
animation: geo-triangle-float 8s ease-in-out infinite reverse;
}
/* 线条装饰 */
.geo-lines {
top: 50%;
left: 3%;
width: 40px;
height: 40px;
position: absolute;
}
.geo-lines::before,
.geo-lines::after {
content: '';
position: absolute;
background: rgba(45, 124, 246, 0.06);
}
.geo-lines::before {
top: 0;
left: 50%;
width: 2px;
height: 100%;
transform: translateX(-50%);
}
.geo-lines::after {
top: 50%;
left: 0;
width: 100%;
height: 2px;
transform: translateY(-50%);
}
/* 几何动画 */
@keyframes geo-rotate-slow {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@keyframes geo-float {
0%, 100% { transform: translateY(0) rotate(45deg); }
50% { transform: translateY(-10px) rotate(50deg); }
}
/* 菱形动画修正 */
.geo-diamond {
animation-name: geo-diamond-float;
}
@keyframes geo-diamond-float {
0%, 100% { transform: rotate(45deg) translateY(0); }
50% { transform: rotate(55deg) translateY(-8px); }
}
@keyframes geo-triangle-float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-10px); }
}
.login-box {
display: flex;
width: 900px;
height: 550px;
max-width: 100%;
background: #ffffff;
border-radius: 24px;
box-shadow:
0 20px 60px rgba(45, 124, 246, 0.1),
0 8px 24px rgba(0, 0, 0, 0.04);
overflow: hidden;
position: relative;
z-index: 1;
}
.login-left {
flex: 0 0 52%;
background: linear-gradient(145deg, #84d0f678 0%, #6755ff45 100%);
display: flex;
align-items: center;
justify-content: center;
padding: 40px;
position: relative;
overflow: hidden;
}
.login-left::before {
content: '';
position: absolute;
top: -40%;
right: -30%;
width: 80%;
height: 80%;
background: radial-gradient(ellipse, rgba(45, 124, 246, 0.08) 0%, transparent 70%);
border-radius: 50%;
pointer-events: none;
}
.login-left::after {
content: '';
position: absolute;
bottom: -30%;
left: -20%;
width: 60%;
height: 60%;
background: radial-gradient(ellipse, rgba(45, 124, 246, 0.05) 0%, transparent 70%);
border-radius: 50%;
pointer-events: none;
}
.brand-wrapper {
text-align: center;
position: relative;
z-index: 1;
}
.logo-icon {
//width: 80px;
//height: 80px;
margin: 0 auto 20px;
display: block;
animation: float 3s ease-in-out infinite;
}
.logo-icon svg {
width: 100%;
height: 100%;
display: block;
}
@keyframes float {
0%,
100% {
transform: translateY(0px);
}
50% {
transform: translateY(-8px);
}
}
.system-name {
font-size: 32px;
font-weight: 700;
color: #1a3a5c;
margin: 0 0 8px 0;
letter-spacing: 2px;
}
.system-slogan {
font-size: 16px;
color: #4a6a8a;
margin: 0 0 24px 0;
font-weight: 400;
letter-spacing: 4px;
}
.brand-decoration {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
}
.deco-line {
width: 40px;
height: 2px;
background: linear-gradient(90deg, transparent, #2d7cf6, transparent);
border-radius: 2px;
}
.deco-dot {
width: 6px;
height: 6px;
background: #2d7cf6;
border-radius: 50%;
opacity: 0.6;
}
.brand-features {
display: flex;
justify-content: center;
gap: 24px;
font-size: 14px;
color: #4a6a8a;
font-weight: 400;
}
.brand-features span {
opacity: 0.7;
transition: opacity 0.3s;
}
.brand-features span:hover {
opacity: 1;
}
/* 右侧面板角落装饰 */
.corner-decoration {
position: absolute;
pointer-events: none;
z-index: 0;
}
.corner-top-right {
top: 20px;
right: 20px;
width: 24px;
height: 24px;
border-top: 2px solid rgba(45, 124, 246, 0.12);
border-right: 2px solid rgba(45, 124, 246, 0.12);
border-radius: 0 8px 0 0;
}
.corner-top-right::before {
content: '';
position: absolute;
top: -2px;
right: -2px;
width: 8px;
height: 8px;
background: rgba(45, 124, 246, 0.08);
border-radius: 50%;
}
.corner-bottom-left {
bottom: 20px;
left: 20px;
width: 20px;
height: 20px;
border-bottom: 2px solid rgba(103, 85, 255, 0.1);
border-left: 2px solid rgba(103, 85, 255, 0.1);
border-radius: 0 0 0 6px;
}
.corner-bottom-left::after {
content: '';
position: absolute;
bottom: -2px;
left: -2px;
width: 6px;
height: 6px;
border: 1px solid rgba(103, 85, 255, 0.15);
border-radius: 50%;
}
.login-right {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 40px 48px;
background: #ffffff;
position: relative;
z-index: 1;
background-image:
linear-gradient(135deg, rgba(45, 124, 246, 0.02) 25%, transparent 25%),
linear-gradient(225deg, rgba(45, 124, 246, 0.02) 25%, transparent 25%),
linear-gradient(45deg, rgba(103, 85, 255, 0.015) 25%, transparent 25%),
linear-gradient(315deg, rgba(103, 85, 255, 0.015) 25%, transparent 25%);
background-size: 24px 24px;
}
.login-card {
width: 100%;
max-width: 380px;
}
.card-header {
}
.card-header h2 {
font-size: 28px;
font-weight: 600;
color: #1a3a5c;
margin: 0 0 6px 0;
letter-spacing: 1px;
}
.card-header p {
font-size: 15px;
color: #8a9aaa;
margin: 0;
font-weight: 400;
}
.login-form {
width: 100%;
}
.login-form :deep(.n-form-item) {
}
.login-form :deep(.n-form-item:last-child) {
margin-bottom: 0;
}
.form-input {
border-radius: 10px;
}
.form-input :deep(.n-input__input-el) {
font-size: 15px;
}
.form-input :deep(.n-input__prefix) {
padding-right: 8px;
}
.captcha-wrapper {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
}
.captcha-input {
flex: 1;
border-radius: 10px;
}
.captcha-input :deep(.n-input__input-el) {
font-size: 15px;
letter-spacing: 2px;
text-transform: uppercase;
}
.captcha-image {
flex-shrink: 0;
width: 120px;
height: 48px;
border-radius: 10px;
overflow: hidden;
cursor: pointer;
border: 1px solid #e8edf4;
transition:
border-color 0.3s,
box-shadow 0.3s;
background: #f8faff;
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.captcha-image:hover {
border-color: #2d7cf6;
box-shadow: 0 0 0 3px rgba(45, 124, 246, 0.1);
}
.captcha-image canvas {
width: 100%;
height: 100%;
display: block;
border-radius: 9px;
}
.captcha-refresh-btn {
flex-shrink: 0;
width: 48px;
height: 48px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
background: #f5f9ff;
border: 1px solid #e8edf4;
transition: all 0.3s;
cursor: pointer;
padding: 0;
}
.captcha-refresh-btn:hover {
background: #e8f0fe;
border-color: #2d7cf6;
transform: rotate(60deg);
}
.captcha-refresh-btn :deep(.n-button__icon) {
margin: 0;
color: #4a6a8a;
}
.login-btn {
height: 48px;
border-radius: 10px;
font-size: 17px;
font-weight: 600;
letter-spacing: 4px;
background: linear-gradient(135deg, #2d7cf6 0%, #1a5fc7 100%);
border: none;
box-shadow: 0 4px 16px rgba(45, 124, 246, 0.3);
transition: all 0.3s;
}
.login-btn:hover {
transform: translateY(-1px);
box-shadow: 0 6px 24px rgba(45, 124, 246, 0.4);
}
.login-btn:active {
transform: translateY(0px);
}
.form-footer {
display: flex;
justify-content: space-between;
align-items: center;
}
.form-footer :deep(.n-checkbox) {
font-size: 14px;
color: #6a7a8a;
}
.forgot-link {
font-size: 14px;
color: #2d7cf6;
text-decoration: none;
transition: color 0.3s;
font-weight: 400;
}
.forgot-link:hover {
color: #1a5fc7;
text-decoration: underline;
}
.divider-text {
cursor: pointer;
font-size: 13px;
color: #2d7cf6;
}
.divider-text:hover {
text-decoration: underline;
}
/** 中等屏幕显示 */
@media (min-width: 1500px) {
.login-box {
max-height: none;
width: 80vw;
height: 80vh;
max-width: 100%;
}
}
/** 大屏幕的显示比例 */
@media (min-width: 1900px) {
.login-box {
max-height: none;
width: 70vw;
height: 70vh;
max-width: 100%;
}
}
/** 超大屏幕的显示比例 */
@media (min-width: 2500px) {
.login-box {
max-height: none;
width: 50vw;
height: 50vh;
max-width: 100%;
}
}
@media (max-width: 900px) {
.geo-decoration,
.corner-decoration {
display: none;
}
.login-container {
background-image: none;
}
.login-box {
flex-direction: column;
height: auto;
max-height: none;
width: 480px;
max-width: 100%;
}
.login-left {
flex: 0 0 auto;
padding: 32px 24px 28px;
width: 100%;
}
.login-left::before,
.login-left::after {
display: none;
}
.logo-icon {
width: 56px;
height: 56px;
margin-bottom: 14px;
}
.system-name {
font-size: 26px;
}
.system-slogan {
font-size: 14px;
margin-bottom: 18px;
}
.brand-decoration {
margin-bottom: 20px;
}
.brand-features {
gap: 16px;
font-size: 13px;
}
.login-right {
padding: 32px 28px 40px;
width: 100%;
}
.login-card {
max-width: 100%;
}
.card-header h2 {
font-size: 24px;
}
.captcha-image {
width: 100px;
height: 44px;
}
.captcha-refresh-btn {
width: 44px;
height: 44px;
}
}
@media (max-width: 480px) {
.login-container {
padding: 12px;
}
.login-box {
border-radius: 16px;
}
.login-left {
padding: 24px 16px 20px;
}
.system-name {
font-size: 22px;
}
.brand-features {
flex-direction: column;
gap: 4px;
font-size: 12px;
}
.brand-decoration {
margin-bottom: 16px;
}
.deco-line {
width: 24px;
}
.login-right {
padding: 24px 16px 32px;
}
.card-header h2 {
font-size: 22px;
}
.card-header p {
font-size: 13px;
}
.captcha-wrapper {
gap: 8px;
}
.captcha-image {
width: 80px;
height: 40px;
}
.captcha-refresh-btn {
width: 40px;
height: 40px;
}
.captcha-refresh-btn :deep(svg) {
width: 16px;
height: 16px;
}
.login-btn {
height: 44px;
font-size: 15px;
letter-spacing: 2px;
}
.form-footer {
flex-direction: column;
gap: 8px;
align-items: flex-start;
}
}
</style>