feat: 添加登录注册条款确认
This commit is contained in:
@@ -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>
|
||||
@@ -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',
|
||||
|
||||
@@ -5234,6 +5234,7 @@ export default {
|
||||
description: '管理注册、邮箱验证、默认值和 SMTP 设置',
|
||||
tabs: {
|
||||
general: '通用设置',
|
||||
agreement: '登录条款',
|
||||
features: '功能开关',
|
||||
security: '安全与认证',
|
||||
users: '用户默认值',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user