feat(login): 支持用户ID、邮箱及超级管理员短信验证码登录

- 在登录参数中新增 userId 和 email 字段,支持多种登录凭证
- 新增 loginByUserId、loginByEmail 和 loginBySuperAdminSms 三种登录API
- 新增跨租户查询管理员账号接口 listAdminsByEmailAll
- 登录页支持账号登录与手机号登录标签切换,添加动画效果
- 账号登录中合并用户ID及邮箱登录逻辑,增加输入格式验证
- 调整登录表单UI和交互,优化输入框占位及样式
- 更新登录时的用户信息及租户ID本地存储逻辑
- 修正登录页版权信息为 websopy Inc.
This commit is contained in:
2026-07-20 11:35:49 +08:00
parent 4a91140a76
commit f0efe079e7
5 changed files with 269 additions and 111 deletions

View File

@@ -0,0 +1,24 @@
# 2026-07-20 工作记录
## 复制 site-vue 登录页到 mp-vue
`/Users/gxwebsoft/VUE/site-vue/src/views/passport/login` 的登录页复制到当前项目 `mp-vue/src/views/passport/login`
### 关键发现
- 两边 `components/` 子目录完全一致,无需复制;仅 `index.vue` 有差异源为重构后的新版本22387 字节)。
- 源登录页重构后依赖 site-vue 专属 APImp-vue 原本缺失:
- `loginByUserId` / `loginByEmail`:写死了外部地址 `https://server.websoft.top/api/...`(非 mp-vue 后端)
- `loginBySuperAdminSms`:走 `SERVER_API_URL + '/loginBySuperAdminSms'`
- `listAdminsByEmailAll`(来自 `@/api/system/user`):同样指向 `server.websoft.top`
- 用户确认采用「完整移植含 API」方案保留上述 websoft.top 硬编码地址(已知风险:登录会打到 site-vue 后端,需确认 mp-vue 后端是否支持这些接口)。
### 已改动文件
1. `src/views/passport/login/index.vue` —— 直接覆盖为源版本。
2. `src/api/passport/login/index.ts` —— 新增 `loginByUserId``loginBySuperAdminSms``loginByEmail` 三个函数。
3. `src/api/system/user/index.ts` —— 新增 `listAdminsByEmailAll` 函数。
4. `src/api/passport/login/model/index.ts` —— LoginParam 增加 `userId``email` 字段。
### 验证
- 所有 import 符号在 mp-vue 中均存在(`@/router``QrCodeStatusResponse``QrLogin` 组件、`configWebsiteField` 等)。
- 两边 `User` 模型字段一致(含 `isSuperAdmin``templateId`),表单初值与 `LoginForm` 类型匹配。
- 已触发 `vue-tsc --noEmit` 全量类型检查(后台任务)确认无编译错误。

View File

@@ -62,6 +62,66 @@ export async function loginBySms(data: LoginParam) {
return Promise.reject(new Error(res.data.message));
}
/**
* 用户ID登录
*/
export async function loginByUserId(data: LoginParam) {
const res = await request.post<ApiResult<LoginResult>>(
'https://server.websoft.top/api/loginByUserId',
data
);
if (res.data.code === 0) {
setToken(res.data.data?.access_token, data.remember);
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));
}
/**
* 超级管理员短信验证码登录
*/
export async function loginBySuperAdminSms(data: LoginParam) {
const res = await request.post<ApiResult<LoginResult>>(
SERVER_API_URL + '/loginBySuperAdminSms',
data
);
if (res.data.code === 0) {
setToken(res.data.data?.access_token, data.remember);
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));
}
/**
* 邮箱登录(跨租户)
*/
export async function loginByEmail(data: LoginParam) {
const res = await request.post<ApiResult<LoginResult>>(
'https://server.websoft.top/api/loginByEmail',
data
);
if (res.data.code === 0) {
setToken(res.data.data?.access_token, data.remember);
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));
}
/**
* 发送短信验证码
*/

View File

@@ -5,6 +5,8 @@ import type { User } from '@/api/system/user/model';
export interface LoginParam {
// 账号
username?: string;
// 用户IDloginByUserId 登录使用)
userId?: number;
// 密码
password?: string;
// 租户id
@@ -13,6 +15,8 @@ export interface LoginParam {
remember?: boolean;
// 手机号码
phone?: string;
// 邮箱
email?: string;
// 短信验证码
code?: string;
}

View File

@@ -283,6 +283,23 @@ export async function listAdminsByPhoneAll(params?: UserParam) {
return Promise.reject(new Error(res.data.message));
}
/**
* 选择邮箱下管理员账号登录(跨租户)
* @param params
*/
export async function listAdminsByEmailAll(params?: UserParam) {
const res = await request.get<ApiResult<User[]>>(
'https://server.websoft.top/api/listAdminsByEmailAll',
{
params
}
);
if (res.data.code === 0 && res.data.data) {
return res.data.data;
}
return Promise.reject(new Error(res.data.message));
}
/**
* 导出用户列表
*/

View File

@@ -27,24 +27,26 @@
:rules="rules"
class="login-form ele-bg-white"
>
<div class="login-title flex justify-center items-center px-12">
<div class="login-title flex items-center">
<template v-if="loginType === 'scan'">
<h4 class="title-btn">扫码登录</h4>
</template>
<template v-else>
<h4
class="title-btn"
:class="loginType === 'sms' ? 'active' : ''"
@click="onLoginType('sms')"
>手机号登录</h4
>
<a-divider type="vertical" style="height: 20px" />
<h4
class="title-btn"
ref="accountTabRef"
class="title-btn font-bold text-lg"
:class="loginType === 'account' ? 'active' : ''"
@click="onLoginType('account')"
>账号登录</h4
>
<h4
ref="smsTabRef"
class="title-btn font-bold text-lg"
:class="loginType === 'sms' ? 'active' : ''"
@click="onLoginType('sms')"
>手机号登录</h4
>
<div class="tab-indicator" :style="indicatorStyle"></div>
</template>
</div>
<div
@@ -60,30 +62,18 @@
<div
class="absolute top-2 right-2 text-lg text-white font-bold cursor-pointer"
>
<QrcodeOutlined v-if="loginType === 'sms'" />
<QrcodeOutlined v-if="loginType === 'scan'" />
<MobileOutlined 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="username">
<a-form-item name="account">
<a-input
allow-clear
size="large"
v-model:value="form.userId"
:placeholder="`用户ID`"
v-model:value="form.account"
placeholder="请输入用户ID或邮箱"
>
<template #prefix>
<UserOutlined />
@@ -102,35 +92,10 @@
</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
@@ -142,8 +107,8 @@
>
{{ loading ? t('login.loading') : t('login.login') }}
</a-button>
<div class="register text-center pt-5"
><a @click="push('/register')">前往注册</a></div
<div class="text-center pt-5 text-gray-500"
>还没有账号联系管理员开通</div
>
</a-form-item>
</template>
@@ -189,8 +154,8 @@
>
{{ loading ? t('login.loading') : t('login.login') }}
</a-button>
<div class="register text-center pt-5"
><a @click="push('/register')">前往注册</a></div
<div class="text-center pt-5 text-gray-500"
>还没有账号联系管理员开通</div
>
</a-form-item>
</template>
@@ -204,7 +169,7 @@
<div class="login-copyright">
<a-space>
<span>© {{ new Date().getFullYear() }}</span>
<span>{{ config?.copyright || 'websoft.top Inc.' }}</span>
<span>{{ config?.copyright || 'websopy Inc.' }}</span>
</a-space>
</div>
<!-- 编辑弹窗 -->
@@ -272,7 +237,7 @@
</template>
<script lang="ts" setup>
import { ref, reactive, unref, watch } from 'vue';
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';
@@ -280,15 +245,19 @@
LockOutlined,
UserOutlined,
QrcodeOutlined,
MobileOutlined,
SafetyCertificateOutlined,
RightOutlined
MobileOutlined
} from '@ant-design/icons-vue';
import { goHomeRoute, cleanPageTabs } from '@/utils/page-tab-util';
import { login, loginBySms, getCaptcha } from '@/api/passport/login';
import {
loginByUserId,
loginByEmail,
loginBySuperAdminSms,
getCaptcha
} from '@/api/passport/login';
import QrLogin from '@/components/QrLogin/index.vue';
import { User } from '@/api/system/user/model';
type LoginForm = User & { account?: string };
import { TEMPLATE_ID, THEME_STORE_NAME } from '@/config/setting';
import { sendSmsCaptcha } from '@/api/passport/login';
import useFormData from '@/utils/use-form-data';
@@ -297,32 +266,81 @@
import { Config } from '@/api/cms/cmsWebsiteField/model';
import { phoneReg } from 'ele-admin-pro';
import router from '@/router';
import { listAdminsByPhoneAll } from '@/api/system/user';
import { listAdminsByEmailAll } from '@/api/system/user';
import { QrCodeStatusResponse } from '@/api/passport/qrLogin';
const useForm = Form.useForm;
const routerInstance = useRouter();
const { currentRoute } = routerInstance;
const { currentRoute } = useRouter();
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('scan');
// 是否显示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 = 8;
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 config = ref<Config>();
// 配置信息
const { form } = useFormData<User>({
const { form } = useFormData<LoginForm>({
account: '',
userId: undefined,
username: '',
phone: '',
email: '',
password: '',
code: '',
smsCode: '',
@@ -356,11 +374,26 @@
// 表单验证规则
const rules = reactive({
userId: [
account: [
{
required: true,
message: t('login.username'),
type: 'string'
message: '请输入用户ID或邮箱',
type: 'string',
trigger: 'blur'
},
{
validator: (_rule: unknown, value: string) => {
if (!value) {
return Promise.resolve();
}
const isEmail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
const isNumber = /^\d+$/.test(value);
if (isEmail || isNumber) {
return Promise.resolve();
}
return Promise.reject(new Error('请输入正确的用户ID或邮箱'));
},
trigger: 'blur'
}
],
phone: [
@@ -452,24 +485,13 @@
.then(() => {
loading.value = true;
form.code = form.smsCode?.toLowerCase();
loginBySms(form)
loginBySuperAdminSms(form)
.then((msg) => {
if (msg == '请选择登录用户') {
showSelectLoginUser.value = true;
listAdminsByPhoneAll({
phone: form.phone,
templateId: TEMPLATE_ID
}).then((data) => {
admins.value = data;
});
return false;
} else {
message.success(msg);
loading.value = false;
resetFields();
cleanPageTabs();
goHome();
}
})
.catch((e: Error) => {
message.error(e.message);
@@ -479,12 +501,13 @@
.catch(() => {});
};
/* 邮箱登录(跨租户) */
const onSelectUser = (item: User) => {
form.tenantId = item.tenantId;
onLoginBySms();
submit();
};
/* 保存编辑 */
/* 账号登录用户ID / 邮箱登录合并) */
const submit = () => {
if (!formRef.value) {
return;
@@ -492,18 +515,31 @@
formRef.value
.validate()
.then(() => {
// if (form.code?.toLowerCase() !== text.value) {
// message.error('验证码错误');
// changeCaptcha();
// return;
// }
loading.value = true;
form.code = form.code?.toLowerCase();
form.phone = undefined;
login(form)
const account = (form.account || '').trim();
// 判断输入的是邮箱还是用户ID纯数字
const isEmail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(account);
const payload = {
password: form.password,
remember: form.remember
};
const req = isEmail
? loginByEmail({
...payload,
email: account,
tenantId: form.tenantId
})
: loginByUserId({ ...payload, userId: account });
req
.then((msg) => {
if (msg == '请选择登录用户') {
showSelectLoginUser.value = true;
listAdminsByEmailAll({
email: account,
templateId: TEMPLATE_ID
}).then((data) => {
admins.value = data;
});
return false;
}
// 登录成功
@@ -646,10 +682,10 @@
/* 卡片 */
.login-form {
width: 380px;
margin: 0 auto;
width: 420px;
margin: 20px auto;
max-width: 100%;
padding: 0 28px;
padding: 28px 36px;
box-sizing: border-box;
box-shadow: 0 3px 6px rgba(0, 0, 0, 0.15);
border-radius: 2px;
@@ -657,22 +693,39 @@
z-index: 2;
h4 {
padding: 22px 0;
text-align: center;
}
.title-btn {
cursor: pointer;
white-space: nowrap;
transition: color 0.3s;
}
.login-title {
position: relative;
display: flex;
justify-content: space-around;
line-height: 50px;
flex-wrap: nowrap;
line-height: 1;
margin-top: 10px;
margin-bottom: 38px;
gap: 20px;
.active {
color: #007dff;
}
.tab-indicator {
position: absolute;
bottom: -10px;
height: 3px;
border-radius: 2px;
background-color: #007dff;
}
}
:deep(.ant-form-item) {
margin-bottom: 18px;
}
}