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
7229b41f
Unverified
Commit
7229b41f
authored
Feb 03, 2026
by
Wesley Liddick
Committed by
GitHub
Feb 03, 2026
Browse files
Merge pull request #420 from shuike/feat-invitation-code
feat: 增加邀请码注册功能
parents
a09478f3
0ed4a404
Changes
26
Hide whitespace changes
Inline
Side-by-side
frontend/src/stores/app.ts
View file @
7229b41f
...
...
@@ -314,6 +314,7 @@ export const useAppStore = defineStore('app', () => {
email_verify_enabled
:
false
,
promo_code_enabled
:
true
,
password_reset_enabled
:
false
,
invitation_code_enabled
:
false
,
turnstile_enabled
:
false
,
turnstile_site_key
:
''
,
site_name
:
siteName
.
value
,
...
...
frontend/src/types/index.ts
View file @
7229b41f
...
...
@@ -55,6 +55,7 @@ export interface RegisterRequest {
verify_code
?:
string
turnstile_token
?:
string
promo_code
?:
string
invitation_code
?:
string
}
export
interface
SendVerifyCodeRequest
{
...
...
@@ -72,6 +73,7 @@ export interface PublicSettings {
email_verify_enabled
:
boolean
promo_code_enabled
:
boolean
password_reset_enabled
:
boolean
invitation_code_enabled
:
boolean
turnstile_enabled
:
boolean
turnstile_site_key
:
string
site_name
:
string
...
...
@@ -701,7 +703,7 @@ export interface UpdateProxyRequest {
// ==================== Usage & Redeem Types ====================
export
type
RedeemCodeType
=
'
balance
'
|
'
concurrency
'
|
'
subscription
'
export
type
RedeemCodeType
=
'
balance
'
|
'
concurrency
'
|
'
subscription
'
|
'
invitation
'
export
interface
UsageLog
{
id
:
number
...
...
frontend/src/views/admin/RedeemView.vue
View file @
7229b41f
...
...
@@ -213,7 +213,7 @@
<
Select
v
-
model
=
"
generateForm.type
"
:
options
=
"
typeOptions
"
/>
<
/div
>
<!--
余额
/
并发类型
:
显示数值输入
-->
<
div
v
-
if
=
"
generateForm.type !== 'subscription'
"
>
<
div
v
-
if
=
"
generateForm.type !== 'subscription'
&& generateForm.type !== 'invitation'
"
>
<
label
class
=
"
input-label
"
>
{{
generateForm
.
type
===
'
balance
'
...
...
@@ -230,6 +230,12 @@
class
=
"
input
"
/>
<
/div
>
<!--
邀请码类型
:
显示提示信息
-->
<
div
v
-
if
=
"
generateForm.type === 'invitation'
"
class
=
"
rounded-lg bg-blue-50 p-3 dark:bg-blue-900/20
"
>
<
p
class
=
"
text-sm text-blue-700 dark:text-blue-300
"
>
{{
t
(
'
admin.redeem.invitationHint
'
)
}}
<
/p
>
<
/div
>
<!--
订阅类型
:
显示分组选择和有效天数
-->
<
template
v
-
if
=
"
generateForm.type === 'subscription'
"
>
<
div
>
...
...
@@ -387,7 +393,7 @@
<
/template
>
<
script
setup
lang
=
"
ts
"
>
import
{
ref
,
reactive
,
computed
,
onMounted
,
onUnmounted
}
from
'
vue
'
import
{
ref
,
reactive
,
computed
,
onMounted
,
onUnmounted
,
watch
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useAppStore
}
from
'
@/stores/app
'
import
{
useClipboard
}
from
'
@/composables/useClipboard
'
...
...
@@ -499,14 +505,16 @@ const columns = computed<Column[]>(() => [
const
typeOptions
=
computed
(()
=>
[
{
value
:
'
balance
'
,
label
:
t
(
'
admin.redeem.balance
'
)
}
,
{
value
:
'
concurrency
'
,
label
:
t
(
'
admin.redeem.concurrency
'
)
}
,
{
value
:
'
subscription
'
,
label
:
t
(
'
admin.redeem.subscription
'
)
}
{
value
:
'
subscription
'
,
label
:
t
(
'
admin.redeem.subscription
'
)
}
,
{
value
:
'
invitation
'
,
label
:
t
(
'
admin.redeem.invitation
'
)
}
])
const
filterTypeOptions
=
computed
(()
=>
[
{
value
:
''
,
label
:
t
(
'
admin.redeem.allTypes
'
)
}
,
{
value
:
'
balance
'
,
label
:
t
(
'
admin.redeem.balance
'
)
}
,
{
value
:
'
concurrency
'
,
label
:
t
(
'
admin.redeem.concurrency
'
)
}
,
{
value
:
'
subscription
'
,
label
:
t
(
'
admin.redeem.subscription
'
)
}
{
value
:
'
subscription
'
,
label
:
t
(
'
admin.redeem.subscription
'
)
}
,
{
value
:
'
invitation
'
,
label
:
t
(
'
admin.redeem.invitation
'
)
}
])
const
filterStatusOptions
=
computed
(()
=>
[
...
...
@@ -546,6 +554,18 @@ const generateForm = reactive({
validity_days
:
30
}
)
// 监听类型变化,邀请码类型时自动设置 value 为 0
watch
(
()
=>
generateForm
.
type
,
(
newType
)
=>
{
if
(
newType
===
'
invitation
'
)
{
generateForm
.
value
=
0
}
else
if
(
generateForm
.
value
===
0
)
{
generateForm
.
value
=
10
}
}
)
const
loadCodes
=
async
()
=>
{
if
(
abortController
)
{
abortController
.
abort
()
...
...
frontend/src/views/admin/SettingsView.vue
View file @
7229b41f
...
...
@@ -339,6 +339,21 @@
<Toggle
v-model=
"form.promo_code_enabled"
/>
</div>
<!-- Invitation Code -->
<div
class=
"flex items-center justify-between border-t border-gray-100 pt-4 dark:border-dark-700"
>
<div>
<label
class=
"font-medium text-gray-900 dark:text-white"
>
{{
t('admin.settings.registration.invitationCode')
}}
</label>
<p
class=
"text-sm text-gray-500 dark:text-gray-400"
>
{{ t('admin.settings.registration.invitationCodeHint') }}
</p>
</div>
<Toggle
v-model=
"form.invitation_code_enabled"
/>
</div>
<!-- Password Reset - Only show when email verification is enabled -->
<div
v-if=
"form.email_verify_enabled"
...
...
@@ -1115,6 +1130,7 @@ const form = reactive<SettingsForm>({
registration_enabled
:
true
,
email_verify_enabled
:
false
,
promo_code_enabled
:
true
,
invitation_code_enabled
:
false
,
password_reset_enabled
:
false
,
totp_enabled
:
false
,
totp_encryption_key_configured
:
false
,
...
...
@@ -1243,6 +1259,7 @@ async function saveSettings() {
registration_enabled
:
form
.
registration_enabled
,
email_verify_enabled
:
form
.
email_verify_enabled
,
promo_code_enabled
:
form
.
promo_code_enabled
,
invitation_code_enabled
:
form
.
invitation_code_enabled
,
password_reset_enabled
:
form
.
password_reset_enabled
,
totp_enabled
:
form
.
totp_enabled
,
default_balance
:
form
.
default_balance
,
...
...
frontend/src/views/auth/EmailVerifyView.vue
View file @
7229b41f
...
...
@@ -201,6 +201,7 @@ const email = ref<string>('')
const
password
=
ref
<
string
>
(
''
)
const
initialTurnstileToken
=
ref
<
string
>
(
''
)
const
promoCode
=
ref
<
string
>
(
''
)
const
invitationCode
=
ref
<
string
>
(
''
)
const
hasRegisterData
=
ref
<
boolean
>
(
false
)
// Public settings
...
...
@@ -230,6 +231,7 @@ onMounted(async () => {
password
.
value
=
registerData
.
password
||
''
initialTurnstileToken
.
value
=
registerData
.
turnstile_token
||
''
promoCode
.
value
=
registerData
.
promo_code
||
''
invitationCode
.
value
=
registerData
.
invitation_code
||
''
hasRegisterData
.
value
=
!!
(
email
.
value
&&
password
.
value
)
}
catch
{
hasRegisterData
.
value
=
false
...
...
@@ -384,7 +386,8 @@ async function handleVerify(): Promise<void> {
password
:
password
.
value
,
verify_code
:
verifyCode
.
value
.
trim
(),
turnstile_token
:
initialTurnstileToken
.
value
||
undefined
,
promo_code
:
promoCode
.
value
||
undefined
promo_code
:
promoCode
.
value
||
undefined
,
invitation_code
:
invitationCode
.
value
||
undefined
})
// Clear session data
...
...
frontend/src/views/auth/RegisterView.vue
View file @
7229b41f
...
...
@@ -95,6 +95,59 @@
<
/p
>
<
/div
>
<!--
Invitation
Code
Input
(
Required
when
enabled
)
-->
<
div
v
-
if
=
"
invitationCodeEnabled
"
>
<
label
for
=
"
invitation_code
"
class
=
"
input-label
"
>
{{
t
(
'
auth.invitationCodeLabel
'
)
}}
<
/label
>
<
div
class
=
"
relative
"
>
<
div
class
=
"
pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3.5
"
>
<
Icon
name
=
"
key
"
size
=
"
md
"
:
class
=
"
invitationValidation.valid ? 'text-green-500' : 'text-gray-400 dark:text-dark-500'
"
/>
<
/div
>
<
input
id
=
"
invitation_code
"
v
-
model
=
"
formData.invitation_code
"
type
=
"
text
"
:
disabled
=
"
isLoading
"
class
=
"
input pl-11 pr-10
"
:
class
=
"
{
'border-green-500 focus:border-green-500 focus:ring-green-500': invitationValidation.valid,
'border-red-500 focus:border-red-500 focus:ring-red-500': invitationValidation.invalid || errors.invitation_code
}
"
:
placeholder
=
"
t('auth.invitationCodePlaceholder')
"
@
input
=
"
handleInvitationCodeInput
"
/>
<!--
Validation
indicator
-->
<
div
v
-
if
=
"
invitationValidating
"
class
=
"
absolute inset-y-0 right-0 flex items-center pr-3.5
"
>
<
svg
class
=
"
h-4 w-4 animate-spin text-gray-400
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
>
<
circle
class
=
"
opacity-25
"
cx
=
"
12
"
cy
=
"
12
"
r
=
"
10
"
stroke
=
"
currentColor
"
stroke
-
width
=
"
4
"
><
/circle
>
<
path
class
=
"
opacity-75
"
fill
=
"
currentColor
"
d
=
"
M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z
"
><
/path
>
<
/svg
>
<
/div
>
<
div
v
-
else
-
if
=
"
invitationValidation.valid
"
class
=
"
absolute inset-y-0 right-0 flex items-center pr-3.5
"
>
<
Icon
name
=
"
checkCircle
"
size
=
"
md
"
class
=
"
text-green-500
"
/>
<
/div
>
<
div
v
-
else
-
if
=
"
invitationValidation.invalid || errors.invitation_code
"
class
=
"
absolute inset-y-0 right-0 flex items-center pr-3.5
"
>
<
Icon
name
=
"
exclamationCircle
"
size
=
"
md
"
class
=
"
text-red-500
"
/>
<
/div
>
<
/div
>
<!--
Invitation
code
validation
result
-->
<
transition
name
=
"
fade
"
>
<
div
v
-
if
=
"
invitationValidation.valid
"
class
=
"
mt-2 flex items-center gap-2 rounded-lg bg-green-50 px-3 py-2 dark:bg-green-900/20
"
>
<
Icon
name
=
"
checkCircle
"
size
=
"
sm
"
class
=
"
text-green-600 dark:text-green-400
"
/>
<
span
class
=
"
text-sm text-green-700 dark:text-green-400
"
>
{{
t
(
'
auth.invitationCodeValid
'
)
}}
<
/span
>
<
/div
>
<
p
v
-
else
-
if
=
"
invitationValidation.invalid
"
class
=
"
input-error-text
"
>
{{
invitationValidation
.
message
}}
<
/p
>
<
p
v
-
else
-
if
=
"
errors.invitation_code
"
class
=
"
input-error-text
"
>
{{
errors
.
invitation_code
}}
<
/p
>
<
/transition
>
<
/div
>
<!--
Promo
Code
Input
(
Optional
)
-->
<
div
v
-
if
=
"
promoCodeEnabled
"
>
<
label
for
=
"
promo_code
"
class
=
"
input-label
"
>
...
...
@@ -239,7 +292,7 @@ import LinuxDoOAuthSection from '@/components/auth/LinuxDoOAuthSection.vue'
import
Icon
from
'
@/components/icons/Icon.vue
'
import
TurnstileWidget
from
'
@/components/TurnstileWidget.vue
'
import
{
useAuthStore
,
useAppStore
}
from
'
@/stores
'
import
{
getPublicSettings
,
validatePromoCode
}
from
'
@/api/auth
'
import
{
getPublicSettings
,
validatePromoCode
,
validateInvitationCode
}
from
'
@/api/auth
'
const
{
t
}
=
useI18n
()
...
...
@@ -261,6 +314,7 @@ const showPassword = ref<boolean>(false)
const
registrationEnabled
=
ref
<
boolean
>
(
true
)
const
emailVerifyEnabled
=
ref
<
boolean
>
(
false
)
const
promoCodeEnabled
=
ref
<
boolean
>
(
true
)
const
invitationCodeEnabled
=
ref
<
boolean
>
(
false
)
const
turnstileEnabled
=
ref
<
boolean
>
(
false
)
const
turnstileSiteKey
=
ref
<
string
>
(
''
)
const
siteName
=
ref
<
string
>
(
'
Sub2API
'
)
...
...
@@ -280,16 +334,27 @@ const promoValidation = reactive({
}
)
let
promoValidateTimeout
:
ReturnType
<
typeof
setTimeout
>
|
null
=
null
// Invitation code validation
const
invitationValidating
=
ref
<
boolean
>
(
false
)
const
invitationValidation
=
reactive
({
valid
:
false
,
invalid
:
false
,
message
:
''
}
)
let
invitationValidateTimeout
:
ReturnType
<
typeof
setTimeout
>
|
null
=
null
const
formData
=
reactive
({
email
:
''
,
password
:
''
,
promo_code
:
''
promo_code
:
''
,
invitation_code
:
''
}
)
const
errors
=
reactive
({
email
:
''
,
password
:
''
,
turnstile
:
''
turnstile
:
''
,
invitation_code
:
''
}
)
// ==================== Lifecycle ====================
...
...
@@ -300,6 +365,7 @@ onMounted(async () => {
registrationEnabled
.
value
=
settings
.
registration_enabled
emailVerifyEnabled
.
value
=
settings
.
email_verify_enabled
promoCodeEnabled
.
value
=
settings
.
promo_code_enabled
invitationCodeEnabled
.
value
=
settings
.
invitation_code_enabled
turnstileEnabled
.
value
=
settings
.
turnstile_enabled
turnstileSiteKey
.
value
=
settings
.
turnstile_site_key
||
''
siteName
.
value
=
settings
.
site_name
||
'
Sub2API
'
...
...
@@ -325,6 +391,9 @@ onUnmounted(() => {
if
(
promoValidateTimeout
)
{
clearTimeout
(
promoValidateTimeout
)
}
if
(
invitationValidateTimeout
)
{
clearTimeout
(
invitationValidateTimeout
)
}
}
)
// ==================== Promo Code Validation ====================
...
...
@@ -400,6 +469,70 @@ function getPromoErrorMessage(errorCode?: string): string {
}
}
// ==================== Invitation Code Validation ====================
function
handleInvitationCodeInput
():
void
{
const
code
=
formData
.
invitation_code
.
trim
()
// Clear previous validation
invitationValidation
.
valid
=
false
invitationValidation
.
invalid
=
false
invitationValidation
.
message
=
''
errors
.
invitation_code
=
''
if
(
!
code
)
{
return
}
// Debounce validation
if
(
invitationValidateTimeout
)
{
clearTimeout
(
invitationValidateTimeout
)
}
invitationValidateTimeout
=
setTimeout
(()
=>
{
validateInvitationCodeDebounced
(
code
)
}
,
500
)
}
async
function
validateInvitationCodeDebounced
(
code
:
string
):
Promise
<
void
>
{
invitationValidating
.
value
=
true
try
{
const
result
=
await
validateInvitationCode
(
code
)
if
(
result
.
valid
)
{
invitationValidation
.
valid
=
true
invitationValidation
.
invalid
=
false
invitationValidation
.
message
=
''
}
else
{
invitationValidation
.
valid
=
false
invitationValidation
.
invalid
=
true
invitationValidation
.
message
=
getInvitationErrorMessage
(
result
.
error_code
)
}
}
catch
{
invitationValidation
.
valid
=
false
invitationValidation
.
invalid
=
true
invitationValidation
.
message
=
t
(
'
auth.invitationCodeInvalid
'
)
}
finally
{
invitationValidating
.
value
=
false
}
}
function
getInvitationErrorMessage
(
errorCode
?:
string
):
string
{
switch
(
errorCode
)
{
case
'
INVITATION_CODE_NOT_FOUND
'
:
return
t
(
'
auth.invitationCodeInvalid
'
)
case
'
INVITATION_CODE_INVALID
'
:
return
t
(
'
auth.invitationCodeInvalid
'
)
case
'
INVITATION_CODE_USED
'
:
return
t
(
'
auth.invitationCodeInvalid
'
)
case
'
INVITATION_CODE_DISABLED
'
:
return
t
(
'
auth.invitationCodeInvalid
'
)
default
:
return
t
(
'
auth.invitationCodeInvalid
'
)
}
}
// ==================== Turnstile Handlers ====================
function
onTurnstileVerify
(
token
:
string
):
void
{
...
...
@@ -429,6 +562,7 @@ function validateForm(): boolean {
errors
.
email
=
''
errors
.
password
=
''
errors
.
turnstile
=
''
errors
.
invitation_code
=
''
let
isValid
=
true
...
...
@@ -450,6 +584,14 @@ function validateForm(): boolean {
isValid
=
false
}
// Invitation code validation (required when enabled)
if
(
invitationCodeEnabled
.
value
)
{
if
(
!
formData
.
invitation_code
.
trim
())
{
errors
.
invitation_code
=
t
(
'
auth.invitationCodeRequired
'
)
isValid
=
false
}
}
// Turnstile validation
if
(
turnstileEnabled
.
value
&&
!
turnstileToken
.
value
)
{
errors
.
turnstile
=
t
(
'
auth.completeVerification
'
)
...
...
@@ -484,6 +626,30 @@ async function handleRegister(): Promise<void> {
}
}
// Check invitation code validation status (if enabled and code provided)
if
(
invitationCodeEnabled
.
value
)
{
// If still validating, wait
if
(
invitationValidating
.
value
)
{
errorMessage
.
value
=
t
(
'
auth.invitationCodeValidating
'
)
return
}
// If invitation code is invalid, block submission
if
(
invitationValidation
.
invalid
)
{
errorMessage
.
value
=
t
(
'
auth.invitationCodeInvalidCannotRegister
'
)
return
}
// If invitation code is required but not validated yet
if
(
formData
.
invitation_code
.
trim
()
&&
!
invitationValidation
.
valid
)
{
errorMessage
.
value
=
t
(
'
auth.invitationCodeValidating
'
)
// Trigger validation
await
validateInvitationCodeDebounced
(
formData
.
invitation_code
.
trim
())
if
(
!
invitationValidation
.
valid
)
{
errorMessage
.
value
=
t
(
'
auth.invitationCodeInvalidCannotRegister
'
)
return
}
}
}
isLoading
.
value
=
true
try
{
...
...
@@ -496,7 +662,8 @@ async function handleRegister(): Promise<void> {
email
:
formData
.
email
,
password
:
formData
.
password
,
turnstile_token
:
turnstileToken
.
value
,
promo_code
:
formData
.
promo_code
||
undefined
promo_code
:
formData
.
promo_code
||
undefined
,
invitation_code
:
formData
.
invitation_code
||
undefined
}
)
)
...
...
@@ -510,7 +677,8 @@ async function handleRegister(): Promise<void> {
email
:
formData
.
email
,
password
:
formData
.
password
,
turnstile_token
:
turnstileEnabled
.
value
?
turnstileToken
.
value
:
undefined
,
promo_code
:
formData
.
promo_code
||
undefined
promo_code
:
formData
.
promo_code
||
undefined
,
invitation_code
:
formData
.
invitation_code
||
undefined
}
)
// Show success toast
...
...
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