Commit 3b7a5fff authored by 陈曦's avatar 陈曦
Browse files

补充openai、gemini以及流失请求的采集数据以及nfs落库

parent 8519a8eb
Pipeline #82284 failed with stage
in 2 minutes and 21 seconds
......@@ -197,6 +197,18 @@ const routes: RouteRecordRaw[] = [
descriptionKey: 'redeem.description'
}
},
{
path: '/affiliate',
name: 'Affiliate',
component: () => import('@/views/user/AffiliateView.vue'),
meta: {
requiresAuth: true,
requiresAdmin: false,
title: 'Affiliate',
titleKey: 'affiliate.title',
descriptionKey: 'affiliate.description'
}
},
{
path: '/available-channels',
name: 'UserAvailableChannels',
......@@ -287,11 +299,11 @@ const routes: RouteRecordRaw[] = [
name: 'StripePayment',
component: () => import('@/views/user/StripePaymentView.vue'),
meta: {
requiresAuth: true,
requiresAuth: false,
requiresAdmin: false,
title: 'Stripe Payment',
titleKey: 'payment.stripePay',
requiresPayment: true
requiresPayment: false
}
},
{
......@@ -299,10 +311,10 @@ const routes: RouteRecordRaw[] = [
name: 'StripePopup',
component: () => import('@/views/user/StripePopupView.vue'),
meta: {
requiresAuth: true,
requiresAuth: false,
requiresAdmin: false,
title: 'Payment',
requiresPayment: true
requiresPayment: false
}
},
{
......
......@@ -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,
}
}
......
......@@ -122,6 +122,33 @@ export interface RegisterRequest {
turnstile_token?: string
promo_code?: string
invitation_code?: string
aff_code?: string
}
export interface AffiliateInvitee {
user_id: number
email: string
username: string
created_at?: string
total_rebate: number
}
export interface UserAffiliateDetail {
user_id: number
aff_code: string
inviter_id?: number | null
aff_count: number
aff_quota: number
aff_frozen_quota: number
aff_history_quota: number
/** 当前用户作为邀请人时实际生效的返利比例(专属覆盖全局)。0-100。 */
effective_rebate_rate_percent: number
invitees: AffiliateInvitee[]
}
export interface AffiliateTransferResponse {
transferred_quota: number
balance: number
}
export interface SendVerifyCodeRequest {
......@@ -189,6 +216,7 @@ export interface PublicSettings {
channel_monitor_enabled: boolean
channel_monitor_default_interval_seconds: number
available_channels_enabled: boolean
affiliate_enabled: boolean
}
export interface AuthResponse {
......@@ -744,8 +772,8 @@ export interface Account {
platform: AccountPlatform
type: AccountType
credentials?: Record<string, unknown>
// Extra fields including Codex usage and model-level rate limits (Antigravity smart retry)
extra?: (CodexUsageSnapshot & {
// Extra fields including Codex usage, OpenAI compact capability, and model-level rate limits.
extra?: (CodexUsageSnapshot & OpenAICompactState & {
model_rate_limits?: Record<string, { rate_limited_at: string; rate_limit_reset_at: string }>
antigravity_credits_overages?: Record<string, { activated_at: string; active_until: string }>
} & Record<string, unknown>)
......@@ -917,6 +945,16 @@ export interface CodexUsageSnapshot {
codex_usage_updated_at?: string // Last update timestamp
}
export type OpenAICompactMode = 'auto' | 'force_on' | 'force_off'
export interface OpenAICompactState {
openai_compact_mode?: OpenAICompactMode
openai_compact_supported?: boolean
openai_compact_checked_at?: string
openai_compact_last_status?: number
openai_compact_last_error?: string
}
export interface CreateAccountRequest {
name: string
notes?: string | null
......
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
clearAffiliateReferralCode,
clearOAuthAffiliateCode,
loadAffiliateReferralCode,
loadOAuthAffiliateCode,
resolveAffiliateReferralCode,
storeAffiliateReferralCode,
storeOAuthAffiliateCode
} from '@/utils/oauthAffiliate'
describe('oauthAffiliate', () => {
beforeEach(() => {
localStorage.clear()
sessionStorage.clear()
vi.useRealTimers()
})
it('persists affiliate referral code across pages', () => {
expect(resolveAffiliateReferralCode(' 5579J7CFG9PF ')).toBe('5579J7CFG9PF')
expect(loadAffiliateReferralCode()).toBe('5579J7CFG9PF')
expect(resolveAffiliateReferralCode()).toBe('5579J7CFG9PF')
})
it('expires stale affiliate referral code', () => {
const now = Date.UTC(2026, 0, 1)
storeAffiliateReferralCode('AFF123', now)
expect(loadAffiliateReferralCode(now + 30 * 24 * 60 * 60 * 1000 - 1)).toBe('AFF123')
expect(loadAffiliateReferralCode(now + 30 * 24 * 60 * 60 * 1000 + 1)).toBe('')
expect(localStorage.getItem('affiliate_referral_code')).toBeNull()
})
it('keeps oauth transient code separate from persistent referral code', () => {
storeAffiliateReferralCode('PERSISTED')
storeOAuthAffiliateCode('OAUTH')
expect(loadAffiliateReferralCode()).toBe('PERSISTED')
expect(loadOAuthAffiliateCode()).toBe('OAUTH')
clearOAuthAffiliateCode()
expect(loadOAuthAffiliateCode()).toBe('')
expect(loadAffiliateReferralCode()).toBe('PERSISTED')
clearAffiliateReferralCode()
expect(loadAffiliateReferralCode()).toBe('')
})
})
......@@ -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
......
const OAUTH_AFFILIATE_CODE_KEY = 'oauth_aff_code'
const AFFILIATE_REFERRAL_CODE_KEY = 'affiliate_referral_code'
const AFFILIATE_REFERRAL_TTL_MS = 30 * 24 * 60 * 60 * 1000
interface StoredAffiliateReferralCode {
code: string
expiresAt: number
}
export function normalizeOAuthAffiliateCode(value?: unknown): string {
const raw = Array.isArray(value) ? value[0] : value
return typeof raw === 'string' ? raw.trim() : ''
}
export function pickOAuthAffiliateCode(...values: unknown[]): string {
for (const value of values) {
const code = normalizeOAuthAffiliateCode(value)
if (code) {
return code
}
}
return ''
}
export function storeAffiliateReferralCode(value?: unknown, now = Date.now()): void {
if (typeof window === 'undefined') {
return
}
const code = normalizeOAuthAffiliateCode(value)
if (!code) {
return
}
try {
const payload: StoredAffiliateReferralCode = {
code,
expiresAt: now + AFFILIATE_REFERRAL_TTL_MS
}
window.localStorage.setItem(AFFILIATE_REFERRAL_CODE_KEY, JSON.stringify(payload))
} catch {
// 忽略浏览器存储异常。
}
}
export function loadAffiliateReferralCode(now = Date.now()): string {
if (typeof window === 'undefined') {
return ''
}
try {
const raw = window.localStorage.getItem(AFFILIATE_REFERRAL_CODE_KEY)
if (!raw) {
return ''
}
const parsed = JSON.parse(raw) as Partial<StoredAffiliateReferralCode>
const code = normalizeOAuthAffiliateCode(parsed.code)
const expiresAt = Number(parsed.expiresAt) || 0
if (!code || expiresAt <= now) {
clearAffiliateReferralCode()
return ''
}
return code
} catch {
clearAffiliateReferralCode()
return ''
}
}
export function clearAffiliateReferralCode(): void {
if (typeof window === 'undefined') {
return
}
try {
window.localStorage.removeItem(AFFILIATE_REFERRAL_CODE_KEY)
} catch {
// 忽略浏览器存储异常。
}
}
export function resolveAffiliateReferralCode(...values: unknown[]): string {
const code = pickOAuthAffiliateCode(...values)
if (code) {
storeAffiliateReferralCode(code)
return code
}
return loadAffiliateReferralCode()
}
export function storeOAuthAffiliateCode(value?: unknown): void {
if (typeof window === 'undefined') {
return
}
const code = normalizeOAuthAffiliateCode(value)
try {
if (code) {
window.sessionStorage.setItem(OAUTH_AFFILIATE_CODE_KEY, code)
} else {
window.sessionStorage.removeItem(OAUTH_AFFILIATE_CODE_KEY)
}
} catch {
// 忽略浏览器存储异常。
}
}
export function loadOAuthAffiliateCode(): string {
if (typeof window === 'undefined') {
return ''
}
try {
return normalizeOAuthAffiliateCode(window.sessionStorage.getItem(OAUTH_AFFILIATE_CODE_KEY))
} catch {
return ''
}
}
export function clearOAuthAffiliateCode(): void {
if (typeof window === 'undefined') {
return
}
try {
window.sessionStorage.removeItem(OAUTH_AFFILIATE_CODE_KEY)
} catch {
// 忽略浏览器存储异常。
}
}
export function clearAllAffiliateReferralCodes(): void {
clearOAuthAffiliateCode()
clearAffiliateReferralCode()
}
export function oauthAffiliatePayload(value?: unknown): { aff_code?: string } {
const code = normalizeOAuthAffiliateCode(value)
return code ? { aff_code: code } : {}
}
......@@ -188,6 +188,13 @@
<template #cell-platform_type="{ row }">
<div class="flex flex-wrap items-center gap-1">
<PlatformTypeBadge :platform="row.platform" :type="row.type" :plan-type="row.credentials?.plan_type" :privacy-mode="row.extra?.privacy_mode" :subscription-expires-at="row.credentials?.subscription_expires_at" />
<span
v-if="getOpenAICompactLabel(row)"
:class="['inline-block rounded px-1.5 py-0.5 text-[10px] font-medium', getOpenAICompactClass(row)]"
:title="getOpenAICompactTitle(row)"
>
{{ getOpenAICompactLabel(row) }}
</span>
<span
v-if="getAntigravityTierLabel(row)"
:class="['inline-block rounded px-1.5 py-0.5 text-[10px] font-medium', getAntigravityTierClass(row)]"
......@@ -932,6 +939,43 @@ function getAntigravityTierLabel(row: any): string | null {
}
}
function getOpenAICompactState(row: any): 'supported' | 'unsupported' | 'unknown' | null {
if (row.platform !== 'openai' || (row.type !== 'oauth' && row.type !== 'apikey')) return null
const extra = row.extra as Record<string, unknown> | undefined
const mode = typeof extra?.openai_compact_mode === 'string' ? extra.openai_compact_mode : 'auto'
if (mode === 'force_on') return 'supported'
if (mode === 'force_off') return 'unsupported'
if (typeof extra?.openai_compact_supported === 'boolean') {
return extra.openai_compact_supported ? 'supported' : 'unsupported'
}
return 'unknown'
}
function getOpenAICompactLabel(row: any): string | null {
switch (getOpenAICompactState(row)) {
case 'supported': return t('admin.accounts.openai.compactSupported')
case 'unsupported': return t('admin.accounts.openai.compactUnsupported')
case 'unknown': return t('admin.accounts.openai.compactUnknown')
default: return null
}
}
function getOpenAICompactClass(row: any): string {
switch (getOpenAICompactState(row)) {
case 'supported': return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300'
case 'unsupported': return 'bg-rose-100 text-rose-700 dark:bg-rose-900/40 dark:text-rose-300'
case 'unknown': return 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300'
default: return ''
}
}
function getOpenAICompactTitle(row: any): string {
const extra = row.extra as Record<string, unknown> | undefined
const checkedAt = typeof extra?.openai_compact_checked_at === 'string' ? extra.openai_compact_checked_at : ''
if (!checkedAt) return getOpenAICompactLabel(row) || ''
return `${getOpenAICompactLabel(row)} | ${t('admin.accounts.openai.compactLastChecked')}: ${formatDateTime(new Date(checkedAt))}`
}
function getAntigravityTierClass(row: any): string {
const tier = getAntigravityTierFromRow(row)
switch (tier) {
......
......@@ -3853,6 +3853,406 @@
</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>
<label class="input-label">
{{ t('admin.settings.features.affiliate.freezeHours') }}
</label>
<input
v-model.number="form.affiliate_rebate_freeze_hours"
type="number"
step="1"
min="0"
max="720"
class="input"
/>
<p class="mt-1 text-xs text-gray-400">
{{ t('admin.settings.features.affiliate.freezeHoursDesc') }}
</p>
</div>
<div>
<label class="input-label">
{{ t('admin.settings.features.affiliate.durationDays') }}
</label>
<input
v-model.number="form.affiliate_rebate_duration_days"
type="number"
step="1"
min="0"
max="3650"
class="input"
/>
<p class="mt-1 text-xs text-gray-400">
{{ t('admin.settings.features.affiliate.durationDaysDesc') }}
</p>
</div>
<div>
<label class="input-label">
{{ t('admin.settings.features.affiliate.perInviteeCap') }}
</label>
<input
v-model.number="form.affiliate_rebate_per_invitee_cap"
type="number"
step="0.01"
min="0"
class="input"
/>
<p class="mt-1 text-xs text-gray-400">
{{ t('admin.settings.features.affiliate.perInviteeCapDesc') }}
</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 -->
......@@ -4768,12 +5168,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 {
......@@ -4810,6 +5219,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";
......@@ -4972,6 +5382,10 @@ const form = reactive<SettingsForm>({
totp_enabled: false,
totp_encryption_key_configured: false,
default_balance: 0,
affiliate_rebate_rate: 20,
affiliate_rebate_freeze_hours: 0,
affiliate_rebate_duration_days: 0,
affiliate_rebate_per_invitee_cap: 0,
default_concurrency: 1,
default_subscriptions: [],
force_email_on_third_party_signup: false,
......@@ -5119,6 +5533,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>(
......@@ -5894,6 +6310,13 @@ async function saveSettings() {
password_reset_enabled: form.password_reset_enabled,
totp_enabled: form.totp_enabled,
default_balance: form.default_balance,
affiliate_rebate_rate: Math.min(
100,
Math.max(0, Number(form.affiliate_rebate_rate) || 0),
),
affiliate_rebate_freeze_hours: Math.max(0, Math.min(720, Number(form.affiliate_rebate_freeze_hours) || 0)),
affiliate_rebate_duration_days: Math.max(0, Math.min(3650, Math.floor(Number(form.affiliate_rebate_duration_days) || 0))),
affiliate_rebate_per_invitee_cap: Math.max(0, Number(form.affiliate_rebate_per_invitee_cap) || 0),
default_concurrency: form.default_concurrency,
default_subscriptions: normalizedDefaultSubscriptions,
force_email_on_third_party_signup: form.force_email_on_third_party_signup,
......@@ -6033,6 +6456,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);
......@@ -6814,6 +7239,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>
......
......@@ -167,6 +167,11 @@ import {
isRegistrationEmailSuffixAllowed,
normalizeRegistrationEmailSuffixWhitelist
} from '@/utils/registrationEmailPolicy'
import {
clearAllAffiliateReferralCodes,
loadAffiliateReferralCode,
oauthAffiliatePayload
} from '@/utils/oauthAffiliate'
const { t, locale } = useI18n()
......@@ -209,6 +214,7 @@ const password = ref<string>('')
const initialTurnstileToken = ref<string>('')
const promoCode = ref<string>('')
const invitationCode = ref<string>('')
const affCode = ref<string>('')
const pendingAuthToken = ref<string>('')
const pendingAuthTokenField = ref<PendingAuthTokenField>('pending_auth_token')
const pendingProvider = ref<string>('')
......@@ -260,6 +266,7 @@ onMounted(async () => {
initialTurnstileToken.value = registerData.turnstile_token || ''
promoCode.value = registerData.promo_code || ''
invitationCode.value = registerData.invitation_code || ''
affCode.value = registerData.aff_code || loadAffiliateReferralCode()
pendingAuthToken.value = registerData.pending_auth_token || activePendingSession?.token || ''
pendingAuthTokenField.value = registerData.pending_auth_token_field || activePendingSession?.token_field || 'pending_auth_token'
pendingProvider.value = registerData.pending_provider || activePendingSession?.provider || ''
......@@ -499,6 +506,7 @@ async function handleVerify(): Promise<void> {
password: password.value,
verify_code: verifyCode.value.trim(),
invitation_code: invitationCode.value || undefined,
...oauthAffiliatePayload(affCode.value || loadAffiliateReferralCode()),
adopt_display_name: pendingAdoptionDecision.value?.adoptDisplayName,
adopt_avatar: pendingAdoptionDecision.value?.adoptAvatar
}
......@@ -524,12 +532,14 @@ async function handleVerify(): Promise<void> {
verify_code: verifyCode.value.trim(),
turnstile_token: initialTurnstileToken.value || undefined,
promo_code: promoCode.value || undefined,
invitation_code: invitationCode.value || undefined
invitation_code: invitationCode.value || undefined,
...(affCode.value ? { aff_code: affCode.value } : {})
})
}
// Clear session data
sessionStorage.removeItem('register_data')
clearAllAffiliateReferralCodes()
// Show success toast
appStore.showSuccess(t('auth.accountCreatedSuccess', { siteName: siteName.value }))
......
......@@ -255,6 +255,11 @@ import {
type OAuthTokenResponse,
type PendingOAuthExchangeResponse
} from '@/api/auth'
import {
clearAllAffiliateReferralCodes,
loadOAuthAffiliateCode,
oauthAffiliatePayload
} from '@/utils/oauthAffiliate'
const route = useRoute()
const router = useRouter()
......@@ -568,6 +573,7 @@ async function finalizeCompletion(completion: PendingOAuthExchangeResponse, redi
if (getOAuthCompletionKind(completion) === 'bind') {
const bindRedirect = sanitizeRedirectPath(completion.redirect || '/profile')
clearPendingAuthSession()
clearAllAffiliateReferralCodes()
appStore.showSuccess(bindSuccessMessage)
await router.replace(bindRedirect)
return
......@@ -579,6 +585,7 @@ async function finalizeCompletion(completion: PendingOAuthExchangeResponse, redi
persistOAuthTokenContext(completion)
await authStore.setToken(completion.access_token)
clearAllAffiliateReferralCodes()
appStore.showSuccess(t('auth.loginSuccess'))
await router.replace(redirect)
}
......@@ -627,18 +634,20 @@ async function handleSubmitInvitation() {
isSubmitting.value = true
try {
const affCode = loadOAuthAffiliateCode()
const decision = currentAdoptionDecision()
const completion: LinuxDoPendingActionResponse = legacyPendingOAuthToken.value
? (
await apiClient.post<LinuxDoPendingActionResponse>('/auth/oauth/linuxdo/complete-registration', {
pending_oauth_token: legacyPendingOAuthToken.value,
invitation_code: invitationCode.value.trim(),
...serializeAdoptionDecision(currentAdoptionDecision())
...oauthAffiliatePayload(affCode),
...serializeAdoptionDecision(decision)
})
).data
: await completeLinuxDoOAuthRegistration(
invitationCode.value.trim(),
currentAdoptionDecision()
)
: affCode
? await completeLinuxDoOAuthRegistration(invitationCode.value.trim(), decision, affCode)
: await completeLinuxDoOAuthRegistration(invitationCode.value.trim(), decision)
await finalizePendingAccountResponse(completion)
} catch (e: unknown) {
const err = e as { message?: string; response?: { data?: { message?: string } } }
......@@ -673,6 +682,7 @@ async function handleCreateAccount(payload: PendingOAuthCreateAccountPayload) {
password: payload.password,
verify_code: payload.verifyCode || undefined,
invitation_code: payload.invitationCode || undefined,
...oauthAffiliatePayload(loadOAuthAffiliateCode()),
...serializeAdoptionDecision(currentAdoptionDecision())
})
await finalizePendingAccountResponse(data)
......@@ -720,6 +730,7 @@ async function handleSubmitTotpChallenge() {
totp_code: code
})
await authStore.setToken(completion.access_token)
clearAllAffiliateReferralCodes()
appStore.showSuccess(t('auth.loginSuccess'))
await router.replace(redirectTo.value)
} catch (e: unknown) {
......@@ -743,6 +754,7 @@ onMounted(async () => {
if (legacyLogin) {
persistOAuthTokenContext(legacyLogin)
await authStore.setToken(legacyLogin.access_token)
clearAllAffiliateReferralCodes()
appStore.showSuccess(t('auth.loginSuccess'))
await router.replace(redirect)
return
......
......@@ -186,6 +186,7 @@ import TurnstileWidget from '@/components/TurnstileWidget.vue'
import { useAuthStore, useAppStore } from '@/stores'
import { getPublicSettings, isTotp2FARequired, isWeChatWebOAuthEnabled } from '@/api/auth'
import type { TotpLoginResponse } from '@/types'
import { clearAllAffiliateReferralCodes } from '@/utils/oauthAffiliate'
const { t } = useI18n()
......@@ -355,6 +356,7 @@ async function handleLogin(): Promise<void> {
}
// Show success toast
clearAllAffiliateReferralCodes()
appStore.showSuccess(t('auth.loginSuccess'))
// Redirect to dashboard or intended route
......@@ -397,6 +399,7 @@ async function handle2FAVerify(code: string): Promise<void> {
// Close modal and show success
show2FAModal.value = false
clearAllAffiliateReferralCodes()
appStore.showSuccess(t('auth.loginSuccess'))
// Redirect to dashboard or intended route
......
......@@ -264,6 +264,11 @@ import {
type OAuthTokenResponse,
type PendingOAuthExchangeResponse
} from '@/api/auth'
import {
clearAllAffiliateReferralCodes,
loadOAuthAffiliateCode,
oauthAffiliatePayload
} from '@/utils/oauthAffiliate'
const route = useRoute()
const router = useRouter()
......@@ -590,6 +595,7 @@ async function finalizeCompletion(completion: PendingOAuthExchangeResponse, redi
if (getOAuthCompletionKind(completion) === 'bind') {
const bindRedirect = sanitizeRedirectPath(completion.redirect || '/profile')
clearPendingAuthSession()
clearAllAffiliateReferralCodes()
appStore.showSuccess(bindSuccessMessage)
await router.replace(bindRedirect)
return
......@@ -601,6 +607,7 @@ async function finalizeCompletion(completion: PendingOAuthExchangeResponse, redi
persistOAuthTokenContext(completion)
await authStore.setToken(completion.access_token)
clearAllAffiliateReferralCodes()
appStore.showSuccess(t('auth.loginSuccess'))
await router.replace(redirect)
}
......@@ -649,18 +656,20 @@ async function handleSubmitInvitation() {
isSubmitting.value = true
try {
const affCode = loadOAuthAffiliateCode()
const decision = currentAdoptionDecision()
const completion: PendingOidcCompletion = legacyPendingOAuthToken.value
? (
await apiClient.post<PendingOidcCompletion>('/auth/oauth/oidc/complete-registration', {
pending_oauth_token: legacyPendingOAuthToken.value,
invitation_code: invitationCode.value.trim(),
...serializeAdoptionDecision(currentAdoptionDecision())
...oauthAffiliatePayload(affCode),
...serializeAdoptionDecision(decision)
})
).data
: await completeOIDCOAuthRegistration(
invitationCode.value.trim(),
currentAdoptionDecision()
)
: affCode
? await completeOIDCOAuthRegistration(invitationCode.value.trim(), decision, affCode)
: await completeOIDCOAuthRegistration(invitationCode.value.trim(), decision)
await finalizePendingAccountResponse(completion)
} catch (e: unknown) {
const err = e as { message?: string; response?: { data?: { message?: string } } }
......@@ -695,6 +704,7 @@ async function handleCreateAccount(payload: PendingOAuthCreateAccountPayload) {
password: payload.password,
verify_code: payload.verifyCode || undefined,
invitation_code: payload.invitationCode || undefined,
...oauthAffiliatePayload(loadOAuthAffiliateCode()),
...serializeAdoptionDecision(currentAdoptionDecision())
})
await finalizePendingAccountResponse(data)
......@@ -742,6 +752,7 @@ async function handleSubmitTotpChallenge() {
totp_code: code
})
await authStore.setToken(completion.access_token)
clearAllAffiliateReferralCodes()
appStore.showSuccess(t('auth.loginSuccess'))
await router.replace(redirectTo.value)
} catch (e: unknown) {
......@@ -767,6 +778,7 @@ onMounted(async () => {
if (legacyLogin) {
persistOAuthTokenContext(legacyLogin)
await authStore.setToken(legacyLogin.access_token)
clearAllAffiliateReferralCodes()
appStore.showSuccess(t('auth.loginSuccess'))
await router.replace(redirect)
return
......
......@@ -15,17 +15,20 @@
<LinuxDoOAuthSection
v-if="linuxdoOAuthEnabled"
:disabled="isLoading"
:aff-code="formData.aff_code"
:show-divider="false"
/>
<WechatOAuthSection
v-if="wechatOAuthEnabled"
:disabled="isLoading"
:aff-code="formData.aff_code"
:show-divider="false"
/>
<OidcOAuthSection
v-if="oidcOAuthEnabled"
:disabled="isLoading"
:provider-name="oidcOAuthProviderName"
:aff-code="formData.aff_code"
:show-divider="false"
/>
<div class="flex items-center gap-3">
......@@ -293,6 +296,11 @@ import {
isRegistrationEmailSuffixAllowed,
normalizeRegistrationEmailSuffixWhitelist
} from '@/utils/registrationEmailPolicy'
import {
clearAffiliateReferralCode,
loadAffiliateReferralCode,
resolveAffiliateReferralCode
} from '@/utils/oauthAffiliate'
const { t, locale } = useI18n()
......@@ -351,7 +359,8 @@ const formData = reactive({
email: '',
password: '',
promo_code: '',
invitation_code: ''
invitation_code: '',
aff_code: ''
})
const errors = reactive({
......@@ -377,9 +386,19 @@ watch(validationToastMessage, (value, previousValue) => {
}
})
function syncAffiliateReferralCode(): string {
const code = resolveAffiliateReferralCode(route.query.aff, route.query.aff_code)
if (code) {
formData.aff_code = code
}
return code
}
// ==================== Lifecycle ====================
onMounted(async () => {
syncAffiliateReferralCode()
try {
const settings = await getPublicSettings()
registrationEnabled.value = settings.registration_enabled
......@@ -406,6 +425,7 @@ onMounted(async () => {
await validatePromoCodeDebounced(promoParam)
}
}
syncAffiliateReferralCode()
} catch (error) {
console.error('Failed to load public settings:', error)
} finally {
......@@ -413,6 +433,13 @@ onMounted(async () => {
}
})
watch(
() => [route.query.aff, route.query.aff_code],
() => {
syncAffiliateReferralCode()
}
)
onUnmounted(() => {
if (promoValidateTimeout) {
clearTimeout(promoValidateTimeout)
......@@ -697,6 +724,11 @@ async function handleRegister(): Promise<void> {
isLoading.value = true
try {
const affCode = formData.aff_code.trim() || loadAffiliateReferralCode()
if (affCode) {
formData.aff_code = affCode
}
// If email verification is enabled, redirect to verification page
if (emailVerifyEnabled.value) {
// Store registration data in sessionStorage
......@@ -707,7 +739,8 @@ async function handleRegister(): Promise<void> {
password: formData.password,
turnstile_token: turnstileToken.value,
promo_code: formData.promo_code || undefined,
invitation_code: formData.invitation_code || undefined
invitation_code: formData.invitation_code || undefined,
...(affCode ? { aff_code: affCode } : {})
})
)
......@@ -722,8 +755,10 @@ async function handleRegister(): Promise<void> {
password: formData.password,
turnstile_token: turnstileEnabled.value ? turnstileToken.value : undefined,
promo_code: formData.promo_code || undefined,
invitation_code: formData.invitation_code || undefined
invitation_code: formData.invitation_code || undefined,
...(affCode ? { aff_code: affCode } : {})
})
clearAffiliateReferralCode()
// Show success toast
appStore.showSuccess(t('auth.accountCreatedSuccess', { siteName: siteName.value }))
......
......@@ -340,6 +340,11 @@ import {
type OAuthTokenResponse,
type PendingOAuthExchangeResponse
} from '@/api/auth'
import {
clearAllAffiliateReferralCodes,
loadOAuthAffiliateCode,
oauthAffiliatePayload
} from '@/utils/oauthAffiliate'
const route = useRoute()
const router = useRouter()
......@@ -802,6 +807,7 @@ async function finalizeCompletion(completion: PendingOAuthExchangeResponse, redi
if (getOAuthCompletionKind(completion) === 'bind') {
const bindRedirect = sanitizeRedirectPath(completion.redirect || '/profile')
clearPendingAuthSession()
clearAllAffiliateReferralCodes()
appStore.showSuccess(bindSuccessMessage)
await router.replace(bindRedirect)
return
......@@ -813,6 +819,7 @@ async function finalizeCompletion(completion: PendingOAuthExchangeResponse, redi
persistOAuthTokenContext(completion)
await authStore.setToken(completion.access_token)
clearAllAffiliateReferralCodes()
appStore.showSuccess(t('auth.loginSuccess'))
await router.replace(redirect)
}
......@@ -861,18 +868,20 @@ async function handleSubmitInvitation() {
isSubmitting.value = true
try {
const affCode = loadOAuthAffiliateCode()
const decision = currentAdoptionDecision()
const completion: PendingWeChatCompletion = legacyPendingOAuthToken.value
? (
await apiClient.post<PendingWeChatCompletion>('/auth/oauth/wechat/complete-registration', {
pending_oauth_token: legacyPendingOAuthToken.value,
invitation_code: invitationCode.value.trim(),
...serializeAdoptionDecision(currentAdoptionDecision())
...oauthAffiliatePayload(affCode),
...serializeAdoptionDecision(decision)
})
).data
: await completeWeChatOAuthRegistration(
invitationCode.value.trim(),
currentAdoptionDecision()
)
: affCode
? await completeWeChatOAuthRegistration(invitationCode.value.trim(), decision, affCode)
: await completeWeChatOAuthRegistration(invitationCode.value.trim(), decision)
await finalizePendingAccountResponse(completion)
} catch (e: unknown) {
const err = e as { message?: string; response?: { data?: { message?: string } } }
......@@ -907,6 +916,7 @@ async function handleCreateAccount(payload: PendingOAuthCreateAccountPayload) {
password: payload.password,
verify_code: payload.verifyCode || undefined,
invitation_code: payload.invitationCode || undefined,
...oauthAffiliatePayload(loadOAuthAffiliateCode()),
...serializeAdoptionDecision(currentAdoptionDecision())
})
await finalizePendingAccountResponse(data)
......@@ -955,6 +965,7 @@ async function handleSubmitTotpChallenge() {
})
persistOAuthTokenContext(completion)
await authStore.setToken(completion.access_token)
clearAllAffiliateReferralCodes()
appStore.showSuccess(t('auth.loginSuccess'))
await router.replace(redirectTo.value)
} catch (e: unknown) {
......@@ -1015,6 +1026,7 @@ onMounted(async () => {
if (legacyLogin) {
persistOAuthTokenContext(legacyLogin)
await authStore.setToken(legacyLogin.access_token)
clearAllAffiliateReferralCodes()
appStore.showSuccess(t('auth.loginSuccess'))
await router.replace(redirect)
return
......
......@@ -112,6 +112,7 @@ describe('EmailVerifyView', () => {
apiClientPostMock.mockReset()
authStoreState.pendingAuthSession = null
sessionStorage.clear()
localStorage.clear()
getPublicSettingsMock.mockResolvedValue({
turnstile_enabled: false,
......@@ -136,6 +137,7 @@ describe('EmailVerifyView', () => {
JSON.stringify({
email: 'fresh@example.com',
password: 'secret-123',
aff_code: 'AFF123',
})
)
......@@ -334,6 +336,7 @@ describe('EmailVerifyView', () => {
email: 'fresh@example.com',
password: 'secret-123',
verify_code: '123456',
aff_code: 'AFF123',
})
expect(persistOAuthTokenContextMock).toHaveBeenCalledWith({
access_token: 'oauth-access-token',
......
......@@ -93,6 +93,7 @@ describe('LinuxDoCallbackView', () => {
})
window.location.hash = ''
localStorage.clear()
sessionStorage.clear()
})
it('accepts the legacy fragment token success callback without pending-session exchange', async () => {
......
......@@ -97,6 +97,7 @@ describe('OidcCallbackView', () => {
})
window.location.hash = ''
localStorage.clear()
sessionStorage.clear()
})
it('accepts the legacy fragment token success callback without pending-session exchange', async () => {
......
......@@ -172,6 +172,7 @@ describe('WechatCallbackView', () => {
appStoreState.cachedPublicSettings = null
appStoreState.publicSettingsLoaded = false
localStorage.clear()
sessionStorage.clear()
locationState.current = {
href: 'http://localhost/auth/wechat/callback',
hash: '',
......
<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 p-5">
<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 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>
<p v-if="detail.aff_frozen_quota > 0" class="mt-1 text-xs text-amber-600 dark:text-amber-400">
{{ t('affiliate.stats.frozenQuota') }}: {{ formatCurrency(detail.aff_frozen_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>
<li v-if="detail.aff_frozen_quota > 0">4. {{ t('affiliate.tips.line4') }}</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 text-right">{{ t('affiliate.invitees.columns.rebate') }}</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-right font-medium text-emerald-600 dark:text-emerald-400">{{ formatCurrency(item.total_rebate) }}</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>
......@@ -693,14 +693,18 @@ async function createOrder(orderAmount: number, orderType: OrderType, planId?: n
}
}
const visibleMethod = normalizeVisibleMethod(requestType) || requestType
const stripeMethod = visibleMethod === 'wxpay' ? 'wechat_pay' : 'alipay'
// When user clicks the dedicated Stripe button, leave method blank so the
// landing page renders Stripe's full Payment Element (card/link/alipay/wxpay).
const stripeMethod = visibleMethod === 'stripe'
? ''
: visibleMethod === 'wxpay' ? 'wechat_pay' : 'alipay'
const stripeRouteUrl = result.client_secret
? router.resolve({
path: '/payment/stripe',
query: {
order_id: String(result.order_id),
client_secret: result.client_secret,
method: stripeMethod,
method: stripeMethod || undefined,
resume_token: result.resume_token || undefined,
},
}).href
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment