feat(passport): 增加多租户手机号登录支持并修复邮箱验证码校验
- 修复邮箱登录验证码未校验问题,恢复输入错误提示及刷新验证码逻辑 - 新增多租户选项登录流程,支持单租户直接登录及多租户选择弹窗 - 扩展登录接口及模型,新增 TenantOption 类型和 LoginOutcome 联合结果类型 - 新增 loginBySelectTenant 二次登录接口及对应组件逻辑 - 统一保存登录态,包括 token、租户ID和用户ID到本地存储 - 调整前端短信登录逻辑,根据返回状态决定流程,支持多租户弹窗选择 - 确保新增代码无类型错误并通过构建验证
This commit is contained in:
@@ -60,3 +60,22 @@
|
|||||||
- 同一个手机号前 3 次直接调用 `sendSmsCaptcha`;第 4 次起弹出原有图形验证码弹窗,输入正确并点击「立即发送」后才真正发送。
|
- 同一个手机号前 3 次直接调用 `sendSmsCaptcha`;第 4 次起弹出原有图形验证码弹窗,输入正确并点击「立即发送」后才真正发送。
|
||||||
- 失败时自动刷新图形验证码;补充了 `.catch`/`.finally`,避免发送异常时按钮一直 loading。
|
- 失败时自动刷新图形验证码;补充了 `.catch`/`.finally`,避免发送异常时按钮一直 loading。
|
||||||
- `vue-tsc` 本次新增代码无类型错误;`vite build --outDir dist-verify-login` 编译通过。
|
- `vue-tsc` 本次新增代码无类型错误;`vite build --outDir dist-verify-login` 编译通过。
|
||||||
|
|
||||||
|
## 修复:邮箱登录图形验证码未校验
|
||||||
|
- **问题**:`src/views/passport/login/index.vue` 第 651-555 行的验证码比对逻辑被注释掉,导致用户输入任意非空字符串即可通过前端校验。
|
||||||
|
- **修复**:取消注释 `form.code?.toLowerCase() !== text.value` 的校验块,恢复「输入错误则提示"验证码错误"并刷新验证码」的逻辑。
|
||||||
|
- **说明**:当前后端 `/captcha` 接口返回明文答案 `data.text`,前端直接比对即可。后续若后端改为返回 key 模式,需同步改为传 key 给后端校验。
|
||||||
|
|
||||||
|
## 新增:手机号登录多租户选择
|
||||||
|
- 参考项目:`/Users/gxwebsoft/VUE/website-admin/app/pages/login.vue`。
|
||||||
|
- `src/api/passport/login/model/index.ts`:
|
||||||
|
- `LoginResult` 新增 `tenants?: TenantOption[]`;
|
||||||
|
- 新增 `TenantOption` 类型与 `LoginOutcome` 联合类型(`ok` / `selectTenant`)。
|
||||||
|
- `src/api/passport/login/index.ts`:
|
||||||
|
- 新增 `saveLoginState` 统一保存 token、TenantId、UserId;
|
||||||
|
- `loginBySuperAdminSms` 改为返回 `LoginOutcome`,检测 `data.tenants` 时返回 `selectTenant`;
|
||||||
|
- 新增 `loginBySelectTenant({ phone, code, tenantId, remember })` 二次登录接口。
|
||||||
|
- `src/views/passport/login/index.vue`:
|
||||||
|
- `onLoginBySms` 根据 `outcome.status` 处理:单租户直接登录成功;多租户弹出「请选择要登录的租户」弹窗。
|
||||||
|
- 新增弹窗与 `onSelectTenant`:选择租户后调用 `loginBySelectTenant` 完成二次登录并跳转。
|
||||||
|
- `vue-tsc` 本次新增代码无类型错误;`vite build --outDir dist-verify-tenant` 编译通过。
|
||||||
|
|||||||
@@ -9,6 +9,30 @@ import type {
|
|||||||
} from './model';
|
} from './model';
|
||||||
import { User } from '@/api/system/user/model';
|
import { User } from '@/api/system/user/model';
|
||||||
import { SERVER_API_URL } from '@/config/setting';
|
import { SERVER_API_URL } from '@/config/setting';
|
||||||
|
import type { LoginOutcome, TenantOption } from './model';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 登录态落地:把后端返回的 access_token / 租户 / 用户 持久化到本地
|
||||||
|
*/
|
||||||
|
function saveLoginState(result: LoginResult | undefined, remember?: boolean) {
|
||||||
|
if (!result) return;
|
||||||
|
if (result.access_token) {
|
||||||
|
setToken(result.access_token, remember);
|
||||||
|
}
|
||||||
|
const user = result.user;
|
||||||
|
if (user) {
|
||||||
|
if (user.tenantId != null) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem('TenantId', String(user.tenantId));
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
if (user.userId != null) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem('UserId', String(user.userId));
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 登录
|
* 登录
|
||||||
@@ -106,23 +130,58 @@ export async function loginBySms(data: LoginParam) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 超管手机号短信登录
|
* 超管手机号短信登录
|
||||||
|
* 后端对应 POST /loginBySuperAdminSms,参数为 { phone, code, tenantId }
|
||||||
|
* - 单租户:直接返回 ok
|
||||||
|
* - 多租户:返回 selectTenant,前端需调用 loginBySelectTenant 二次登录
|
||||||
*/
|
*/
|
||||||
export async function loginBySuperAdminSms(data: LoginParam) {
|
export async function loginBySuperAdminSms(
|
||||||
|
data: LoginParam
|
||||||
|
): Promise<LoginOutcome> {
|
||||||
const res = await request.post<ApiResult<LoginResult>>(
|
const res = await request.post<ApiResult<LoginResult>>(
|
||||||
SERVER_API_URL + '/loginBySuperAdminSms',
|
SERVER_API_URL + '/loginBySuperAdminSms',
|
||||||
data
|
{ phone: data.phone, code: data.smsCode, smsCode: data.smsCode, tenantId: data.tenantId }
|
||||||
);
|
);
|
||||||
if (res.data.code === 0) {
|
if (res.data.code !== 0) {
|
||||||
setToken(res.data.data?.access_token, data.remember);
|
return Promise.reject(new Error(res.data.message));
|
||||||
if (res.data.data?.user) {
|
|
||||||
const user = res.data.data?.user;
|
|
||||||
localStorage.setItem('TenantId', String(user.tenantId));
|
|
||||||
localStorage.setItem('UserId', String(user.userId));
|
|
||||||
}
|
|
||||||
|
|
||||||
return res.data.message;
|
|
||||||
}
|
}
|
||||||
return Promise.reject(new Error(res.data.message));
|
const result = res.data.data;
|
||||||
|
if (result?.tenants && result.tenants.length > 0) {
|
||||||
|
return {
|
||||||
|
status: 'selectTenant',
|
||||||
|
tenants: result.tenants as TenantOption[],
|
||||||
|
message: res.data.message || '请选择要登录的租户'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (result?.user) {
|
||||||
|
saveLoginState(result, data.remember);
|
||||||
|
return { status: 'ok', user: result.user, message: res.data.message || '登录成功' };
|
||||||
|
}
|
||||||
|
return Promise.reject(new Error(res.data.message || '登录失败'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 选择租户后登录(多租户短信登录专用二次登录接口)
|
||||||
|
* 后端对应 POST /loginBySelectTenant,参数为 { phone, code, tenantId }
|
||||||
|
*/
|
||||||
|
export async function loginBySelectTenant(data: {
|
||||||
|
phone: string;
|
||||||
|
code: string;
|
||||||
|
tenantId: number;
|
||||||
|
remember?: boolean;
|
||||||
|
}): Promise<LoginOutcome> {
|
||||||
|
const res = await request.post<ApiResult<LoginResult>>(
|
||||||
|
SERVER_API_URL + '/loginBySelectTenant',
|
||||||
|
{ phone: data.phone, code: data.code, smsCode: data.code, tenantId: data.tenantId }
|
||||||
|
);
|
||||||
|
if (res.data.code !== 0) {
|
||||||
|
return Promise.reject(new Error(res.data.message));
|
||||||
|
}
|
||||||
|
const result = res.data.data;
|
||||||
|
if (result?.user) {
|
||||||
|
saveLoginState(result, data.remember);
|
||||||
|
return { status: 'ok', user: result.user, message: res.data.message || '登录成功' };
|
||||||
|
}
|
||||||
|
return Promise.reject(new Error(res.data.message || '登录失败'));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ export interface LoginParam {
|
|||||||
phone?: string;
|
phone?: string;
|
||||||
// 短信验证码
|
// 短信验证码
|
||||||
code?: string;
|
code?: string;
|
||||||
|
// 短信验证码(别名,与后端 LoginParam.code 对应)
|
||||||
|
smsCode?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -29,8 +31,30 @@ export interface LoginResult {
|
|||||||
access_token?: string;
|
access_token?: string;
|
||||||
// 用户信息
|
// 用户信息
|
||||||
user?: User;
|
user?: User;
|
||||||
|
// 多租户场景下返回,此时 access_token / user 为 null
|
||||||
|
tenants?: TenantOption[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 多租户登录时后端返回的租户选项
|
||||||
|
*/
|
||||||
|
export interface TenantOption {
|
||||||
|
tenantId?: number;
|
||||||
|
tenantName?: string;
|
||||||
|
userId?: number;
|
||||||
|
username?: string;
|
||||||
|
nickname?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 登录接口统一返回结果
|
||||||
|
* - ok:单租户直接登录成功
|
||||||
|
* - selectTenant:多租户,需要前端弹出选择租户列表
|
||||||
|
*/
|
||||||
|
export type LoginOutcome =
|
||||||
|
| { status: 'ok'; user: NonNullable<LoginResult['user']>; message: string }
|
||||||
|
| { status: 'selectTenant'; tenants: TenantOption[]; message: string };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 图形验证码返回结果
|
* 图形验证码返回结果
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -270,6 +270,35 @@
|
|||||||
</a-list>
|
</a-list>
|
||||||
</div>
|
</div>
|
||||||
</a-modal>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -291,8 +320,10 @@
|
|||||||
loginBySuperAdminSms,
|
loginBySuperAdminSms,
|
||||||
getCaptcha,
|
getCaptcha,
|
||||||
loginByEmail,
|
loginByEmail,
|
||||||
loginByUserId
|
loginByUserId,
|
||||||
|
loginBySelectTenant
|
||||||
} from '@/api/passport/login';
|
} from '@/api/passport/login';
|
||||||
|
import type { TenantOption } from '@/api/passport/login/model';
|
||||||
import QrLogin from '@/components/QrLogin/index.vue';
|
import QrLogin from '@/components/QrLogin/index.vue';
|
||||||
|
|
||||||
import { User } from '@/api/system/user/model';
|
import { User } from '@/api/system/user/model';
|
||||||
@@ -467,6 +498,10 @@
|
|||||||
// 多用户选择账号登录
|
// 多用户选择账号登录
|
||||||
const showSelectLoginUser = ref<boolean>(false);
|
const showSelectLoginUser = ref<boolean>(false);
|
||||||
const admins = ref<User[]>([]);
|
const admins = ref<User[]>([]);
|
||||||
|
// 多租户选择登录
|
||||||
|
const showSelectTenantModal = ref<boolean>(false);
|
||||||
|
const pendingTenants = ref<TenantOption[]>([]);
|
||||||
|
const selectingTenant = ref<boolean>(false);
|
||||||
// const tenantId = getTenantId();
|
// const tenantId = getTenantId();
|
||||||
|
|
||||||
// 表格选中数据
|
// 表格选中数据
|
||||||
@@ -585,23 +620,18 @@
|
|||||||
loading.value = true;
|
loading.value = true;
|
||||||
form.code = form.smsCode?.toLowerCase();
|
form.code = form.smsCode?.toLowerCase();
|
||||||
loginBySuperAdminSms(form)
|
loginBySuperAdminSms(form)
|
||||||
.then((msg) => {
|
.then((outcome) => {
|
||||||
if (msg == '请选择登录用户') {
|
if (outcome.status === 'selectTenant') {
|
||||||
showSelectLoginUser.value = true;
|
pendingTenants.value = outcome.tenants;
|
||||||
listAdminsByPhoneAll({
|
showSelectTenantModal.value = true;
|
||||||
phone: form.phone,
|
|
||||||
templateId: TEMPLATE_ID
|
|
||||||
}).then((data) => {
|
|
||||||
admins.value = data;
|
|
||||||
});
|
|
||||||
return false;
|
|
||||||
} else {
|
|
||||||
message.success(msg);
|
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
resetFields();
|
return;
|
||||||
cleanPageTabs();
|
|
||||||
goHome();
|
|
||||||
}
|
}
|
||||||
|
message.success(outcome.message);
|
||||||
|
loading.value = false;
|
||||||
|
resetFields();
|
||||||
|
cleanPageTabs();
|
||||||
|
goHome();
|
||||||
})
|
})
|
||||||
.catch((e: Error) => {
|
.catch((e: Error) => {
|
||||||
message.error(e.message);
|
message.error(e.message);
|
||||||
@@ -616,6 +646,29 @@
|
|||||||
onLoginBySms();
|
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 = () => {
|
const submit = () => {
|
||||||
if (!formRef.value) {
|
if (!formRef.value) {
|
||||||
@@ -624,11 +677,11 @@
|
|||||||
formRef.value
|
formRef.value
|
||||||
.validate()
|
.validate()
|
||||||
.then(() => {
|
.then(() => {
|
||||||
// if (form.code?.toLowerCase() !== text.value) {
|
if (form.code?.toLowerCase() !== text.value) {
|
||||||
// message.error('验证码错误');
|
message.error('验证码错误');
|
||||||
// changeCaptcha();
|
changeCaptcha();
|
||||||
// return;
|
return;
|
||||||
// }
|
}
|
||||||
const account = (form.account || '').trim();
|
const account = (form.account || '').trim();
|
||||||
// 邮箱格式判断
|
// 邮箱格式判断
|
||||||
const isEmail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(account);
|
const isEmail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(account);
|
||||||
|
|||||||
Reference in New Issue
Block a user