feat:完善租户端注册时的用户协议功能

This commit is contained in:
qian committed 2026-06-12 00:43:55 +08:00
1 parent a1f637fb2a
commit 73ab89d551
8 files changed
+79 -147

No files matched your search

+2 -2
View File
@@ -35,7 +35,7 @@ type TenantAgreementAcceptRes struct{}
// --- 公开端点(登录/注册页面使用,无需认证) ---
type AgreementCurrentListReq struct {
g.Meta `path:"/agreements/current" method:"get" mime:"json" tags:"公开-用户协议" summary:"所有当前生效的协议列表" group:"public" middleware:"-"`
g.Meta `path:"/agreements/current" method:"get" mime:"json" tags:"公开-用户协议" summary:"所有当前生效的协议列表" middleware:"-"`
}
type PublicAgreementItem struct {
@@ -52,7 +52,7 @@ type AgreementCurrentListRes struct {
}
type AgreementCurrentGetReq struct {
g.Meta `path:"/agreements/current/{code}" method:"get" mime:"json" tags:"公开-用户协议" summary:"按标识码获取当前协议详情" group:"public" middleware:"-"`
g.Meta `path:"/agreements/current/{code}" method:"get" mime:"json" tags:"公开-用户协议" summary:"按标识码获取当前协议详情" middleware:"-"`
Code string `json:"code" in:"path" v:"required|length:1,50"`
}
+14
View File
@@ -1,6 +1,8 @@
package middleware
import (
"strings"
"github.com/gogf/gf/v2/net/ghttp"
"github.com/qianfree/team-api/internal/dao"
@@ -21,6 +23,12 @@ var tenantPublicPaths = map[string]bool{
"/api/tenant/email/reset-password": true,
"/api/tenant/members/join": true,
"/api/tenant/members/invite-info": true,
"/api/tenant/agreements/current": true,
}
// tenantPublicPrefixes lists path prefixes that skip JWT auth (for dynamic routes like /current/{code}).
var tenantPublicPrefixes = []string{
"/api/tenant/agreements/current/",
}
// TenantAuth is JWT authentication middleware for tenant console.
@@ -31,6 +39,12 @@ func TenantAuth(r *ghttp.Request) {
r.Middleware.Next()
return
}
for _, prefix := range tenantPublicPrefixes {
if strings.HasPrefix(r.URL.Path, prefix) {
r.Middleware.Next()
return
}
}
tokenStr := extractBearerToken(r)
if tokenStr == "" {
@@ -14,6 +14,7 @@ import ContentFilterTab from './settings/ContentFilterTab.vue'
import ChannelTab from './settings/ChannelTab.vue'
import StorageTab from './settings/StorageTab.vue'
import DataGovernanceTab from './settings/DataGovernanceTab.vue'
import AgreementTab from './settings/AgreementTab.vue'
interface SettingCategory {
key: string
@@ -34,6 +35,7 @@ const categoryMap: Record<string, Component> = {
channel: ChannelTab,
storage: StorageTab,
data_governance: DataGovernanceTab,
agreement: AgreementTab,
}
const categories = ref<SettingCategory[]>([])
-6
View File
@@ -32,12 +32,6 @@ const tenantRoutes: RouteRecordRaw[] = [
component: () => import('@/views/auth/ForgotPasswordPage.vue'),
meta: { requiresAuth: false, title: '找回密码' },
},
{
path: '/tenant/agreement/:code',
name: 'TenantAgreement',
component: () => import('@/views/auth/AgreementPage.vue'),
meta: { requiresAuth: false, title: '协议详情' },
},
{
path: '/tenant/join',
name: 'TenantJoin',
+1 -1
View File
@@ -104,7 +104,7 @@ async function doRefresh(): Promise<TokenPair> {
return newTokens
}
const PUBLIC_PATHS = ['/settings/', '/captcha', '/captcha/', '/auth/login', '/auth/register']
const PUBLIC_PATHS = ['/settings/', '/captcha', '/captcha/', '/auth/login', '/auth/register', '/agreements/']
function isPublicPath(url?: string): boolean {
if (!url) return false
-108
View File
@@ -1,108 +0,0 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { useRoute } from 'vue-router'
import { marked } from 'marked'
import request from '@/utils/request'
import AuthLayout from '@/components/layout/AuthLayout.vue'
import Icon from '@/components/common/Icon.vue'
const route = useRoute()
const code = computed(() => route.params.code as string)
const title = ref('')
const version = ref('')
const content = ref('')
const loading = ref(true)
const error = ref('')
const contentHtml = computed(() => {
if (!content.value) return ''
return marked(content.value) as string
})
onMounted(async () => {
try {
const res = await request.get(`/tenant/agreements/current/${code.value}`)
const data = res.data?.data
if (data) {
title.value = data.title
version.value = data.version
content.value = data.content || ''
} else {
error.value = '未找到该协议'
}
} catch {
error.value = '加载协议内容失败'
} finally {
loading.value = false
}
})
</script>
<template>
<AuthLayout>
<div class="animate-slide-up">
<!-- Loading -->
<div v-if="loading" class="flex items-center justify-center py-16">
<div class="spinner h-6 w-6 border-primary-500"></div>
</div>
<!-- Error -->
<div v-else-if="error" class="text-center py-12">
<div class="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl bg-gray-100">
<Icon name="document" size="lg" class="text-gray-400" />
</div>
<p class="text-sm text-gray-500">{{ error }}</p>
</div>
<!-- Content -->
<div v-else>
<div class="mb-4">
<h2 class="text-xl font-bold text-gray-900">{{ title }}</h2>
<p v-if="version" class="mt-1 text-xs text-gray-400">版本 {{ version }}</p>
</div>
<div class="agreement-content rounded-xl border border-gray-200 bg-gray-50 p-5" v-html="contentHtml" />
</div>
</div>
<template #footer>
<p class="text-gray-500">
<router-link to="/tenant/login" class="text-primary-600 font-medium hover:text-primary-700 transition-colors">
返回登录
</router-link>
</p>
</template>
</AuthLayout>
</template>
<style scoped>
.agreement-content {
max-height: 500px;
overflow-y: auto;
line-height: 1.8;
font-size: 14px;
color: #334155;
}
.agreement-content :deep(h1),
.agreement-content :deep(h2),
.agreement-content :deep(h3) {
margin-top: 16px;
margin-bottom: 8px;
color: #0f172a;
font-weight: 600;
}
.agreement-content :deep(h1) { font-size: 20px; }
.agreement-content :deep(h2) { font-size: 17px; }
.agreement-content :deep(h3) { font-size: 15px; }
.agreement-content :deep(ul),
.agreement-content :deep(ol) {
padding-left: 20px;
}
.agreement-content :deep(p) {
margin-bottom: 8px;
}
</style>
+26 -5
View File
@@ -10,6 +10,7 @@ import Icon from '@/components/common/Icon.vue'
import SlideCaptcha from '@/components/common/SlideCaptcha.vue'
import Turnstile from '@/components/common/Turnstile.vue'
import AgreementAcceptModal from '@/components/common/AgreementAcceptModal.vue'
import AgreementViewModal from '@/components/common/AgreementViewModal.vue'
const router = useRouter()
const route = useRoute()
@@ -46,6 +47,15 @@ const accountInput = ref<HTMLInputElement | null>(null)
// Pending agreements
const showAgreements = ref(false)
// Agreement view modal
const showAgreementModal = ref(false)
const agreementModalCode = ref('')
function openAgreement(code: string) {
agreementModalCode.value = code
showAgreementModal.value = true
}
function proceedAfterLogin() {
const pending = authStore.pendingAgreements
if (pending.length > 0) {
@@ -360,6 +370,11 @@ async function handleOAuthLogin(provider: string) {
<div v-if="loading" class="spinner h-4 w-4 border-white"></div>
{{ loading ? '登录中...' : '登录' }}
</button>
<p class="mt-4 text-center text-xs text-gray-400">
<button type="button" class="hover:text-gray-500 transition-colors" @click="openAgreement('terms')">服务条款</button>
<span class="mx-1">·</span>
<button type="button" class="hover:text-gray-500 transition-colors" @click="openAgreement('privacy')">隐私政策</button>
</p>
</form>
<!-- RAM Login Form -->
@@ -452,6 +467,11 @@ async function handleOAuthLogin(provider: string) {
<div v-if="loading" class="spinner h-4 w-4 border-white"></div>
{{ loading ? '登录中...' : '登录' }}
</button>
<p class="mt-4 text-center text-xs text-gray-400">
<button type="button" class="hover:text-gray-500 transition-colors" @click="openAgreement('terms')">服务条款</button>
<span class="mx-1">·</span>
<button type="button" class="hover:text-gray-500 transition-colors" @click="openAgreement('privacy')">隐私政策</button>
</p>
</form>
<!-- OAuth Login -->
@@ -494,11 +514,6 @@ async function handleOAuthLogin(provider: string) {
立即创建
</router-link>
</p>
<p class="mt-2 text-xs text-gray-400">
<router-link to="/tenant/agreement/terms" target="_blank" class="hover:text-gray-500 transition-colors">服务条款</router-link>
<span class="mx-1">·</span>
<router-link to="/tenant/agreement/privacy" target="_blank" class="hover:text-gray-500 transition-colors">隐私政策</router-link>
</p>
</template>
</AuthLayout>
@@ -507,6 +522,12 @@ async function handleOAuthLogin(provider: string) {
:agreements="authStore.pendingAgreements"
@accepted="onAgreementsAccepted"
/>
<AgreementViewModal
:show="showAgreementModal"
:code="agreementModalCode"
@close="showAgreementModal = false"
/>
</template>
<style scoped>
+34 -25
View File
@@ -7,7 +7,7 @@ import AuthLayout from '@/components/layout/AuthLayout.vue'
import SlideCaptcha from '@/components/common/SlideCaptcha.vue'
import Turnstile from '@/components/common/Turnstile.vue'
import Icon from '@/components/common/Icon.vue'
import AgreementAcceptModal from '@/components/common/AgreementAcceptModal.vue'
import AgreementViewModal from '@/components/common/AgreementViewModal.vue'
import request from '@/utils/request'
import { extractApiError } from '@/utils/request'
@@ -21,6 +21,7 @@ const codeSending = ref(false)
const countdown = ref(0)
const emailVerification = ref(true)
const captcha = ref<{ captchaKey: string; captchaX: number }>({ captchaKey: '', captchaX: 0 })
const captchaRef = ref<InstanceType<typeof SlideCaptcha> | null>(null)
const turnstileToken = ref('')
let countdownTimer: ReturnType<typeof setInterval> | null = null
@@ -31,26 +32,24 @@ const userForm = reactive({
password: '',
confirmPassword: '',
username: '',
agreed: false,
})
const orgErrors = reactive<Record<string, string>>({})
const userErrors = reactive<Record<string, string>>({})
// Pending agreements
const showAgreements = ref(false)
function proceedAfterRegister() {
const pending = authStore.pendingAgreements
if (pending.length > 0) {
showAgreements.value = true
return
}
router.push('/tenant/dashboard')
// Agreement view modal
const showAgreementModal = ref(false)
const agreementModalCode = ref('')
function openAgreement(code: string) {
agreementModalCode.value = code
showAgreementModal.value = true
}
function onAgreementsAccepted() {
authStore.clearPendingAgreements()
showAgreements.value = false
function proceedAfterRegister() {
router.push('/tenant/dashboard')
}
@@ -200,6 +199,7 @@ async function handleRegister() {
await authStore.register(payload)
proceedAfterRegister()
} catch (err: any) {
captchaRef.value?.resetCaptcha()
const apiErr = extractApiError(err)
const msg = apiErr?.message || '注册失败'
if (msg.includes('邮箱') || msg.includes('email')) {
@@ -389,7 +389,7 @@ async function handleRegister() {
<!-- Slide Captcha (shown when email verification is disabled) -->
<template v-else>
<SlideCaptcha v-model="captcha" />
<SlideCaptcha ref="captchaRef" v-model="captcha" />
<p v-if="userErrors.code" class="input-error-text">{{ userErrors.code }}</p>
</template>
@@ -419,16 +419,25 @@ async function handleRegister() {
<p v-if="userErrors.confirmPassword" class="input-error-text">{{ userErrors.confirmPassword }}</p>
</div>
<!-- Agreement Notice -->
<p class="text-xs text-gray-400 text-center">
注册即表示您同意我们的
<router-link to="/tenant/agreement/terms" target="_blank" class="text-primary-600 hover:text-primary-700 underline underline-offset-2">服务条款</router-link>
和
<router-link to="/tenant/agreement/privacy" target="_blank" class="text-primary-600 hover:text-primary-700 underline underline-offset-2">隐私政策</router-link>
</p>
<!-- Agreement Checkbox -->
<div class="flex items-start gap-2.5">
<label class="mt-0.5 cursor-pointer">
<input
v-model="userForm.agreed"
type="checkbox"
class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500/30 transition-colors cursor-pointer"
/>
</label>
<span class="text-xs text-gray-500 leading-relaxed">
我已阅读并同意
<button type="button" class="text-primary-600 hover:text-primary-700 underline underline-offset-2" @click.prevent="openAgreement('terms')">服务条款</button>
和
<button type="button" class="text-primary-600 hover:text-primary-700 underline underline-offset-2" @click.prevent="openAgreement('privacy')">隐私政策</button>
</span>
</div>
<!-- Submit -->
<button type="submit" :disabled="loading" class="btn btn-primary btn-lg w-full">
<button type="submit" :disabled="loading || !userForm.agreed" class="btn btn-primary btn-lg w-full disabled:opacity-50 disabled:cursor-not-allowed">
<div v-if="loading" class="spinner h-4 w-4 border-white"></div>
{{ loading ? '创建中...' : '创建组织' }}
</button>
@@ -445,9 +454,9 @@ async function handleRegister() {
</template>
</AuthLayout>
<AgreementAcceptModal
:show="showAgreements"
:agreements="authStore.pendingAgreements"
@accepted="onAgreementsAccepted"
<AgreementViewModal
:show="showAgreementModal"
:code="agreementModalCode"
@close="showAgreementModal = false"
/>
</template>