mirror of
https://gitee.com/bootx/dax-pay-ui
synced 2026-08-13 07:45:41 +08:00
feat(authentication): 登录页新增用户协议与隐私政策勾选校验
登录前必须勾选《用户协议》和《隐私政策》,未勾选时表单红字校验并触发协议行左右抖动动画;勾选状态在登录成功后持久化到 localStorage,后续登录自动勾选免重复操作;账号登录与第三方登录统一拦截,未同意均阻断并联动红字提示;新增用户协议/隐私政策占位页与公开路由,协议链接新标签页打开;同步 zh-CN/en-US 国际化文案
This commit is contained in:
@@ -122,6 +122,28 @@ const coreRoutes: RouteRecordRaw[] = [
|
||||
hideInMenu: true,
|
||||
},
|
||||
},
|
||||
// 国际化:用户协议(公开页,登录前可访问)
|
||||
{
|
||||
name: 'AgreementTerms',
|
||||
path: 'agreement/terms',
|
||||
component: () => import('#/views/_core/authentication/agreement-terms.vue'),
|
||||
meta: {
|
||||
// 用户协议
|
||||
title: $t('authentication.termsTitle'),
|
||||
hideInMenu: true,
|
||||
},
|
||||
},
|
||||
// 国际化:隐私政策(公开页,登录前可访问)
|
||||
{
|
||||
name: 'AgreementPrivacy',
|
||||
path: 'agreement/privacy',
|
||||
component: () => import('#/views/_core/authentication/agreement-privacy.vue'),
|
||||
meta: {
|
||||
// 隐私政策
|
||||
title: $t('authentication.privacyTitle'),
|
||||
hideInMenu: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
// 第三方绑定回调(独立路由,弹窗模式)
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
<script lang="ts" setup>
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import { AuthPageCard } from './components';
|
||||
|
||||
defineOptions({ name: 'AgreementPrivacy' });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 国际化:隐私政策 -->
|
||||
<AuthPageCard :title="$t('authentication.privacyTitle')">
|
||||
<!-- 占位提示,后续替换为正式协议内容 -->
|
||||
<a-alert :message="$t('authentication.agreementContentPlaceholder')" type="info" show-icon />
|
||||
</AuthPageCard>
|
||||
</template>
|
||||
@@ -0,0 +1,15 @@
|
||||
<script lang="ts" setup>
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import { AuthPageCard } from './components';
|
||||
|
||||
defineOptions({ name: 'AgreementTerms' });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 国际化:用户协议 -->
|
||||
<AuthPageCard :title="$t('authentication.termsTitle')">
|
||||
<!-- 占位提示,后续替换为正式协议内容 -->
|
||||
<a-alert :message="$t('authentication.agreementContentPlaceholder')" type="info" show-icon />
|
||||
</AuthPageCard>
|
||||
</template>
|
||||
@@ -11,12 +11,16 @@
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
showDivider: true,
|
||||
mode: 'LOGIN',
|
||||
// 登录前的协议勾选守卫(触发调用方表单校验,返回是否已同意)
|
||||
ensureAgreement: undefined,
|
||||
});
|
||||
|
||||
interface Props {
|
||||
showDivider?: boolean;
|
||||
// 授权场景: LOGIN=未登录直接登录, BIND=已登录绑定
|
||||
mode?: 'BIND' | 'LOGIN';
|
||||
// 登录前的协议勾选守卫(触发调用方表单校验,返回是否已同意)
|
||||
ensureAgreement?: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
// 已启用的第三方平台列表(由后端配置驱动)
|
||||
@@ -34,6 +38,11 @@
|
||||
* 点击三方图标, 获取授权地址并跳转
|
||||
*/
|
||||
async function handleSocialLogin(source: string) {
|
||||
// 仅登录场景校验协议勾选,提示由父表单红字显示(绑定场景不校验)
|
||||
if (props.mode === 'LOGIN' && props.ensureAgreement) {
|
||||
const ok = await props.ensureAgreement();
|
||||
if (!ok) return;
|
||||
}
|
||||
const { data: url } = await SocialApi.render(source, 'admin', props.mode);
|
||||
if (url) {
|
||||
// 跳转到第三方授权页, 授权后由后端回调处理并重定向回前端 /oauth-callback
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
import type { FormInstance, FormProps } from 'antdv-next';
|
||||
|
||||
import { reactive, ref } from 'vue';
|
||||
import { nextTick, reactive, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { $t } from '@vben/locales';
|
||||
@@ -19,12 +19,25 @@
|
||||
|
||||
const formRef = ref<FormInstance>();
|
||||
|
||||
// 用户协议/隐私政策"已同意"在 localStorage 中的键名(登录成功后持久化,下次免勾选)
|
||||
const AGREEMENT_ACCEPTED_KEY = 'daxpay_admin_agreement_accepted';
|
||||
|
||||
// 协议未勾选时触发的抖动动画状态(校验失败时左右轻晃,提醒用户)
|
||||
const shaking = ref(false);
|
||||
let shakeTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
// 用户协议 / 隐私政策页面地址(新标签页打开,避免离开登录页丢失已输入内容)
|
||||
const termsUrl = router.resolve({ name: 'AgreementTerms' }).href;
|
||||
const privacyUrl = router.resolve({ name: 'AgreementPrivacy' }).href;
|
||||
|
||||
// 表单数据
|
||||
const formData = reactive({
|
||||
account: '',
|
||||
password: '',
|
||||
// 国际化:验证码输入值
|
||||
captchaCode: '',
|
||||
// 是否已阅读并同意用户协议和隐私政策(从本地恢复,登录成功后持久化)
|
||||
agreed: localStorage.getItem(AGREEMENT_ACCEPTED_KEY) === 'true',
|
||||
});
|
||||
|
||||
// 验证码相关状态
|
||||
@@ -52,6 +65,14 @@
|
||||
trigger: 'blur',
|
||||
},
|
||||
],
|
||||
// 国际化:请先阅读并同意用户协议和隐私政策
|
||||
agreed: [
|
||||
{
|
||||
validator: (_rule, value) =>
|
||||
value ? Promise.resolve() : Promise.reject($t('authentication.agreeRequiredTip')),
|
||||
trigger: 'change',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -84,8 +105,14 @@
|
||||
captchaKey: needCaptcha.value ? captchaKey.value : undefined,
|
||||
captchaCode: needCaptcha.value ? formData.captchaCode : undefined,
|
||||
});
|
||||
// 登录成功后持久化"已同意",后续登录免勾选
|
||||
localStorage.setItem(AGREEMENT_ACCEPTED_KEY, 'true');
|
||||
}
|
||||
} catch (error: any) {
|
||||
// 表单校验失败:协议未勾选时触发协议行抖动
|
||||
if (!formData.agreed) {
|
||||
triggerShake();
|
||||
}
|
||||
// 业务错误码由全局拦截器统一提示,此处仅处理验证码联动
|
||||
const code = error?.code;
|
||||
if (code === 40_001) {
|
||||
@@ -100,6 +127,36 @@
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 触发协议行抖动动画(连续校验失败可重复触发)
|
||||
*/
|
||||
function triggerShake() {
|
||||
if (shakeTimer) {
|
||||
clearTimeout(shakeTimer);
|
||||
}
|
||||
// 先复位再触发,确保连续失败时动画能重新播放
|
||||
shaking.value = false;
|
||||
nextTick(() => {
|
||||
shaking.value = true;
|
||||
shakeTimer = setTimeout(() => (shaking.value = false), 400);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 协议勾选守卫:触发 agreed 表单校验,返回是否已同意
|
||||
* 供三方面板登录前复用,提示与账号登录一致(红字+抖动)
|
||||
*/
|
||||
async function ensureAgreement(): Promise<boolean> {
|
||||
try {
|
||||
await formRef.value?.validateFields(['agreed']);
|
||||
return true;
|
||||
} catch {
|
||||
// 未同意时触发协议行抖动
|
||||
triggerShake();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 跳转到验证码登录
|
||||
*/
|
||||
@@ -174,6 +231,19 @@
|
||||
</div>
|
||||
</a-form-item>
|
||||
|
||||
<!-- 国际化:我已阅读并同意《用户协议》和《隐私政策》 -->
|
||||
<a-form-item name="agreed" class="agreement-item">
|
||||
<a-checkbox v-model:checked="formData.agreed" :class="{ 'agreement-shake': shaking }">
|
||||
<span>
|
||||
{{ $t('authentication.agreePrefix') }}
|
||||
<!-- 新标签页打开协议页,避免离开登录页丢失输入 -->
|
||||
<a :href="termsUrl" target="_blank" @click.stop>{{ $t('authentication.termsOfService') }}</a>
|
||||
{{ $t('authentication.and') }}
|
||||
<a :href="privacyUrl" target="_blank" @click.stop>{{ $t('authentication.privacyPolicy') }}</a>
|
||||
</span>
|
||||
</a-checkbox>
|
||||
</a-form-item>
|
||||
|
||||
<a-button
|
||||
type="primary"
|
||||
html-type="submit"
|
||||
@@ -200,6 +270,46 @@
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<AuthThirdPartyPanel v-if="!authStore.twoFactorRequired" />
|
||||
<AuthThirdPartyPanel v-if="!authStore.twoFactorRequired" :ensure-agreement="ensureAgreement" />
|
||||
</AuthPageCard>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 协议勾选项:收紧下间距,使其与常规表单项视觉一致 */
|
||||
.agreement-item {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* 取消 control 默认 min-height,避免 checkbox 被撑高、错误提示偏远 */
|
||||
.agreement-item :deep(.ant-form-item-control-input) {
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
/* 未勾选校验失败时,协议行左右轻晃提醒(曲线参考 vben-lock-shake) */
|
||||
.agreement-shake {
|
||||
animation: login-agree-shake 0.4s ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes login-agree-shake {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
20% {
|
||||
transform: translateX(-8px);
|
||||
}
|
||||
|
||||
40% {
|
||||
transform: translateX(8px);
|
||||
}
|
||||
|
||||
60% {
|
||||
transform: translateX(-6px);
|
||||
}
|
||||
|
||||
80% {
|
||||
transform: translateX(6px);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -44,6 +44,14 @@
|
||||
"google": "Google",
|
||||
"loginAgainTitle": "Please Log In Again",
|
||||
"loginAgainSubTitle": "Your login session has expired. Please log in again to continue.",
|
||||
"agreePrefix": "I have read and agree to the",
|
||||
"and": "and",
|
||||
"termsOfService": "Terms of Service",
|
||||
"privacyPolicy": "Privacy Policy",
|
||||
"agreeRequiredTip": "Please read and agree to the Terms of Service and Privacy Policy first",
|
||||
"termsTitle": "Terms of Service",
|
||||
"privacyTitle": "Privacy Policy",
|
||||
"agreementContentPlaceholder": "This agreement is being drafted and will be available in a future release.",
|
||||
"layout": {
|
||||
"center": "Align Center",
|
||||
"alignLeft": "Align Left",
|
||||
|
||||
@@ -44,6 +44,14 @@
|
||||
"google": "Google",
|
||||
"loginAgainTitle": "重新登录",
|
||||
"loginAgainSubTitle": "您的登录状态已过期,请重新登录以继续。",
|
||||
"agreePrefix": "我已阅读并同意",
|
||||
"and": "和",
|
||||
"termsOfService": "《用户协议》",
|
||||
"privacyPolicy": "《隐私政策》",
|
||||
"agreeRequiredTip": "请先阅读并同意用户协议和隐私政策",
|
||||
"termsTitle": "用户协议",
|
||||
"privacyTitle": "隐私政策",
|
||||
"agreementContentPlaceholder": "协议内容正在完善中,正式版将在后续版本提供。",
|
||||
"layout": {
|
||||
"center": "居中",
|
||||
"alignLeft": "居左",
|
||||
|
||||
Reference in New Issue
Block a user