Skip to content
GitLab
Menu
Projects
Groups
Snippets
Loading...
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Sign in / Register
Toggle navigation
Menu
Open sidebar
陈曦
sub2api
Commits
3b7a5fff
Commit
3b7a5fff
authored
Apr 27, 2026
by
陈曦
Browse files
补充openai、gemini以及流失请求的采集数据以及nfs落库
parent
8519a8eb
Pipeline
#82284
failed with stage
in 2 minutes and 21 seconds
Changes
180
Pipelines
1
Show whitespace changes
Inline
Side-by-side
frontend/src/router/index.ts
View file @
3b7a5fff
...
...
@@ -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
:
tru
e
,
requiresAuth
:
fals
e
,
requiresAdmin
:
false
,
title
:
'
Stripe Payment
'
,
titleKey
:
'
payment.stripePay
'
,
requiresPayment
:
tru
e
requiresPayment
:
fals
e
}
},
{
...
...
@@ -299,10 +311,10 @@ const routes: RouteRecordRaw[] = [
name
:
'
StripePopup
'
,
component
:
()
=>
import
(
'
@/views/user/StripePopupView.vue
'
),
meta
:
{
requiresAuth
:
tru
e
,
requiresAuth
:
fals
e
,
requiresAdmin
:
false
,
title
:
'
Payment
'
,
requiresPayment
:
tru
e
requiresPayment
:
fals
e
}
},
{
...
...
frontend/src/stores/app.ts
View file @
3b7a5fff
...
...
@@ -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
,
}
}
...
...
frontend/src/types/index.ts
View file @
3b7a5fff
...
...
@@ -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
...
...
frontend/src/utils/__tests__/oauthAffiliate.spec.ts
0 → 100644
View file @
3b7a5fff
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
(
''
)
})
})
frontend/src/utils/featureFlags.ts
View file @
3b7a5fff
...
...
@@ -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
...
...
frontend/src/utils/oauthAffiliate.ts
0 → 100644
View file @
3b7a5fff
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
}
:
{}
}
frontend/src/views/admin/AccountsView.vue
View file @
3b7a5fff
...
...
@@ -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
)
{
...
...
frontend/src/views/admin/SettingsView.vue
View file @
3b7a5fff
...
...
@@ -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
>
...
...
frontend/src/views/auth/EmailVerifyView.vue
View file @
3b7a5fff
...
...
@@ -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
}
))
...
...
frontend/src/views/auth/LinuxDoCallbackView.vue
View file @
3b7a5fff
...
...
@@ -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
...
...
frontend/src/views/auth/LoginView.vue
View file @
3b7a5fff
...
...
@@ -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
...
...
frontend/src/views/auth/OidcCallbackView.vue
View file @
3b7a5fff
...
...
@@ -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
...
...
frontend/src/views/auth/RegisterView.vue
View file @
3b7a5fff
...
...
@@ -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
}
))
...
...
frontend/src/views/auth/WechatCallbackView.vue
View file @
3b7a5fff
...
...
@@ -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
...
...
frontend/src/views/auth/__tests__/EmailVerifyView.spec.ts
View file @
3b7a5fff
...
...
@@ -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
'
,
...
...
frontend/src/views/auth/__tests__/LinuxDoCallbackView.spec.ts
View file @
3b7a5fff
...
...
@@ -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
()
=>
{
...
...
frontend/src/views/auth/__tests__/OidcCallbackView.spec.ts
View file @
3b7a5fff
...
...
@@ -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
()
=>
{
...
...
frontend/src/views/auth/__tests__/WechatCallbackView.spec.ts
View file @
3b7a5fff
...
...
@@ -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
:
''
,
...
...
frontend/src/views/user/AffiliateView.vue
0 → 100644
View file @
3b7a5fff
<
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
>
frontend/src/views/user/PaymentView.vue
View file @
3b7a5fff
...
...
@@ -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
...
...
Prev
1
…
5
6
7
8
9
Next
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
.
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment