feat: add affiliate invite rebate flow and admin rebate-rate setting

This commit is contained in:
VpSanta33
2026-04-24 21:41:26 +08:00
parent d162604f32
commit f03de00cb9
33 changed files with 1744 additions and 42 deletions
+30
View File
@@ -2153,6 +2153,31 @@
{{ t("admin.settings.defaults.defaultBalanceHint") }}
</p>
</div>
<div>
<label
class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{ t("admin.settings.defaults.affiliateRebateRate") }}
</label>
<div class="relative">
<input
v-model.number="form.affiliate_rebate_rate"
type="number"
step="0.01"
min="0"
max="100"
class="input pr-8"
placeholder="20"
/>
<span
class="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-gray-400"
>%</span
>
</div>
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
{{ t("admin.settings.defaults.affiliateRebateRateHint") }}
</p>
</div>
<div>
<label
class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
@@ -4972,6 +4997,7 @@ const form = reactive<SettingsForm>({
totp_enabled: false,
totp_encryption_key_configured: false,
default_balance: 0,
affiliate_rebate_rate: 20,
default_concurrency: 1,
default_subscriptions: [],
force_email_on_third_party_signup: false,
@@ -5894,6 +5920,10 @@ async function saveSettings() {
password_reset_enabled: form.password_reset_enabled,
totp_enabled: form.totp_enabled,
default_balance: form.default_balance,
affiliate_rebate_rate: Math.min(
100,
Math.max(0, Number(form.affiliate_rebate_rate) || 0),
),
default_concurrency: form.default_concurrency,
default_subscriptions: normalizedDefaultSubscriptions,
force_email_on_third_party_signup: form.force_email_on_third_party_signup,
+4 -1
View File
@@ -209,6 +209,7 @@ const password = ref<string>('')
const initialTurnstileToken = ref<string>('')
const promoCode = ref<string>('')
const invitationCode = ref<string>('')
const affCode = ref<string>('')
const pendingAuthToken = ref<string>('')
const pendingAuthTokenField = ref<PendingAuthTokenField>('pending_auth_token')
const pendingProvider = ref<string>('')
@@ -260,6 +261,7 @@ onMounted(async () => {
initialTurnstileToken.value = registerData.turnstile_token || ''
promoCode.value = registerData.promo_code || ''
invitationCode.value = registerData.invitation_code || ''
affCode.value = registerData.aff_code || ''
pendingAuthToken.value = registerData.pending_auth_token || activePendingSession?.token || ''
pendingAuthTokenField.value = registerData.pending_auth_token_field || activePendingSession?.token_field || 'pending_auth_token'
pendingProvider.value = registerData.pending_provider || activePendingSession?.provider || ''
@@ -524,7 +526,8 @@ async function handleVerify(): Promise<void> {
verify_code: verifyCode.value.trim(),
turnstile_token: initialTurnstileToken.value || undefined,
promo_code: promoCode.value || undefined,
invitation_code: invitationCode.value || undefined
invitation_code: invitationCode.value || undefined,
...(affCode.value ? { aff_code: affCode.value } : {})
})
}
+10 -3
View File
@@ -351,7 +351,8 @@ const formData = reactive({
email: '',
password: '',
promo_code: '',
invitation_code: ''
invitation_code: '',
aff_code: ''
})
const errors = reactive({
@@ -406,6 +407,10 @@ onMounted(async () => {
await validatePromoCodeDebounced(promoParam)
}
}
const affParam = (route.query.aff as string) || (route.query.aff_code as string)
if (affParam) {
formData.aff_code = affParam.trim()
}
} catch (error) {
console.error('Failed to load public settings:', error)
} finally {
@@ -707,7 +712,8 @@ async function handleRegister(): Promise<void> {
password: formData.password,
turnstile_token: turnstileToken.value,
promo_code: formData.promo_code || undefined,
invitation_code: formData.invitation_code || undefined
invitation_code: formData.invitation_code || undefined,
...(formData.aff_code ? { aff_code: formData.aff_code } : {})
})
)
@@ -722,7 +728,8 @@ async function handleRegister(): Promise<void> {
password: formData.password,
turnstile_token: turnstileEnabled.value ? turnstileToken.value : undefined,
promo_code: formData.promo_code || undefined,
invitation_code: formData.invitation_code || undefined
invitation_code: formData.invitation_code || undefined,
...(formData.aff_code ? { aff_code: formData.aff_code } : {})
})
// Show success toast
+201
View File
@@ -0,0 +1,201 @@
<template>
<AppLayout>
<div class="space-y-6">
<div v-if="loading" class="flex justify-center py-12">
<div
class="h-8 w-8 animate-spin rounded-full border-2 border-primary-500 border-t-transparent"
></div>
</div>
<template v-else-if="detail">
<div class="grid gap-4 md:grid-cols-3">
<div class="card p-5">
<p class="text-sm text-gray-500 dark:text-dark-400">{{ t('affiliate.stats.invitedUsers') }}</p>
<p class="mt-2 text-2xl font-semibold text-gray-900 dark:text-white">
{{ formatCount(detail.aff_count) }}
</p>
</div>
<div class="card p-5">
<p class="text-sm text-gray-500 dark:text-dark-400">{{ t('affiliate.stats.availableQuota') }}</p>
<p class="mt-2 text-2xl font-semibold text-emerald-600 dark:text-emerald-400">
{{ formatCurrency(detail.aff_quota) }}
</p>
</div>
<div class="card p-5">
<p class="text-sm text-gray-500 dark:text-dark-400">{{ t('affiliate.stats.totalQuota') }}</p>
<p class="mt-2 text-2xl font-semibold text-gray-900 dark:text-white">
{{ formatCurrency(detail.aff_history_quota) }}
</p>
</div>
</div>
<div class="card p-6">
<h3 class="text-base font-semibold text-gray-900 dark:text-white">{{ t('affiliate.title') }}</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-dark-400">{{ t('affiliate.description') }}</p>
<div class="mt-5 grid gap-4 md:grid-cols-2">
<div class="space-y-2">
<p class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('affiliate.yourCode') }}</p>
<div class="flex items-center gap-2 rounded-xl border border-gray-200 bg-gray-50 px-3 py-2 dark:border-dark-700 dark:bg-dark-900">
<code class="flex-1 truncate text-sm font-semibold text-gray-900 dark:text-white">{{ detail.aff_code }}</code>
<button class="btn btn-secondary btn-sm" @click="copyCode">
<Icon name="copy" size="sm" />
<span>{{ t('affiliate.copyCode') }}</span>
</button>
</div>
</div>
<div class="space-y-2">
<p class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('affiliate.inviteLink') }}</p>
<div class="flex items-center gap-2 rounded-xl border border-gray-200 bg-gray-50 px-3 py-2 dark:border-dark-700 dark:bg-dark-900">
<code class="flex-1 truncate text-sm text-gray-700 dark:text-gray-300">{{ inviteLink }}</code>
<button class="btn btn-secondary btn-sm" @click="copyInviteLink">
<Icon name="copy" size="sm" />
<span>{{ t('affiliate.copyLink') }}</span>
</button>
</div>
</div>
</div>
<div class="mt-5 rounded-xl border border-primary-200 bg-primary-50 p-4 dark:border-primary-900/40 dark:bg-primary-900/20">
<p class="text-sm font-medium text-primary-800 dark:text-primary-200">{{ t('affiliate.tips.title') }}</p>
<ul class="mt-2 space-y-1 text-sm text-primary-700 dark:text-primary-300">
<li>1. {{ t('affiliate.tips.line1') }}</li>
<li>2. {{ t('affiliate.tips.line2') }}</li>
<li>3. {{ t('affiliate.tips.line3') }}</li>
</ul>
</div>
</div>
<div class="card p-6">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h3 class="text-base font-semibold text-gray-900 dark:text-white">{{ t('affiliate.transfer.title') }}</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-dark-400">{{ t('affiliate.transfer.description') }}</p>
</div>
<button
class="btn btn-primary"
:disabled="transferring || detail.aff_quota <= 0"
@click="transferQuota"
>
<Icon v-if="transferring" name="refresh" size="sm" class="animate-spin" />
<Icon v-else name="dollar" size="sm" />
<span>{{ transferring ? t('affiliate.transfer.transferring') : t('affiliate.transfer.button') }}</span>
</button>
</div>
<p v-if="detail.aff_quota <= 0" class="mt-3 text-sm text-amber-600 dark:text-amber-400">
{{ t('affiliate.transfer.empty') }}
</p>
</div>
<div class="card p-6">
<h3 class="text-base font-semibold text-gray-900 dark:text-white">{{ t('affiliate.invitees.title') }}</h3>
<div v-if="detail.invitees.length === 0" class="mt-4 rounded-xl border border-dashed border-gray-300 p-6 text-center text-sm text-gray-500 dark:border-dark-700 dark:text-dark-400">
{{ t('affiliate.invitees.empty') }}
</div>
<div v-else class="mt-4 overflow-x-auto">
<table class="w-full min-w-[560px] text-left text-sm">
<thead>
<tr class="border-b border-gray-200 text-gray-500 dark:border-dark-700 dark:text-dark-400">
<th class="px-3 py-2 font-medium">{{ t('affiliate.invitees.columns.email') }}</th>
<th class="px-3 py-2 font-medium">{{ t('affiliate.invitees.columns.username') }}</th>
<th class="px-3 py-2 font-medium">{{ t('affiliate.invitees.columns.joinedAt') }}</th>
</tr>
</thead>
<tbody>
<tr
v-for="item in detail.invitees"
:key="item.user_id"
class="border-b border-gray-100 last:border-b-0 dark:border-dark-800"
>
<td class="px-3 py-3 text-gray-900 dark:text-white">{{ item.email || '-' }}</td>
<td class="px-3 py-3 text-gray-700 dark:text-gray-300">{{ item.username || '-' }}</td>
<td class="px-3 py-3 text-gray-700 dark:text-gray-300">{{ formatDateTime(item.created_at) || '-' }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
</div>
</AppLayout>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import AppLayout from '@/components/layout/AppLayout.vue'
import Icon from '@/components/icons/Icon.vue'
import userAPI from '@/api/user'
import type { UserAffiliateDetail } from '@/types'
import { useAppStore } from '@/stores/app'
import { useAuthStore } from '@/stores/auth'
import { useClipboard } from '@/composables/useClipboard'
import { formatCurrency, formatDateTime } from '@/utils/format'
import { extractApiErrorMessage } from '@/utils/apiError'
const { t } = useI18n()
const appStore = useAppStore()
const authStore = useAuthStore()
const { copyToClipboard } = useClipboard()
const loading = ref(true)
const transferring = ref(false)
const detail = ref<UserAffiliateDetail | null>(null)
const inviteLink = computed(() => {
if (!detail.value) return ''
if (typeof window === 'undefined') return `/register?aff=${encodeURIComponent(detail.value.aff_code)}`
return `${window.location.origin}/register?aff=${encodeURIComponent(detail.value.aff_code)}`
})
function formatCount(value: number): string {
return value.toLocaleString()
}
async function loadAffiliateDetail(silent = false): Promise<void> {
if (!silent) {
loading.value = true
}
try {
detail.value = await userAPI.getAffiliateDetail()
} catch (error) {
appStore.showError(extractApiErrorMessage(error, t('affiliate.loadFailed')))
} finally {
if (!silent) {
loading.value = false
}
}
}
async function copyCode(): Promise<void> {
if (!detail.value?.aff_code) return
await copyToClipboard(detail.value.aff_code, t('affiliate.codeCopied'))
}
async function copyInviteLink(): Promise<void> {
if (!inviteLink.value) return
await copyToClipboard(inviteLink.value, t('affiliate.linkCopied'))
}
async function transferQuota(): Promise<void> {
if (!detail.value || detail.value.aff_quota <= 0 || transferring.value) return
transferring.value = true
try {
const resp = await userAPI.transferAffiliateQuota()
appStore.showSuccess(t('affiliate.transfer.success', { amount: formatCurrency(resp.transferred_quota) }))
await Promise.all([
loadAffiliateDetail(true),
authStore.refreshUser().catch(() => undefined),
])
} catch (error) {
appStore.showError(extractApiErrorMessage(error, t('affiliate.transferFailed')))
} finally {
transferring.value = false
}
}
onMounted(() => {
void loadAffiliateDetail()
})
</script>