feat: 添加登录注册条款确认

This commit is contained in:
shaw
2026-05-07 17:35:05 +08:00
parent 6681aee98d
commit e872cbec0b
16 changed files with 1524 additions and 124 deletions
+14 -1
View File
@@ -4,7 +4,12 @@
*/
import { apiClient } from "../client";
import type { CustomMenuItem, CustomEndpoint, NotifyEmailEntry } from "@/types";
import type {
CustomEndpoint,
CustomMenuItem,
LoginAgreementDocument,
NotifyEmailEntry,
} from "@/types";
export interface DefaultSubscriptionSetting {
group_id: number;
@@ -314,6 +319,10 @@ export interface SystemSettings {
invitation_code_enabled: boolean;
totp_enabled: boolean; // TOTP 双因素认证
totp_encryption_key_configured: boolean; // TOTP 加密密钥是否已配置
login_agreement_enabled: boolean;
login_agreement_mode: "modal" | "checkbox" | string;
login_agreement_updated_at: string;
login_agreement_documents: LoginAgreementDocument[];
// Default settings
default_balance: number;
affiliate_rebate_rate: number;
@@ -528,6 +537,10 @@ export interface UpdateSettingsRequest {
frontend_url?: string;
invitation_code_enabled?: boolean;
totp_enabled?: boolean; // TOTP 双因素认证
login_agreement_enabled?: boolean;
login_agreement_mode?: "modal" | "checkbox" | string;
login_agreement_updated_at?: string;
login_agreement_documents?: LoginAgreementDocument[];
default_balance?: number;
affiliate_rebate_rate?: number;
affiliate_rebate_freeze_hours?: number;
@@ -0,0 +1,221 @@
<template>
<div
v-if="mode === 'checkbox' && documents.length > 0"
class="px-0.5"
>
<div class="flex items-start gap-2">
<input
id="login-agreement-consent"
type="checkbox"
:checked="accepted"
class="mt-[2px] h-4 w-4 flex-shrink-0 rounded border-gray-300 text-primary-600 focus:ring-primary-500 dark:border-dark-600 dark:bg-dark-900"
@change="handleCheckboxChange"
/>
<div class="min-w-0 flex-1">
<p class="text-[13px] leading-5 text-gray-600 dark:text-dark-300">
<label
for="login-agreement-consent"
class="cursor-pointer text-gray-700 dark:text-dark-200"
>
我已阅读并同意
</label>
<template v-for="(doc, index) in documents" :key="doc.id || doc.title">
<RouterLink
:to="documentRoute(doc)"
target="_blank"
rel="noopener noreferrer"
class="font-medium text-primary-600 underline-offset-4 transition hover:text-primary-700 hover:underline dark:text-primary-300 dark:hover:text-primary-200"
>
{{ doc.title }}
</RouterLink>
<span v-if="index < documents.length - 1"></span>
</template>
</p>
</div>
</div>
</div>
<div
v-else-if="!accepted && documents.length > 0"
class="rounded-lg border border-primary-100 bg-primary-50/70 p-3 text-sm text-primary-900 dark:border-primary-500/20 dark:bg-primary-500/10 dark:text-primary-100"
>
<div class="flex items-start gap-3">
<Icon name="shield" size="sm" class="mt-0.5 flex-shrink-0 text-primary-600 dark:text-primary-300" />
<div class="min-w-0 flex-1">
<p class="font-medium">继续登录前需要先同意最新条款</p>
<p class="mt-1 text-primary-700 dark:text-primary-200/80">
未同意前账号密码输入和快捷登录会保持禁用
</p>
</div>
<button
type="button"
class="flex-shrink-0 rounded-md bg-primary-600 px-3 py-1.5 text-xs font-medium text-white transition hover:bg-primary-700"
@click="emit('open')"
>
查看条款
</button>
</div>
</div>
<Teleport to="body">
<Transition name="agreement-fade">
<div
v-if="dialogVisible"
class="fixed inset-0 z-[140] flex items-center justify-center overflow-y-auto bg-gray-950/60 p-4 backdrop-blur-sm"
>
<div class="w-full max-w-[600px] overflow-hidden rounded-2xl bg-white shadow-2xl ring-1 ring-black/10 dark:bg-dark-900 dark:ring-white/10">
<div class="border-b border-gray-100 bg-white px-6 py-6 dark:border-dark-800 dark:bg-dark-900">
<div class="flex items-start gap-4">
<span class="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-xl bg-primary-50 text-primary-700 ring-1 ring-primary-100 dark:bg-primary-500/10 dark:text-primary-300 dark:ring-primary-500/20">
<Icon name="shield" size="md" />
</span>
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<h2 class="text-xl font-bold tracking-normal text-gray-950 dark:text-white">
条款更新通知
</h2>
<span
v-if="updatedAt"
class="rounded-full bg-gray-100 px-2.5 py-1 text-xs font-medium text-gray-600 dark:bg-dark-800 dark:text-dark-300"
>
{{ updatedAt }}
</span>
</div>
<p class="mt-2 text-sm leading-6 text-gray-600 dark:text-dark-300">
我们的服务条款已于 {{ updatedAt || '近期' }} 更新在继续使用服务之前请仔细阅读并同意以下条款
</p>
</div>
</div>
</div>
<div class="max-h-[58vh] overflow-y-auto px-6 py-5">
<div class="mb-3 flex items-center justify-between gap-3">
<p class="text-sm font-semibold text-gray-900 dark:text-white">相关文档</p>
</div>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<RouterLink
v-for="(doc, index) in documents"
:key="doc.id || doc.title"
:to="documentRoute(doc)"
target="_blank"
rel="noopener noreferrer"
class="group flex min-h-[72px] w-full items-center gap-3 rounded-xl border border-gray-200 bg-gray-50/70 px-4 py-3 text-left transition hover:-translate-y-0.5 hover:border-primary-200 hover:bg-white hover:shadow-sm dark:border-dark-700 dark:bg-dark-800/70 dark:hover:border-primary-500/30 dark:hover:bg-dark-800"
>
<span class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg bg-white text-gray-700 ring-1 ring-gray-200 transition group-hover:bg-primary-50 group-hover:text-primary-700 group-hover:ring-primary-100 dark:bg-dark-900 dark:text-dark-200 dark:ring-dark-700 dark:group-hover:bg-primary-500/10 dark:group-hover:text-primary-200 dark:group-hover:ring-primary-500/20">
<Icon :name="documentIcon(index, doc.title)" size="sm" />
</span>
<span class="min-w-0 flex-1">
<span class="block truncate text-sm font-semibold text-gray-950 dark:text-white">{{ doc.title }}</span>
</span>
<span class="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full text-gray-400 transition group-hover:bg-primary-50 group-hover:text-primary-600 dark:group-hover:bg-primary-500/10 dark:group-hover:text-primary-300">
<Icon name="externalLink" size="sm" />
</span>
</RouterLink>
</div>
</div>
<div class="border-t border-gray-100 bg-gray-50/80 px-6 py-4 dark:border-dark-800 dark:bg-dark-950/60">
<div class="grid grid-cols-2 gap-3">
<button
type="button"
class="rounded-xl border border-gray-200 bg-white px-4 py-3 text-sm font-semibold text-gray-700 transition hover:bg-gray-100 dark:border-dark-700 dark:bg-dark-800 dark:text-dark-200 dark:hover:bg-dark-700"
@click="emit('reject')"
>
拒绝
</button>
<button
type="button"
class="rounded-xl bg-primary-600 px-4 py-3 text-sm font-semibold text-white shadow-sm shadow-primary-600/20 transition hover:bg-primary-700"
@click="emit('accept')"
>
同意并继续
</button>
</div>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import Icon from '@/components/icons/Icon.vue'
import type { LoginAgreementDocument } from '@/types'
const props = withDefaults(defineProps<{
accepted: boolean
documents: LoginAgreementDocument[]
mode: 'modal' | 'checkbox' | string
updatedAt?: string
visible: boolean
}>(), {
updatedAt: ''
})
const emit = defineEmits<{
accept: []
reject: []
open: []
}>()
const dialogVisible = computed(() => props.visible && documents.value.length > 0)
const documents = computed(() => props.documents.filter((doc) => doc.title.trim()))
const updatedAt = computed(() => props.updatedAt || '')
const accepted = computed(() => props.accepted)
const mode = computed(() => props.mode === 'checkbox' ? 'checkbox' : 'modal')
function documentRoute(doc: LoginAgreementDocument) {
return {
name: 'LegalDocument',
params: {
documentId: doc.id || doc.title,
},
}
}
function handleCheckboxChange(event: Event): void {
const checked = (event.target as HTMLInputElement).checked
if (checked) {
emit('accept')
} else {
emit('reject')
}
}
function documentIcon(index: number, title: string): 'document' | 'shield' | 'globe' | 'cog' {
if (title.includes('政策') || title.includes('隐私')) {
return 'shield'
}
if (title.includes('国家') || title.includes('地区')) {
return 'globe'
}
if (index === 3) {
return 'cog'
}
return 'document'
}
</script>
<style scoped>
.agreement-fade-enter-active,
.agreement-fade-leave-active {
transition: opacity 0.18s ease;
}
.agreement-fade-enter-from,
.agreement-fade-leave-to {
opacity: 0;
}
.agreement-fade-enter-active > div,
.agreement-fade-leave-active > div {
transition: transform 0.18s ease, opacity 0.18s ease;
}
.agreement-fade-enter-from > div,
.agreement-fade-leave-to > div {
opacity: 0;
transform: translateY(8px) scale(0.98);
}
</style>
+1
View File
@@ -5071,6 +5071,7 @@ export default {
description: 'Manage registration, email verification, default values, and SMTP settings',
tabs: {
general: 'General',
agreement: 'Agreement',
features: 'Feature Switches',
security: 'Security',
users: 'Users',
+1
View File
@@ -5234,6 +5234,7 @@ export default {
description: '管理注册、邮箱验证、默认值和 SMTP 设置',
tabs: {
general: '通用设置',
agreement: '登录条款',
features: '功能开关',
security: '安全与认证',
users: '用户默认值',
+10 -1
View File
@@ -144,6 +144,15 @@ const routes: RouteRecordRaw[] = [
title: 'Key Usage',
}
},
{
path: '/legal/:documentId',
name: 'LegalDocument',
component: () => import('@/views/public/LegalDocumentView.vue'),
meta: {
requiresAuth: false,
title: 'Legal Document'
}
},
// ==================== User Routes ====================
{
@@ -647,7 +656,7 @@ let authInitialized = false
const navigationLoading = useNavigationLoadingState()
// 延迟初始化预加载,传入 router 实例
let routePrefetch: ReturnType<typeof useRoutePrefetch> | null = null
const BACKEND_MODE_ALLOWED_PATHS = ['/login', '/key-usage', '/setup', '/payment/result']
const BACKEND_MODE_ALLOWED_PATHS = ['/login', '/key-usage', '/setup', '/payment/result', '/legal']
const BACKEND_MODE_CALLBACK_PATHS = [
'/auth/callback',
'/auth/linuxdo/callback',
+11
View File
@@ -179,6 +179,12 @@ export interface CustomEndpoint {
description: string
}
export interface LoginAgreementDocument {
id: string
title: string
content_md: string
}
export interface PublicSettings {
registration_enabled: boolean
email_verify_enabled: boolean
@@ -187,6 +193,11 @@ export interface PublicSettings {
promo_code_enabled: boolean
password_reset_enabled: boolean
invitation_code_enabled: boolean
login_agreement_enabled?: boolean
login_agreement_mode?: 'modal' | 'checkbox' | string
login_agreement_updated_at?: string
login_agreement_revision?: string
login_agreement_documents?: LoginAgreementDocument[]
turnstile_enabled: boolean
turnstile_site_key: string
site_name: string
+356 -8
View File
@@ -3881,11 +3881,11 @@
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t("admin.settings.site.backendModeDescription") }}
</p>
</div>
<Toggle v-model="form.backend_mode_enabled" />
</div>
</div>
<Toggle v-model="form.backend_mode_enabled" />
</div>
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
<div>
<label
class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
@@ -4401,10 +4401,212 @@
</button>
</div>
</div>
</div>
<!-- /Tab: General -->
</div>
<!-- /Tab: General -->
<!-- Tab: Features (功能开关) -->
<!-- Tab: Login Agreement -->
<div v-show="activeTab === 'agreement'" class="space-y-6">
<div class="card">
<div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700">
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div>
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
{{ localText("登录条款确认", "Login agreement") }}
</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{
localText(
"控制登录页是否要求用户先阅读并同意服务条款、隐私政策或其他 Markdown 文档。",
"Control whether the login page requires users to accept Markdown policy documents first.",
)
}}
</p>
</div>
<div class="flex items-center gap-3">
<span class="text-sm text-gray-600 dark:text-gray-300">
{{ form.login_agreement_enabled ? localText("已启用", "Enabled") : localText("未启用", "Disabled") }}
</span>
<Toggle v-model="form.login_agreement_enabled" />
</div>
</div>
</div>
<div class="space-y-6 p-6">
<div class="grid grid-cols-1 gap-5 lg:grid-cols-[minmax(0,1fr)_220px]">
<div>
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
{{ localText("展示形式", "Display mode") }}
</label>
<div class="grid grid-cols-2 gap-2 rounded-lg bg-gray-100 p-1 dark:bg-dark-700">
<button
type="button"
class="inline-flex items-center justify-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition"
:class="
form.login_agreement_mode === 'modal'
? 'bg-white text-primary-700 shadow-sm dark:bg-dark-800 dark:text-primary-300'
: 'text-gray-600 hover:text-gray-900 dark:text-dark-300 dark:hover:text-white'
"
@click="form.login_agreement_mode = 'modal'"
>
<Icon name="shield" size="sm" />
{{ localText("弹窗", "Modal") }}
</button>
<button
type="button"
class="inline-flex items-center justify-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition"
:class="
form.login_agreement_mode === 'checkbox'
? 'bg-white text-primary-700 shadow-sm dark:bg-dark-800 dark:text-primary-300'
: 'text-gray-600 hover:text-gray-900 dark:text-dark-300 dark:hover:text-white'
"
@click="form.login_agreement_mode = 'checkbox'"
>
<Icon name="checkCircle" size="sm" />
{{ localText("复选框", "Checkbox") }}
</button>
</div>
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
{{
form.login_agreement_mode === "checkbox"
? localText("复选框会显示在登录按钮下方,未勾选前所有登录入口禁用。", "The checkbox appears below the login button and gates all login actions.")
: localText("弹窗会在登录页打开,用户拒绝后所有登录入口保持禁用。", "The modal opens on the login page and gates all login actions until accepted.")
}}
</p>
</div>
<div>
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
{{ localText("条款更新日期", "Updated date") }}
</label>
<input
v-model="form.login_agreement_updated_at"
type="date"
class="input"
/>
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
{{ localText("日期或文档内容变化后,用户需要重新同意。", "Changing the date or content requires fresh consent.") }}
</p>
</div>
</div>
<div>
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h3 class="text-sm font-medium text-gray-900 dark:text-white">
{{ localText("协议文档", "Agreement documents") }}
</h3>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{
localText(
"文档名称可自定义,内容按 Markdown 保存。可参考:服务条款、使用政策、支持的国家和地区、服务特定条款。",
"Document titles are customizable and content is saved as Markdown.",
)
}}
</p>
</div>
<button
type="button"
class="btn btn-primary btn-sm inline-flex items-center gap-1.5"
@click="addLoginAgreementDocument"
>
<Icon name="plus" size="sm" />
{{ localText("添加文档", "Add document") }}
</button>
</div>
<div class="mt-4 space-y-3">
<div
v-for="(doc, index) in form.login_agreement_documents"
:key="doc.id || index"
class="rounded-lg border border-gray-200 bg-white p-4 dark:border-dark-700 dark:bg-dark-800/60"
>
<div class="mb-3 flex items-center justify-between gap-3">
<div class="flex min-w-0 items-center gap-3">
<span class="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-md bg-gray-100 text-gray-700 dark:bg-dark-700 dark:text-dark-200">
<Icon
:name="
index === 1
? 'shield'
: index === 2
? 'globe'
: index === 3
? 'cog'
: 'document'
"
size="sm"
/>
</span>
<div class="min-w-0">
<p class="truncate text-sm font-semibold text-gray-900 dark:text-white">
{{ doc.title || localText("未命名文档", "Untitled document") }}
</p>
<p class="truncate text-xs text-gray-500 dark:text-gray-400">
{{ loginAgreementRoutePath(doc, index) }}
</p>
</div>
</div>
<button
type="button"
class="rounded-md p-2 text-red-400 transition hover:bg-red-50 hover:text-red-600 disabled:cursor-not-allowed disabled:opacity-40 dark:hover:bg-red-900/20"
:disabled="
form.login_agreement_enabled &&
form.login_agreement_documents.length <= 1
"
@click="removeLoginAgreementDocument(index)"
>
<Icon name="trash" size="sm" />
</button>
</div>
<div class="grid grid-cols-1 gap-3 lg:grid-cols-2">
<div>
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
{{ localText("文档名称", "Document title") }}
</label>
<input
v-model="doc.title"
type="text"
class="input text-sm"
:placeholder="localText('例如:服务条款', 'Example: Terms of Service')"
/>
</div>
<div>
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
{{ localText("路由标识", "Route slug") }}
</label>
<div class="flex overflow-hidden rounded-lg border border-gray-300 bg-white focus-within:border-primary-500 focus-within:ring-1 focus-within:ring-primary-500 dark:border-dark-600 dark:bg-dark-900">
<span class="inline-flex flex-shrink-0 items-center border-r border-gray-200 bg-gray-50 px-3 text-sm text-gray-500 dark:border-dark-700 dark:bg-dark-800 dark:text-dark-400">
/legal/
</span>
<input
v-model="doc.id"
type="text"
class="min-w-0 flex-1 border-0 bg-transparent px-3 py-2 text-sm text-gray-900 outline-none placeholder:text-gray-400 focus:ring-0 dark:text-white dark:placeholder:text-dark-500"
placeholder="usage-policy"
/>
</div>
</div>
</div>
<div class="mt-3">
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
{{ localText("Markdown 内容", "Markdown content") }}
</label>
<textarea
v-model="doc.content_md"
rows="8"
class="input font-mono text-sm"
:placeholder="localText('在这里填写正式 Markdown 内容。', 'Write the final Markdown content here.')"
></textarea>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- /Tab: Login Agreement -->
<!-- Tab: Features (功能开关) -->
<div v-show="activeTab === 'features'" class="space-y-6">
<div class="card">
@@ -5875,7 +6077,12 @@ import type {
WebSearchProviderConfig,
WebSearchTestResult,
} from "@/api/admin/settings";
import type { AdminGroup, Proxy, NotifyEmailEntry } from "@/types";
import type {
AdminGroup,
LoginAgreementDocument,
NotifyEmailEntry,
Proxy,
} from "@/types";
import type { ProviderInstance } from "@/types/payment";
import AppLayout from "@/components/layout/AppLayout.vue";
import Icon from "@/components/icons/Icon.vue";
@@ -5925,6 +6132,7 @@ const paymentMethodsHref = computed(() =>
type SettingsTab =
| "general"
| "agreement"
| "features"
| "security"
| "users"
@@ -5935,6 +6143,7 @@ type SettingsTab =
const activeTab = ref<SettingsTab>("general");
const settingsTabs = [
{ key: "general" as SettingsTab, icon: "home" as const },
{ key: "agreement" as SettingsTab, icon: "document" as const },
{ key: "features" as SettingsTab, icon: "bolt" as const },
{ key: "security" as SettingsTab, icon: "shield" as const },
{ key: "users" as SettingsTab, icon: "user" as const },
@@ -6029,6 +6238,49 @@ const tablePageSizeMin = 5;
const tablePageSizeMax = 1000;
const tablePageSizeDefault = 20;
function defaultLoginAgreementDocuments(): LoginAgreementDocument[] {
return [
{
id: "terms",
title: "服务条款",
content_md: "",
},
{
id: "usage-policy",
title: "使用政策",
content_md: "",
},
{
id: "supported-regions",
title: "支持的国家和地区",
content_md: "",
},
{
id: "service-specific-terms",
title: "服务特定条款",
content_md: "",
},
];
}
function normalizeLoginAgreementDocumentId(raw: string): string {
return raw
.trim()
.toLowerCase()
.replace(/[^a-z0-9_-]+/g, "-")
.replace(/[-_]{2,}/g, "-")
.replace(/^[-_]+|[-_]+$/g, "");
}
function loginAgreementRoutePath(
doc: LoginAgreementDocument,
index: number,
): string {
const id =
normalizeLoginAgreementDocumentId(doc.id || doc.title) || `doc-${index + 1}`;
return `/legal/${id}`;
}
interface DefaultSubscriptionGroupOption {
value: number;
label: string;
@@ -6071,6 +6323,10 @@ const form = reactive<SettingsForm>({
password_reset_enabled: false,
totp_enabled: false,
totp_encryption_key_configured: false,
login_agreement_enabled: false,
login_agreement_mode: "modal",
login_agreement_updated_at: "2026-03-31",
login_agreement_documents: defaultLoginAgreementDocuments(),
default_balance: 0,
affiliate_rebate_rate: 20,
affiliate_rebate_freeze_hours: 0,
@@ -6753,6 +7009,43 @@ function removeEndpoint(index: number) {
form.custom_endpoints.splice(index, 1);
}
function addLoginAgreementDocument() {
form.login_agreement_documents.push({
id: `custom-${Date.now().toString(36)}`,
title: "",
content_md: "",
});
}
function removeLoginAgreementDocument(index: number) {
form.login_agreement_documents.splice(index, 1);
}
function normalizeLoginAgreementDocumentsForSave(): LoginAgreementDocument[] {
return form.login_agreement_documents
.map((doc, index) => ({
id:
normalizeLoginAgreementDocumentId(doc.id || doc.title) ||
`doc-${index + 1}`,
title: doc.title.trim(),
content_md: doc.content_md.trim(),
}))
.filter((doc) => doc.title || doc.content_md);
}
function findDuplicateLoginAgreementDocumentId(
documents: LoginAgreementDocument[],
): string | null {
const seen = new Set<string>();
for (const doc of documents) {
if (seen.has(doc.id)) {
return doc.id;
}
seen.add(doc.id);
}
return null;
}
function formatTablePageSizeOptions(options: number[]): string {
return options.join(", ");
}
@@ -6797,6 +7090,19 @@ async function loadSettings() {
(form as Record<string, unknown>)[key] = value;
}
}
form.login_agreement_mode =
settings.login_agreement_mode === "checkbox" ? "checkbox" : "modal";
form.login_agreement_updated_at =
settings.login_agreement_updated_at || "2026-03-31";
form.login_agreement_documents =
Array.isArray(settings.login_agreement_documents) &&
settings.login_agreement_documents.length > 0
? settings.login_agreement_documents.map((doc) => ({
id: doc.id || "",
title: doc.title || "",
content_md: doc.content_md || "",
}))
: defaultLoginAgreementDocuments();
Object.assign(authSourceDefaults, buildAuthSourceDefaultsState(settings));
form.backend_mode_enabled = settings.backend_mode_enabled;
form.default_subscriptions = normalizeDefaultSubscriptionSettings(
@@ -7008,6 +7314,44 @@ async function saveSettings() {
form.table_default_page_size = normalizedTableDefaultPageSize;
form.table_page_size_options = normalizedTablePageSizeOptions;
const normalizedLoginAgreementDocuments =
normalizeLoginAgreementDocumentsForSave();
if (form.login_agreement_enabled && normalizedLoginAgreementDocuments.length === 0) {
appStore.showError(
localText(
"启用登录条款确认时,至少需要保留一份文档。",
"At least one document is required when login agreement is enabled.",
),
);
return;
}
const emptyTitleDocument = normalizedLoginAgreementDocuments.find(
(doc) => !doc.title,
);
if (emptyTitleDocument) {
appStore.showError(
localText(
"登录条款文档名称不能为空。",
"Login agreement document title cannot be empty.",
),
);
return;
}
const duplicateLoginAgreementDocumentId =
findDuplicateLoginAgreementDocumentId(normalizedLoginAgreementDocuments);
if (duplicateLoginAgreementDocumentId) {
appStore.showError(
localText(
`登录条款文档路由不能重复:/legal/${duplicateLoginAgreementDocumentId}`,
`Login agreement document routes cannot be duplicated: /legal/${duplicateLoginAgreementDocumentId}`,
),
);
return;
}
form.login_agreement_mode =
form.login_agreement_mode === "checkbox" ? "checkbox" : "modal";
form.login_agreement_documents = normalizedLoginAgreementDocuments;
const normalizedDefaultSubscriptions = normalizeDefaultSubscriptionSettings(
form.default_subscriptions,
);
@@ -7085,6 +7429,10 @@ async function saveSettings() {
invitation_code_enabled: form.invitation_code_enabled,
password_reset_enabled: form.password_reset_enabled,
totp_enabled: form.totp_enabled,
login_agreement_enabled: form.login_agreement_enabled,
login_agreement_mode: form.login_agreement_mode,
login_agreement_updated_at: form.login_agreement_updated_at,
login_agreement_documents: form.login_agreement_documents,
default_balance: form.default_balance,
affiliate_rebate_rate: Math.min(
100,
+114 -8
View File
@@ -28,7 +28,7 @@
required
autofocus
autocomplete="email"
:disabled="isLoading"
:disabled="authActionDisabled"
class="input pl-11"
:class="{ 'input-error': errors.email }"
:placeholder="t('auth.emailPlaceholder')"
@@ -51,7 +51,7 @@
:type="showPassword ? 'text' : 'password'"
required
autocomplete="current-password"
:disabled="isLoading"
:disabled="authActionDisabled"
class="input pl-11 pr-11"
:class="{ 'input-error': errors.password }"
:placeholder="t('auth.passwordPlaceholder')"
@@ -59,6 +59,7 @@
<button
type="button"
@click="showPassword = !showPassword"
:disabled="authActionDisabled"
class="absolute inset-y-0 right-0 flex items-center pr-3.5 text-gray-400 transition-colors hover:text-gray-600 dark:hover:text-dark-300"
>
<Icon v-if="showPassword" name="eyeOff" size="md" />
@@ -91,7 +92,7 @@
<!-- Submit Button -->
<button
type="submit"
:disabled="isLoading || (turnstileEnabled && !turnstileToken)"
:disabled="authActionDisabled || (turnstileEnabled && !turnstileToken)"
class="btn btn-primary w-full"
>
<svg
@@ -118,6 +119,18 @@
{{ isLoading ? t('auth.signingIn') : t('auth.signIn') }}
</button>
<LoginAgreementPrompt
v-if="loginAgreementEnabled"
:accepted="agreementAccepted"
:documents="loginAgreementDocuments"
:mode="loginAgreementMode"
:updated-at="loginAgreementUpdatedAt"
:visible="showAgreementModal"
@accept="acceptLoginAgreement"
@reject="rejectLoginAgreement"
@open="showAgreementModal = true"
/>
<div v-if="showOAuthLogin" class="space-y-3 pt-1">
<div class="flex items-center gap-3">
<div class="h-px flex-1 bg-gray-200 dark:bg-dark-700"></div>
@@ -128,7 +141,7 @@
</div>
<EmailOAuthButtons
:disabled="isLoading"
:disabled="authActionDisabled"
:github-enabled="githubOAuthEnabled"
:google-enabled="googleOAuthEnabled"
:show-divider="false"
@@ -136,17 +149,17 @@
<LinuxDoOAuthSection
v-if="linuxdoOAuthEnabled"
:disabled="isLoading"
:disabled="authActionDisabled"
:show-divider="false"
/>
<WechatOAuthSection
v-if="wechatOAuthEnabled"
:disabled="isLoading"
:disabled="authActionDisabled"
:show-divider="false"
/>
<OidcOAuthSection
v-if="oidcOAuthEnabled"
:disabled="isLoading"
:disabled="authActionDisabled"
:provider-name="oidcOAuthProviderName"
:show-divider="false"
/>
@@ -188,16 +201,18 @@ import LinuxDoOAuthSection from '@/components/auth/LinuxDoOAuthSection.vue'
import OidcOAuthSection from '@/components/auth/OidcOAuthSection.vue'
import WechatOAuthSection from '@/components/auth/WechatOAuthSection.vue'
import EmailOAuthButtons from '@/components/auth/EmailOAuthButtons.vue'
import LoginAgreementPrompt from '@/components/auth/LoginAgreementPrompt.vue'
import TotpLoginModal from '@/components/auth/TotpLoginModal.vue'
import Icon from '@/components/icons/Icon.vue'
import TurnstileWidget from '@/components/TurnstileWidget.vue'
import { useAuthStore, useAppStore } from '@/stores'
import { getPublicSettings, isTotp2FARequired, isWeChatWebOAuthEnabled } from '@/api/auth'
import type { TotpLoginResponse } from '@/types'
import type { LoginAgreementDocument, TotpLoginResponse } from '@/types'
import { extractI18nErrorMessage } from '@/utils/apiError'
import { clearAllAffiliateReferralCodes } from '@/utils/oauthAffiliate'
const { t } = useI18n()
const LOGIN_AGREEMENT_STORAGE_KEY = 'sub2api_login_agreement_consent'
// ==================== Router & Stores ====================
@@ -210,6 +225,7 @@ const appStore = useAppStore()
const isLoading = ref<boolean>(false)
const errorMessage = ref<string>('')
const showPassword = ref<boolean>(false)
const publicSettingsLoaded = ref<boolean>(false)
// Public settings
const turnstileEnabled = ref<boolean>(false)
@@ -222,6 +238,13 @@ const oidcOAuthProviderName = ref<string>('OIDC')
const githubOAuthEnabled = ref<boolean>(false)
const googleOAuthEnabled = ref<boolean>(false)
const passwordResetEnabled = ref<boolean>(false)
const loginAgreementEnabled = ref<boolean>(false)
const loginAgreementMode = ref<'modal' | 'checkbox' | string>('modal')
const loginAgreementUpdatedAt = ref<string>('')
const loginAgreementRevision = ref<string>('')
const loginAgreementDocuments = ref<LoginAgreementDocument[]>([])
const agreementAccepted = ref<boolean>(false)
const showAgreementModal = ref<boolean>(false)
// Turnstile
const turnstileRef = ref<InstanceType<typeof TurnstileWidget> | null>(null)
@@ -248,6 +271,14 @@ const validationToastMessage = computed(
() => errors.email || errors.password || errors.turnstile || ''
)
const agreementGateActive = computed(
() => loginAgreementEnabled.value && !agreementAccepted.value
)
const authActionDisabled = computed(
() => isLoading.value || !publicSettingsLoaded.value || agreementGateActive.value
)
const showOAuthLogin = computed(
() =>
!backendModeEnabled.value &&
@@ -288,11 +319,78 @@ onMounted(async () => {
googleOAuthEnabled.value = settings.google_oauth_enabled
backendModeEnabled.value = settings.backend_mode_enabled
passwordResetEnabled.value = settings.password_reset_enabled
applyLoginAgreementSettings(settings)
} catch (error) {
console.error('Failed to load public settings:', error)
loginAgreementEnabled.value = false
agreementAccepted.value = true
} finally {
publicSettingsLoaded.value = true
}
})
// ==================== Login Agreement ====================
function applyLoginAgreementSettings(settings: {
login_agreement_enabled?: boolean
login_agreement_mode?: string
login_agreement_updated_at?: string
login_agreement_revision?: string
login_agreement_documents?: LoginAgreementDocument[]
}): void {
const documents = Array.isArray(settings.login_agreement_documents)
? settings.login_agreement_documents.filter((doc) => doc.title?.trim())
: []
loginAgreementDocuments.value = documents
loginAgreementEnabled.value = settings.login_agreement_enabled === true && documents.length > 0
loginAgreementMode.value = settings.login_agreement_mode === 'checkbox' ? 'checkbox' : 'modal'
loginAgreementUpdatedAt.value = settings.login_agreement_updated_at || ''
loginAgreementRevision.value =
settings.login_agreement_revision ||
`${loginAgreementUpdatedAt.value}:${documents.map((doc) => `${doc.id}:${doc.title}`).join('|')}`
agreementAccepted.value = !loginAgreementEnabled.value || hasAcceptedLoginAgreement(loginAgreementRevision.value)
showAgreementModal.value =
loginAgreementEnabled.value && !agreementAccepted.value && loginAgreementMode.value !== 'checkbox'
}
function hasAcceptedLoginAgreement(revision: string): boolean {
if (!revision) {
return false
}
try {
const raw = localStorage.getItem(LOGIN_AGREEMENT_STORAGE_KEY)
if (!raw) {
return false
}
const parsed = JSON.parse(raw) as { revision?: string }
return parsed.revision === revision
} catch {
return false
}
}
function acceptLoginAgreement(): void {
if (loginAgreementRevision.value) {
localStorage.setItem(
LOGIN_AGREEMENT_STORAGE_KEY,
JSON.stringify({
revision: loginAgreementRevision.value,
accepted_at: new Date().toISOString()
})
)
}
agreementAccepted.value = true
showAgreementModal.value = false
}
function rejectLoginAgreement(): void {
localStorage.removeItem(LOGIN_AGREEMENT_STORAGE_KEY)
agreementAccepted.value = false
showAgreementModal.value = false
appStore.showWarning('未同意最新条款前,无法输入账号密码或使用快捷登录。')
}
// ==================== Turnstile Handlers ====================
function onTurnstileVerify(token: string): void {
@@ -320,6 +418,14 @@ function validateForm(): boolean {
let isValid = true
if (agreementGateActive.value) {
appStore.showWarning('请先阅读并同意最新条款后再登录。')
if (loginAgreementMode.value !== 'checkbox') {
showAgreementModal.value = true
}
return false
}
// Email validation
if (!formData.email.trim()) {
errors.email = t('auth.emailRequired')
+113 -9
View File
@@ -44,7 +44,7 @@
required
autofocus
autocomplete="email"
:disabled="isLoading"
:disabled="registrationActionDisabled"
class="input pl-11"
:class="{ 'input-error': errors.email }"
:placeholder="t('auth.emailPlaceholder')"
@@ -67,13 +67,14 @@
:type="showPassword ? 'text' : 'password'"
required
autocomplete="new-password"
:disabled="isLoading"
:disabled="registrationActionDisabled"
class="input pl-11 pr-11"
:class="{ 'input-error': errors.password }"
:placeholder="t('auth.createPasswordPlaceholder')"
/>
<button
type="button"
:disabled="registrationActionDisabled"
@click="showPassword = !showPassword"
class="absolute inset-y-0 right-0 flex items-center pr-3.5 text-gray-400 transition-colors hover:text-gray-600 dark:hover:text-dark-300"
>
@@ -99,7 +100,7 @@
id="invitation_code"
v-model="formData.invitation_code"
type="text"
:disabled="isLoading"
:disabled="registrationActionDisabled"
class="input pl-11 pr-10"
:class="{
'border-green-500 focus:border-green-500 focus:ring-green-500': invitationValidation.valid,
@@ -147,7 +148,7 @@
id="promo_code"
v-model="formData.promo_code"
type="text"
:disabled="isLoading"
:disabled="registrationActionDisabled"
class="input pl-11 pr-10"
:class="{
'border-green-500 focus:border-green-500 focus:ring-green-500': promoValidation.valid,
@@ -192,10 +193,22 @@
/>
</div>
<LoginAgreementPrompt
v-if="loginAgreementEnabled"
:accepted="agreementAccepted"
:documents="loginAgreementDocuments"
:mode="loginAgreementMode"
:updated-at="loginAgreementUpdatedAt"
:visible="showAgreementModal"
@accept="acceptLoginAgreement"
@reject="rejectLoginAgreement"
@open="showAgreementModal = true"
/>
<!-- Submit Button -->
<button
type="submit"
:disabled="isLoading || (turnstileEnabled && !turnstileToken)"
:disabled="registrationActionDisabled || (turnstileEnabled && !turnstileToken)"
class="btn btn-primary w-full"
>
<svg
@@ -240,7 +253,7 @@
</div>
<EmailOAuthButtons
:disabled="isLoading"
:disabled="registrationActionDisabled"
:aff-code="formData.aff_code"
:github-enabled="githubOAuthEnabled"
:google-enabled="googleOAuthEnabled"
@@ -249,19 +262,19 @@
<LinuxDoOAuthSection
v-if="linuxdoOAuthEnabled"
:disabled="isLoading"
:disabled="registrationActionDisabled"
:aff-code="formData.aff_code"
:show-divider="false"
/>
<WechatOAuthSection
v-if="wechatOAuthEnabled"
:disabled="isLoading"
:disabled="registrationActionDisabled"
:aff-code="formData.aff_code"
:show-divider="false"
/>
<OidcOAuthSection
v-if="oidcOAuthEnabled"
:disabled="isLoading"
:disabled="registrationActionDisabled"
:provider-name="oidcOAuthProviderName"
:aff-code="formData.aff_code"
:show-divider="false"
@@ -293,6 +306,7 @@ import LinuxDoOAuthSection from '@/components/auth/LinuxDoOAuthSection.vue'
import OidcOAuthSection from '@/components/auth/OidcOAuthSection.vue'
import WechatOAuthSection from '@/components/auth/WechatOAuthSection.vue'
import EmailOAuthButtons from '@/components/auth/EmailOAuthButtons.vue'
import LoginAgreementPrompt from '@/components/auth/LoginAgreementPrompt.vue'
import Icon from '@/components/icons/Icon.vue'
import TurnstileWidget from '@/components/TurnstileWidget.vue'
import { useAuthStore, useAppStore } from '@/stores'
@@ -312,8 +326,10 @@ import {
loadAffiliateReferralCode,
resolveAffiliateReferralCode
} from '@/utils/oauthAffiliate'
import type { LoginAgreementDocument } from '@/types'
const { t, locale } = useI18n()
const LOGIN_AGREEMENT_STORAGE_KEY = 'sub2api_login_agreement_consent'
// ==================== Router & Stores ====================
@@ -344,6 +360,13 @@ const oidcOAuthProviderName = ref<string>('OIDC')
const githubOAuthEnabled = ref<boolean>(false)
const googleOAuthEnabled = ref<boolean>(false)
const registrationEmailSuffixWhitelist = ref<string[]>([])
const loginAgreementEnabled = ref<boolean>(false)
const loginAgreementMode = ref<'modal' | 'checkbox' | string>('modal')
const loginAgreementUpdatedAt = ref<string>('')
const loginAgreementRevision = ref<string>('')
const loginAgreementDocuments = ref<LoginAgreementDocument[]>([])
const agreementAccepted = ref<boolean>(false)
const showAgreementModal = ref<boolean>(false)
// Turnstile
const turnstileRef = ref<InstanceType<typeof TurnstileWidget> | null>(null)
@@ -402,6 +425,14 @@ const showOAuthLogin = computed(
googleOAuthEnabled.value
)
const agreementGateActive = computed(
() => loginAgreementEnabled.value && !agreementAccepted.value
)
const registrationActionDisabled = computed(
() => isLoading.value || !settingsLoaded.value || agreementGateActive.value
)
watch(validationToastMessage, (value, previousValue) => {
if (value && value !== previousValue) {
appStore.showError(value)
@@ -439,6 +470,7 @@ onMounted(async () => {
registrationEmailSuffixWhitelist.value = normalizeRegistrationEmailSuffixWhitelist(
settings.registration_email_suffix_whitelist || []
)
applyLoginAgreementSettings(settings)
// Read promo code from URL parameter only if promo code is enabled
if (promoCodeEnabled.value) {
@@ -452,6 +484,8 @@ onMounted(async () => {
syncAffiliateReferralCode()
} catch (error) {
console.error('Failed to load public settings:', error)
loginAgreementEnabled.value = false
agreementAccepted.value = true
} finally {
settingsLoaded.value = true
}
@@ -473,6 +507,68 @@ onUnmounted(() => {
}
})
// ==================== Login Agreement ====================
function applyLoginAgreementSettings(settings: {
login_agreement_enabled?: boolean
login_agreement_mode?: string
login_agreement_updated_at?: string
login_agreement_revision?: string
login_agreement_documents?: LoginAgreementDocument[]
}): void {
const documents = Array.isArray(settings.login_agreement_documents)
? settings.login_agreement_documents.filter((doc) => doc.title?.trim())
: []
loginAgreementDocuments.value = documents
loginAgreementEnabled.value = settings.login_agreement_enabled === true && documents.length > 0
loginAgreementMode.value = settings.login_agreement_mode === 'checkbox' ? 'checkbox' : 'modal'
loginAgreementUpdatedAt.value = settings.login_agreement_updated_at || ''
loginAgreementRevision.value =
settings.login_agreement_revision ||
`${loginAgreementUpdatedAt.value}:${documents.map((doc) => `${doc.id}:${doc.title}`).join('|')}`
agreementAccepted.value = !loginAgreementEnabled.value || hasAcceptedLoginAgreement(loginAgreementRevision.value)
showAgreementModal.value =
loginAgreementEnabled.value && !agreementAccepted.value && loginAgreementMode.value !== 'checkbox'
}
function hasAcceptedLoginAgreement(revision: string): boolean {
if (!revision) {
return false
}
try {
const raw = localStorage.getItem(LOGIN_AGREEMENT_STORAGE_KEY)
if (!raw) {
return false
}
const parsed = JSON.parse(raw) as { revision?: string }
return parsed.revision === revision
} catch {
return false
}
}
function acceptLoginAgreement(): void {
if (loginAgreementRevision.value) {
localStorage.setItem(
LOGIN_AGREEMENT_STORAGE_KEY,
JSON.stringify({
revision: loginAgreementRevision.value,
accepted_at: new Date().toISOString()
})
)
}
agreementAccepted.value = true
showAgreementModal.value = false
}
function rejectLoginAgreement(): void {
localStorage.removeItem(LOGIN_AGREEMENT_STORAGE_KEY)
agreementAccepted.value = false
showAgreementModal.value = false
appStore.showWarning('未同意最新条款前,无法注册或使用快捷登录。')
}
// ==================== Promo Code Validation ====================
function handlePromoCodeInput(): void {
@@ -656,6 +752,14 @@ function validateForm(): boolean {
let isValid = true
if (agreementGateActive.value) {
appStore.showWarning('请先阅读并同意最新条款后再注册。')
if (loginAgreementMode.value !== 'checkbox') {
showAgreementModal.value = true
}
return false
}
// Email validation
if (!formData.email.trim()) {
errors.email = t('auth.emailRequired')
@@ -0,0 +1,241 @@
<template>
<div class="min-h-screen bg-gray-50 text-gray-900 dark:bg-dark-950 dark:text-white">
<header class="border-b border-gray-200 bg-white/95 dark:border-dark-800 dark:bg-dark-900/95">
<div class="mx-auto flex max-w-5xl items-center justify-between gap-4 px-4 py-4 sm:px-6">
<RouterLink to="/home" class="flex min-w-0 items-center gap-3">
<span class="flex h-10 w-10 flex-shrink-0 items-center justify-center overflow-hidden rounded-xl bg-white shadow-sm ring-1 ring-gray-200 dark:bg-dark-800 dark:ring-dark-700">
<img :src="siteLogo || '/logo.png'" alt="Logo" class="h-full w-full object-contain" />
</span>
<span class="truncate text-base font-semibold text-gray-950 dark:text-white">
{{ siteName }}
</span>
</RouterLink>
<RouterLink
to="/login"
class="inline-flex flex-shrink-0 items-center justify-center rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white shadow-sm shadow-primary-600/20 transition hover:bg-primary-700"
>
登录
</RouterLink>
</div>
</header>
<main class="mx-auto max-w-4xl px-4 py-8 sm:px-6 lg:py-10">
<div v-if="loading" class="flex min-h-[320px] items-center justify-center">
<div class="h-8 w-8 animate-spin rounded-full border-b-2 border-primary-600"></div>
</div>
<section
v-else-if="loadError"
class="rounded-lg border border-red-200 bg-red-50 p-6 text-red-700 dark:border-red-500/30 dark:bg-red-500/10 dark:text-red-200"
>
<h1 class="text-lg font-semibold">文档加载失败</h1>
<p class="mt-2 text-sm">请稍后刷新页面重试</p>
</section>
<section
v-else-if="!currentDocument"
class="rounded-lg border border-gray-200 bg-white p-6 dark:border-dark-700 dark:bg-dark-900"
>
<div class="flex items-start gap-3">
<span class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-md bg-gray-100 text-gray-600 dark:bg-dark-800 dark:text-dark-300">
<Icon name="document" size="sm" />
</span>
<div>
<h1 class="text-lg font-semibold text-gray-900 dark:text-white">文档不存在</h1>
<p class="mt-2 text-sm leading-6 text-gray-600 dark:text-dark-300">
当前条款文档不存在或已被管理员移除
</p>
</div>
</div>
</section>
<article v-else>
<div class="mb-8 border-b border-gray-200 pb-6 dark:border-dark-700">
<div class="flex items-start gap-4">
<span class="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-md bg-primary-50 text-primary-700 dark:bg-primary-500/10 dark:text-primary-300">
<Icon :name="documentIcon" size="md" />
</span>
<div class="min-w-0">
<p class="text-sm font-medium text-primary-700 dark:text-primary-300">登录条款</p>
<h1 class="mt-2 break-words text-2xl font-bold tracking-normal text-gray-950 dark:text-white sm:text-3xl">
{{ currentDocument.title }}
</h1>
<p v-if="updatedAt" class="mt-3 text-sm text-gray-500 dark:text-dark-400">
更新日期{{ updatedAt }}
</p>
</div>
</div>
</div>
<div
v-if="hasContent"
class="legal-document-content"
v-html="renderedHtml"
></div>
<div
v-else
class="rounded-lg border border-dashed border-gray-300 bg-white px-6 py-14 text-center text-sm text-gray-500 dark:border-dark-700 dark:bg-dark-900 dark:text-dark-400"
>
暂无正文内容
</div>
</article>
</main>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { marked } from 'marked'
import DOMPurify from 'dompurify'
import Icon from '@/components/icons/Icon.vue'
import { getPublicSettings } from '@/api/auth'
import { sanitizeUrl } from '@/utils/url'
import type { LoginAgreementDocument, PublicSettings } from '@/types'
type LegalDocumentIcon = 'document' | 'shield' | 'globe' | 'cog'
const route = useRoute()
const settings = ref<PublicSettings | null>(null)
const loading = ref(true)
const loadError = ref(false)
marked.setOptions({
breaks: true,
gfm: true,
})
const documentId = computed(() => String(route.params.documentId || ''))
const documents = computed(() => settings.value?.login_agreement_documents ?? [])
const siteName = computed(() => settings.value?.site_name || 'Sub2API')
const siteLogo = computed(() => sanitizeUrl(settings.value?.site_logo || '', {
allowRelative: true,
allowDataUrl: true,
}))
const updatedAt = computed(() => settings.value?.login_agreement_updated_at || '')
const currentDocument = computed<LoginAgreementDocument | null>(() => {
const id = documentId.value
if (!id) {
return null
}
return documents.value.find((doc) => doc.id === id) ?? null
})
const hasContent = computed(() => Boolean(currentDocument.value?.content_md?.trim()))
const renderedHtml = computed(() => {
const content = currentDocument.value?.content_md?.trim() || ''
if (!content) {
return ''
}
const html = marked.parse(content) as string
return DOMPurify.sanitize(html)
})
const documentIcon = computed<LegalDocumentIcon>(() => {
const title = currentDocument.value?.title || ''
if (title.includes('政策') || title.includes('隐私')) {
return 'shield'
}
if (title.includes('国家') || title.includes('地区')) {
return 'globe'
}
if (title.includes('特定')) {
return 'cog'
}
return 'document'
})
onMounted(async () => {
loading.value = true
loadError.value = false
try {
settings.value = await getPublicSettings()
} catch {
loadError.value = true
} finally {
loading.value = false
}
})
</script>
<style scoped>
.legal-document-content {
line-height: 1.75;
overflow-wrap: anywhere;
color: inherit;
}
.legal-document-content :deep(h1) {
@apply mb-4 mt-8 border-b border-gray-200 pb-3 text-3xl font-bold dark:border-dark-700;
}
.legal-document-content :deep(h2) {
@apply mb-3 mt-7 text-2xl font-bold;
}
.legal-document-content :deep(h3) {
@apply mb-2 mt-6 text-xl font-semibold;
}
.legal-document-content :deep(h4) {
@apply mb-2 mt-5 text-lg font-semibold;
}
.legal-document-content :deep(p) {
@apply mb-4 text-gray-700 dark:text-dark-200;
}
.legal-document-content :deep(a) {
@apply text-primary-600 underline underline-offset-4 hover:text-primary-700 dark:text-primary-300 dark:hover:text-primary-200;
}
.legal-document-content :deep(ul) {
@apply mb-4 list-disc pl-6;
}
.legal-document-content :deep(ol) {
@apply mb-4 list-decimal pl-6;
}
.legal-document-content :deep(li) {
@apply mb-1 text-gray-700 dark:text-dark-200;
}
.legal-document-content :deep(blockquote) {
@apply my-5 border-l-4 border-gray-300 pl-4 text-gray-600 dark:border-dark-600 dark:text-dark-300;
}
.legal-document-content :deep(code) {
@apply rounded bg-gray-100 px-1.5 py-0.5 font-mono text-sm dark:bg-dark-800;
}
.legal-document-content :deep(pre) {
@apply my-5 overflow-x-auto rounded-lg bg-gray-950 p-4 text-gray-100;
}
.legal-document-content :deep(pre code) {
@apply bg-transparent p-0 text-inherit;
}
.legal-document-content :deep(table) {
@apply my-5 block w-full overflow-x-auto border-collapse;
}
.legal-document-content :deep(th) {
@apply border border-gray-300 bg-gray-50 px-3 py-2 text-left font-semibold dark:border-dark-600 dark:bg-dark-800;
}
.legal-document-content :deep(td) {
@apply border border-gray-300 px-3 py-2 dark:border-dark-600;
}
.legal-document-content :deep(img) {
@apply my-5 h-auto max-w-full rounded-lg;
}
.legal-document-content :deep(hr) {
@apply my-7 border-gray-200 dark:border-dark-700;
}
</style>