522 lines
14 KiB
Vue
522 lines
14 KiB
Vue
<template>
|
||
<a-form ref="loginFormRef" :model="loginForm" :rules="loginRules" layout="vertical">
|
||
<a-form-item v-if="tenantOption === 'CHOSE'" field="tenantId">
|
||
<a-select
|
||
v-model="loginForm.tenantId"
|
||
:options="tenantOptions"
|
||
placeholder="请选择租户"
|
||
allow-clear
|
||
>
|
||
<template #option="{ option }">
|
||
{{ option.name }}
|
||
</template>
|
||
</a-select>
|
||
</a-form-item>
|
||
<a-form-item field="orgId">
|
||
<a-select
|
||
v-model="loginForm.orgId"
|
||
:options="orgOptions"
|
||
placeholder="请选择组织"
|
||
allow-clear
|
||
:loading="orgLoading"
|
||
size="large"
|
||
/>
|
||
</a-form-item>
|
||
<a-form-item field="account">
|
||
<a-input
|
||
v-model="loginForm.account"
|
||
placeholder="请输入用户名"
|
||
size="large"
|
||
>
|
||
<template #prefix>
|
||
<icon-user />
|
||
</template>
|
||
</a-input>
|
||
</a-form-item>
|
||
<a-form-item field="password">
|
||
<a-input-password
|
||
v-model="loginForm.password"
|
||
placeholder="请输入密码"
|
||
size="large"
|
||
allow-clear
|
||
>
|
||
<template #prefix>
|
||
<icon-lock />
|
||
</template>
|
||
</a-input-password>
|
||
</a-form-item>
|
||
<a-form-item v-if="captchaOpen" field="validCode">
|
||
<div class="captcha-container">
|
||
<a-input
|
||
v-model="loginForm.validCode"
|
||
placeholder="请输入验证码"
|
||
size="large"
|
||
>
|
||
<template #prefix>
|
||
<icon-safe />
|
||
</template>
|
||
</a-input>
|
||
<div class="captcha-img-wrapper">
|
||
<img
|
||
:src="validCodeBase64"
|
||
alt="验证码"
|
||
class="captcha-img"
|
||
@click="loginCaptcha"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</a-form-item>
|
||
</a-form>
|
||
<div class="login-btn">
|
||
<a-button type="primary" size="large" :loading="loading" @click="handleSubmit">
|
||
登录
|
||
</a-button>
|
||
</div>
|
||
|
||
<!-- 模块选择弹窗 -->
|
||
<ModuleSelector
|
||
v-model:visible="moduleSelectorVisible"
|
||
:module-list="moduleList"
|
||
:loading="loading"
|
||
@confirm="handleModuleConfirm"
|
||
/>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, reactive, onMounted, onBeforeUnmount, nextTick } from 'vue';
|
||
import { useRouter } from 'vue-router';
|
||
import { Message } from '@arco-design/web-vue';
|
||
import { IconUser, IconLock, IconSafe } from '@arco-design/web-vue/es/icon';
|
||
import { sysAuthBLoginPost, sysAuthBGetPicCaptchaGet } from '@/api/SimpleAdmin/bEndPermissionVerify';
|
||
import { sysLoginPolicyGet, sysTenantListGet } from '@/api/SimpleAdmin/common';
|
||
import { setToken } from '@/utils/auth';
|
||
import { useUserStore } from '@/store';
|
||
import smCrypto from '@/utils/smCrypto';
|
||
import ModuleSelector from '../module-selector.vue';
|
||
|
||
const router = useRouter();
|
||
const userStore = useUserStore();
|
||
|
||
// props
|
||
interface PwdProps {
|
||
tenantOption?: string;
|
||
tenantOptions?: any[];
|
||
}
|
||
|
||
const props = withDefaults(defineProps<PwdProps>(), {
|
||
tenantOption: 'CLOSE',
|
||
tenantOptions: () => [],
|
||
});
|
||
|
||
const loginFormRef = ref();
|
||
const loading = ref(false);
|
||
const captchaOpen = ref(false);
|
||
const validCodeBase64 = ref('');
|
||
const validCodeReqNo = ref('');
|
||
const orgLoading = ref(false);
|
||
const orgOptions = ref<Array<{ label: string; value: number }>>([]);
|
||
const moduleSelectorVisible = ref(false);
|
||
const moduleList = ref<any[]>([]);
|
||
const selectedModuleId = ref<number | null>(null);
|
||
|
||
// 表单数据
|
||
const loginForm = reactive({
|
||
account: 'superAdmin',
|
||
password: '123456',
|
||
validCode: '',
|
||
validCodeReqNo: '',
|
||
tenantId: undefined as number | undefined,
|
||
orgId: undefined as number | undefined,
|
||
});
|
||
|
||
// 表单验证规则
|
||
const loginRules = reactive({
|
||
account: [{ required: true, message: '请输入用户名' }],
|
||
password: [{ required: true, message: '请输入密码' }],
|
||
tenantId: [{ required: props.tenantOption === 'CHOSE', message: '请选择租户' }],
|
||
orgId: [{ required: false, message: '请选择组织' }],
|
||
validCode: [{ required: false, message: '请输入验证码' }],
|
||
});
|
||
|
||
// 获取租户列表(组织列表)
|
||
const getOrgTree = async () => {
|
||
try {
|
||
orgLoading.value = true;
|
||
const res = await sysTenantListGet();
|
||
|
||
// request.ts 已经处理了响应格式,如果响应是 { code: 200, data: [...] },会直接返回 data
|
||
// 所以这里需要兼容两种情况:
|
||
// 1. res 直接是数组(request.ts 已经提取了 data)
|
||
// 2. res 是对象,包含 data 或 list 字段
|
||
let list: any[] = [];
|
||
|
||
if (res) {
|
||
if (Array.isArray(res)) {
|
||
// 如果 res 直接是数组,说明 request.ts 已经提取了 data
|
||
list = res;
|
||
} else if (res && typeof res === 'object') {
|
||
// 如果 res 是对象,检查是否有 data 或 list 字段
|
||
if (Array.isArray(res.data)) {
|
||
list = res.data;
|
||
} else if (Array.isArray(res.list)) {
|
||
list = res.list;
|
||
} else if (Array.isArray(res)) {
|
||
list = res;
|
||
}
|
||
}
|
||
}
|
||
|
||
// 转换为下拉选项格式
|
||
orgOptions.value = list.map((item: any) => ({
|
||
label: item.name || item.tenantName || item.title || item.label || '',
|
||
value: item.id || item.tenantId || 0,
|
||
name: item.name || item.tenantName || item.title || '', // 保留原始 name
|
||
}));
|
||
|
||
// 默认选择第一个组织(如果未选择且有数据)
|
||
if (!loginForm.orgId && orgOptions.value.length > 0) {
|
||
loginForm.orgId = orgOptions.value[0].value as number;
|
||
}
|
||
} catch (error) {
|
||
console.error('获取组织列表失败', error);
|
||
} finally {
|
||
orgLoading.value = false;
|
||
}
|
||
};
|
||
|
||
// 加载验证码
|
||
const loginCaptcha = async () => {
|
||
try {
|
||
const res = await sysAuthBGetPicCaptchaGet();
|
||
// request.ts 已经处理了响应格式,如果响应是 { code: 200, data: {...} },会直接返回 data
|
||
const data = res?.data || res || {};
|
||
validCodeBase64.value = data.validCodeBase64 || '';
|
||
validCodeReqNo.value = data.validCodeReqNo || '';
|
||
} catch (error) {
|
||
console.error('获取验证码失败', error);
|
||
}
|
||
};
|
||
|
||
// 检查验证码策略
|
||
const checkCaptchaPolicy = async () => {
|
||
try {
|
||
const res = await sysLoginPolicyGet();
|
||
// request.ts 已经处理了响应格式,如果响应是 { code: 200, data: [...] },会直接返回 data
|
||
const policies = Array.isArray(res) ? res : (Array.isArray(res?.data) ? res.data : []);
|
||
const captchaPolicy = policies.find((item: any) => item.configKey === 'LOGIN_CAPTCHA_OPEN');
|
||
captchaOpen.value = captchaPolicy?.configValue === 'true';
|
||
if (captchaOpen.value) {
|
||
await loginCaptcha();
|
||
}
|
||
} catch (error) {
|
||
console.error('获取登录策略失败', error);
|
||
}
|
||
};
|
||
|
||
// 提交登录
|
||
const handleSubmit = async () => {
|
||
try {
|
||
await loginFormRef.value?.validate();
|
||
loading.value = true;
|
||
|
||
// 构建登录数据,确保字段格式正确
|
||
const loginData: API.LoginInput = {
|
||
account: loginForm.account,
|
||
password: smCrypto.doSm2Encrypt(loginForm.password), // SM2加密密码
|
||
device: 'PC',
|
||
validCode: captchaOpen.value ? loginForm.validCode : '', // 没有验证码时发送空字符串
|
||
validCodeReqNo: captchaOpen.value ? validCodeReqNo.value : '', // 没有验证码时发送空字符串
|
||
tenantId: props.tenantOption === 'CHOSE' ? (loginForm.tenantId || 0) : 0, // 没有租户时发送 0
|
||
};
|
||
|
||
const res = await sysAuthBLoginPost(loginData);
|
||
|
||
// request.ts 已经处理了响应格式,如果响应是 { code: 200, data: {...} },会直接返回 data
|
||
const loginResult = res?.data || res || {};
|
||
|
||
if (loginResult && (loginResult.token || loginResult.userInfo)) {
|
||
|
||
// 保存 token(从响应数据或响应头中获取)
|
||
if (loginResult.token) {
|
||
setToken(loginResult.token);
|
||
}
|
||
|
||
// 保存用户信息
|
||
let userInfo: any = null;
|
||
if (loginResult.userInfo) {
|
||
userInfo = loginResult.userInfo;
|
||
userStore.setInfo(userInfo);
|
||
} else {
|
||
// 如果没有用户信息,调用获取用户信息接口
|
||
try {
|
||
const { sysAuthBGetLoginUserGet } = await import('@/api/SimpleAdmin/bEndPermissionVerify');
|
||
const userRes = await sysAuthBGetLoginUserGet();
|
||
// request.ts 已经处理了响应格式,如果响应是 { code: 200, data: {...} },会直接返回 data
|
||
userInfo = userRes?.data || userRes || null;
|
||
if (userInfo) {
|
||
userStore.setInfo(userInfo);
|
||
}
|
||
} catch (error) {
|
||
console.error('获取用户信息失败', error);
|
||
}
|
||
}
|
||
|
||
// 检查是否需要选择模块
|
||
if (userInfo?.moduleList && Array.isArray(userInfo.moduleList) && userInfo.moduleList.length > 1) {
|
||
// 如果用户有多个模块,显示模块选择弹窗
|
||
moduleList.value = userInfo.moduleList;
|
||
moduleSelectorVisible.value = true;
|
||
} else {
|
||
// 如果只有一个模块或没有模块,直接跳转
|
||
const moduleId = userInfo?.moduleList?.[0]?.id || userInfo?.defaultModule || 0;
|
||
selectedModuleId.value = moduleId;
|
||
// 设置当前选择的模块(类似 SimpleAdmin)
|
||
if (moduleId) {
|
||
userStore.setModule(moduleId);
|
||
}
|
||
// 调用模块确认处理(会初始化动态路由)
|
||
await handleModuleConfirm(moduleId);
|
||
}
|
||
} else {
|
||
Message.error('登录失败,请检查账号密码');
|
||
if (captchaOpen.value) {
|
||
await loginCaptcha();
|
||
loginForm.validCode = '';
|
||
}
|
||
}
|
||
} catch (err: any) {
|
||
if (err?.errors) {
|
||
// 表单验证错误
|
||
return;
|
||
}
|
||
console.error('登录失败', err);
|
||
const errorMessage = err?.message || err?.response?.data?.message || err?.response?.data?.msg || '登录失败';
|
||
Message.error(errorMessage);
|
||
// 如果验证码开启,刷新验证码
|
||
if (captchaOpen.value) {
|
||
await loginCaptcha();
|
||
loginForm.validCode = '';
|
||
}
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
};
|
||
|
||
// 取消重置按钮,保留方法以防引用
|
||
const resetForm = () => {};
|
||
|
||
// 确认选择模块
|
||
const handleModuleConfirm = async (moduleId: number) => {
|
||
try {
|
||
loading.value = true;
|
||
selectedModuleId.value = moduleId;
|
||
moduleSelectorVisible.value = false;
|
||
|
||
// 保存选择的模块到用户信息中
|
||
if (userStore.userInfo) {
|
||
userStore.setInfo({
|
||
...userStore.userInfo,
|
||
defaultModule: moduleId,
|
||
});
|
||
// 设置当前选择的模块(类似 SimpleAdmin)
|
||
userStore.setModule(moduleId);
|
||
}
|
||
|
||
// 初始化动态路由(获取授权菜单并添加路由)
|
||
const { initDynamicRouter } = await import('@/router/dynamicRouter');
|
||
const homePath = await initDynamicRouter();
|
||
|
||
// 跳转
|
||
const { redirect, ...othersQuery } = router.currentRoute.value.query;
|
||
|
||
// 构建跳转参数
|
||
const redirectParams: any = {
|
||
query: {
|
||
...othersQuery,
|
||
moduleId: moduleId.toString(),
|
||
},
|
||
};
|
||
|
||
// 判断 redirect 是路径还是路由名称
|
||
if (redirect) {
|
||
const redirectStr = redirect as string;
|
||
if (redirectStr.startsWith('/')) {
|
||
// 如果是路径,使用 path
|
||
redirectParams.path = redirectStr;
|
||
} else {
|
||
// 如果是路由名称,使用 name
|
||
redirectParams.name = redirectStr;
|
||
}
|
||
} else {
|
||
// 使用动态路由返回的首页路径
|
||
if (homePath.startsWith('/')) {
|
||
redirectParams.path = homePath;
|
||
} else {
|
||
redirectParams.name = homePath;
|
||
}
|
||
}
|
||
|
||
// 先显示成功消息,再跳转
|
||
Message.success('登录成功');
|
||
|
||
// 使用 nextTick 确保消息显示后再跳转,提升用户体验
|
||
await nextTick();
|
||
await router.push(redirectParams);
|
||
} catch (error) {
|
||
console.error('模块选择失败', error);
|
||
Message.error('模块选择失败');
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
};
|
||
|
||
onMounted(() => {
|
||
// 监听 Enter 键
|
||
document.onkeydown = (e: KeyboardEvent) => {
|
||
if ((e.code === 'Enter' || e.code === 'NumpadEnter') && !loading.value) {
|
||
handleSubmit();
|
||
}
|
||
};
|
||
|
||
// 检查验证码策略
|
||
checkCaptchaPolicy();
|
||
|
||
// 如果有租户选项,默认选中第一个
|
||
if (props.tenantOption === 'CHOSE' && props.tenantOptions.length > 0 && !loginForm.tenantId) {
|
||
loginForm.tenantId = props.tenantOptions[0].id;
|
||
}
|
||
|
||
// 加载组织树
|
||
getOrgTree();
|
||
});
|
||
|
||
onBeforeUnmount(() => {
|
||
document.onkeydown = null;
|
||
});
|
||
</script>
|
||
|
||
<style lang="less" scoped>
|
||
:deep(.arco-form-item) {
|
||
margin-bottom: 24px;
|
||
|
||
// 完全隐藏 label-col 和所有星号相关元素
|
||
.arco-form-item-label-col {
|
||
display: none !important;
|
||
}
|
||
|
||
// 隐藏所有可能的星号元素
|
||
.arco-form-item-label-required,
|
||
.arco-form-item-label-asterisk,
|
||
.arco-form-item-label-required-symbol {
|
||
display: none !important;
|
||
}
|
||
|
||
.arco-input-wrapper {
|
||
border-radius: 8px;
|
||
transition: all 0.3s;
|
||
border-color: #d9d9d9;
|
||
background-color: #ffffff;
|
||
|
||
&:hover {
|
||
border-color: #2D8C8C;
|
||
}
|
||
|
||
&.arco-input-focus {
|
||
border-color: #2D8C8C;
|
||
box-shadow: 0 0 0 2px rgba(45, 140, 140, 0.15);
|
||
}
|
||
|
||
.arco-input {
|
||
font-size: 14px;
|
||
color: #262626;
|
||
|
||
&::placeholder {
|
||
color: #bfbfbf;
|
||
}
|
||
}
|
||
|
||
.arco-input-prefix {
|
||
color: #8c8c8c;
|
||
transition: color 0.3s;
|
||
}
|
||
|
||
&:hover .arco-input-prefix,
|
||
&.arco-input-focus .arco-input-prefix {
|
||
color: #2D8C8C;
|
||
}
|
||
}
|
||
|
||
.arco-select {
|
||
border-radius: 8px;
|
||
transition: all 0.3s;
|
||
border-color: #d9d9d9;
|
||
background-color: #ffffff;
|
||
|
||
&:hover {
|
||
border-color: #2D8C8C;
|
||
}
|
||
|
||
&.arco-select-focus {
|
||
border-color: #2D8C8C;
|
||
box-shadow: 0 0 0 2px rgba(45, 140, 140, 0.15);
|
||
}
|
||
|
||
.arco-select-view-value {
|
||
color: #262626;
|
||
}
|
||
|
||
.arco-select-view-placeholder {
|
||
color: #bfbfbf;
|
||
}
|
||
}
|
||
}
|
||
|
||
.captcha-container {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
|
||
.captcha-img-wrapper {
|
||
flex-shrink: 0;
|
||
|
||
.captcha-img {
|
||
height: 32px;
|
||
cursor: pointer;
|
||
border: 1px solid #d9d9d9;
|
||
border-radius: 4px;
|
||
transition: all 0.3s;
|
||
|
||
&:hover {
|
||
border-color: #2D8C8C;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
.login-btn {
|
||
width: 100%;
|
||
margin-top: 30px;
|
||
|
||
.arco-btn {
|
||
width: 100%;
|
||
height: 48px;
|
||
line-height: 46px;
|
||
border-radius: 12px;
|
||
font-weight: 600;
|
||
transition: all 0.3s;
|
||
|
||
&.arco-btn-primary {
|
||
background: #1890ff;
|
||
border-color: #1890ff;
|
||
|
||
&:hover {
|
||
background: #40a9ff;
|
||
border-color: #40a9ff;
|
||
box-shadow: 0 4px 12px rgba(24, 144, 255, 0.3);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
</style>
|
||
|