feat(affiliate): 完善邀请返利系统
- 修复返利不到账的根因:tryClaimAffiliateRebateAudit 中 PostgreSQL 参数类型推断冲突 - 补全 OAuth 注册路径(LinuxDo/OIDC/WeChat/Pending Flow)的邀请码绑定 - 前端 OAuth 注册页面传递 aff_code 参数 - 新增返利冻结期机制:可配置冻结时间,到期后自动解冻(懒解冻) - 新增返利有效期:绑定后 N 天内有效,过期不再产生返利 - 新增单人返利上限:超出上限部分精确截断 - 增强返利流程 slog 结构化日志,便于排查问题 - 已邀请用户列表增加返利明细列
This commit is contained in:
@@ -74,6 +74,26 @@ describe('oauth adoption auth api', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('posts affiliate code when completing linuxdo oauth registration', async () => {
|
||||
const { completeLinuxDoOAuthRegistration } = await import('@/api/auth')
|
||||
|
||||
await completeLinuxDoOAuthRegistration(
|
||||
'invite-code',
|
||||
{
|
||||
adoptDisplayName: true,
|
||||
adoptAvatar: false
|
||||
},
|
||||
' AFF123 '
|
||||
)
|
||||
|
||||
expect(post).toHaveBeenCalledWith('/auth/oauth/linuxdo/complete-registration', {
|
||||
invitation_code: 'invite-code',
|
||||
aff_code: 'AFF123',
|
||||
adopt_display_name: true,
|
||||
adopt_avatar: false
|
||||
})
|
||||
})
|
||||
|
||||
it('posts oidc invitation completion with adoption decisions', async () => {
|
||||
const { completeOIDCOAuthRegistration } = await import('@/api/auth')
|
||||
|
||||
@@ -134,6 +154,26 @@ describe('oauth adoption auth api', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('posts affiliate code when creating pending wechat oauth account', async () => {
|
||||
const { createPendingWeChatOAuthAccount } = await import('@/api/auth')
|
||||
|
||||
await createPendingWeChatOAuthAccount(
|
||||
'invite-code',
|
||||
{
|
||||
adoptDisplayName: false,
|
||||
adoptAvatar: true
|
||||
},
|
||||
'WXAFF'
|
||||
)
|
||||
|
||||
expect(post).toHaveBeenCalledWith('/auth/oauth/wechat/complete-registration', {
|
||||
invitation_code: 'invite-code',
|
||||
aff_code: 'WXAFF',
|
||||
adopt_display_name: false,
|
||||
adopt_avatar: true
|
||||
})
|
||||
})
|
||||
|
||||
it('classifies oauth completion results as login or bind', async () => {
|
||||
const { getOAuthCompletionKind } = await import('@/api/auth')
|
||||
|
||||
|
||||
@@ -309,6 +309,9 @@ export interface SystemSettings {
|
||||
// Default settings
|
||||
default_balance: number;
|
||||
affiliate_rebate_rate: number;
|
||||
affiliate_rebate_freeze_hours: number;
|
||||
affiliate_rebate_duration_days: number;
|
||||
affiliate_rebate_per_invitee_cap: number;
|
||||
default_concurrency: number;
|
||||
default_user_rpm_limit: number;
|
||||
default_subscriptions: DefaultSubscriptionSetting[];
|
||||
@@ -494,6 +497,9 @@ export interface UpdateSettingsRequest {
|
||||
totp_enabled?: boolean; // TOTP 双因素认证
|
||||
default_balance?: number;
|
||||
affiliate_rebate_rate?: number;
|
||||
affiliate_rebate_freeze_hours?: number;
|
||||
affiliate_rebate_duration_days?: number;
|
||||
affiliate_rebate_per_invitee_cap?: number;
|
||||
default_concurrency?: number;
|
||||
default_user_rpm_limit?: number;
|
||||
default_subscriptions?: DefaultSubscriptionSetting[];
|
||||
|
||||
@@ -564,9 +564,10 @@ export async function resetPassword(request: ResetPasswordRequest): Promise<Rese
|
||||
*/
|
||||
export async function completeLinuxDoOAuthRegistration(
|
||||
invitationCode: string,
|
||||
decision?: OAuthAdoptionDecision
|
||||
decision?: OAuthAdoptionDecision,
|
||||
affiliateCode?: string
|
||||
): Promise<OAuthTokenResponse> {
|
||||
return createPendingLinuxDoOAuthAccount(invitationCode, decision)
|
||||
return createPendingLinuxDoOAuthAccount(invitationCode, decision, affiliateCode)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -576,27 +577,32 @@ export async function completeLinuxDoOAuthRegistration(
|
||||
*/
|
||||
export async function completeOIDCOAuthRegistration(
|
||||
invitationCode: string,
|
||||
decision?: OAuthAdoptionDecision
|
||||
decision?: OAuthAdoptionDecision,
|
||||
affiliateCode?: string
|
||||
): Promise<OAuthTokenResponse> {
|
||||
return createPendingOIDCOAuthAccount(invitationCode, decision)
|
||||
return createPendingOIDCOAuthAccount(invitationCode, decision, affiliateCode)
|
||||
}
|
||||
|
||||
export async function completeWeChatOAuthRegistration(
|
||||
invitationCode: string,
|
||||
decision?: OAuthAdoptionDecision
|
||||
decision?: OAuthAdoptionDecision,
|
||||
affiliateCode?: string
|
||||
): Promise<OAuthTokenResponse> {
|
||||
return createPendingWeChatOAuthAccount(invitationCode, decision)
|
||||
return createPendingWeChatOAuthAccount(invitationCode, decision, affiliateCode)
|
||||
}
|
||||
|
||||
async function createPendingOAuthAccount(
|
||||
provider: 'linuxdo' | 'oidc' | 'wechat',
|
||||
invitationCode: string,
|
||||
decision?: OAuthAdoptionDecision
|
||||
decision?: OAuthAdoptionDecision,
|
||||
affiliateCode?: string
|
||||
): Promise<PendingOAuthCreateAccountResponse> {
|
||||
const normalizedAffiliateCode = affiliateCode?.trim()
|
||||
const { data } = await apiClient.post<PendingOAuthCreateAccountResponse>(
|
||||
`/auth/oauth/${provider}/complete-registration`,
|
||||
{
|
||||
invitation_code: invitationCode,
|
||||
...(normalizedAffiliateCode ? { aff_code: normalizedAffiliateCode } : {}),
|
||||
...serializeOAuthAdoptionDecision(decision)
|
||||
}
|
||||
)
|
||||
@@ -605,23 +611,26 @@ async function createPendingOAuthAccount(
|
||||
|
||||
export async function createPendingLinuxDoOAuthAccount(
|
||||
invitationCode: string,
|
||||
decision?: OAuthAdoptionDecision
|
||||
decision?: OAuthAdoptionDecision,
|
||||
affiliateCode?: string
|
||||
): Promise<PendingOAuthCreateAccountResponse> {
|
||||
return createPendingOAuthAccount('linuxdo', invitationCode, decision)
|
||||
return createPendingOAuthAccount('linuxdo', invitationCode, decision, affiliateCode)
|
||||
}
|
||||
|
||||
export async function createPendingOIDCOAuthAccount(
|
||||
invitationCode: string,
|
||||
decision?: OAuthAdoptionDecision
|
||||
decision?: OAuthAdoptionDecision,
|
||||
affiliateCode?: string
|
||||
): Promise<PendingOAuthCreateAccountResponse> {
|
||||
return createPendingOAuthAccount('oidc', invitationCode, decision)
|
||||
return createPendingOAuthAccount('oidc', invitationCode, decision, affiliateCode)
|
||||
}
|
||||
|
||||
export async function createPendingWeChatOAuthAccount(
|
||||
invitationCode: string,
|
||||
decision?: OAuthAdoptionDecision
|
||||
decision?: OAuthAdoptionDecision,
|
||||
affiliateCode?: string
|
||||
): Promise<PendingOAuthCreateAccountResponse> {
|
||||
return createPendingOAuthAccount('wechat', invitationCode, decision)
|
||||
return createPendingOAuthAccount('wechat', invitationCode, decision, affiliateCode)
|
||||
}
|
||||
|
||||
export async function completePendingOAuthBindLogin(
|
||||
|
||||
@@ -42,9 +42,11 @@
|
||||
<script setup lang="ts">
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { resolveAffiliateReferralCode, storeOAuthAffiliateCode } from '@/utils/oauthAffiliate'
|
||||
|
||||
withDefaults(defineProps<{
|
||||
const props = withDefaults(defineProps<{
|
||||
disabled?: boolean
|
||||
affCode?: string
|
||||
showDivider?: boolean
|
||||
}>(), {
|
||||
showDivider: true
|
||||
@@ -55,6 +57,7 @@ const { t } = useI18n()
|
||||
|
||||
function startLogin(): void {
|
||||
const redirectTo = (route.query.redirect as string) || '/dashboard'
|
||||
storeOAuthAffiliateCode(resolveAffiliateReferralCode(props.affCode, route.query.aff, route.query.aff_code))
|
||||
const apiBase = (import.meta.env.VITE_API_BASE_URL as string | undefined) || '/api/v1'
|
||||
const normalized = apiBase.replace(/\/$/, '')
|
||||
const startURL = `${normalized}/auth/oauth/linuxdo/start?redirect=${encodeURIComponent(redirectTo)}`
|
||||
|
||||
@@ -23,9 +23,11 @@
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { resolveAffiliateReferralCode, storeOAuthAffiliateCode } from '@/utils/oauthAffiliate'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
disabled?: boolean
|
||||
affCode?: string
|
||||
providerName?: string
|
||||
showDivider?: boolean
|
||||
}>(), {
|
||||
@@ -45,6 +47,7 @@ const providerInitial = computed(() => normalizedProviderName.value.charAt(0).to
|
||||
|
||||
function startLogin(): void {
|
||||
const redirectTo = (route.query.redirect as string) || '/dashboard'
|
||||
storeOAuthAffiliateCode(resolveAffiliateReferralCode(props.affCode, route.query.aff, route.query.aff_code))
|
||||
const apiBase = (import.meta.env.VITE_API_BASE_URL as string | undefined) || '/api/v1'
|
||||
const normalized = apiBase.replace(/\/$/, '')
|
||||
const startURL = `${normalized}/auth/oauth/oidc/start?redirect=${encodeURIComponent(redirectTo)}`
|
||||
|
||||
@@ -33,9 +33,11 @@ import { useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { resolveWeChatOAuthStart } from '@/api/auth'
|
||||
import { useAppStore } from '@/stores'
|
||||
import { resolveAffiliateReferralCode, storeOAuthAffiliateCode } from '@/utils/oauthAffiliate'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
disabled?: boolean
|
||||
affCode?: string
|
||||
showDivider?: boolean
|
||||
}>(), {
|
||||
showDivider: true,
|
||||
@@ -84,6 +86,7 @@ function startLogin(): void {
|
||||
return
|
||||
}
|
||||
const redirectTo = (route.query.redirect as string) || '/dashboard'
|
||||
storeOAuthAffiliateCode(resolveAffiliateReferralCode(props.affCode, route.query.aff, route.query.aff_code))
|
||||
const apiBase = (import.meta.env.VITE_API_BASE_URL as string | undefined) || '/api/v1'
|
||||
const normalized = apiBase.replace(/\/$/, '')
|
||||
const mode = resolvedStart.value.mode
|
||||
|
||||
@@ -989,6 +989,8 @@ export default {
|
||||
rebateRateHint: 'What you earn each time an invitee recharges',
|
||||
invitedUsers: 'Invited Users',
|
||||
availableQuota: 'Available Rebate Quota',
|
||||
frozenQuota: 'Frozen',
|
||||
frozenQuotaHint: 'Recently earned rebates pending release',
|
||||
totalQuota: 'Historical Rebate Quota'
|
||||
},
|
||||
transfer: {
|
||||
@@ -1005,6 +1007,7 @@ export default {
|
||||
columns: {
|
||||
email: 'Email',
|
||||
username: 'Username',
|
||||
rebate: 'Rebate',
|
||||
joinedAt: 'Joined At'
|
||||
}
|
||||
},
|
||||
@@ -1012,7 +1015,8 @@ export default {
|
||||
title: 'How It Works',
|
||||
line1: 'Share your affiliate code or invite link with new users.',
|
||||
line2: 'When invitees recharge, you receive {rate} of the recharge as rebate quota.',
|
||||
line3: 'Transfer rebate quota to balance at any time.'
|
||||
line3: 'Transfer rebate quota to balance at any time.',
|
||||
line4: 'Newly earned rebates may have a waiting period before they can be transferred.'
|
||||
}
|
||||
},
|
||||
|
||||
@@ -4788,6 +4792,12 @@ export default {
|
||||
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%).',
|
||||
freezeHours: 'Rebate Freeze Period (hours)',
|
||||
freezeHoursDesc: 'New rebates will be frozen for this period before becoming available for withdrawal. 0 = no freeze.',
|
||||
durationDays: 'Rebate Duration (days)',
|
||||
durationDaysDesc: 'Rebate relationship expires after this many days since invitee registration. 0 = permanent.',
|
||||
perInviteeCap: 'Per-Invitee Rebate Cap',
|
||||
perInviteeCapDesc: 'Maximum total rebate from a single invitee. 0 = no limit.',
|
||||
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.',
|
||||
|
||||
@@ -993,6 +993,8 @@ export default {
|
||||
rebateRateHint: '被邀请用户每次充值后你可获得的返利比例',
|
||||
invitedUsers: '邀请人数',
|
||||
availableQuota: '可转返利额度',
|
||||
frozenQuota: '冻结中',
|
||||
frozenQuotaHint: '新产生的返利正在冻结期中',
|
||||
totalQuota: '历史返利额度'
|
||||
},
|
||||
transfer: {
|
||||
@@ -1009,6 +1011,7 @@ export default {
|
||||
columns: {
|
||||
email: '邮箱',
|
||||
username: '用户名',
|
||||
rebate: '返利明细',
|
||||
joinedAt: '注册时间'
|
||||
}
|
||||
},
|
||||
@@ -1016,7 +1019,8 @@ export default {
|
||||
title: '使用说明',
|
||||
line1: '将邀请码或邀请链接分享给新用户。',
|
||||
line2: '被邀请用户充值后,你可获得 {rate} 的返利额度。',
|
||||
line3: '返利额度可随时转入账户余额。'
|
||||
line3: '返利额度可随时转入账户余额。',
|
||||
line4: '新产生的返利需要经过冻结期后才能提现。'
|
||||
}
|
||||
},
|
||||
|
||||
@@ -4951,6 +4955,12 @@ export default {
|
||||
enabledHint: '关闭后用户菜单中的邀请页面入口隐藏、注册时忽略邀请码、新充值不再产生返利。已有返利额度仍可转入余额。',
|
||||
rebateRate: '全局返利比例',
|
||||
rebateRateHint: '充值后返给邀请人的默认比例(0-100%,例如填写 10 表示返利 10%)。',
|
||||
freezeHours: '返利冻结期(小时)',
|
||||
freezeHoursDesc: '新产生的返利将在冻结期内无法提现。0 = 不冻结。',
|
||||
durationDays: '返利有效期(天)',
|
||||
durationDaysDesc: '被邀请用户注册后多少天内的充值产生返利。0 = 永久有效。',
|
||||
perInviteeCap: '单人返利上限',
|
||||
perInviteeCapDesc: '每个被邀请用户最多产生的返利总额。0 = 无上限。',
|
||||
customUsers: {
|
||||
title: '专属用户配置',
|
||||
description: '为指定用户设置专属邀请码或专属返利比例。仅展示已设置过专属配置的用户。',
|
||||
|
||||
@@ -130,6 +130,7 @@ export interface AffiliateInvitee {
|
||||
email: string
|
||||
username: string
|
||||
created_at?: string
|
||||
total_rebate: number
|
||||
}
|
||||
|
||||
export interface UserAffiliateDetail {
|
||||
@@ -138,6 +139,7 @@ export interface UserAffiliateDetail {
|
||||
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
|
||||
|
||||
48
frontend/src/utils/__tests__/oauthAffiliate.spec.ts
Normal file
48
frontend/src/utils/__tests__/oauthAffiliate.spec.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
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('')
|
||||
})
|
||||
})
|
||||
133
frontend/src/utils/oauthAffiliate.ts
Normal file
133
frontend/src/utils/oauthAffiliate.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
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 } : {}
|
||||
}
|
||||
@@ -3898,6 +3898,56 @@
|
||||
</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">
|
||||
@@ -5333,6 +5383,9 @@ const form = reactive<SettingsForm>({
|
||||
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,
|
||||
@@ -6261,6 +6314,9 @@ async function saveSettings() {
|
||||
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,
|
||||
|
||||
@@ -167,6 +167,11 @@ import {
|
||||
isRegistrationEmailSuffixAllowed,
|
||||
normalizeRegistrationEmailSuffixWhitelist
|
||||
} from '@/utils/registrationEmailPolicy'
|
||||
import {
|
||||
clearAllAffiliateReferralCodes,
|
||||
loadAffiliateReferralCode,
|
||||
oauthAffiliatePayload
|
||||
} from '@/utils/oauthAffiliate'
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
|
||||
@@ -261,7 +266,7 @@ onMounted(async () => {
|
||||
initialTurnstileToken.value = registerData.turnstile_token || ''
|
||||
promoCode.value = registerData.promo_code || ''
|
||||
invitationCode.value = registerData.invitation_code || ''
|
||||
affCode.value = registerData.aff_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 || ''
|
||||
@@ -501,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
|
||||
}
|
||||
@@ -533,6 +539,7 @@ async function handleVerify(): Promise<void> {
|
||||
|
||||
// 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()
|
||||
|
||||
@@ -378,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
|
||||
@@ -407,10 +425,7 @@ onMounted(async () => {
|
||||
await validatePromoCodeDebounced(promoParam)
|
||||
}
|
||||
}
|
||||
const affParam = (route.query.aff as string) || (route.query.aff_code as string)
|
||||
if (affParam) {
|
||||
formData.aff_code = affParam.trim()
|
||||
}
|
||||
syncAffiliateReferralCode()
|
||||
} catch (error) {
|
||||
console.error('Failed to load public settings:', error)
|
||||
} finally {
|
||||
@@ -418,6 +433,13 @@ onMounted(async () => {
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
() => [route.query.aff, route.query.aff_code],
|
||||
() => {
|
||||
syncAffiliateReferralCode()
|
||||
}
|
||||
)
|
||||
|
||||
onUnmounted(() => {
|
||||
if (promoValidateTimeout) {
|
||||
clearTimeout(promoValidateTimeout)
|
||||
@@ -702,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
|
||||
@@ -713,7 +740,7 @@ async function handleRegister(): Promise<void> {
|
||||
turnstile_token: turnstileToken.value,
|
||||
promo_code: formData.promo_code || undefined,
|
||||
invitation_code: formData.invitation_code || undefined,
|
||||
...(formData.aff_code ? { aff_code: formData.aff_code } : {})
|
||||
...(affCode ? { aff_code: affCode } : {})
|
||||
})
|
||||
)
|
||||
|
||||
@@ -729,8 +756,9 @@ async function handleRegister(): Promise<void> {
|
||||
turnstile_token: turnstileEnabled.value ? turnstileToken.value : undefined,
|
||||
promo_code: formData.promo_code || undefined,
|
||||
invitation_code: formData.invitation_code || undefined,
|
||||
...(formData.aff_code ? { aff_code: formData.aff_code } : {})
|
||||
...(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: '',
|
||||
|
||||
@@ -9,21 +9,17 @@
|
||||
|
||||
<template v-else-if="detail">
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<!-- 返利比例:用主色突出,让用户一眼看到「能拿多少」 -->
|
||||
<div class="card relative overflow-hidden p-5">
|
||||
<div class="absolute -right-6 -top-6 h-24 w-24 rounded-full bg-primary-500/10"></div>
|
||||
<div class="relative">
|
||||
<p class="flex items-center gap-1.5 text-sm text-gray-500 dark:text-dark-400">
|
||||
<Icon name="dollar" size="sm" class="text-primary-500" />
|
||||
{{ t('affiliate.stats.rebateRate') }}
|
||||
</p>
|
||||
<p class="mt-2 text-2xl font-semibold text-primary-600 dark:text-primary-400">
|
||||
{{ formattedRebateRate }}<span class="ml-0.5 text-base font-medium">%</span>
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-gray-400 dark:text-dark-500">
|
||||
{{ t('affiliate.stats.rebateRateHint') }}
|
||||
</p>
|
||||
</div>
|
||||
<div 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>
|
||||
@@ -42,6 +38,9 @@
|
||||
<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>
|
||||
|
||||
@@ -79,6 +78,7 @@
|
||||
<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>
|
||||
@@ -115,6 +115,7 @@
|
||||
<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>
|
||||
@@ -126,6 +127,7 @@
|
||||
>
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user