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)); 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 { export interface LoginParam {
// 账号 // 账号
username?: string; username?: string;
// 用户IDloginByUserId 登录使用)
userId?: number;
// 密码 // 密码
password?: string; password?: string;
// 租户id // 租户id
@@ -13,6 +15,8 @@ export interface LoginParam {
remember?: boolean; remember?: boolean;
// 手机号码 // 手机号码
phone?: string; phone?: string;
// 邮箱
email?: string;
// 短信验证码 // 短信验证码
code?: string; code?: string;
} }

View File

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