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:
108
frontend/src/api/admin/affiliates.ts
Normal file
108
frontend/src/api/admin/affiliates.ts
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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 invitee’s 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.',
|
||||
|
||||
@@ -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 设置。',
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user