Files
guilixu-admin/src/views/passport/login/index.vue
赵忠林 45fc45680f feat(passport): 增加多租户手机号登录支持并修复邮箱验证码校验
- 修复邮箱登录验证码未校验问题,恢复输入错误提示及刷新验证码逻辑
- 新增多租户选项登录流程,支持单租户直接登录及多租户选择弹窗
- 扩展登录接口及模型,新增 TenantOption 类型和 LoginOutcome 联合结果类型
- 新增 loginBySelectTenant 二次登录接口及对应组件逻辑
- 统一保存登录态,包括 token、租户ID和用户ID到本地存储
- 调整前端短信登录逻辑,根据返回状态决定流程,支持多租户弹窗选择
- 确保新增代码无类型错误并通过构建验证
2026-07-24 22:33:39 +08:00

1016 lines
27 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div
:class="[
'login-wrapper',
['', 'login-form-right', 'login-form-left'][direction]
]"
:style="{
backgroundImage: 'url(https://oss.wsdns.cn/20250105/314d2a3da10048b09ef0f0464fdacbff.jpg)'
}"
>
<div class="logo-login" v-if="config?.siteName">
<img
:src="
config.siteLogo
"
class="logo"
/>
<h4>{{ config.siteName }}</h4>
</div>
<div class="company-name" v-if="config?.loginTitle"
>{{ config?.loginTitle }}
</div>
<a-form
ref="formRef"
:model="form"
:rules="rules"
class="login-form ele-bg-white"
>
<div class="login-title flex justify-center items-center px-12">
<template v-if="loginType === 'scan'">
<h4 class="title-btn">扫码登录</h4>
</template>
<template v-else>
<h4
ref="accountTabRef"
class="title-btn"
:class="loginType === 'account' ? 'active' : ''"
@click="onLoginType('account')"
>邮箱登录</h4
>
<a-divider type="vertical" style="height: 20px" />
<h4
ref="smsTabRef"
class="title-btn"
:class="loginType === 'sms' ? 'active' : ''"
@click="onLoginType('sms')"
>手机号登录</h4
>
<div class="tab-indicator" :style="indicatorStyle"></div>
</template>
</div>
<div
class="login-bar absolute top-0 z-50 right-0 cursor-pointer"
@click="onScan"
>
<div class="go-to-register cursor-pointer">
<img
src="@/assets/O1CN01yz6fEl1MwaRtkJyvf_!!6000000001499-55-tps-70-70.svg"
alt=""
/>
</div>
<div
class="absolute top-2 right-2 text-lg text-white font-bold cursor-pointer"
>
<MobileOutlined v-if="loginType === 'scan'" />
<QrcodeOutlined v-else />
</div>
<!-- <span class="absolute top-3 right-1.5 text-sm text-white font-bold cursor-pointer">{{ '登录' }}</span>-->
</div>
<template v-if="loginType === 'account'">
<!-- <a-form-item name="tenantId">-->
<!-- <a-input-->
<!-- allow-clear-->
<!-- size="large"-->
<!-- v-model:value="form.tenantId"-->
<!-- :placeholder="`请输入租户ID`"-->
<!-- >-->
<!-- <template #prefix>-->
<!-- <UserOutlined />-->
<!-- </template>-->
<!-- </a-input>-->
<!-- </a-form-item>-->
<a-form-item name="account">
<a-input
allow-clear
size="large"
v-model:value="form.account"
placeholder="请输入邮箱"
>
<template #prefix>
<UserOutlined />
</template>
</a-input>
</a-form-item>
<a-form-item name="password">
<a-input-password
size="large"
v-model:value="form.password"
placeholder="请输入登录密码"
@pressEnter="submit"
>
<template #prefix>
<lock-outlined />
</template>
</a-input-password>
</a-form-item>
<a-form-item name="code">
<div class="login-input-group">
<a-input
allow-clear
size="large"
type="text"
:maxlength="5"
v-model:value="form.code"
placeholder="验证码"
@pressEnter="submit"
>
<template #prefix>
<safety-certificate-outlined />
</template>
</a-input>
<a-button class="login-captcha" @click="changeCaptcha">
<img v-if="captcha" :src="captcha" alt="" />
</a-button>
</div>
</a-form-item>
<a-form-item>
<div class="flex justify-between">
<a-checkbox v-model:checked="form.remember">
{{ t('login.remember') }}
</a-checkbox>
<a class="login-forget" @click="push('/forget')">
{{ t('login.forget') }}
</a>
</div>
</a-form-item>
<a-form-item>
<a-button
block
size="large"
type="primary"
:loading="loading"
@click="submit"
>
{{ loading ? t('login.loading') : t('login.login') }}
</a-button>
<div class="text-center pt-5 text-gray-500"
>还没有账号联系管理员开通</div
>
</a-form-item>
</template>
<template v-if="loginType === 'sms'">
<a-form-item name="phone">
<a-input
allow-clear
size="large"
:maxlength="11"
v-model:value="form.phone"
:placeholder="`请输入手机号码`"
>
<template #addonBefore> +86</template>
</a-input>
</a-form-item>
<a-form-item name="smsCode">
<div class="login-input-group">
<a-input
placeholder="请输入验证码"
v-model:value="form.smsCode"
size="large"
:maxlength="6"
allow-clear
@pressEnter="onLoginBySms"
/>
<a-button
class="login-captcha"
:disabled="!!countdownTime"
@click="handleSendCode"
>
<span v-if="!countdownTime">发送验证码</span>
<span v-else>已发送 {{ countdownTime }} s</span>
</a-button>
</div>
</a-form-item>
<a-form-item>
<a-button
block
size="large"
type="primary"
:loading="loading"
@click="onLoginBySms"
>
{{ loading ? t('login.loading') : t('login.login') }}
</a-button>
<div class="text-center pt-5 text-gray-500"
>还没有账号联系管理员开通</div
>
</a-form-item>
</template>
<template v-if="loginType == 'scan'">
<QrLogin
@loginSuccess="onQrLoginSuccess"
@loginError="onQrLoginError"
/>
</template>
</a-form>
<div class="login-copyright">
<a-space>
<span>© {{ new Date().getFullYear() }}</span>
<span>{{ config?.copyright || 'websoft.top Inc.' }}</span>
</a-space>
</div>
<!-- 编辑弹窗 -->
<a-modal
:width="340"
:footer="null"
title="发送验证码"
v-model:visible="visible"
>
<div class="login-input-group" style="margin-bottom: 16px">
<a-input
v-model:value="imgCode"
:maxlength="5"
size="large"
placeholder="请输入图形验证码"
allow-clear
@pressEnter="() => sendCode(imgCode.value)"
/>
<a-button class="login-captcha">
<img alt="" :src="captcha" @click="changeCaptcha" />
</a-button>
</div>
<a-button
block
size="large"
type="primary"
:loading="codeLoading"
@click="() => sendCode(imgCode.value)"
>
立即发送
</a-button>
</a-modal>
<!-- 多账户选择登录 -->
<a-modal
:width="500"
:footer="null"
title="选择账号登录"
v-model:visible="showSelectLoginUser"
>
<div class="flex flex-col justify-start">
<a-list item-layout="horizontal" :data-source="admins">
<template #renderItem="{ item }">
<a-list-item
class="cursor-pointer hover:border-gray-100"
@click="onSelectUser(item)"
>
<a-list-item-meta :description="`租户ID: ${item.tenantId}`">
<template #title>
{{ item.tenantName }}
</template>
<template #avatar>
<a-avatar :src="item.avatar" />
</template>
</a-list-item-meta>
<template #actions>
<RightOutlined />
</template>
</a-list-item>
</template>
</a-list>
</div>
</a-modal>
<!-- 多租户选择登录 -->
<a-modal
:width="420"
:footer="null"
:mask-closable="false"
title="请选择要登录的租户"
v-model:visible="showSelectTenantModal"
>
<div class="flex flex-col justify-start">
<a-list item-layout="horizontal" :data-source="pendingTenants">
<template #renderItem="{ item }">
<a-list-item
class="cursor-pointer hover:border-gray-100"
@click="onSelectTenant(item)"
>
<a-list-item-meta :description="`租户ID: ${item.tenantId}`">
<template #title>
{{ item.tenantName }}
</template>
</a-list-item-meta>
<template #actions>
<RightOutlined />
</template>
</a-list-item>
</template>
</a-list>
</div>
</a-modal>
</div>
</template>
<script lang="ts" setup>
import { ref, reactive, unref, watch, nextTick, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { Form, message } from 'ant-design-vue';
import {
LockOutlined,
UserOutlined,
QrcodeOutlined,
MobileOutlined,
SafetyCertificateOutlined,
RightOutlined
} from '@ant-design/icons-vue';
import { goHomeRoute, cleanPageTabs } from '@/utils/page-tab-util';
import {
loginBySuperAdminSms,
getCaptcha,
loginByEmail,
loginByUserId,
loginBySelectTenant
} from '@/api/passport/login';
import type { TenantOption } from '@/api/passport/login/model';
import QrLogin from '@/components/QrLogin/index.vue';
import { User } from '@/api/system/user/model';
import { TEMPLATE_ID, THEME_STORE_NAME } from '@/config/setting';
import { sendSmsCaptcha } from '@/api/passport/login';
import useFormData from '@/utils/use-form-data';
import { FormInstance } from 'ant-design-vue/es/form';
import { configWebsiteField } from '@/api/cms/cmsWebsiteField';
import { Config } from '@/api/cms/cmsWebsiteField/model';
import { phoneReg } from 'ele-admin-pro';
import router from '@/router';
import { listAdminsByPhoneAll } from '@/api/system/user';
import { QrCodeStatusResponse } from '@/api/passport/qrLogin';
const useForm = Form.useForm;
const routerInstance = useRouter();
const { currentRoute } = routerInstance;
const { t } = useI18n();
const { locale } = useI18n();
// 路由导航函数
const push = (path: string) => {
routerInstance.push(path);
};
// 登录框方向, 0 居中, 1 居右, 2 居左
const direction = ref(0);
// 加载状态
const loading = ref(false);
// 是否显示tenantId填写输入框默认进入账号登录/手机号登录切换模式
const loginType = ref('account');
// 标签下划线指示器
const accountTabRef = ref<HTMLElement>();
const smsTabRef = ref<HTMLElement>();
const indicatorStyle = ref({ left: '0px', width: '0px', transition: 'none' });
// 收缩/展开两阶段动画时长(ms),模拟阿里云"先收一下再展开"的弹性效果
const SHRINK_MS = 150;
const EXPAND_MS = 220;
let indicatorTimer: number | null = null;
const updateIndicator = (animate = true) => {
if (indicatorTimer != null) {
clearTimeout(indicatorTimer);
indicatorTimer = null;
}
nextTick(() => {
const activeEl =
loginType.value === 'account' ? accountTabRef.value : smsTabRef.value;
if (!activeEl) {
return;
}
const targetLeft = activeEl.offsetLeft;
const targetWidth = activeEl.offsetWidth;
// 非动画:直接定位(首次渲染 / 窗口缩放)
if (!animate) {
indicatorStyle.value = {
left: `${targetLeft}px`,
width: `${targetWidth}px`,
transition: 'none'
};
return;
}
// 阶段一:收缩成一条短线,并滑向目标中心
const targetCenter = targetLeft + targetWidth / 2;
const smallWidth = 24;
indicatorStyle.value = {
left: `${targetCenter - smallWidth / 2}px`,
width: `${smallWidth}px`,
transition: `left ${SHRINK_MS}ms ease-in, width ${SHRINK_MS}ms ease-in`
};
// 阶段二:展开到目标标签的宽度
indicatorTimer = window.setTimeout(() => {
indicatorStyle.value = {
left: `${targetLeft}px`,
width: `${targetWidth}px`,
transition: `left ${EXPAND_MS}ms ease-out, width ${EXPAND_MS}ms ease-out`
};
indicatorTimer = null;
}, SHRINK_MS);
});
};
watch(loginType, () => updateIndicator(true), { immediate: true });
onMounted(() => {
updateIndicator(false);
window.addEventListener('resize', () => updateIndicator(false));
});
// 默认配置
const defaultConfig: Config = {
siteName: '小程序商城',
siteLogo: 'https://oss.wsdns.cn/20240822/0252ad4ed46449cdafe12f8d3d96c2ea.svg',
domain: '',
icpNo: '',
copyright: '',
loginBgImg: '',
address: '',
tel: '',
kefu2: '',
kefu1: '',
email: '',
loginTitle: '',
sysLogo: ''
};
// 配置信息(使用默认值初始化)
const config = ref<Config>({ ...defaultConfig });
// 加载网站配置
const loadWebsiteConfig = async () => {
try {
const data = await configWebsiteField({ lang: locale.value });
if (data) {
// 用接口返回的数据覆盖默认值
config.value = {
...defaultConfig,
...data,
};
}
} catch (e: any) {
console.warn('获取网站配置失败,使用默认配置:', e.message);
}
};
type LoginForm = User & { account?: string };
const { form } = useFormData<LoginForm>({
account: '',
username: '',
phone: '',
password: '',
code: '',
smsCode: '',
tenantId: undefined,
templateId: TEMPLATE_ID,
isSuperAdmin: true,
remember: true
});
// 验证码 base64 数据
const captcha = ref('');
// 验证码内容, 实际项目去掉
const text = ref('');
// 是否显示图形验证码弹窗
const visible = ref(false);
// 图形验证码
const imgCode = ref('');
// 发送验证码按钮loading
const codeLoading = ref(false);
// 验证码倒计时时间
const countdownTime = ref(0);
// 验证码倒计时定时器
let countdownTimer: number | null = null;
// 同一个手机号前 3 次发送不需要图形验证码
const FREE_SEND_TIMES = 3;
// 根据手机号生成发送次数缓存 key
const getSendCountKey = () => `login_sms_send_count_${form.phone}`;
// 读取当前手机号已发送次数
const getSendCount = () => {
const key = getSendCountKey();
if (!key) return 0;
const count = localStorage.getItem(key);
return count ? Number(count) || 0 : 0;
};
// 增加发送次数
const increaseSendCount = () => {
const key = getSendCountKey();
if (!key) return;
localStorage.setItem(key, String(getSendCount() + 1));
};
// 多用户选择账号登录
const showSelectLoginUser = ref<boolean>(false);
const admins = ref<User[]>([]);
// 多租户选择登录
const showSelectTenantModal = ref<boolean>(false);
const pendingTenants = ref<TenantOption[]>([]);
const selectingTenant = ref<boolean>(false);
// const tenantId = getTenantId();
// 表格选中数据
const formRef = ref<FormInstance | null>(null);
// 表单验证规则
const rules = reactive({
account: [
{
required: true,
message: t('login.username'),
type: 'string'
}
],
phone: [
{
pattern: phoneReg,
required: true,
type: 'string',
message: '手机号格式不正确',
trigger: 'blur'
}
],
password: [
{
required: true,
message: t('login.password'),
type: 'string'
}
],
code: [
{
required: true,
message: t('login.code'),
type: 'string'
}
],
smsCode: [
{
required: true,
message: t('login.code'),
type: 'string'
}
]
});
/* 处理发送短信验证码按钮点击 */
const handleSendCode = () => {
if (!form.phone) {
message.error('请输入手机号码');
return;
}
if (getSendCount() >= FREE_SEND_TIMES) {
imgCode.value = '';
changeCaptcha();
visible.value = true;
} else {
sendCode();
}
};
/* 发送短信验证码 */
const sendCode = (captchaCode?: string) => {
if (captchaCode !== undefined) {
if (!captchaCode) {
message.error('请输入图形验证码');
return;
}
if (text.value !== captchaCode.toLowerCase()) {
message.error('图形验证码不正确');
return;
}
}
codeLoading.value = true;
sendSmsCaptcha({ phone: form.phone })
.then(() => {
message.success('短信验证码发送成功, 请注意查收!');
visible.value = false;
increaseSendCount();
countdownTime.value = 30;
// 开始对按钮进行倒计时
countdownTimer = window.setInterval(() => {
if (countdownTime.value <= 1) {
countdownTimer && clearInterval(countdownTimer);
countdownTimer = null;
}
countdownTime.value--;
}, 1000);
})
.catch((e: Error) => {
message.error(e.message);
changeCaptcha();
})
.finally(() => {
codeLoading.value = false;
});
};
// const { clearValidate, validate, validateInfos } = useForm(form, rules);
const { resetFields } = useForm(form, rules);
const goHome = () => {
const { query } = unref(currentRoute);
goHomeRoute(query.from as string);
localStorage.removeItem(THEME_STORE_NAME);
};
const onLoginBySms = () => {
if (!formRef.value) {
return;
}
formRef.value
.validate()
.then(() => {
loading.value = true;
form.code = form.smsCode?.toLowerCase();
loginBySuperAdminSms(form)
.then((outcome) => {
if (outcome.status === 'selectTenant') {
pendingTenants.value = outcome.tenants;
showSelectTenantModal.value = true;
loading.value = false;
return;
}
message.success(outcome.message);
loading.value = false;
resetFields();
cleanPageTabs();
goHome();
})
.catch((e: Error) => {
message.error(e.message);
loading.value = false;
});
})
.catch(() => {});
};
const onSelectUser = (item: User) => {
form.tenantId = item.tenantId;
onLoginBySms();
};
/* 选择租户后二次登录 */
const onSelectTenant = (item: TenantOption) => {
if (selectingTenant.value || item.tenantId == null) return;
selectingTenant.value = true;
loginBySelectTenant({
phone: form.phone,
code: form.smsCode || '',
tenantId: item.tenantId,
remember: form.remember
})
.then((outcome) => {
showSelectTenantModal.value = false;
message.success(outcome.message);
resetFields();
cleanPageTabs();
goHome();
})
.catch((e: Error) => message.error(e.message))
.finally(() => {
selectingTenant.value = false;
});
};
/* 保存编辑 */
const submit = () => {
if (!formRef.value) {
return;
}
formRef.value
.validate()
.then(() => {
if (form.code?.toLowerCase() !== text.value) {
message.error('验证码错误');
changeCaptcha();
return;
}
const account = (form.account || '').trim();
// 邮箱格式判断
const isEmail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(account);
// 纯数字判断用户ID
const isPureNumber = /^\d+$/.test(account);
if (!isEmail && !isPureNumber) {
message.error('请输入正确的邮箱或用户ID');
return;
}
loading.value = true;
const payload = {
password: form.password,
code: form.code?.toLowerCase(),
remember: form.remember
};
const loginApi = isEmail
? loginByEmail({ ...payload, email: account })
: loginByUserId({ ...payload, userId: account });
loginApi
.then((msg) => {
if (msg == '请选择登录用户') {
showSelectLoginUser.value = true;
return false;
}
// 登录成功
message.success(msg);
loading.value = false;
resetFields();
cleanPageTabs();
goHome();
})
.catch((e: Error) => {
message.error(e.message);
loading.value = false;
});
})
.catch(() => {});
};
// const doRegister = () => {
// registerUser(form)
// .then(() => {
// loading.value = false;
// message.success('注册成功');
// })
// .catch((e) => {
// loading.value = false;
// message.error(e.message);
// });
// };
const onLoginType = (text) => {
loginType.value = text;
};
const onScan = () => {
if (loginType.value == 'scan') {
loginType.value = 'account';
} else {
loginType.value = 'scan';
}
};
/* 二维码登录成功处理 */
const onQrLoginSuccess = async (item: QrCodeStatusResponse) => {
// 设置token到localStorage或其他存储
localStorage.setItem('access_token', item.accessToken || '');
message.success('扫码登录成功');
if (item) {
localStorage.setItem('access_token', `${item.accessToken}`);
localStorage.setItem('TenantId', `${item.tenantId}`);
cleanPageTabs();
goHome();
}
};
/* 二维码登录错误处理 */
const onQrLoginError = (error: string) => {
message.error(error || '扫码登录失败');
};
// const goBack = () => {
// openUrl(getDomain());
// return;
// };
const loginConnect = () => {
// getWxWorkQrConnect().then((result) => {
// console.log(result);
// qrConnect.value = result;
// });
};
/* 获取图形验证码 */
const changeCaptcha = () => {
// configWebsiteField({ lang: locale.value }).then((data) => {
// config.value = data;
// });
// 这里演示的验证码是后端返回base64格式的形式, 如果后端地址直接是图片请参考忘记密码页面
getCaptcha()
.then((data) => {
captcha.value = data.base64;
// 实际项目后端一般会返回验证码的key而不是直接返回验证码的内容, 登录用key去验证, 你可以根据自己后端接口修改
text.value = data.text;
// 自动回填验证码, 实际项目去掉这个
// form.code = data.text;
// resetFields();
})
.catch((e) => {
message.error(e.message);
});
};
// 首次加载
// loadWebsiteConfig();
changeCaptcha();
loginConnect();
watch(
router.currentRoute,
(data) => {
if (data.query?.loginPhone) {
form.phone = `${data.query?.loginPhone}`;
} else {
localStorage.clear();
}
},
{ immediate: true }
);
</script>
<style lang="less" scoped>
/* 背景 */
.login-wrapper {
padding: 48px 16px 0 16px;
position: relative;
box-sizing: border-box;
background-image: url('@/assets/bg-2.jpeg');
background-color: var(--grey-5);
background-repeat: no-repeat;
background-size: cover;
min-height: 100vh;
&:before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.2);
}
}
.company-name {
position: absolute;
top: 17%;
left: 50%;
transform: translateX(-50%);
font-size: 24px;
color: #ffffff;
}
/* 卡片 */
.login-form {
width: 380px;
margin: 0 auto;
max-width: 100%;
padding: 0 28px;
box-sizing: border-box;
box-shadow: 0 3px 6px rgba(0, 0, 0, 0.15);
border-radius: 2px;
position: relative;
z-index: 2;
h4 {
padding: 22px 0;
text-align: center;
}
.title-btn {
cursor: pointer;
white-space: nowrap;
}
.login-title {
position: relative;
display: flex;
justify-content: space-around;
line-height: 50px;
.active {
color: #007dff;
}
.tab-indicator {
position: absolute;
bottom: -10px;
height: 3px;
border-radius: 2px;
background-color: #007dff;
}
}
}
.login-form-right .login-form {
margin: 0 15% 0 auto;
}
.login-form-left .login-form {
margin: 0 auto 0 15%;
}
/* 验证码 */
.login-input-group {
display: flex;
align-items: center;
background-repeat: no-repeat;
:deep(.ant-input-affix-wrapper) {
flex: 1;
}
.login-captcha {
width: 102px;
height: 40px;
margin-left: 10px;
padding: 0;
& > img {
width: 100%;
height: 100%;
}
}
}
/* 第三方登录图标 */
.login-oauth-icon {
color: #fff;
padding: 5px;
margin: 0 12px;
font-size: 18px;
border-radius: 50%;
cursor: pointer;
}
.work-icon {
width: 50px;
height: 50px;
}
/* 底部版权 */
.login-copyright {
color: #eee;
text-align: center;
padding: 48px 0 22px 0;
position: relative;
z-index: 1;
}
/* 响应式 */
@media screen and (min-height: 640px) {
.login-wrapper {
padding-top: 0;
}
.login-form {
position: absolute;
top: 50%;
left: 50%;
transform: translateX(-50%);
margin-top: -230px;
}
.login-form-right .login-form,
.login-form-left .login-form {
left: auto;
right: 15%;
transform: translateX(0);
margin: -230px auto auto auto;
}
.login-form-left .login-form {
right: auto;
left: 15%;
}
.login-copyright {
position: absolute;
left: 0;
right: 0;
bottom: 0;
}
}
@media screen and (max-width: 768px) {
.login-form-right .login-form,
.login-form-left .login-form {
left: 50%;
right: auto;
margin-left: 0;
margin-right: auto;
transform: translateX(-50%);
}
}
.wwLogin_qrcode_head {
padding: 10px 0 40px 0 !important;
}
.logo-login {
position: absolute;
top: 20px;
left: 20px;
display: flex;
align-items: center;
cursor: pointer;
img {
width: 28px;
height: 28px;
border-radius: 4px;
}
h4 {
margin-left: 6px;
color: #ffffff;
font-size: 20px;
}
}
</style>