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
6c86501d
Commit
6c86501d
authored
Jan 29, 2026
by
shuike
Browse files
feat: 增加邀请码注册功能
parent
0ab68aa9
Changes
26
Show whitespace changes
Inline
Side-by-side
frontend/src/stores/app.ts
View file @
6c86501d
...
...
@@ -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 @
6c86501d
...
...
@@ -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 @
6c86501d
...
...
@@ -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 @
6c86501d
...
...
@@ -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 @
6c86501d
...
...
@@ -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 @
6c86501d
...
...
@@ -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