Files
MrleeAdmin/app/components/login-dialog/login.vue
T
Mr.April d84d972e73 feat: 适配5.x后端,重构前端架构
主要变更:
1. 重构API目录结构,采用模块化设计,适配5.x后端接口规范
2. 优化布局组件,提升用户界面体验
3. 增强国际化支持,完善多语言配置
4. 新增权限控制功能,支持前端路由权限管理
5. 优化认证流程,支持JWT认证机制
6. 新增中间件和插件,提升系统扩展性
7. 优化状态管理,采用更清晰的模块化设计
8. 新增搜索框、广告等组件,丰富页面功能
9. 更改包管理工具从npm到pnpm,提升依赖管理效率
10. 完善配置系统,支持多环境配置

适配5.x后端特性:
- 采用新的API接口规范
- 支持JWT认证机制
- 适配后端权限控制体系
- 支持后端国际化接口
- 优化数据交互方式
2026-04-07 11:01:11 +08:00

675 lines
17 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="login-container" :class="{'dark-mode': isDarkMode}">
<!-- 登录方式切换图标 -->
<div class="login-mode-toggle">
<Icon
v-if="isAccountLoginActive"
@click="toggleLoginMode"
icon="ant-design:qrcode-outlined"
:color="isDarkMode ? '#ffffff' : '#000000'"
size="50px"
class="login-toggle-icon"
/>
<Icon
v-else
@click="toggleLoginMode"
icon="ant-design:desktop-outlined"
:color="isDarkMode ? '#ffffff' : '#000000'"
size="50px"
class="login-toggle-icon"
/>
</div>
<!-- 账号密码登录表单 -->
<div v-if="isAccountLoginActive" class="login-form-container">
<div class="login-type-tabs">
<div
v-for="(item, index) in availableLoginTypes"
:key="item.type"
class="login-type-tab"
:class="{
'login-type-tab--active': currentLoginType === item.type,
'login-type-tab--margin': index + 1 !== availableLoginTypes.length,
}"
@click="currentLoginType = item.type"
>
{{ item.title }}
</div>
</div>
<el-form
:model="formData"
ref="formRef"
:rules="formRules"
:validate-on-rule-change="false"
class="login-form"
>
<!-- 用户名密码登录 -->
<div v-show="currentLoginType === 'username'" class="login-form-section">
<el-form-item prop="username" class="form-item">
<div class="input-container">
<el-input
v-model="formData.username"
:placeholder="t('auth.login.username_placeholder')"
clearable
:inline-message="true"
:readonly="isUsernameInputReadonly"
@click="isUsernameInputReadonly = false"
@blur="isUsernameInputReadonly = true"
class="login-input"
>
<template #prefix>
<Icon
icon="ant-design:user-outlined"
:color="isDarkMode ? '#ffffff' : '#000000'"
class="input-prefix-icon"
/>
</template>
</el-input>
</div>
</el-form-item>
<el-form-item prop="password" class="form-item">
<div class="input-container">
<el-input
v-model="formData.password"
:placeholder="t('auth.login.password_placeholder')"
type="password"
clearable
show-password
class="login-input"
>
<template #prefix>
<Icon
icon="ant-design:lock-outlined"
:color="isDarkMode ? '#ffffff' : '#000000'"
class="input-prefix-icon"
/>
</template>
</el-input>
</div>
</el-form-item>
</div>
<!-- 手机号登录 -->
<div v-show="currentLoginType === 'mobile'" class="login-form-section">
<el-form-item prop="mobile" class="form-item">
<div class="input-container">
<el-input
v-model="formData.mobile"
:placeholder="t('auth.login.mobile_placeholder')"
clearable
class="login-input"
>
<template #prefix>
<Icon
icon="ant-design:phone-outlined"
:color="isDarkMode ? '#ffffff' : '#000000'"
class="input-prefix-icon"
/>
</template>
</el-input>
</div>
</el-form-item>
<el-form-item prop="mobile_code" class="form-item">
<div class="input-container">
<el-input
v-model="formData.mobile_code"
:placeholder="t('auth.login.code_placeholder')"
class="login-input"
>
<template #prefix>
<Icon
icon="ant-design:key-outlined"
:color="isDarkMode ? '#ffffff' : '#000000'"
class="input-prefix-icon"
/>
</template>
<template #suffix>
<sms-code
:mobile="formData.mobile"
type="login"
v-model="formData.mobile_key"
@click="handleSendSmsCode"
ref="smsCodeRef"
></sms-code>
</template>
</el-input>
</div>
</el-form-item>
</div>
<!-- 注册链接和忘记密码 -->
<div class="form-actions">
<el-button type="primary" link @click="switchToRegister" class="register-link">
{{ t("auth.login.no_account") }},{{ t("auth.login.to_register") }}
</el-button>
<el-button
v-if="currentLoginType === 'username'"
type="primary"
link
@click="switchToForgetPassword"
class="forget-password-link"
>
{{ t("auth.login.resetpwd") }}
</el-button>
</div>
<!-- 登录按钮 -->
<div class="login-button-container">
<el-button
type="primary"
class="login-button"
size="large"
@click="handleLoginSubmit"
:loading="isLoading"
>
{{ isLoading ? t("auth.login.logining") : t("auth.login.login") }}
</el-button>
</div>
<!-- 协议同意 -->
<div v-if="configStore.login.agreement_show" class="agreement-container">
<span
class="iconfont agreement-checkbox"
:class="isAgreeProtocol ? 'icon-xuanze1' : 'icon-checkbox_nol'"
@click="isAgreeProtocol = !isAgreeProtocol"
></span>
{{ t("agree_tips") }}
<NuxtLink :to="protocolUrls.serviceUrl" target="_blank">
<span class="agreement-link">{{ t("auth.register.user_agreement") }}</span>
</NuxtLink>
{{ t("and") }}
<NuxtLink :to="protocolUrls.privacyUrl" target="_blank">
<span class="agreement-link">{{ t("auth.register.privacy_agreement") }}</span>
</NuxtLink>
</div>
</el-form>
</div>
<!-- 微信扫码登录 -->
<div v-else class="wechat-login-container">
<div class="wechat-login-title">{{ t("auth.login.wechat_scan_login") }}</div>
<div class="qrcode-container">
<div class="qrcode-wrapper">
<el-image v-if="wechatQrCode.url" :src="wechatQrCode.url" class="qrcode-image" />
<div v-else class="qrcode-placeholder"></div>
<div v-if="wechatQrCode.isExpired" class="qrcode-overlay">
<span class="qrcode-error-text">{{ wechatQrCode.expiredMessage }}</span>
<span @click="handleGenerateWechatQrCode" class="qrcode-refresh-link">{{ t("auth.login.click_refresh") }}</span>
</div>
</div>
<div class="wechat-tip">
<span class="iconfont icon-weixin1 wechat-icon"></span>
<span class="wechat-text">{{ t("auth.login.wechat_scan_tip") }}</span>
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, reactive, computed, onUnmounted } from "vue";
import { authenticateUser, authenticateWithMobile, authenticateWithWechat,generateWechatQrCode, checkWechatScanStatus } from "@/api/auth";
import useConfigStore from "@/stores/config";
import { useMemberStore } from "@/stores/member";
import QRCode from "qrcode";
import type { FormInstance } from "element-plus";
import { Icon } from '~/components/icon'
import {t}from "~/composables/lang";
import { ElMessage } from "element-plus";
import validate from "~/utils/validate";
// Store 实例
const memberStore = useMemberStore();
const configStore = useConfigStore();
// 暗黑模式状态
const isDarkMode = computed(() => {
const root = document.querySelector('html')
return root?.classList.contains('dark') || false
});
// 协议链接配置
const protocolUrls = computed(() => {
const basePath = location.pathname.includes("web") ? "/web/auth/agreement" : "/auth/agreement";
return {
serviceUrl: `${basePath}?key=service`,
privacyUrl: `${basePath}?key=privacy`,
};
})as any;
// 登录模式状态
const isAccountLoginActive = ref(true);
const currentLoginType = ref("");
let qrCodeTimer: any = null;
// 微信扫码登录状态
const wechatQrCode = ref({
url: "",
key: "",
isExpired: false,
expiredMessage: t("qrcode_generate_failed"),
});
// 表单数据
const isLoading = ref(false);
const isAgreeProtocol = ref(false);
const isUsernameInputReadonly = ref(true);
const formData = reactive({
username: "",
password: "",
mobile: "",
mobile_code: "",
mobile_key: "",
});
// 表单引用
const formRef = ref<FormInstance>();
const smsCodeRef = ref<AnyObject | null>(null);
// 计算属性
const availableLoginTypes = computed(() => {
const types = [];
if (configStore.login.is_username) {
types.push({ type: "username", title: t("auth.login.username_login") });
}
if (configStore.login.is_mobile) {
types.push({ type: "mobile", title: t("auth.login.mobile_login") });
}
currentLoginType.value = types[0] ? types[0].type : "";
return types;
});
const formRules = computed(() => ({
username: {
required: currentLoginType.value === "username",
message: t("auth.login.username_placeholder"),
trigger: ["blur", "change"],
},
password: {
required: currentLoginType.value === "username",
message: t("auth.login.password_placeholder"),
trigger: ["blur", "change"],
},
mobile: [
{
required: currentLoginType.value === "mobile",
message: t("mobile_placeholder"),
trigger: ["blur", "change"],
},
{
validator(rule: any, value: string, callback: any) {
if (currentLoginType.value !== "mobile") return true;
return validate.mobile(value);
},
message: t("auth.login.mobile_error"),
trigger: ["blur"],
},
],
mobile_code: {
required: currentLoginType.value === "mobile",
message: t("auth.login.sms_code_placeholder"),
trigger: ["change"],
},
}));
// 生命周期
onUnmounted(() => {
clearTimeout(qrCodeTimer);
});
// 初始化配置
configStore.getWebGroupConfigs();
// 登录模式切换
const toggleLoginMode = () => {
if (!isAccountLoginActive.value) {
isAccountLoginActive.value = !isAccountLoginActive.value;
clearTimeout(qrCodeTimer);
} else {
// 检查是否开启微信登录
if (!configStore.login?.is_wechat) {
ElMessage.error(t('auth.login.wechat_login_not_available'));
return;
}
isAccountLoginActive.value = !isAccountLoginActive.value;
handleGenerateWechatQrCode();
}
};
// 微信扫码登录相关方法
const handleGenerateWechatQrCode = async () => {
try {
const data = await generateWechatQrCode() as any;
wechatQrCode.value.key = data.scene_id;
if (data.qr_code_url) {
const qrCodeUrl = await QRCode.toDataURL(data.qr_code_url, {
errorCorrectionLevel: "L",
margin: 0,
width: 100,
});
wechatQrCode.value.url = qrCodeUrl;
wechatQrCode.value.isExpired = false;
setTimeout(() => {
checkScan(wechatQrCode.value.key);
}, 1000);
}
} catch (error) {
wechatQrCode.value.isExpired = true;
wechatQrCode.value.expiredMessage = t("qrcode_generate_failed");
}
};
/**
* 检查微信扫码登录状态
* @param key - 扫码登录密钥
*/
const checkScan = (scene_id: string) => {
checkWechatScanStatus({ scene_id })
.then((data: any) => {
switch (data.status) {
case "wait":
qrCodeTimer = setTimeout(() => {
checkScan(wechatQrCode?.value.key);
}, 1000);
break;
case "success":
handleWechatLoginSuccess(data.login_data);
break;
case "fail":
wechatQrCode.value.expiredMessage = data.fail_reason;
wechatQrCode.value.isExpired = true;
break;
}
})
.catch((error) => {
wechatQrCode.value.isExpired = true;
wechatQrCode.value.expiredMessage = error.msg || t("scan_check_failed");
});
};
const handleWechatLoginSuccess = (loginData: any) => {
if (!loginData.access_token) {
useCookie("openId").value = loginData.openid;
navigateTo("/auth/bind");
memberStore.logClose();
} else {
memberStore.setToken(loginData.access_token, loginData.refresh_token || '');
memberStore.logClose();
}
};
// 表单处理方法
const handleLoginSubmit = async () => {
const isValid = await formRef.value?.validate();
if (!isValid) return;
if (configStore.login.agreement_show && !isAgreeProtocol.value) {
ElMessage.error(t("auth.login.is_agree_tips"));
return;
}
if (isLoading.value) return;
isLoading.value = true;
try {
const loginMethod = currentLoginType.value === "username" ? authenticateUser : authenticateWithMobile;
const data = await loginMethod(formData) as any;
await memberStore.setToken(data?.access_token || '', data?.refresh_token || '');
memberStore.logClose();
} catch (error) {
isLoading.value = false;
}
};
const handleSendSmsCode = async () => {
const isValid = await formRef.value?.validateField("mobile");
if (isValid) {
smsCodeRef.value?.send();
}
};
// 组件通信
const emit = defineEmits(["typeChange"]);
const switchToRegister = () => {
emit("typeChange", "register");
};
const switchToForgetPassword = () => {
emit("typeChange", "forget-password");
};
</script>
<style lang="scss" scoped>
.login-container {
position: relative;
width: 100%;
&.dark-mode {
background: var(--el-bg-color);
color: var(--el-text-color-primary);
}
}
.login-mode-toggle {
position: absolute;
top: 0;
right: 0;
}
.login-toggle-icon {
font-size: 50px;
cursor: pointer;
}
.login-form-container {
background: var(--el-bg-color);
width: 100%;
padding: 60px 30px;
border-radius: var(--rounded-big);
}
.login-type-tabs {
display: flex;
align-items: flex-end;
justify-content: center;
margin-bottom: 30px;
}
.login-type-tab {
font-size: 18px;
cursor: pointer;
color: var(--el-text-color-secondary);
line-height: 24px;
font-family: "OppoSans-R";
&--active {
color: var(--el-text-color-primary);
font-weight: 600;
}
&--margin {
margin-right: 70px;
}
}
.login-form {
width: 100%;
}
.form-item {
margin-bottom: 20px;
}
.input-container {
flex: 1;
height: 50px;
border: 1px solid var(--el-border-color);
border-radius: 8px;
display: flex;
align-items: center;
background: var(--el-bg-color);
}
.login-input {
width: 100%;
border: none;
:deep(.el-input__wrapper) {
box-shadow: none;
background: transparent;
.el-input__inner {
color: var(--el-text-color-primary);
&::placeholder {
color: var(--el-text-color-placeholder);
}
}
}
}
.input-prefix-icon {
margin-right: 14px;
}
.form-actions {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
gap: 10px;
}
.register-link,
.forget-password-link {
font-size: 12px;
}
.login-button-container {
margin-top: 20px;
}
.login-button {
width: 100%;
height: 50px;
border-radius: 8px;
font-family: "OppoSans-M";
}
.agreement-container {
font-size: 12px;
line-height: 24px;
display: flex;
align-items: center;
width: 100%;
margin-top: 20px;
color: var(--el-text-color-secondary);
}
.agreement-checkbox {
color: var(--el-color-primary);
margin-right: 5px;
cursor: pointer;
}
.agreement-link {
color: var(--el-color-primary);
margin: 0 4px;
&:hover {
color: var(--el-color-primary-light-3);
}
}
.wechat-login-container {
display: flex;
flex-direction: column;
align-items: center;
padding: 60px 30px;
background: var(--el-bg-color);
}
.wechat-login-title {
font-size: 18px;
color: var(--el-text-color-primary);
font-weight: 600;
line-height: 24px;
font-family: "OppoSans-R";
}
.qrcode-container {
margin-top: 30px;
}
.qrcode-wrapper {
padding: 20px;
border: 1px solid var(--el-border-color);
border-radius: var(--rounded-small);
position: relative;
background: var(--el-bg-color);
}
.qrcode-image {
width: 200px;
height: 200px;
}
.qrcode-placeholder {
width: 202px;
height: 202px;
background: var(--el-bg-color-page);
}
.qrcode-overlay {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
position: absolute;
inset: 0;
background: var(--el-bg-color-overlay);
}
.qrcode-error-text {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.qrcode-refresh-link {
font-size: 12px;
cursor: pointer;
color: var(--el-color-primary);
margin-top: 8px;
&:hover {
color: var(--el-color-primary-light-3);
}
}
.wechat-tip {
margin-top: 22px;
display: flex;
align-items: center;
justify-content: center;
}
.wechat-icon {
color: #00c22c;
font-size: 16px;
}
.wechat-text {
font-size: 14px;
color: var(--el-text-color-secondary);
margin-left: 4px;
}
</style>