feat: add affiliate invite rebate flow and admin rebate-rate setting
This commit is contained in:
@@ -308,6 +308,7 @@ export interface SystemSettings {
|
||||
totp_encryption_key_configured: boolean; // TOTP 加密密钥是否已配置
|
||||
// Default settings
|
||||
default_balance: number;
|
||||
affiliate_rebate_rate: number;
|
||||
default_concurrency: number;
|
||||
default_user_rpm_limit: number;
|
||||
default_subscriptions: DefaultSubscriptionSetting[];
|
||||
@@ -489,6 +490,7 @@ export interface UpdateSettingsRequest {
|
||||
invitation_code_enabled?: boolean;
|
||||
totp_enabled?: boolean; // TOTP 双因素认证
|
||||
default_balance?: number;
|
||||
affiliate_rebate_rate?: number;
|
||||
default_concurrency?: number;
|
||||
default_user_rpm_limit?: number;
|
||||
default_subscriptions?: DefaultSubscriptionSetting[];
|
||||
|
||||
@@ -9,7 +9,14 @@ import {
|
||||
prepareOAuthBindAccessTokenCookie,
|
||||
type WeChatOAuthPublicSettings,
|
||||
} from './auth'
|
||||
import type { User, ChangePasswordRequest, NotifyEmailEntry, UserAuthProvider } from '@/types'
|
||||
import type {
|
||||
User,
|
||||
ChangePasswordRequest,
|
||||
NotifyEmailEntry,
|
||||
UserAuthProvider,
|
||||
UserAffiliateDetail,
|
||||
AffiliateTransferResponse
|
||||
} from '@/types'
|
||||
|
||||
/**
|
||||
* Get current user profile
|
||||
@@ -168,6 +175,16 @@ export async function startOAuthBinding(
|
||||
window.location.href = startURL
|
||||
}
|
||||
|
||||
export async function getAffiliateDetail(): Promise<UserAffiliateDetail> {
|
||||
const { data } = await apiClient.get<UserAffiliateDetail>('/user/aff')
|
||||
return data
|
||||
}
|
||||
|
||||
export async function transferAffiliateQuota(): Promise<AffiliateTransferResponse> {
|
||||
const { data } = await apiClient.post<AffiliateTransferResponse>('/user/aff/transfer')
|
||||
return data
|
||||
}
|
||||
|
||||
export const userAPI = {
|
||||
getProfile,
|
||||
updateProfile,
|
||||
@@ -180,7 +197,9 @@ export const userAPI = {
|
||||
bindEmailIdentity,
|
||||
unbindAuthIdentity,
|
||||
buildOAuthBindingStartURL,
|
||||
startOAuthBinding
|
||||
startOAuthBinding,
|
||||
getAffiliateDetail,
|
||||
transferAffiliateQuota
|
||||
}
|
||||
|
||||
export default userAPI
|
||||
|
||||
@@ -656,6 +656,7 @@ function buildSelfNavItems(withDashboard: boolean): NavItem[] {
|
||||
{ path: '/purchase', label: t('nav.buySubscription'), icon: RechargeSubscriptionIcon, hideInSimpleMode: true, featureFlag: flagPayment },
|
||||
{ path: '/orders', label: t('nav.myOrders'), icon: OrderListIcon, hideInSimpleMode: true, featureFlag: flagPayment },
|
||||
{ path: '/redeem', label: t('nav.redeem'), icon: GiftIcon, hideInSimpleMode: true },
|
||||
{ path: '/affiliate', label: t('nav.affiliate'), icon: UsersIcon, hideInSimpleMode: true },
|
||||
{ path: '/profile', label: t('nav.profile'), icon: UserIcon },
|
||||
...customMenuItemsForUser.value.map((item): NavItem => ({
|
||||
path: `/custom/${item.id}`,
|
||||
|
||||
@@ -346,6 +346,7 @@ export default {
|
||||
apiKeys: 'API Keys',
|
||||
usage: 'Usage',
|
||||
redeem: 'Redeem',
|
||||
affiliate: 'Affiliate Rebates',
|
||||
profile: 'Profile',
|
||||
users: 'Users',
|
||||
groups: 'Groups',
|
||||
@@ -972,6 +973,47 @@ export default {
|
||||
}
|
||||
},
|
||||
|
||||
affiliate: {
|
||||
title: 'Affiliate Rebates',
|
||||
description: 'Invite new users and convert your rebate quota into account balance',
|
||||
yourCode: 'Your Affiliate Code',
|
||||
inviteLink: 'Invite Link',
|
||||
copyCode: 'Copy Code',
|
||||
copyLink: 'Copy Link',
|
||||
codeCopied: 'Affiliate code copied',
|
||||
linkCopied: 'Invite link copied',
|
||||
loadFailed: 'Failed to load affiliate data',
|
||||
transferFailed: 'Failed to transfer affiliate quota',
|
||||
stats: {
|
||||
invitedUsers: 'Invited Users',
|
||||
availableQuota: 'Available Rebate Quota',
|
||||
totalQuota: 'Historical Rebate Quota'
|
||||
},
|
||||
transfer: {
|
||||
title: 'Transfer Rebate Quota',
|
||||
description: 'Move available rebate quota into your account balance',
|
||||
button: 'Transfer to Balance',
|
||||
transferring: 'Transferring...',
|
||||
empty: 'No available rebate quota',
|
||||
success: '{amount} has been transferred to your balance'
|
||||
},
|
||||
invitees: {
|
||||
title: 'Invited Users',
|
||||
empty: 'No invited users yet',
|
||||
columns: {
|
||||
email: 'Email',
|
||||
username: 'Username',
|
||||
joinedAt: 'Joined At'
|
||||
}
|
||||
},
|
||||
tips: {
|
||||
title: 'How It Works',
|
||||
line1: 'Share your affiliate code or invite link with new users.',
|
||||
line2: 'When invitees recharge, you receive rebate quota based on the configured rate.',
|
||||
line3: 'Transfer rebate quota to balance at any time.'
|
||||
}
|
||||
},
|
||||
|
||||
// Redeem
|
||||
redeem: {
|
||||
title: 'Redeem Code',
|
||||
@@ -4837,6 +4879,9 @@ export default {
|
||||
description: 'Default values for new users',
|
||||
defaultBalance: 'Default Balance',
|
||||
defaultBalanceHint: 'Initial balance for new users',
|
||||
affiliateRebateRate: 'Affiliate Rebate Rate',
|
||||
affiliateRebateRateHint:
|
||||
'Rebate percentage credited to inviter after recharge (0-100%, e.g. 10 means 10%)',
|
||||
defaultConcurrency: 'Default Concurrency',
|
||||
defaultConcurrencyHint: 'Maximum concurrent requests for new users',
|
||||
defaultUserRpmLimit: 'Default User RPM Limit',
|
||||
|
||||
@@ -346,6 +346,7 @@ export default {
|
||||
apiKeys: 'API 密钥',
|
||||
usage: '使用记录',
|
||||
redeem: '兑换',
|
||||
affiliate: '邀请返利',
|
||||
profile: '个人资料',
|
||||
users: '用户管理',
|
||||
groups: '分组管理',
|
||||
@@ -976,6 +977,47 @@ export default {
|
||||
}
|
||||
},
|
||||
|
||||
affiliate: {
|
||||
title: '邀请返利',
|
||||
description: '邀请新用户注册,并将返利额度转入账户余额',
|
||||
yourCode: '我的邀请码',
|
||||
inviteLink: '邀请链接',
|
||||
copyCode: '复制邀请码',
|
||||
copyLink: '复制链接',
|
||||
codeCopied: '邀请码已复制',
|
||||
linkCopied: '邀请链接已复制',
|
||||
loadFailed: '加载邀请返利数据失败',
|
||||
transferFailed: '转入余额失败',
|
||||
stats: {
|
||||
invitedUsers: '邀请人数',
|
||||
availableQuota: '可转返利额度',
|
||||
totalQuota: '历史返利额度'
|
||||
},
|
||||
transfer: {
|
||||
title: '返利额度转余额',
|
||||
description: '将当前可用返利额度一键转入账户余额',
|
||||
button: '转入余额',
|
||||
transferring: '转入中...',
|
||||
empty: '当前没有可转入额度',
|
||||
success: '已转入余额:{amount}'
|
||||
},
|
||||
invitees: {
|
||||
title: '已邀请用户',
|
||||
empty: '暂无邀请记录',
|
||||
columns: {
|
||||
email: '邮箱',
|
||||
username: '用户名',
|
||||
joinedAt: '注册时间'
|
||||
}
|
||||
},
|
||||
tips: {
|
||||
title: '使用说明',
|
||||
line1: '将邀请码或邀请链接分享给新用户。',
|
||||
line2: '被邀请用户充值后,你可获得对应比例的返利额度。',
|
||||
line3: '返利额度可随时转入账户余额。'
|
||||
}
|
||||
},
|
||||
|
||||
// Redeem
|
||||
redeem: {
|
||||
title: '兑换码',
|
||||
@@ -5000,6 +5042,8 @@ export default {
|
||||
description: '新用户的默认值',
|
||||
defaultBalance: '默认余额',
|
||||
defaultBalanceHint: '新用户的初始余额',
|
||||
affiliateRebateRate: '邀请返利比例',
|
||||
affiliateRebateRateHint: '充值后返给邀请人的比例(0-100%,例如填写 10 表示返利 10%)',
|
||||
defaultConcurrency: '默认并发数',
|
||||
defaultConcurrencyHint: '新用户的最大并发请求数',
|
||||
defaultUserRpmLimit: '默认用户 RPM 限制',
|
||||
|
||||
@@ -197,6 +197,18 @@ const routes: RouteRecordRaw[] = [
|
||||
descriptionKey: 'redeem.description'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/affiliate',
|
||||
name: 'Affiliate',
|
||||
component: () => import('@/views/user/AffiliateView.vue'),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
requiresAdmin: false,
|
||||
title: 'Affiliate',
|
||||
titleKey: 'affiliate.title',
|
||||
descriptionKey: 'affiliate.description'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/available-channels',
|
||||
name: 'UserAvailableChannels',
|
||||
|
||||
@@ -122,6 +122,29 @@ export interface RegisterRequest {
|
||||
turnstile_token?: string
|
||||
promo_code?: string
|
||||
invitation_code?: string
|
||||
aff_code?: string
|
||||
}
|
||||
|
||||
export interface AffiliateInvitee {
|
||||
user_id: number
|
||||
email: string
|
||||
username: string
|
||||
created_at?: string
|
||||
}
|
||||
|
||||
export interface UserAffiliateDetail {
|
||||
user_id: number
|
||||
aff_code: string
|
||||
inviter_id?: number | null
|
||||
aff_count: number
|
||||
aff_quota: number
|
||||
aff_history_quota: number
|
||||
invitees: AffiliateInvitee[]
|
||||
}
|
||||
|
||||
export interface AffiliateTransferResponse {
|
||||
transferred_quota: number
|
||||
balance: number
|
||||
}
|
||||
|
||||
export interface SendVerifyCodeRequest {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 } : {})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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
frontend/src/views/user/AffiliateView.vue
Normal file
201
frontend/src/views/user/AffiliateView.vue
Normal 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>
|
||||
Reference in New Issue
Block a user