feat(authentication): 登录页新增用户协议与隐私政策勾选校验

登录前必须勾选《用户协议》和《隐私政策》,未勾选时表单红字校验并触发协议行左右抖动动画;勾选状态在登录成功后持久化到 localStorage,后续登录自动勾选免重复操作;账号登录与第三方登录统一拦截,未同意均阻断并联动红字提示;新增用户协议/隐私政策占位页与公开路由,协议链接新标签页打开;同步 zh-CN/en-US 国际化文案
This commit is contained in:
DaxPay Dev
2026-06-28 12:44:12 +08:00
parent 907ceb4c48
commit 839ccddc95
7 changed files with 189 additions and 2 deletions

View File

@@ -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,
},
},
],
},
// 第三方绑定回调(独立路由,弹窗模式)

View File

@@ -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>

View File

@@ -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>

View File

@@ -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

View File

@@ -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>

View File

@@ -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",

View File

@@ -44,6 +44,14 @@
"google": "Google",
"loginAgainTitle": "重新登录",
"loginAgainSubTitle": "您的登录状态已过期,请重新登录以继续。",
"agreePrefix": "我已阅读并同意",
"and": "和",
"termsOfService": "《用户协议》",
"privacyPolicy": "《隐私政策》",
"agreeRequiredTip": "请先阅读并同意用户协议和隐私政策",
"termsTitle": "用户协议",
"privacyTitle": "隐私政策",
"agreementContentPlaceholder": "协议内容正在完善中,正式版将在后续版本提供。",
"layout": {
"center": "居中",
"alignLeft": "居左",