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
5b5db885
Unverified
Commit
5b5db885
authored
Apr 24, 2026
by
Wesley Liddick
Committed by
GitHub
Apr 24, 2026
Browse files
Merge pull request #1897 from VpSanta33/codex/invite-affiliate-rebate
feat: 新增邀请返利功能,并支持后台配置返利比例
parents
76aae5aa
f03de00c
Changes
33
Hide whitespace changes
Inline
Side-by-side
backend/migrations/130_add_user_affiliates.sql
0 → 100644
View file @
5b5db885
CREATE
TABLE
IF
NOT
EXISTS
user_affiliates
(
user_id
BIGINT
PRIMARY
KEY
REFERENCES
users
(
id
)
ON
DELETE
CASCADE
,
aff_code
VARCHAR
(
32
)
NOT
NULL
UNIQUE
,
inviter_id
BIGINT
NULL
REFERENCES
users
(
id
)
ON
DELETE
SET
NULL
,
aff_count
INTEGER
NOT
NULL
DEFAULT
0
,
aff_quota
DECIMAL
(
20
,
8
)
NOT
NULL
DEFAULT
0
,
aff_history_quota
DECIMAL
(
20
,
8
)
NOT
NULL
DEFAULT
0
,
created_at
TIMESTAMPTZ
NOT
NULL
DEFAULT
NOW
(),
updated_at
TIMESTAMPTZ
NOT
NULL
DEFAULT
NOW
()
);
CREATE
INDEX
IF
NOT
EXISTS
idx_user_affiliates_inviter_id
ON
user_affiliates
(
inviter_id
);
CREATE
INDEX
IF
NOT
EXISTS
idx_user_affiliates_aff_quota
ON
user_affiliates
(
aff_quota
);
COMMENT
ON
TABLE
user_affiliates
IS
'用户邀请返利信息'
;
COMMENT
ON
COLUMN
user_affiliates
.
aff_code
IS
'用户邀请代码'
;
COMMENT
ON
COLUMN
user_affiliates
.
inviter_id
IS
'邀请人用户ID'
;
COMMENT
ON
COLUMN
user_affiliates
.
aff_count
IS
'累计邀请人数'
;
COMMENT
ON
COLUMN
user_affiliates
.
aff_quota
IS
'当前可提取返利金额'
;
COMMENT
ON
COLUMN
user_affiliates
.
aff_history_quota
IS
'累计返利历史金额'
;
backend/migrations/131_affiliate_rebate_hardening.sql
0 → 100644
View file @
5b5db885
-- 1) Normalize historical affiliate rebate rate values.
-- Legacy compatibility treated 0<x<=1 as fractional inputs (e.g. 0.2 => 20%).
-- We now use pure percentage semantics, so convert persisted fractional values once.
UPDATE
settings
SET
value
=
to_char
((
value
::
numeric
*
100
),
'FM999999990.########'
),
updated_at
=
NOW
()
WHERE
key
=
'affiliate_rebate_rate'
AND
value
~
'^-?[0-9]+(
\\
.[0-9]+)?$'
AND
value
::
numeric
>
0
AND
value
::
numeric
<=
1
;
-- 2) Affiliate ledger for accrual/transfer traceability.
CREATE
TABLE
IF
NOT
EXISTS
user_affiliate_ledger
(
id
BIGSERIAL
PRIMARY
KEY
,
user_id
BIGINT
NOT
NULL
REFERENCES
users
(
id
)
ON
DELETE
CASCADE
,
action
VARCHAR
(
32
)
NOT
NULL
,
amount
DECIMAL
(
20
,
8
)
NOT
NULL
,
source_user_id
BIGINT
NULL
REFERENCES
users
(
id
)
ON
DELETE
SET
NULL
,
created_at
TIMESTAMPTZ
NOT
NULL
DEFAULT
NOW
(),
updated_at
TIMESTAMPTZ
NOT
NULL
DEFAULT
NOW
()
);
CREATE
INDEX
IF
NOT
EXISTS
idx_user_affiliate_ledger_user_id
ON
user_affiliate_ledger
(
user_id
);
CREATE
INDEX
IF
NOT
EXISTS
idx_user_affiliate_ledger_action
ON
user_affiliate_ledger
(
action
);
COMMENT
ON
TABLE
user_affiliate_ledger
IS
'邀请返利资金流水(累计/转入)'
;
COMMENT
ON
COLUMN
user_affiliate_ledger
.
action
IS
'accrue|transfer'
;
-- 3) Enforce idempotency at DB layer for payment audit actions.
WITH
ranked
AS
(
SELECT
id
,
ROW_NUMBER
()
OVER
(
PARTITION
BY
order_id
,
action
ORDER
BY
id
)
AS
rn
FROM
payment_audit_logs
)
DELETE
FROM
payment_audit_logs
p
USING
ranked
r
WHERE
p
.
id
=
r
.
id
AND
r
.
rn
>
1
;
CREATE
UNIQUE
INDEX
IF
NOT
EXISTS
idx_payment_audit_logs_order_action_uniq
ON
payment_audit_logs
(
order_id
,
action
);
-- 4) Prevent retroactive affiliate rebate issuance for legacy completed balance orders.
INSERT
INTO
payment_audit_logs
(
order_id
,
action
,
detail
,
operator
,
created_at
)
SELECT
po
.
id
::
text
,
'AFFILIATE_REBATE_SKIPPED'
,
'{"reason":"baseline before affiliate rebate idempotency rollout"}'
,
'system'
,
NOW
()
FROM
payment_orders
po
WHERE
po
.
order_type
=
'balance'
AND
po
.
status
=
'COMPLETED'
AND
NOT
EXISTS
(
SELECT
1
FROM
payment_audit_logs
pal
WHERE
pal
.
order_id
=
po
.
id
::
text
AND
pal
.
action
IN
(
'AFFILIATE_REBATE_APPLIED'
,
'AFFILIATE_REBATE_SKIPPED'
)
);
frontend/src/api/admin/settings.ts
View file @
5b5db885
...
...
@@ -308,6 +308,7 @@ export interface SystemSettings {
totp_encryption_key_configured
:
boolean
;
// TOTP 加密密钥是否已配置
// Default settings
default_balance
:
number
;
affiliate_rebate_rate
:
number
;
default_concurrency
:
number
;
default_user_rpm_limit
:
number
;
default_subscriptions
:
DefaultSubscriptionSetting
[];
...
...
@@ -489,6 +490,7 @@ export interface UpdateSettingsRequest {
invitation_code_enabled
?:
boolean
;
totp_enabled
?:
boolean
;
// TOTP 双因素认证
default_balance
?:
number
;
affiliate_rebate_rate
?:
number
;
default_concurrency
?:
number
;
default_user_rpm_limit
?:
number
;
default_subscriptions
?:
DefaultSubscriptionSetting
[];
...
...
frontend/src/api/user.ts
View file @
5b5db885
...
...
@@ -9,7 +9,14 @@ import {
prepareOAuthBindAccessTokenCookie
,
type
WeChatOAuthPublicSettings
,
}
from
'
./auth
'
import
type
{
User
,
ChangePasswordRequest
,
NotifyEmailEntry
,
UserAuthProvider
}
from
'
@/types
'
import
type
{
User
,
ChangePasswordRequest
,
NotifyEmailEntry
,
UserAuthProvider
,
UserAffiliateDetail
,
AffiliateTransferResponse
}
from
'
@/types
'
/**
* Get current user profile
...
...
@@ -168,6 +175,16 @@ export async function startOAuthBinding(
window
.
location
.
href
=
startURL
}
export
async
function
getAffiliateDetail
():
Promise
<
UserAffiliateDetail
>
{
const
{
data
}
=
await
apiClient
.
get
<
UserAffiliateDetail
>
(
'
/user/aff
'
)
return
data
}
export
async
function
transferAffiliateQuota
():
Promise
<
AffiliateTransferResponse
>
{
const
{
data
}
=
await
apiClient
.
post
<
AffiliateTransferResponse
>
(
'
/user/aff/transfer
'
)
return
data
}
export
const
userAPI
=
{
getProfile
,
updateProfile
,
...
...
@@ -180,7 +197,9 @@ export const userAPI = {
bindEmailIdentity
,
unbindAuthIdentity
,
buildOAuthBindingStartURL
,
startOAuthBinding
startOAuthBinding
,
getAffiliateDetail
,
transferAffiliateQuota
}
export
default
userAPI
frontend/src/components/layout/AppSidebar.vue
View file @
5b5db885
...
...
@@ -656,6 +656,7 @@ function buildSelfNavItems(withDashboard: boolean): NavItem[] {
{
path
:
'
/purchase
'
,
label
:
t
(
'
nav.buySubscription
'
),
icon
:
RechargeSubscriptionIcon
,
hideInSimpleMode
:
true
,
featureFlag
:
flagPayment
},
{
path
:
'
/orders
'
,
label
:
t
(
'
nav.myOrders
'
),
icon
:
OrderListIcon
,
hideInSimpleMode
:
true
,
featureFlag
:
flagPayment
},
{
path
:
'
/redeem
'
,
label
:
t
(
'
nav.redeem
'
),
icon
:
GiftIcon
,
hideInSimpleMode
:
true
},
{
path
:
'
/affiliate
'
,
label
:
t
(
'
nav.affiliate
'
),
icon
:
UsersIcon
,
hideInSimpleMode
:
true
},
{
path
:
'
/profile
'
,
label
:
t
(
'
nav.profile
'
),
icon
:
UserIcon
},
...
customMenuItemsForUser
.
value
.
map
((
item
):
NavItem
=>
({
path
:
`/custom/
${
item
.
id
}
`
,
...
...
frontend/src/i18n/locales/en.ts
View file @
5b5db885
...
...
@@ -346,6 +346,7 @@ export default {
apiKeys
:
'
API Keys
'
,
usage
:
'
Usage
'
,
redeem
:
'
Redeem
'
,
affiliate
:
'
Affiliate Rebates
'
,
profile
:
'
Profile
'
,
users
:
'
Users
'
,
groups
:
'
Groups
'
,
...
...
@@ -972,6 +973,47 @@ export default {
}
},
affiliate
:
{
title
:
'
Affiliate Rebates
'
,
description
:
'
Invite new users and convert your rebate quota into account balance
'
,
yourCode
:
'
Your Affiliate Code
'
,
inviteLink
:
'
Invite Link
'
,
copyCode
:
'
Copy Code
'
,
copyLink
:
'
Copy Link
'
,
codeCopied
:
'
Affiliate code copied
'
,
linkCopied
:
'
Invite link copied
'
,
loadFailed
:
'
Failed to load affiliate data
'
,
transferFailed
:
'
Failed to transfer affiliate quota
'
,
stats
:
{
invitedUsers
:
'
Invited Users
'
,
availableQuota
:
'
Available Rebate Quota
'
,
totalQuota
:
'
Historical Rebate Quota
'
},
transfer
:
{
title
:
'
Transfer Rebate Quota
'
,
description
:
'
Move available rebate quota into your account balance
'
,
button
:
'
Transfer to Balance
'
,
transferring
:
'
Transferring...
'
,
empty
:
'
No available rebate quota
'
,
success
:
'
{amount} has been transferred to your balance
'
},
invitees
:
{
title
:
'
Invited Users
'
,
empty
:
'
No invited users yet
'
,
columns
:
{
email
:
'
Email
'
,
username
:
'
Username
'
,
joinedAt
:
'
Joined At
'
}
},
tips
:
{
title
:
'
How It Works
'
,
line1
:
'
Share your affiliate code or invite link with new users.
'
,
line2
:
'
When invitees recharge, you receive rebate quota based on the configured rate.
'
,
line3
:
'
Transfer rebate quota to balance at any time.
'
}
},
// Redeem
redeem
:
{
title
:
'
Redeem Code
'
,
...
...
@@ -4837,6 +4879,9 @@ export default {
description
:
'
Default values for new users
'
,
defaultBalance
:
'
Default Balance
'
,
defaultBalanceHint
:
'
Initial balance for new users
'
,
affiliateRebateRate
:
'
Affiliate Rebate Rate
'
,
affiliateRebateRateHint
:
'
Rebate percentage credited to inviter after recharge (0-100%, e.g. 10 means 10%)
'
,
defaultConcurrency
:
'
Default Concurrency
'
,
defaultConcurrencyHint
:
'
Maximum concurrent requests for new users
'
,
defaultUserRpmLimit
:
'
Default User RPM Limit
'
,
...
...
frontend/src/i18n/locales/zh.ts
View file @
5b5db885
...
...
@@ -346,6 +346,7 @@ export default {
apiKeys
:
'
API 密钥
'
,
usage
:
'
使用记录
'
,
redeem
:
'
兑换
'
,
affiliate
:
'
邀请返利
'
,
profile
:
'
个人资料
'
,
users
:
'
用户管理
'
,
groups
:
'
分组管理
'
,
...
...
@@ -976,6 +977,47 @@ export default {
}
},
affiliate
:
{
title
:
'
邀请返利
'
,
description
:
'
邀请新用户注册,并将返利额度转入账户余额
'
,
yourCode
:
'
我的邀请码
'
,
inviteLink
:
'
邀请链接
'
,
copyCode
:
'
复制邀请码
'
,
copyLink
:
'
复制链接
'
,
codeCopied
:
'
邀请码已复制
'
,
linkCopied
:
'
邀请链接已复制
'
,
loadFailed
:
'
加载邀请返利数据失败
'
,
transferFailed
:
'
转入余额失败
'
,
stats
:
{
invitedUsers
:
'
邀请人数
'
,
availableQuota
:
'
可转返利额度
'
,
totalQuota
:
'
历史返利额度
'
},
transfer
:
{
title
:
'
返利额度转余额
'
,
description
:
'
将当前可用返利额度一键转入账户余额
'
,
button
:
'
转入余额
'
,
transferring
:
'
转入中...
'
,
empty
:
'
当前没有可转入额度
'
,
success
:
'
已转入余额:{amount}
'
},
invitees
:
{
title
:
'
已邀请用户
'
,
empty
:
'
暂无邀请记录
'
,
columns
:
{
email
:
'
邮箱
'
,
username
:
'
用户名
'
,
joinedAt
:
'
注册时间
'
}
},
tips
:
{
title
:
'
使用说明
'
,
line1
:
'
将邀请码或邀请链接分享给新用户。
'
,
line2
:
'
被邀请用户充值后,你可获得对应比例的返利额度。
'
,
line3
:
'
返利额度可随时转入账户余额。
'
}
},
// Redeem
redeem
:
{
title
:
'
兑换码
'
,
...
...
@@ -5000,6 +5042,8 @@ export default {
description
:
'
新用户的默认值
'
,
defaultBalance
:
'
默认余额
'
,
defaultBalanceHint
:
'
新用户的初始余额
'
,
affiliateRebateRate
:
'
邀请返利比例
'
,
affiliateRebateRateHint
:
'
充值后返给邀请人的比例(0-100%,例如填写 10 表示返利 10%)
'
,
defaultConcurrency
:
'
默认并发数
'
,
defaultConcurrencyHint
:
'
新用户的最大并发请求数
'
,
defaultUserRpmLimit
:
'
默认用户 RPM 限制
'
,
...
...
frontend/src/router/index.ts
View file @
5b5db885
...
...
@@ -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
'
,
...
...
frontend/src/types/index.ts
View file @
5b5db885
...
...
@@ -122,6 +122,29 @@ 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
}
export
interface
UserAffiliateDetail
{
user_id
:
number
aff_code
:
string
inviter_id
?:
number
|
null
aff_count
:
number
aff_quota
:
number
aff_history_quota
:
number
invitees
:
AffiliateInvitee
[]
}
export
interface
AffiliateTransferResponse
{
transferred_quota
:
number
balance
:
number
}
export
interface
SendVerifyCodeRequest
{
...
...
frontend/src/views/admin/SettingsView.vue
View file @
5b5db885
...
...
@@ -2153,6 +2153,31 @@
{{
t
(
"
admin.settings.defaults.defaultBalanceHint
"
)
}}
<
/p
>
<
/div
>
<
div
>
<
label
class
=
"
mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300
"
>
{{
t
(
"
admin.settings.defaults.affiliateRebateRate
"
)
}}
<
/label
>
<
div
class
=
"
relative
"
>
<
input
v
-
model
.
number
=
"
form.affiliate_rebate_rate
"
type
=
"
number
"
step
=
"
0.01
"
min
=
"
0
"
max
=
"
100
"
class
=
"
input pr-8
"
placeholder
=
"
20
"
/>
<
span
class
=
"
pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-gray-400
"
>%<
/spa
n
>
<
/div
>
<
p
class
=
"
mt-1.5 text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
"
admin.settings.defaults.affiliateRebateRateHint
"
)
}}
<
/p
>
<
/div
>
<
div
>
<
label
class
=
"
mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300
"
...
...
@@ -4972,6 +4997,7 @@ const form = reactive<SettingsForm>({
totp_enabled
:
false
,
totp_encryption_key_configured
:
false
,
default_balance
:
0
,
affiliate_rebate_rate
:
20
,
default_concurrency
:
1
,
default_subscriptions
:
[],
force_email_on_third_party_signup
:
false
,
...
...
@@ -5894,6 +5920,10 @@ 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
),
),
default_concurrency
:
form
.
default_concurrency
,
default_subscriptions
:
normalizedDefaultSubscriptions
,
force_email_on_third_party_signup
:
form
.
force_email_on_third_party_signup
,
...
...
frontend/src/views/auth/EmailVerifyView.vue
View file @
5b5db885
...
...
@@ -209,6 +209,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 +261,7 @@ onMounted(async () => {
initialTurnstileToken
.
value
=
registerData
.
turnstile_token
||
''
promoCode
.
value
=
registerData
.
promo_code
||
''
invitationCode
.
value
=
registerData
.
invitation_code
||
''
affCode
.
value
=
registerData
.
aff_code
||
''
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
||
''
...
...
@@ -524,7 +526,8 @@ 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
}
:
{
}
)
}
)
}
...
...
frontend/src/views/auth/RegisterView.vue
View file @
5b5db885
...
...
@@ -351,7 +351,8 @@ const formData = reactive({
email
:
''
,
password
:
''
,
promo_code
:
''
,
invitation_code
:
''
invitation_code
:
''
,
aff_code
:
''
}
)
const
errors
=
reactive
({
...
...
@@ -406,6 +407,10 @@ 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
()
}
}
catch
(
error
)
{
console
.
error
(
'
Failed to load public settings:
'
,
error
)
}
finally
{
...
...
@@ -707,7 +712,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
,
...(
formData
.
aff_code
?
{
aff_code
:
formData
.
aff_code
}
:
{
}
)
}
)
)
...
...
@@ -722,7 +728,8 @@ 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
,
...(
formData
.
aff_code
?
{
aff_code
:
formData
.
aff_code
}
:
{
}
)
}
)
// Show success toast
...
...
frontend/src/views/user/AffiliateView.vue
0 → 100644
View file @
5b5db885
<
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 md:grid-cols-3"
>
<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>
</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
'
)
}}
</li>
<li>
3.
{{
t
(
'
affiliate.tips.line3
'
)
}}
</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"
>
{{
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-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
)}
`
})
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
>
Prev
1
2
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