UNPKG

@jianghujs/jianghu

Version:

Progressive Enterprise Framework

442 lines (395 loc) 14.2 kB
{% extends 'template/jhTemplateV4.html'%} {% block vueTemplate %} <script type="text/html" id="app-template"> <div> <v-app> <v-main> <v-row class="align-center" style="height: 100vh"> <v-col cols="12" align="center"> <div class="mb-10 text-h7 font-weight-bold success--text"><$ ctx.app.config.appTitle $></div> <v-card class="login-form-card pa-8"> <v-card-text class="pa-0"> <!-- 第一步:用户名密码登录 --> <div v-if="loginStep === 1"> <div class="title">登录您的账户</div> <v-form ref="loginForm" lazy-validation> <!--表单 [注:登录表单包含密码输入框,chrome密码自动填充的时候会与vuetify的v-input组件样式冲突,使用原生input避免冲突]--> <v-row class="my-0"> <v-col cols="12"> <input class="jh-cus-input" v-model="userId" placeholder="用户名"/> </v-col> <v-col cols="12"> <div class="password-wrapper"> <input class="jh-cus-input" v-model="password" :type="isPasswordShown ? 'text' : 'password'" data-type="password" @keyup.enter.exact="doUiAction('login')" placeholder="密码" /> <v-icon @click="isPasswordShown = !isPasswordShown" small class="mdi-eye">{{isPasswordShown ? 'mdi-eye-off-outline' : 'mdi-eye-outline'}}</v-icon> </div> </v-col> <v-col cols="12" v-if="captchaSvg"> <div class="captcha-wrapper"> <div class="captcha-image" @click="getCaptcha" title="点击刷新验证码" > <img :src="captchaSvg" class="captcha-img" alt="验证码" /> </div> <div>=</div> <input class="jh-cus-input captcha-input" v-model="captchaCode" type="text" placeholder="验证码" /> </div> </v-col> <v-col cols="12"> <v-checkbox class="jh-v-input" dense single-line filled v-model="isRememberPassword" color="success" label="记住密码"/> </v-col> </v-row> <!--操作按钮--> <v-row class="my-0 px-3"> <v-btn block color="success" @click="doUiAction('login')" small :loading="isLoggingIn">登录</v-btn> </v-row> </v-form> </div> <!-- 第二步:MFA验证 --> <div v-if="loginStep === 2"> <div class="title mb-4">二次验证</div> <div class="text-center mb-4"> <v-icon large color="primary">mdi-shield-key-outline</v-icon> <div class="mt-2 text-body-2">请输入Microsoft Authenticator中的验证码</div> <div class="mt-1 text-caption text--secondary">验证码每30秒更新一次</div> </div> <!-- MFA验证组件 --> <mfa-verification-component :challenge-id="mfaChallengeId" page-id="login" action-id="verifyMfaLogin" submit-button-text="登录" :show-timer="false" @verification-success="onMfaLoginSuccess" @verification-failed="onMfaLoginFailed" @challenge-expired="onMfaLoginExpired" @max-retry-exceeded="onMfaLoginExpired" @system-error="onMfaLoginFailed" ></mfa-verification-component> <v-row class="my-2 px-3"> <v-btn block text small @click="doUiAction('backToStep1')"> <v-icon left small>mdi-arrow-left</v-icon> 返回重新输入密码 </v-btn> </v-row> </div> <!-- 需要绑定MFA提示 --> <div v-if="loginStep === 3"> <div class="title mb-4">需要绑定MFA</div> <v-alert type="info" class="mb-4"> <div class="text-h6">🛡️ 安全提醒</div> <div class="mt-2">系统已启用多因子认证(MFA),请先绑定Microsoft Authenticator应用。</div> </v-alert> <v-row class="my-0 px-3"> <v-btn block color="primary" @click="redirectToPreMfaLoginURL" small> 前往绑定MFA </v-btn> </v-row> <v-row class="my-2 px-3"> <v-btn block text small @click="doUiAction('backToStep1')"> <v-icon left small>mdi-arrow-left</v-icon> 返回登录 </v-btn> </v-row> </div> </v-card-text> </v-card> </v-col> </v-row> </v-main> </v-app> <jh-toast/> <jh-mask/> <jh-confirm-dialog/> </div> </script> <div id="app"> </div> {% endblock %} {% block vueScript %} <!-- 加载页面组件 --> {% include 'utility/jianghuJs/prepareDeviceIdV4.html' %} <!-- 引入MFA验证组件 --> {% include 'component/jianghuJs/jhMfaVerification.html' %} <script type="module"> new Vue({ el: '#app', template: '#app-template', vueComponent: 'page', vuetify: new Vuetify(), components: {}, data() { return { // 登录步骤: 1-用户名密码, 2-MFA验证, 3-需要绑定MFA loginStep: 1, userId: '', password: '', isPasswordShown: false, isRememberPassword: true, isLoggingIn: false, // MFA相关数据 mfaChallengeId: null, loginResult: null, redirectUrl: null, // 验证码相关数据 // 验证码 enableLoginCaptcha: '<$ ctx.app.config.jianghuConfig.enableLoginCaptcha $>' === 'true', captchaSvg: '', captchaCode: '' }; }, async mounted() { await this.doUiAction('getUrlObj'); await this.doUiAction('getCaptcha'); }, methods: { async doUiAction(uiActionId, uiActionData) { switch (uiActionId) { case 'getUrlObj': await this.getUrlObj(); break; case 'getCaptcha': await this.getCaptcha(); break; case 'login': await this.prepareLoginData(); await this.doMfaPasswordLogin(); break; case 'backToStep1': await this.backToStep1(); break; default: console.error("[doUiAction] uiActionId not found", { uiActionId }); break; } }, /** * 获取URL参数 */ async getUrlObj() { const urlObj = new URLSearchParams(location.search); if (urlObj.get('errorReason')) { window.vtoast.fail(urlObj.get('errorReason')); } this.redirectUrl = urlObj.get('redirectUrl'); }, /** * 准备登录数据 */ async prepareLoginData() { this.deviceId = window.deviceId; this.userId = _.replace(this.userId, /\s+/g, ''); this.password = _.toString(this.password); }, /** * 执行MFA密码登录 */ async doMfaPasswordLogin() { this.isLoggingIn = true; try { const result = (await window.jianghuAxios({ data: { appData: { pageId: 'login', actionId: 'passwordLogin', actionData: { userId: this.userId, password: this.password, deviceId: this.deviceId, captchaCode: this.captchaCode }, } } })).data.appData.resultData; if (result.success && !result.needBind) { // 情况A: 直接登录成功(无需MFA) this.loginResult = result; await this.setLocalStorage(); await this.redirectToPreLoginURL(); window.vtoast.success('登录成功'); } else if (result.success && result.needBind) { // 情况B: 登录成功但需要绑定MFA - 先保存登录信息,然后跳转到绑定页面 this.loginResult = result; await this.setLocalStorage(); await this.redirectToPreMfaLoginURL(); window.vtoast.success('登录成功,请绑定MFA'); } else if (result.needMfa) { // 情况C: 需要MFA验证 this.mfaChallengeId = result.challengeId; this.loginStep = 2; window.vtoast.success('密码验证成功,请输入MFA验证码'); } else { // 其他错误情况 window.vtoast.fail(result.message || '登录失败'); } } catch (error) { console.error('登录失败:', error); this.getCaptcha(); const { errorCode, errorReason } = error || {}; if (errorCode) { window.vtoast.fail(errorReason); } else { window.vtoast.fail('登录失败,请检查用户名和密码'); } } finally { this.isLoggingIn = false; } }, /** * 获取验证码 */ async getCaptcha() { if (!this.enableLoginCaptcha) { return; } try { const result = await window.jianghuAxios({ data: { appData: { pageId: 'login', actionId: 'getLoginCaptcha', actionData: { deviceId: window.deviceId } } } }); let captchaSvg = result.data.appData.resultData; if (captchaSvg) { this.captchaSvg = captchaSvg; this.captchaCode = ''; } } catch (error) { console.error('获取验证码失败:', error); } }, /** * 返回第一步 */ async backToStep1() { this.loginStep = 1; this.mfaChallengeId = null; this.password = ''; // 清空密码,增加安全性 }, /** * 设置本地存储 */ async setLocalStorage() { if (this.loginResult) { localStorage.setItem(`${window.appInfo.appId}_authToken`, this.loginResult.authToken); localStorage.setItem(`${window.appInfo.appId}_userId`, this.loginResult.userId); localStorage.setItem(`${window.appInfo.appId}_deviceId`, this.deviceId); } }, /** * 重定向到登录前页面 */ async redirectToPreLoginURL() { let redirectTo = `/${window.appInfo.appId}`; if (this.redirectUrl) { redirectTo = decodeURIComponent(this.redirectUrl); } window.location.href = redirectTo; }, async redirectToPreMfaLoginURL() { let redirectTo = `/${window.appInfo.appId}`; if (this.redirectUrl) { redirectTo = decodeURIComponent(this.redirectUrl); // 添加MFA参数到重定向URL if (redirectTo.indexOf('?') > -1) { redirectTo += '&mfa=true'; } else { redirectTo += '?mfa=true'; } } window.location.href = redirectTo; }, // ========== MFA组件事件处理器 ========== onMfaLoginSuccess(result) { this.loginResult = result; this.setLocalStorage(); this.redirectToPreLoginURL(); window.vtoast.success('登录成功'); }, onMfaLoginFailed(error) { console.error('MFA验证失败:', error); // MFA验证失败,用户可以重试,不需要特殊处理 }, onMfaLoginExpired() { window.vtoast.fail('验证已过期,请重新登录'); this.doUiAction('backToStep1'); }, } }); </script> <style scoped> .login-form-card { width: 400px; } /* ---------- 输入框 >>>>>>>>>> -------- */ .jh-cus-input { border: 1px solid rgba(0, 0, 0, .06); width: 100%; height: 32px; border-radius: 6px; padding: 0 12px; color: #5E6278 !important; outline: none; font-size: 13px !important; } .jh-cus-input:focus, .jh-cus-input:focus-visible, input:focus-visible { border: thin solid #4caf50 !important; } /* ---------- <<<<<<<<<<< 输入框 -------- */ /* ---------- 密码框 >>>>>>>>>> -------- */ .password-wrapper { position: relative; } .password-wrapper .mdi-eye { position: absolute; right: 8px; top: 8px; } /* ---------- <<<<<<<<<<< 密码框 -------- */ /* ---------- 验证码框 >>>>>>>>>> -------- */ .captcha-wrapper { display: flex; align-items: center; gap: 8px; } .captcha-image { cursor: pointer; border: 1px solid rgba(0, 0, 0, .06); border-radius: 4px; overflow: hidden; min-width: 80px; height: 32px; } .captcha-img { width: 100%; height: 100%; object-fit: cover; } .captcha-input { flex: 1; min-width: 100px; } /* ---------- <<<<<<<<<<< 验证码框 -------- */ </style> {% endblock %}