feat(admin): 用户协议与隐私政策展示页接入后端接口

- 新增 AgreementContentView 公共组件,调用 findDefault 接口渲染 Markdown 正文
- agreement-terms/agreement-privacy 改为薄壳页面
- 协议路由移出 Authentication children 为独立顶级路由,全屏展示
- request.ts 增加 silentError 静默错误支持,协议未配置时不弹 toast
- 10 语种 authentication.json 同步新增 agreementEmpty/agreementEffectiveTime
This commit is contained in:
DaxPay Dev
2026-07-16 13:56:40 +08:00
parent 6afd693b78
commit cf3fa26446
17 changed files with 167 additions and 36 deletions

View File

@@ -93,6 +93,10 @@ function createRequestClient(baseURL: string, options?: RequestClientOptions) {
// 通用的错误处理,如果没有进入上面的错误处理逻辑,就会进入这里
client.addResponseInterceptor(
errorMessageResponseInterceptor((msg: string, error) => {
// 静默错误(请求 config 中设置 silentError=true), 不弹全局 toast, 由业务自行处理
if (error?.config?.silentError) {
return;
}
const { message } = useMessage();
// HTTP 错误(401等)响应体在 response.data; 业务错误(200但code非0)在顶层 data
const body = error?.response?.data ?? error?.data ?? {};

View File

@@ -46,9 +46,13 @@ export const UserProtocolApi = {
clientTypeOptions(): Promise<Result<LabelValue[]>> {
return defHttp.get({ url: '/user/protocol/client-type-options' });
},
/** 查询默认协议内容(对外, 各端展示用) */
/** 查询默认协议内容(对外, 各端展示用; 协议未配置时会抛异常, 静默错误由调用方自行处理空态) */
findDefault(type: string, clientType: string, language?: string): Promise<Result<UserProtocolContent>> {
return defHttp.get({ url: '/user/protocol/find-default', params: { type, clientType, language } });
return defHttp.get({
url: '/user/protocol/find-default',
params: { type, clientType, language },
silentError: true,
});
},
};

View File

@@ -51,6 +51,30 @@ const socialBindCallbackRoute: RouteRecordRaw = {
},
};
/** 协议展示页(独立全屏路由,不走 AuthPageLayout免登录预览协议正文 */
const agreementTermsRoute: RouteRecordRaw = {
name: 'AgreementTerms',
path: '/auth/agreement/terms',
component: () => import('#/views/_core/authentication/agreement-terms.vue'),
meta: {
// 用户协议
title: 'authentication.termsTitle',
hideInMenu: true,
},
};
/** 隐私政策展示页(独立全屏路由,不走 AuthPageLayout免登录预览协议正文 */
const agreementPrivacyRoute: RouteRecordRaw = {
name: 'AgreementPrivacy',
path: '/auth/agreement/privacy',
component: () => import('#/views/_core/authentication/agreement-privacy.vue'),
meta: {
// 隐私政策
title: 'authentication.privacyTitle',
hideInMenu: true,
},
};
// 服务不可用提示页(后端不可用时的兜底,独立全屏,不走权限拦截)
const serviceUnavailableRoute: RouteRecordRaw = {
name: 'ServiceUnavailable',
@@ -131,30 +155,13 @@ const coreRoutes: RouteRecordRaw[] = [
hideInMenu: true,
},
},
{
name: 'AgreementTerms',
path: 'agreement/terms',
component: () => import('#/views/_core/authentication/agreement-terms.vue'),
meta: {
// 用户协议
title: 'authentication.termsTitle',
hideInMenu: true,
},
},
{
name: 'AgreementPrivacy',
path: 'agreement/privacy',
component: () => import('#/views/_core/authentication/agreement-privacy.vue'),
meta: {
// 隐私政策
title: 'authentication.privacyTitle',
hideInMenu: true,
},
},
],
},
// 第三方绑定回调(独立路由,弹窗模式)
socialBindCallbackRoute,
// 协议展示页(独立全屏路由,不走 AuthPageLayout
agreementTermsRoute,
agreementPrivacyRoute,
// 服务不可用提示页(后端不可用兜底,独立全屏,不走权限拦截)
serviceUnavailableRoute,
];

View File

@@ -1,15 +1,10 @@
<script lang="ts" setup>
import { $t } from '@vben/locales';
import { AuthPageCard } from './components';
import { AgreementContentView } from './components';
defineOptions({ name: 'AgreementPrivacy' });
</script>
<template>
<!-- 国际化隐私政策 -->
<AuthPageCard :title="$t('authentication.privacyTitle')">
<!-- 占位提示后续替换为正式协议内容 -->
<a-alert :message="$t('authentication.agreementContentPlaceholder')" type="info" show-icon />
</AuthPageCard>
<AgreementContentView type="PRIVACY_POLICY" />
</template>

View File

@@ -1,15 +1,10 @@
<script lang="ts" setup>
import { $t } from '@vben/locales';
import { AuthPageCard } from './components';
import { AgreementContentView } from './components';
defineOptions({ name: 'AgreementTerms' });
</script>
<template>
<!-- 国际化用户协议 -->
<AuthPageCard :title="$t('authentication.termsTitle')">
<!-- 占位提示后续替换为正式协议内容 -->
<a-alert :message="$t('authentication.agreementContentPlaceholder')" type="info" show-icon />
</AuthPageCard>
<AgreementContentView type="USER_AGREEMENT" />
</template>

View File

@@ -0,0 +1,105 @@
<script lang="ts" setup>
import { computed, onMounted, ref, watch } from 'vue';
import { $t, useI18n } from '@vben/locales';
import dayjs from 'dayjs';
import { MdPreview } from 'md-editor-v3';
import { UserProtocolApi, type UserProtocolContent } from '#/api/system/protocol/user-protocol.api';
import 'md-editor-v3/lib/preview.css';
defineOptions({ name: 'AgreementContentView' });
const props = defineProps<{
/** 协议类型: USER_AGREEMENT(用户协议) | PRIVACY_POLICY(隐私政策) */
type: 'PRIVACY_POLICY' | 'USER_AGREEMENT';
}>();
const { locale } = useI18n();
// 协议内容
const content = ref<UserProtocolContent>({});
const loading = ref(false);
const isEmpty = ref(false);
// 标题: 优先取后端返回的 title, 否则按协议类型取 i18n
const title = computed(() => {
return (
content.value.title ||
$t(props.type === 'USER_AGREEMENT' ? 'authentication.termsTitle' : 'authentication.privacyTitle')
);
});
// 版本标签与生效时间等元信息
const metaText = computed(() => {
const parts: string[] = [];
if (content.value.versionLabel) {
parts.push(content.value.versionLabel);
} else if (content.value.versionNo) {
// 版本号
parts.push(`v${content.value.versionNo}`);
}
if (content.value.effectiveTime) {
// 生效时间
const formatted = dayjs(content.value.effectiveTime).format('YYYY-MM-DD');
if (formatted && formatted !== 'Invalid Date') {
parts.push(`${$t('authentication.agreementEffectiveTime')}: ${formatted}`);
}
}
return parts.join(' \u00B7 ');
});
/** 加载协议内容 */
async function loadContent() {
loading.value = true;
isEmpty.value = false;
try {
const result = await UserProtocolApi.findDefault(props.type, 'WEB', locale.value);
// 接口返回空或无正文内容时视为未配置
if (!result?.data || !result.data.content) {
isEmpty.value = true;
content.value = {};
} else {
content.value = result.data;
}
} catch {
// 协议未配置等情况, 显示空态(silentError 已拦截全局 toast)
isEmpty.value = true;
content.value = {};
} finally {
loading.value = false;
}
}
onMounted(loadContent);
// 语言切换时重新拉取对应语言的协议内容
watch(locale, loadContent);
</script>
<template>
<div class="flex min-h-screen w-full justify-center px-4 py-10">
<div class="w-full max-w-4xl">
<a-card class="rounded-lg shadow-lg" variant="borderless">
<!-- 标题区 -->
<div class="mb-2 text-center">
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100">
{{ title }}
</h1>
<p v-if="metaText" class="mt-2 text-sm text-gray-400">
{{ metaText }}
</p>
</div>
<a-divider />
<!-- 加载态 -->
<a-skeleton v-if="loading" active :paragraph="{ rows: 12 }" />
<!-- 空态: 协议未配置 -->
<a-empty v-else-if="isEmpty" :description="$t('authentication.agreementEmpty')" />
<!-- 协议正文(Markdown 渲染) -->
<MdPreview v-else id="agreement-content-preview" :model-value="content.content || ''" />
</a-card>
</div>
</div>
</template>

View File

@@ -1,3 +1,4 @@
export { default as AgreementContentView } from './AgreementContentView.vue';
export { default as AuthFormActions } from './AuthFormActions.vue';
export { default as AuthPageCard } from './AuthPageCard.vue';
export { default as AuthPageFooterActions } from './AuthPageFooterActions.vue';

View File

@@ -43,6 +43,8 @@
"termsTitle": "Terms of Service",
"privacyTitle": "Privacy Policy",
"agreementContentPlaceholder": "This agreement is being drafted and will be available in a future release.",
"agreementEmpty": "No agreement content has been configured. Please contact the administrator to configure it.",
"agreementEffectiveTime": "Effective Date",
"layout": {
"center": "Align Center",
"alignLeft": "Align Left",

View File

@@ -43,6 +43,8 @@
"termsTitle": "Ketentuan Layanan",
"privacyTitle": "Kebijakan Privasi",
"agreementContentPlaceholder": "Perjanjian ini sedang dirancang dan akan tersedia pada rilis mendatang.",
"agreementEmpty": "Konten perjanjian belum dikonfigurasi. Silakan hubungi administrator untuk mengonfigurasinya.",
"agreementEffectiveTime": "Tanggal Berlaku",
"layout": {
"center": "Sejajarkan Pusat",
"alignLeft": "Sejajarkan ke Kiri",

View File

@@ -43,6 +43,8 @@
"termsTitle": "利用規約",
"privacyTitle": "プライバシーポリシー",
"agreementContentPlaceholder": "この契約は現在草案中であり、将来のリリースで利用可能になる予定です。",
"agreementEmpty": "利用規約の内容が設定されていません。管理者にご連絡のうえ設定をご依頼ください。",
"agreementEffectiveTime": "発効日",
"layout": {
"center": "中央揃え",
"alignLeft": "左揃え",

View File

@@ -43,6 +43,8 @@
"termsTitle": "서비스 약관",
"privacyTitle": "개인 정보 보호 정책",
"agreementContentPlaceholder": "본 계약은 초안 작성 중이며 향후 릴리스에서 제공될 예정입니다.",
"agreementEmpty": "약관 내용이 구성되지 않았습니다. 관리자에게 문의하여 구성해 주세요.",
"agreementEffectiveTime": "시행일",
"layout": {
"center": "중심 정렬",
"alignLeft": "왼쪽 정렬",

View File

@@ -43,6 +43,8 @@
"termsTitle": "Syarat Perkhidmatan",
"privacyTitle": "Dasar Privasi",
"agreementContentPlaceholder": "Perjanjian ini sedang dirangka dan akan tersedia dalam keluaran akan datang.",
"agreementEmpty": "Kandungan perjanjian belum dikonfigurasi. Sila hubungi pentadbir untuk mengkonfigurasikannya.",
"agreementEffectiveTime": "Tarikh Berkuat Kuasa",
"layout": {
"center": "Jajarkan Tengah",
"alignLeft": "Jajar Kiri",

View File

@@ -43,6 +43,8 @@
"termsTitle": "ข้อกำหนดในการให้บริการ",
"privacyTitle": "นโยบายความเป็นส่วนตัว",
"agreementContentPlaceholder": "ข้อตกลงนี้อยู่ระหว่างการร่างและจะพร้อมใช้งานในรุ่นต่อๆ ไป",
"agreementEmpty": "ยังไม่ได้กำหนดเนื้อหาข้อตกลง โปรดติดต่อผู้ดูแลระบบเพื่อกำหนด",
"agreementEffectiveTime": "วันที่มีผลบังคับ",
"layout": {
"center": "จัดกึ่งกลาง",
"alignLeft": "จัดชิดซ้าย",

View File

@@ -43,6 +43,8 @@
"termsTitle": "Điều khoản dịch vụ",
"privacyTitle": "Chính sách bảo mật",
"agreementContentPlaceholder": "Thỏa thuận này đang được soạn thảo và sẽ có trong bản phát hành trong tương lai.",
"agreementEmpty": "Nội dung thỏa thuận chưa được cấu hình. Vui lòng liên hệ quản trị viên để cấu hình.",
"agreementEffectiveTime": "Ngày có hiệu lực",
"layout": {
"center": "Căn giữa",
"alignLeft": "Căn trái",

View File

@@ -43,6 +43,8 @@
"termsTitle": "用户协议",
"privacyTitle": "隐私政策",
"agreementContentPlaceholder": "协议内容正在完善中,正式版将在后续版本提供。",
"agreementEmpty": "暂未配置协议内容,请联系管理员配置后再查看。",
"agreementEffectiveTime": "生效时间",
"layout": {
"center": "居中",
"alignLeft": "居左",

View File

@@ -43,6 +43,8 @@
"termsTitle": "用户協議",
"privacyTitle": "隱私政策",
"agreementContentPlaceholder": "協議內容正在完善中,正式版將在後續版本提供。",
"agreementEmpty": "暫未配置協議內容,請聯絡管理員配置後再查看。",
"agreementEffectiveTime": "生效時間",
"layout": {
"center": "居中",
"alignLeft": "居左",

View File

@@ -43,6 +43,8 @@
"termsTitle": "使用者協議",
"privacyTitle": "隱私政策",
"agreementContentPlaceholder": "協議內容正在完善中,正式版將在後續版本提供。",
"agreementEmpty": "暫未配置協議內容,請聯繫管理員配置後再查看。",
"agreementEffectiveTime": "生效時間",
"layout": {
"center": "居中",
"alignLeft": "居左",