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 位系统码与新自定义码
- 补充单元测试与集成测试覆盖新方法、冲突路径与边界值
This commit is contained in:
shaw
2026-04-25 19:14:34 +08:00
parent 9d1751ec57
commit 4e1bb2b445
28 changed files with 2010 additions and 141 deletions

View File

@@ -0,0 +1,108 @@
/**
* Admin Affiliate API endpoints
* Manage per-user affiliate (邀请返利) configurations:
* exclusive invite codes (overrides aff_code) and exclusive rebate rates.
*/
import { apiClient } from '../client'
import type { PaginatedResponse } from '@/types'
export interface AffiliateAdminEntry {
user_id: number
email: string
username: string
aff_code: string
aff_code_custom: boolean
aff_rebate_rate_percent?: number | null
aff_count: number
}
export interface ListAffiliateUsersParams {
page?: number
page_size?: number
search?: string
}
export interface UpdateAffiliateUserRequest {
aff_code?: string
aff_rebate_rate_percent?: number | null
/** Set true to explicitly clear the per-user rate (sets it to NULL). */
clear_rebate_rate?: boolean
}
export interface BatchSetRateRequest {
user_ids: number[]
aff_rebate_rate_percent?: number | null
/** Set true to clear rates instead of setting. */
clear?: boolean
}
export interface SimpleUser {
id: number
email: string
username: string
}
export async function listUsers(
params: ListAffiliateUsersParams = {},
): Promise<PaginatedResponse<AffiliateAdminEntry>> {
const { data } = await apiClient.get<PaginatedResponse<AffiliateAdminEntry>>(
'/admin/affiliates/users',
{
params: {
page: params.page ?? 1,
page_size: params.page_size ?? 20,
search: params.search ?? '',
},
},
)
return data
}
export async function lookupUsers(q: string): Promise<SimpleUser[]> {
const { data } = await apiClient.get<SimpleUser[]>(
'/admin/affiliates/users/lookup',
{ params: { q } },
)
return data
}
export async function updateUserSettings(
userId: number,
payload: UpdateAffiliateUserRequest,
): Promise<{ user_id: number }> {
const { data } = await apiClient.put<{ user_id: number }>(
`/admin/affiliates/users/${userId}`,
payload,
)
return data
}
export async function clearUserSettings(
userId: number,
): Promise<{ user_id: number }> {
const { data } = await apiClient.delete<{ user_id: number }>(
`/admin/affiliates/users/${userId}`,
)
return data
}
export async function batchSetRate(
payload: BatchSetRateRequest,
): Promise<{ affected: number }> {
const { data } = await apiClient.post<{ affected: number }>(
'/admin/affiliates/users/batch-rate',
payload,
)
return data
}
export const affiliatesAPI = {
listUsers,
lookupUsers,
updateUserSettings,
clearUserSettings,
batchSetRate,
}
export default affiliatesAPI

View File

@@ -29,6 +29,7 @@ import channelsAPI from './channels'
import channelMonitorAPI from './channelMonitor'
import channelMonitorTemplateAPI from './channelMonitorTemplate'
import adminPaymentAPI from './payment'
import affiliatesAPI from './affiliates'
/**
* Unified admin API object for convenient access
@@ -59,7 +60,8 @@ export const adminAPI = {
channels: channelsAPI,
channelMonitor: channelMonitorAPI,
channelMonitorTemplate: channelMonitorTemplateAPI,
payment: adminPaymentAPI
payment: adminPaymentAPI,
affiliates: affiliatesAPI
}
export {
@@ -88,7 +90,8 @@ export {
channelsAPI,
channelMonitorAPI,
channelMonitorTemplateAPI,
adminPaymentAPI
adminPaymentAPI,
affiliatesAPI
}
export default adminAPI

View File

@@ -478,6 +478,9 @@ export interface SystemSettings {
// Available Channels feature switch
available_channels_enabled: boolean;
// Affiliate (邀请返利) feature switch
affiliate_enabled: boolean;
}
export interface UpdateSettingsRequest {
@@ -636,6 +639,9 @@ export interface UpdateSettingsRequest {
// Available Channels feature switch
available_channels_enabled?: boolean;
// Affiliate (邀请返利) feature switch
affiliate_enabled?: boolean;
}
/**

View File

@@ -634,6 +634,7 @@ const ChevronDownIcon = {
const flagChannelMonitor = makeSidebarFlag(FeatureFlags.channelMonitor)
const flagPayment = makeSidebarFlag(FeatureFlags.payment)
const flagAvailableChannels = makeSidebarFlag(FeatureFlags.availableChannels)
const flagAffiliate = makeSidebarFlag(FeatureFlags.affiliate)
const flagOpsMonitoring = () => adminSettingsStore.opsMonitoringEnabled
const flagAdminPayment = () => adminSettingsStore.paymentEnabled
@@ -656,7 +657,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: '/affiliate', label: t('nav.affiliate'), icon: UsersIcon, hideInSimpleMode: true, featureFlag: flagAffiliate },
{ path: '/profile', label: t('nav.profile'), icon: UserIcon },
...customMenuItemsForUser.value.map((item): NavItem => ({
path: `/custom/${item.id}`,

View File

@@ -985,6 +985,8 @@ export default {
loadFailed: 'Failed to load affiliate data',
transferFailed: 'Failed to transfer affiliate quota',
stats: {
rebateRate: 'My Rebate Rate',
rebateRateHint: 'What you earn each time an invitee recharges',
invitedUsers: 'Invited Users',
availableQuota: 'Available Rebate Quota',
totalQuota: 'Historical Rebate Quota'
@@ -1009,7 +1011,7 @@ export default {
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.',
line2: 'When invitees recharge, you receive {rate} of the recharge as rebate quota.',
line3: 'Transfer rebate quota to balance at any time.'
}
},
@@ -4779,6 +4781,55 @@ export default {
enabled: 'Enable Available Channels',
enabledHint: 'When off, the sidebar entry is hidden and the endpoint returns an empty list.',
},
affiliate: {
title: 'Affiliate (Invite Rebate)',
description: 'Existing users invite new ones; the inviter earns a percentage rebate on the invitees recharges. Disabled by default.',
enabled: 'Enable Affiliate',
enabledHint: 'When off, the affiliate menu is hidden, the aff parameter is ignored at signup, and new recharges generate no rebate. Existing rebate balances can still be transferred.',
rebateRate: 'Global Rebate Rate',
rebateRateHint: 'Default percentage given back to the inviter on recharges (0-100, e.g. 10 = 10%).',
customUsers: {
title: 'Per-User Overrides',
description: 'Set a custom invite code or exclusive rebate rate for specific users. Lists only users that have an override applied.',
addButton: 'Add Custom User',
searchPlaceholder: 'Search by email or username',
batchButton: 'Batch Set Rate ({count} selected)',
empty: 'No users with custom affiliate settings yet',
customBadge: 'custom',
useGlobal: 'use global',
resetTitle: 'Reset Custom Settings',
resetMessage: 'Reset all custom settings for {email}?\n• The exclusive rebate rate will be cleared (fall back to the global rate)\n• The invite code will be regenerated as a new system code (previously shared links will stop working)',
totalLabel: '{total} total',
col: {
email: 'Email',
username: 'Username',
code: 'Invite Code',
rate: 'Custom Rate',
actions: 'Actions',
},
},
modal: {
addTitle: 'Add Custom User',
editTitle: 'Edit Custom Settings',
userLabel: 'User',
userPlaceholder: 'Search by email or username',
changeUser: 'Change user',
codeLabel: 'Custom Invite Code (optional)',
codePlaceholder: 'e.g. VIP2026',
codeHint: '4-32 characters; A-Z, 0-9, underscore, dash. Leave empty to keep current. Input is upper-cased.',
rateLabel: 'Exclusive Rebate Rate (optional)',
ratePlaceholder: 'e.g. 30',
rateHint: '0-100. Leave empty (in edit mode) to clear and fall back to the global rate.',
errorBadRate: 'Please enter a number between 0 and 100',
errorEmpty: 'Fill at least one: custom invite code or exclusive rebate rate',
},
batchModal: {
title: 'Batch Set Rate ({count} users selected)',
hint: 'Apply the same exclusive rebate rate to all selected users.',
placeholder: 'e.g. 30',
clearHint: 'Submitting empty will clear the exclusive rate for selected users.',
},
},
},
emailTabDisabledTitle: 'Email Verification Not Enabled',
emailTabDisabledHint: 'Enable email verification in the Security tab to configure SMTP settings.',

View File

@@ -989,6 +989,8 @@ export default {
loadFailed: '加载邀请返利数据失败',
transferFailed: '转入余额失败',
stats: {
rebateRate: '我的返利比例',
rebateRateHint: '被邀请用户每次充值后你可获得的返利比例',
invitedUsers: '邀请人数',
availableQuota: '可转返利额度',
totalQuota: '历史返利额度'
@@ -1013,7 +1015,7 @@ export default {
tips: {
title: '使用说明',
line1: '将邀请码或邀请链接分享给新用户。',
line2: '被邀请用户充值后,你可获得对应比例的返利额度。',
line2: '被邀请用户充值后,你可获得 {rate} 的返利额度。',
line3: '返利额度可随时转入账户余额。'
}
},
@@ -4942,6 +4944,55 @@ export default {
enabled: '启用可用渠道',
enabledHint: '关闭后用户端侧边栏入口隐藏,接口返回空数组。',
},
affiliate: {
title: '邀请返利',
description: '老用户邀请新用户注册,新用户充值后老用户按比例获得返利额度。默认关闭。',
enabled: '启用邀请返利',
enabledHint: '关闭后用户菜单中的邀请页面入口隐藏、注册时忽略邀请码、新充值不再产生返利。已有返利额度仍可转入余额。',
rebateRate: '全局返利比例',
rebateRateHint: '充值后返给邀请人的默认比例0-100%,例如填写 10 表示返利 10%)。',
customUsers: {
title: '专属用户配置',
description: '为指定用户设置专属邀请码或专属返利比例。仅展示已设置过专属配置的用户。',
addButton: '添加专属用户',
searchPlaceholder: '搜索邮箱或用户名',
batchButton: '批量设置比例(已选 {count}',
empty: '暂无专属配置用户',
customBadge: '自定义',
useGlobal: '沿用全局',
resetTitle: '重置该用户的专属配置',
resetMessage: '确认将 {email} 的专属配置全部重置为默认?\n• 专属返利比例将清除(沿用全局)\n• 邀请码将重新生成为系统随机码(已分发的旧邀请链接将失效)',
totalLabel: '共 {total} 条',
col: {
email: '邮箱',
username: '用户名',
code: '邀请码',
rate: '专属比例',
actions: '操作',
},
},
modal: {
addTitle: '添加专属用户',
editTitle: '编辑专属配置',
userLabel: '用户',
userPlaceholder: '搜索邮箱或用户名',
changeUser: '更换用户',
codeLabel: '专属邀请码(可选)',
codePlaceholder: '例如 VIP2026',
codeHint: '4-32 位,仅支持大写字母、数字、下划线、连字符;留空表示不修改;输入将自动转大写。',
rateLabel: '专属返利比例(可选)',
ratePlaceholder: '例如 30',
rateHint: '0-100%;留空(编辑模式下)表示清除专属比例并沿用全局。',
errorBadRate: '请输入 0-100 之间的比例',
errorEmpty: '至少填写一项:专属邀请码或专属返利比例',
},
batchModal: {
title: '批量设置专属比例(已选 {count} 个用户)',
hint: '为所选用户统一设置专属返利比例。',
placeholder: '例如 30',
clearHint: '留空提交将清除所选用户的专属比例。',
},
},
},
emailTabDisabledTitle: '邮箱验证未启用',
emailTabDisabledHint: '请在「安全与认证」选项卡中启用邮箱验证后,再配置 SMTP 设置。',

View File

@@ -355,6 +355,7 @@ export const useAppStore = defineStore('app', () => {
channel_monitor_enabled: true,
channel_monitor_default_interval_seconds: 60,
available_channels_enabled: false,
affiliate_enabled: false,
}
}

View File

@@ -139,6 +139,8 @@ export interface UserAffiliateDetail {
aff_count: number
aff_quota: number
aff_history_quota: number
/** 当前用户作为邀请人时实际生效的返利比例专属覆盖全局。0-100。 */
effective_rebate_rate_percent: number
invitees: AffiliateInvitee[]
}
@@ -212,6 +214,7 @@ export interface PublicSettings {
channel_monitor_enabled: boolean
channel_monitor_default_interval_seconds: number
available_channels_enabled: boolean
affiliate_enabled: boolean
}
export interface AuthResponse {

View File

@@ -109,6 +109,11 @@ export const FeatureFlags = {
mode: 'opt-out',
label: 'Payment',
}),
affiliate: defineFlag({
key: 'affiliate_enabled',
mode: 'opt-in',
label: 'Affiliate',
}),
} as const
export type RegisteredFeatureFlag = keyof typeof FeatureFlags

View File

@@ -2153,31 +2153,6 @@
{{ 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"
@@ -3878,6 +3853,356 @@
</div>
</div>
<!-- Affiliate (邀请返利) feature card -->
<div class="card">
<div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
{{ t('admin.settings.features.affiliate.title') }}
</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{ t('admin.settings.features.affiliate.description') }}
</p>
</div>
<div class="space-y-5 p-6">
<div class="flex items-center justify-between">
<div>
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.settings.features.affiliate.enabled') }}
</label>
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.settings.features.affiliate.enabledHint') }}
</p>
</div>
<Toggle v-model="form.affiliate_enabled" />
</div>
<div v-if="form.affiliate_enabled" class="space-y-6">
<div>
<label class="input-label">
{{ t('admin.settings.features.affiliate.rebateRate') }}
</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 text-xs text-gray-400">
{{ t('admin.settings.features.affiliate.rebateRateHint') }}
</p>
</div>
<!-- 专属用户管理 -->
<div class="border-t border-gray-100 pt-6 dark:border-dark-700">
<div class="mb-3 flex items-center justify-between">
<div>
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
{{ t('admin.settings.features.affiliate.customUsers.title') }}
</h3>
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.settings.features.affiliate.customUsers.description') }}
</p>
</div>
<button
type="button"
class="btn btn-primary btn-sm"
@click="openAffiliateModal(null)"
>
+ {{ t('admin.settings.features.affiliate.customUsers.addButton') }}
</button>
</div>
<div class="mb-3 flex items-center gap-2">
<input
v-model="affiliateState.search"
type="text"
class="input flex-1"
:placeholder="t('admin.settings.features.affiliate.customUsers.searchPlaceholder')"
@input="onAffiliateSearchInput"
/>
<button
v-if="affiliateState.selected.length > 0"
type="button"
class="btn btn-secondary btn-sm"
@click="openAffiliateBatchModal"
>
{{ t('admin.settings.features.affiliate.customUsers.batchButton', { count: affiliateState.selected.length }) }}
</button>
</div>
<div class="overflow-hidden rounded-lg border border-gray-200 dark:border-dark-700">
<table class="min-w-full divide-y divide-gray-200 dark:divide-dark-700">
<thead class="bg-gray-50 dark:bg-dark-800">
<tr>
<th class="px-3 py-2 text-left">
<input
type="checkbox"
:checked="affiliateState.entries.length > 0 && affiliateState.selected.length === affiliateState.entries.length"
@change="toggleAffiliateSelectAll"
/>
</th>
<th class="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500">{{ t('admin.settings.features.affiliate.customUsers.col.email') }}</th>
<th class="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500">{{ t('admin.settings.features.affiliate.customUsers.col.username') }}</th>
<th class="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500">{{ t('admin.settings.features.affiliate.customUsers.col.code') }}</th>
<th class="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500">{{ t('admin.settings.features.affiliate.customUsers.col.rate') }}</th>
<th class="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500">{{ t('admin.settings.features.affiliate.customUsers.col.actions') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white dark:divide-dark-700 dark:bg-dark-900">
<tr v-if="affiliateState.loading">
<td colspan="6" class="px-3 py-6 text-center text-sm text-gray-500">
{{ t('common.loading') }}
</td>
</tr>
<tr v-else-if="affiliateState.entries.length === 0">
<td colspan="6" class="px-3 py-6 text-center text-sm text-gray-500">
{{ t('admin.settings.features.affiliate.customUsers.empty') }}
</td>
</tr>
<tr v-for="entry in affiliateState.entries" :key="entry.user_id">
<td class="px-3 py-2">
<input
type="checkbox"
:checked="affiliateState.selected.includes(entry.user_id)"
@change="toggleAffiliateSelect(entry.user_id)"
/>
</td>
<td class="px-3 py-2 text-sm text-gray-900 dark:text-white">{{ entry.email }}</td>
<td class="px-3 py-2 text-sm text-gray-600 dark:text-gray-300">{{ entry.username }}</td>
<td class="px-3 py-2 text-sm font-mono">
{{ entry.aff_code }}
<span
v-if="entry.aff_code_custom"
class="ml-1 inline-block rounded bg-primary-100 px-1.5 py-0.5 text-[10px] font-medium text-primary-700 dark:bg-primary-900/30 dark:text-primary-300"
>{{ t('admin.settings.features.affiliate.customUsers.customBadge') }}</span>
</td>
<td class="px-3 py-2 text-sm">
<span v-if="entry.aff_rebate_rate_percent != null">{{ entry.aff_rebate_rate_percent }}%</span>
<span v-else class="text-gray-400">{{ t('admin.settings.features.affiliate.customUsers.useGlobal') }}</span>
</td>
<td class="px-3 py-2 text-sm">
<div class="flex items-center gap-2">
<button type="button" class="text-primary-600 hover:underline" @click="openAffiliateModal(entry)">
{{ t('common.edit') }}
</button>
<button
type="button"
class="text-red-600 hover:underline"
@click="askResetAffiliateUser(entry)"
>
{{ t('common.delete') }}
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<div v-if="affiliateState.total > affiliateState.pageSize" class="mt-3 flex items-center justify-between text-sm">
<span class="text-gray-500">
{{ t('admin.settings.features.affiliate.customUsers.totalLabel', { total: affiliateState.total }) }}
</span>
<div class="flex items-center gap-2">
<button
type="button"
class="btn btn-secondary btn-sm"
:disabled="affiliateState.page <= 1"
@click="changeAffiliatePage(affiliateState.page - 1)"
>
{{ t('pagination.previous') }}
</button>
<span class="text-gray-500">{{ affiliateState.page }} / {{ Math.max(1, Math.ceil(affiliateState.total / affiliateState.pageSize)) }}</span>
<button
type="button"
class="btn btn-secondary btn-sm"
:disabled="affiliateState.page >= Math.ceil(affiliateState.total / affiliateState.pageSize)"
@click="changeAffiliatePage(affiliateState.page + 1)"
>
{{ t('pagination.next') }}
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Affiliate add/edit modal -->
<div
v-if="affiliateModal.open"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
@click.self="closeAffiliateModal"
>
<div class="w-full max-w-md rounded-lg bg-white p-6 shadow-xl dark:bg-dark-900">
<h3 class="mb-4 text-lg font-semibold">
{{ affiliateModal.mode === 'add' ? t('admin.settings.features.affiliate.modal.addTitle') : t('admin.settings.features.affiliate.modal.editTitle') }}
</h3>
<div class="space-y-4">
<div v-if="affiliateModal.mode === 'add'">
<label class="input-label">{{ t('admin.settings.features.affiliate.modal.userLabel') }}</label>
<!-- Chip showing the picked user; clicking it re-opens the search -->
<div
v-if="affiliateModal.selectedUser"
class="flex items-center justify-between rounded-md border border-primary-200 bg-primary-50 px-3 py-2 dark:border-primary-700/50 dark:bg-primary-900/20"
>
<div class="text-sm">
<span class="font-medium text-gray-900 dark:text-white">{{ affiliateModal.selectedUser.email }}</span>
<span class="ml-1 text-xs text-gray-500">({{ affiliateModal.selectedUser.username }})</span>
</div>
<button
type="button"
class="text-lg leading-none text-gray-400 hover:text-red-600"
:title="t('admin.settings.features.affiliate.modal.changeUser')"
@click="clearSelectedAffiliateUser"
>
×
</button>
</div>
<!-- Search input + result dropdown — hidden once a selection is made -->
<template v-else>
<input
v-model="affiliateModal.userQuery"
type="text"
class="input"
:placeholder="t('admin.settings.features.affiliate.modal.userPlaceholder')"
@input="onAffiliateUserSearchInput"
/>
<div
v-if="affiliateModal.userResults.length > 0"
class="mt-1 max-h-40 overflow-y-auto rounded border border-gray-200 dark:border-dark-700"
>
<button
v-for="u in affiliateModal.userResults"
:key="u.id"
type="button"
class="w-full px-3 py-1.5 text-left text-sm hover:bg-gray-100 dark:hover:bg-dark-800"
@click="selectAffiliateUser(u)"
>
{{ u.email }} <span class="text-xs text-gray-500">({{ u.username }})</span>
</button>
</div>
</template>
</div>
<div v-else>
<label class="input-label">{{ t('admin.settings.features.affiliate.modal.userLabel') }}</label>
<input
type="text"
class="input"
:value="affiliateModal.editingEntry ? affiliateModal.editingEntry.email : ''"
disabled
/>
</div>
<div>
<label class="input-label">{{ t('admin.settings.features.affiliate.modal.codeLabel') }}</label>
<input
v-model="affiliateModal.code"
type="text"
class="input font-mono"
:placeholder="t('admin.settings.features.affiliate.modal.codePlaceholder')"
maxlength="32"
/>
<p class="mt-1 text-xs text-gray-400">
{{ t('admin.settings.features.affiliate.modal.codeHint') }}
</p>
</div>
<div>
<label class="input-label">{{ t('admin.settings.features.affiliate.modal.rateLabel') }}</label>
<div class="relative">
<input
v-model="affiliateModal.rate"
type="number"
step="0.01"
min="0"
max="100"
class="input pr-8"
:placeholder="t('admin.settings.features.affiliate.modal.ratePlaceholder')"
/>
<span class="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-gray-400">%</span>
</div>
<p class="mt-1 text-xs text-gray-400">
{{ t('admin.settings.features.affiliate.modal.rateHint') }}
</p>
</div>
</div>
<div class="mt-6 flex items-center justify-between gap-3">
<p
v-if="!affiliateModalCanSubmit"
class="text-xs text-gray-500 dark:text-gray-400"
>
{{ t('admin.settings.features.affiliate.modal.errorEmpty') }}
</p>
<span v-else></span>
<div class="flex gap-2">
<button type="button" class="btn btn-secondary" @click="closeAffiliateModal">
{{ t('common.cancel') }}
</button>
<button
type="button"
class="btn btn-primary"
:disabled="affiliateModal.saving || !affiliateModalCanSubmit"
@click="submitAffiliateModal"
>
{{ affiliateModal.saving ? t('common.saving') : t('common.save') }}
</button>
</div>
</div>
</div>
</div>
<!-- Affiliate batch rate modal -->
<div
v-if="affiliateBatchModal.open"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
@click.self="affiliateBatchModal.open = false"
>
<div class="w-full max-w-md rounded-lg bg-white p-6 shadow-xl dark:bg-dark-900">
<h3 class="mb-4 text-lg font-semibold">
{{ t('admin.settings.features.affiliate.batchModal.title', { count: affiliateState.selected.length }) }}
</h3>
<p class="mb-4 text-sm text-gray-500">
{{ t('admin.settings.features.affiliate.batchModal.hint') }}
</p>
<div class="relative">
<input
v-model="affiliateBatchModal.rate"
type="number"
step="0.01"
min="0"
max="100"
class="input pr-8"
:placeholder="t('admin.settings.features.affiliate.batchModal.placeholder')"
/>
<span class="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-gray-400">%</span>
</div>
<p class="mt-2 text-xs text-gray-400">
{{ t('admin.settings.features.affiliate.batchModal.clearHint') }}
</p>
<div class="mt-6 flex justify-end gap-2">
<button type="button" class="btn btn-secondary" @click="affiliateBatchModal.open = false">
{{ t('common.cancel') }}
</button>
<button
type="button"
class="btn btn-primary"
:disabled="affiliateBatchModal.saving"
@click="submitAffiliateBatchModal"
>
{{ affiliateBatchModal.saving ? t('common.saving') : t('common.save') }}
</button>
</div>
</div>
</div>
</div><!-- /Tab: Features -->
<!-- Tab: Email -->
@@ -4793,12 +5118,21 @@
@confirm="handleDeleteProvider"
@cancel="showDeleteProviderDialog = false"
/>
<ConfirmDialog
:show="affiliateConfirmDialog.show"
:title="affiliateConfirmDialog.title"
:message="affiliateConfirmDialog.message"
:confirm-text="affiliateConfirmDialog.confirmText"
danger
@confirm="handleAffiliateConfirm"
@cancel="cancelAffiliateConfirm"
/>
</div>
</AppLayout>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from "vue";
import { ref, reactive, computed, onMounted, watch } from "vue";
import { useI18n } from "vue-i18n";
import { adminAPI } from "@/api";
import {
@@ -4835,6 +5169,7 @@ import ProxySelector from "@/components/common/ProxySelector.vue";
import ImageUpload from "@/components/common/ImageUpload.vue";
import BackupSettings from "@/views/admin/BackupView.vue";
import { useClipboard } from "@/composables/useClipboard";
import { affiliatesAPI, type AffiliateAdminEntry, type SimpleUser as AffiliateSimpleUser } from "@/api/admin/affiliates";
import { extractApiErrorMessage, extractI18nErrorMessage } from "@/utils/apiError";
import { useAppStore } from "@/stores";
import { useAdminSettingsStore } from "@/stores/adminSettings";
@@ -5145,6 +5480,8 @@ const form = reactive<SettingsForm>({
channel_monitor_default_interval_seconds: 60,
// Available Channels feature switch
available_channels_enabled: false,
// Affiliate (邀请返利) feature switch
affiliate_enabled: false,
});
const authSourceDefaults = reactive<AuthSourceDefaultsState>(
@@ -6063,6 +6400,8 @@ async function saveSettings() {
Number(form.channel_monitor_default_interval_seconds) || 60,
// Available Channels feature switch
available_channels_enabled: form.available_channels_enabled,
// Affiliate (邀请返利) feature switch
affiliate_enabled: form.affiliate_enabled,
};
appendAuthSourceDefaultsToUpdateRequest(payload, authSourceDefaults);
@@ -6844,6 +7183,359 @@ onMounted(() => {
loadBetaPolicySettings();
loadProviders();
});
// =========================
// Affiliate (邀请返利) 专属用户管理
// =========================
interface AffiliateState {
loading: boolean;
entries: AffiliateAdminEntry[];
total: number;
page: number;
pageSize: number;
search: string;
selected: number[];
searchTimer: number | null;
}
const affiliateState = reactive<AffiliateState>({
loading: false,
entries: [],
total: 0,
page: 1,
pageSize: 20,
search: "",
selected: [],
searchTimer: null,
});
// `rate` is typed as string|number because <input type="number"> makes Vue's
// v-model auto-cast the bound value to a Number on every keystroke. We keep
// both shapes and normalize at read time.
interface AffiliateModalState {
open: boolean;
mode: "add" | "edit";
saving: boolean;
userQuery: string;
userResults: AffiliateSimpleUser[];
selectedUser: AffiliateSimpleUser | null;
editingEntry: AffiliateAdminEntry | null;
code: string;
rate: string | number;
searchTimer: number | null;
}
const affiliateModal = reactive<AffiliateModalState>({
open: false,
mode: "add",
saving: false,
userQuery: "",
userResults: [],
selectedUser: null,
editingEntry: null,
code: "",
rate: "",
searchTimer: null,
});
const affiliateBatchModal = reactive<{
open: boolean;
saving: boolean;
rate: string | number;
}>({
open: false,
saving: false,
rate: "",
});
// affiliateConfirmDialog drives the project-standard <ConfirmDialog>. We can't
// `await` the user's response from the dialog component, so the confirm action
// runs from the @confirm callback once the user clicks the dialog's confirm
// button.
const affiliateConfirmDialog = reactive<{
show: boolean;
title: string;
message: string;
confirmText: string;
pending: (() => Promise<unknown>) | null;
}>({
show: false,
title: "",
message: "",
confirmText: "",
pending: null,
});
function openAffiliateConfirm(
title: string,
message: string,
confirmText: string,
fn: () => Promise<unknown>,
) {
affiliateConfirmDialog.title = title;
affiliateConfirmDialog.message = message;
affiliateConfirmDialog.confirmText = confirmText;
affiliateConfirmDialog.pending = fn;
affiliateConfirmDialog.show = true;
}
async function handleAffiliateConfirm() {
const fn = affiliateConfirmDialog.pending;
affiliateConfirmDialog.show = false;
affiliateConfirmDialog.pending = null;
if (!fn) return;
try {
await fn();
appStore.showSuccess(t("common.saved"));
await loadAffiliateUsers();
} catch (err) {
appStore.showError(extractApiErrorMessage(err, t("common.error")));
}
}
function cancelAffiliateConfirm() {
affiliateConfirmDialog.show = false;
affiliateConfirmDialog.pending = null;
}
// debounceTimer wires a single timer slot to a callback with a delay,
// canceling any pending invocation. Used for type-as-you-go search inputs.
function debounceTimer(slot: { searchTimer: number | null }, delayMs: number, run: () => void) {
if (slot.searchTimer != null) window.clearTimeout(slot.searchTimer);
slot.searchTimer = window.setTimeout(run, delayMs);
}
// parseRebateRate validates 0-100 numeric input. Returns the parsed number on
// success, null when the field is empty (caller decides empty semantics), or
// undefined on invalid input (after surfacing a toast).
//
// Accepts unknown because <input type="number"> makes Vue's v-model coerce
// the value to Number on each keystroke (e.g. typing "30" lands a `30: number`
// in state, not a `"30": string`). String("") and (30).trim() would crash, so
// we normalize here instead of forcing every caller to remember.
function parseRebateRate(raw: unknown): number | null | undefined {
const s = String(raw ?? "").trim();
if (s === "") return null;
const parsed = Number(s);
if (Number.isNaN(parsed) || parsed < 0 || parsed > 100) {
appStore.showError(t("admin.settings.features.affiliate.modal.errorBadRate"));
return undefined;
}
return parsed;
}
async function loadAffiliateUsers() {
affiliateState.loading = true;
try {
const res = await affiliatesAPI.listUsers({
page: affiliateState.page,
page_size: affiliateState.pageSize,
search: affiliateState.search,
});
affiliateState.entries = res.items ?? [];
affiliateState.total = res.total ?? 0;
// Drop selections that are no longer visible.
const visibleIds = new Set(affiliateState.entries.map((e) => e.user_id));
affiliateState.selected = affiliateState.selected.filter((id) => visibleIds.has(id));
} catch (err) {
appStore.showError(extractApiErrorMessage(err, t("common.error")));
} finally {
affiliateState.loading = false;
}
}
function onAffiliateSearchInput() {
debounceTimer(affiliateState, 300, () => {
affiliateState.page = 1;
loadAffiliateUsers();
});
}
function changeAffiliatePage(page: number) {
if (page < 1) return;
affiliateState.page = page;
loadAffiliateUsers();
}
function toggleAffiliateSelectAll(e: Event) {
const checked = (e.target as HTMLInputElement).checked;
affiliateState.selected = checked ? affiliateState.entries.map((entry) => entry.user_id) : [];
}
function toggleAffiliateSelect(userId: number) {
const idx = affiliateState.selected.indexOf(userId);
if (idx >= 0) affiliateState.selected.splice(idx, 1);
else affiliateState.selected.push(userId);
}
// openAffiliateModal opens the add/edit modal, prefilling fields from the
// edited entry when present and resetting them otherwise.
function openAffiliateModal(entry: AffiliateAdminEntry | null) {
affiliateModal.open = true;
affiliateModal.mode = entry ? "edit" : "add";
affiliateModal.userQuery = "";
affiliateModal.userResults = [];
affiliateModal.selectedUser = null;
affiliateModal.editingEntry = entry;
affiliateModal.code = entry?.aff_code_custom ? entry.aff_code : "";
affiliateModal.rate =
entry?.aff_rebate_rate_percent != null ? String(entry.aff_rebate_rate_percent) : "";
}
function closeAffiliateModal() {
affiliateModal.open = false;
if (affiliateModal.searchTimer != null) {
window.clearTimeout(affiliateModal.searchTimer);
affiliateModal.searchTimer = null;
}
}
function onAffiliateUserSearchInput() {
const q = affiliateModal.userQuery.trim();
if (!q) {
affiliateModal.userResults = [];
return;
}
debounceTimer(affiliateModal, 300, async () => {
try {
affiliateModal.userResults = await affiliatesAPI.lookupUsers(q);
} catch (err) {
appStore.showError(extractApiErrorMessage(err, t("common.error")));
}
});
}
// selectAffiliateUser picks a user from the dropdown and collapses the search
// UI. Clearing the result list also clears the visual dropdown.
function selectAffiliateUser(user: AffiliateSimpleUser) {
affiliateModal.selectedUser = user;
affiliateModal.userQuery = "";
affiliateModal.userResults = [];
}
function clearSelectedAffiliateUser() {
affiliateModal.selectedUser = null;
}
// affiliateModalCanSubmit guards the Save button: must have a user picked AND
// produce at least one field change. Without this the admin could "save" an
// empty payload that silently does nothing — the user reported exactly that
// confusion.
const affiliateModalCanSubmit = computed(() => {
if (affiliateModal.mode === "add") {
if (!affiliateModal.selectedUser) return false;
} else if (!affiliateModal.editingEntry) {
return false;
}
const codeFilled = affiliateModal.code.trim() !== "";
const rateFilled = String(affiliateModal.rate ?? "").trim() !== "";
if (codeFilled || rateFilled) return true;
// Edit mode + empty rate input is a meaningful "clear" only if the user
// currently has an exclusive rate to clear.
return (
affiliateModal.mode === "edit" &&
affiliateModal.editingEntry?.aff_rebate_rate_percent != null
);
});
async function submitAffiliateModal() {
if (!affiliateModalCanSubmit.value) {
// Should be unreachable because the button is disabled, but keep a guard.
appStore.showError(t("admin.settings.features.affiliate.modal.errorEmpty"));
return;
}
let userId: number;
if (affiliateModal.mode === "add") {
userId = affiliateModal.selectedUser!.id;
} else {
userId = affiliateModal.editingEntry!.user_id;
}
const payload: Parameters<typeof affiliatesAPI.updateUserSettings>[1] = {};
const codeRaw = affiliateModal.code.trim();
if (codeRaw) payload.aff_code = codeRaw.toUpperCase();
const rateInput = parseRebateRate(affiliateModal.rate);
if (rateInput === undefined) return; // toast already shown
if (rateInput === null) {
if (affiliateModal.mode === "edit" && affiliateModal.editingEntry?.aff_rebate_rate_percent != null) {
payload.clear_rebate_rate = true;
}
} else {
payload.aff_rebate_rate_percent = rateInput;
}
affiliateModal.saving = true;
try {
await affiliatesAPI.updateUserSettings(userId, payload);
appStore.showSuccess(t("common.saved"));
closeAffiliateModal();
affiliateState.page = 1;
await loadAffiliateUsers();
} catch (err) {
appStore.showError(extractApiErrorMessage(err, t("common.error")));
} finally {
affiliateModal.saving = false;
}
}
// askResetAffiliateUser prompts via the project ConfirmDialog, then on confirm
// calls the backend "reset all" endpoint that clears both the exclusive rate
// AND regenerates the invite code as a system random one.
function askResetAffiliateUser(entry: AffiliateAdminEntry) {
openAffiliateConfirm(
t("admin.settings.features.affiliate.customUsers.resetTitle"),
t("admin.settings.features.affiliate.customUsers.resetMessage", {
email: entry.email || `#${entry.user_id}`,
}),
t("common.delete"),
() => affiliatesAPI.clearUserSettings(entry.user_id),
);
}
function openAffiliateBatchModal() {
if (affiliateState.selected.length === 0) return;
affiliateBatchModal.open = true;
affiliateBatchModal.rate = "";
}
async function submitAffiliateBatchModal() {
const rateInput = parseRebateRate(affiliateBatchModal.rate);
if (rateInput === undefined) return;
const userIDs = [...affiliateState.selected];
const payload: Parameters<typeof affiliatesAPI.batchSetRate>[0] =
rateInput === null
? { user_ids: userIDs, clear: true }
: { user_ids: userIDs, aff_rebate_rate_percent: rateInput };
affiliateBatchModal.saving = true;
try {
await affiliatesAPI.batchSetRate(payload);
appStore.showSuccess(t("common.saved"));
affiliateBatchModal.open = false;
affiliateState.selected = [];
await loadAffiliateUsers();
} catch (err) {
appStore.showError(extractApiErrorMessage(err, t("common.error")));
} finally {
affiliateBatchModal.saving = false;
}
}
// Load the per-user table the first time the affiliate switch is observed
// as enabled. The form starts disabled and is updated to the server's value
// after the settings load — so this fires either when the saved value is
// truthy on first paint, or when the admin manually toggles it on.
watch(
() => form.affiliate_enabled,
(enabled, prev) => {
if (enabled && !prev) {
loadAffiliateUsers();
}
},
);
</script>
<style scoped>

View File

@@ -8,7 +8,23 @@
</div>
<template v-else-if="detail">
<div class="grid gap-4 md:grid-cols-3">
<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">
@@ -61,7 +77,7 @@
<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>2. {{ t('affiliate.tips.line2', { rate: `${formattedRebateRate}%` }) }}</li>
<li>3. {{ t('affiliate.tips.line3') }}</li>
</ul>
</div>
@@ -149,6 +165,14 @@ const inviteLink = computed(() => {
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()
}