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
f03de00c
Commit
f03de00c
authored
Apr 24, 2026
by
VpSanta33
Browse files
feat: add affiliate invite rebate flow and admin rebate-rate setting
parent
d162604f
Changes
33
Show whitespace changes
Inline
Side-by-side
backend/migrations/130_add_user_affiliates.sql
0 → 100644
View file @
f03de00c
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 @
f03de00c
-- 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 @
f03de00c
...
...
@@ -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 @
f03de00c
...
...
@@ -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 @
f03de00c
...
...
@@ -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 @
f03de00c
...
...
@@ -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 @
f03de00c
...
...
@@ -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 @
f03de00c
...
...
@@ -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 @
f03de00c
...
...
@@ -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 @
f03de00c
...
...
@@ -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 @
f03de00c
...
...
@@ -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 @
f03de00c
...
...
@@ -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 @
f03de00c
<
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