feat(login): 支持用户ID、邮箱及超级管理员短信验证码登录
- 在登录参数中新增 userId 和 email 字段,支持多种登录凭证 - 新增 loginByUserId、loginByEmail 和 loginBySuperAdminSms 三种登录API - 新增跨租户查询管理员账号接口 listAdminsByEmailAll - 登录页支持账号登录与手机号登录标签切换,添加动画效果 - 账号登录中合并用户ID及邮箱登录逻辑,增加输入格式验证 - 调整登录表单UI和交互,优化输入框占位及样式 - 更新登录时的用户信息及租户ID本地存储逻辑 - 修正登录页版权信息为 websopy Inc.
This commit is contained in:
24
.workbuddy/memory/2026-07-20.md
Normal file
24
.workbuddy/memory/2026-07-20.md
Normal 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 专属 API,mp-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` 全量类型检查(后台任务)确认无编译错误。
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送短信验证码
|
||||
*/
|
||||
|
||||
@@ -5,6 +5,8 @@ import type { User } from '@/api/system/user/model';
|
||||
export interface LoginParam {
|
||||
// 账号
|
||||
username?: string;
|
||||
// 用户ID(loginByUserId 登录使用)
|
||||
userId?: number;
|
||||
// 密码
|
||||
password?: string;
|
||||
// 租户id
|
||||
@@ -13,6 +15,8 @@ export interface LoginParam {
|
||||
remember?: boolean;
|
||||
// 手机号码
|
||||
phone?: string;
|
||||
// 邮箱
|
||||
email?: string;
|
||||
// 短信验证码
|
||||
code?: string;
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出用户列表
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user