Files
sub2api/frontend/src/views/user/AffiliateView.vue
shaw 4e1bb2b445 feat(affiliate): add feature toggle and per-user custom invite settings
- 在系统设置「功能开关」中新增邀请返利总开关,默认关闭;
  关闭态:菜单隐藏、注册忽略 aff、新充值不返利,但已有 quota 仍可转余额
- 支持管理员为指定用户设置专属邀请码(覆盖随机码,全局唯一)
- 支持管理员为指定用户设置专属返利比例(覆盖全局比例,可单条/批量调整)
- 在系统设置邀请返利卡片内嵌入专属用户管理表格(搜索/编辑/批量/删除),
  删除采用项目通用 ConfirmDialog,会同时清除专属比例并把邀请码重置为系统随机码
- /affiliate 用户页新增「我的返利比例」卡片与动态使用说明,让用户直观看到
  分享后能拿到多少(同源 resolveRebateRatePercent 计算,与实际充值一致)
- 新增数据库迁移 132 添加 aff_rebate_rate_percent 与 aff_code_custom 列
- 新增 admin 路由组 /api/v1/admin/affiliates/users/* 共 5 个端点
- AffiliateService 改为只依赖 *SettingService,去除冗余的 SettingRepository
- 邀请码格式校验放宽到 [A-Z0-9_-]{4,32},兼容旧 12 位系统码与新自定义码
- 补充单元测试与集成测试覆盖新方法、冲突路径与边界值
2026-04-25 20:22:07 +08:00

226 lines
9.9 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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 sm:grid-cols-2 lg:grid-cols-4">
<!-- 返利比例用主色突出让用户一眼看到能拿多少 -->
<div class="card relative overflow-hidden p-5">
<div class="absolute -right-6 -top-6 h-24 w-24 rounded-full bg-primary-500/10"></div>
<div class="relative">
<p class="flex items-center gap-1.5 text-sm text-gray-500 dark:text-dark-400">
<Icon name="dollar" size="sm" class="text-primary-500" />
{{ t('affiliate.stats.rebateRate') }}
</p>
<p class="mt-2 text-2xl font-semibold text-primary-600 dark:text-primary-400">
{{ formattedRebateRate }}<span class="ml-0.5 text-base font-medium">%</span>
</p>
<p class="mt-1 text-xs text-gray-400 dark:text-dark-500">
{{ t('affiliate.stats.rebateRateHint') }}
</p>
</div>
</div>
<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', { rate: `${formattedRebateRate}%` }) }}</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)}`
})
// Rebate rate is a percentage in the range [0, 100]; backend already clamps it.
// We trim trailing zeros (e.g. 20.00 → "20", 12.50 → "12.5") for a cleaner UI.
const formattedRebateRate = computed(() => {
const v = detail.value?.effective_rebate_rate_percent ?? 0
const rounded = Math.round(v * 100) / 100
return Number.isInteger(rounded) ? String(rounded) : rounded.toString()
})
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>