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
e79dbad6
Commit
e79dbad6
authored
Jan 10, 2026
by
yangjianbo
Browse files
Merge branch 'main' into test
parents
6a9cc13e
d1a6d6b1
Changes
83
Hide whitespace changes
Inline
Side-by-side
frontend/src/views/admin/PromoCodesView.vue
0 → 100644
View file @
e79dbad6
<
template
>
<AppLayout>
<TablePageLayout>
<template
#actions
>
<div
class=
"flex justify-end gap-3"
>
<button
@
click=
"loadCodes"
:disabled=
"loading"
class=
"btn btn-secondary"
:title=
"t('common.refresh')"
>
<Icon
name=
"refresh"
size=
"md"
:class=
"loading ? 'animate-spin' : ''"
/>
</button>
<button
@
click=
"showCreateDialog = true"
class=
"btn btn-primary"
>
<Icon
name=
"plus"
size=
"md"
class=
"mr-1"
/>
{{
t
(
'
admin.promo.createCode
'
)
}}
</button>
</div>
</
template
>
<
template
#filters
>
<div
class=
"flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"
>
<div
class=
"max-w-md flex-1"
>
<input
v-model=
"searchQuery"
type=
"text"
:placeholder=
"t('admin.promo.searchCodes')"
class=
"input"
@
input=
"handleSearch"
/>
</div>
<div
class=
"flex gap-2"
>
<Select
v-model=
"filters.status"
:options=
"filterStatusOptions"
class=
"w-36"
@
change=
"loadCodes"
/>
</div>
</div>
</
template
>
<
template
#table
>
<DataTable
:columns=
"columns"
:data=
"codes"
:loading=
"loading"
>
<template
#cell-code
="
{ value }">
<div
class=
"flex items-center space-x-2"
>
<code
class=
"font-mono text-sm text-gray-900 dark:text-gray-100"
>
{{
value
}}
</code>
<button
@
click=
"copyToClipboard(value)"
:class=
"[
'flex items-center transition-colors',
copiedCode === value
? 'text-green-500'
: 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'
]"
:title=
"copiedCode === value ? t('admin.promo.copied') : t('keys.copyToClipboard')"
>
<Icon
v-if=
"copiedCode !== value"
name=
"copy"
size=
"sm"
:stroke-width=
"2"
/>
<svg
v-else
class=
"h-4 w-4"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M5 13l4 4L19 7"
/>
</svg>
</button>
</div>
</
template
>
<
template
#cell-bonus_amount=
"{ value }"
>
<span
class=
"text-sm font-medium text-gray-900 dark:text-white"
>
$
{{
value
.
toFixed
(
2
)
}}
</span>
</
template
>
<
template
#cell-usage=
"{ row }"
>
<span
class=
"text-sm text-gray-600 dark:text-gray-300"
>
{{
row
.
used_count
}}
/
{{
row
.
max_uses
===
0
?
'
∞
'
:
row
.
max_uses
}}
</span>
</
template
>
<
template
#cell-status=
"{ value, row }"
>
<span
:class=
"[
'badge',
getStatusClass(value, row)
]"
>
{{
getStatusLabel
(
value
,
row
)
}}
</span>
</
template
>
<
template
#cell-expires_at=
"{ value }"
>
<span
class=
"text-sm text-gray-500 dark:text-dark-400"
>
{{
value
?
formatDateTime
(
value
)
:
t
(
'
admin.promo.neverExpires
'
)
}}
</span>
</
template
>
<
template
#cell-created_at=
"{ value }"
>
<span
class=
"text-sm text-gray-500 dark:text-dark-400"
>
{{
formatDateTime
(
value
)
}}
</span>
</
template
>
<
template
#cell-actions=
"{ row }"
>
<div
class=
"flex items-center space-x-1"
>
<button
@
click=
"copyRegisterLink(row)"
class=
"flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-green-50 hover:text-green-600 dark:hover:bg-green-900/20 dark:hover:text-green-400"
:title=
"t('admin.promo.copyRegisterLink')"
>
<Icon
name=
"link"
size=
"sm"
/>
</button>
<button
@
click=
"handleViewUsages(row)"
class=
"flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-blue-50 hover:text-blue-600 dark:hover:bg-blue-900/20 dark:hover:text-blue-400"
:title=
"t('admin.promo.viewUsages')"
>
<Icon
name=
"eye"
size=
"sm"
/>
</button>
<button
@
click=
"handleEdit(row)"
class=
"flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700 dark:hover:bg-dark-600 dark:hover:text-gray-300"
:title=
"t('common.edit')"
>
<Icon
name=
"edit"
size=
"sm"
/>
</button>
<button
@
click=
"handleDelete(row)"
class=
"flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400"
:title=
"t('common.delete')"
>
<Icon
name=
"trash"
size=
"sm"
/>
</button>
</div>
</
template
>
</DataTable>
</template>
<
template
#pagination
>
<Pagination
v-if=
"pagination.total > 0"
:page=
"pagination.page"
:total=
"pagination.total"
:page-size=
"pagination.page_size"
@
update:page=
"handlePageChange"
@
update:pageSize=
"handlePageSizeChange"
/>
</
template
>
</TablePageLayout>
<!-- Create Dialog -->
<BaseDialog
:show=
"showCreateDialog"
:title=
"t('admin.promo.createCode')"
width=
"normal"
@
close=
"showCreateDialog = false"
>
<form
id=
"create-promo-form"
@
submit.prevent=
"handleCreate"
class=
"space-y-4"
>
<div>
<label
class=
"input-label"
>
{{ t('admin.promo.code') }}
<span
class=
"ml-1 text-xs font-normal text-gray-400"
>
({{ t('admin.promo.autoGenerate') }})
</span>
</label>
<input
v-model=
"createForm.code"
type=
"text"
class=
"input font-mono uppercase"
:placeholder=
"t('admin.promo.codePlaceholder')"
/>
</div>
<div>
<label
class=
"input-label"
>
{{ t('admin.promo.bonusAmount') }}
</label>
<input
v-model.number=
"createForm.bonus_amount"
type=
"number"
step=
"0.01"
min=
"0"
required
class=
"input"
/>
</div>
<div>
<label
class=
"input-label"
>
{{ t('admin.promo.maxUses') }}
<span
class=
"ml-1 text-xs font-normal text-gray-400"
>
({{ t('admin.promo.zeroUnlimited') }})
</span>
</label>
<input
v-model.number=
"createForm.max_uses"
type=
"number"
min=
"0"
class=
"input"
/>
</div>
<div>
<label
class=
"input-label"
>
{{ t('admin.promo.expiresAt') }}
<span
class=
"ml-1 text-xs font-normal text-gray-400"
>
({{ t('common.optional') }})
</span>
</label>
<input
v-model=
"createForm.expires_at_str"
type=
"datetime-local"
class=
"input"
/>
</div>
<div>
<label
class=
"input-label"
>
{{ t('admin.promo.notes') }}
<span
class=
"ml-1 text-xs font-normal text-gray-400"
>
({{ t('common.optional') }})
</span>
</label>
<textarea
v-model=
"createForm.notes"
rows=
"2"
class=
"input"
:placeholder=
"t('admin.promo.notesPlaceholder')"
></textarea>
</div>
</form>
<
template
#footer
>
<div
class=
"flex justify-end gap-3"
>
<button
type=
"button"
@
click=
"showCreateDialog = false"
class=
"btn btn-secondary"
>
{{
t
(
'
common.cancel
'
)
}}
</button>
<button
type=
"submit"
form=
"create-promo-form"
:disabled=
"creating"
class=
"btn btn-primary"
>
{{
creating
?
t
(
'
common.creating
'
)
:
t
(
'
common.create
'
)
}}
</button>
</div>
</
template
>
</BaseDialog>
<!-- Edit Dialog -->
<BaseDialog
:show=
"showEditDialog"
:title=
"t('admin.promo.editCode')"
width=
"normal"
@
close=
"closeEditDialog"
>
<form
id=
"edit-promo-form"
@
submit.prevent=
"handleUpdate"
class=
"space-y-4"
>
<div>
<label
class=
"input-label"
>
{{ t('admin.promo.code') }}
</label>
<input
v-model=
"editForm.code"
type=
"text"
class=
"input font-mono uppercase"
/>
</div>
<div>
<label
class=
"input-label"
>
{{ t('admin.promo.bonusAmount') }}
</label>
<input
v-model.number=
"editForm.bonus_amount"
type=
"number"
step=
"0.01"
min=
"0"
required
class=
"input"
/>
</div>
<div>
<label
class=
"input-label"
>
{{ t('admin.promo.maxUses') }}
<span
class=
"ml-1 text-xs font-normal text-gray-400"
>
({{ t('admin.promo.zeroUnlimited') }})
</span>
</label>
<input
v-model.number=
"editForm.max_uses"
type=
"number"
min=
"0"
class=
"input"
/>
</div>
<div>
<label
class=
"input-label"
>
{{ t('admin.promo.status') }}
</label>
<Select
v-model=
"editForm.status"
:options=
"statusOptions"
/>
</div>
<div>
<label
class=
"input-label"
>
{{ t('admin.promo.expiresAt') }}
<span
class=
"ml-1 text-xs font-normal text-gray-400"
>
({{ t('common.optional') }})
</span>
</label>
<input
v-model=
"editForm.expires_at_str"
type=
"datetime-local"
class=
"input"
/>
</div>
<div>
<label
class=
"input-label"
>
{{ t('admin.promo.notes') }}
<span
class=
"ml-1 text-xs font-normal text-gray-400"
>
({{ t('common.optional') }})
</span>
</label>
<textarea
v-model=
"editForm.notes"
rows=
"2"
class=
"input"
></textarea>
</div>
</form>
<
template
#footer
>
<div
class=
"flex justify-end gap-3"
>
<button
type=
"button"
@
click=
"closeEditDialog"
class=
"btn btn-secondary"
>
{{
t
(
'
common.cancel
'
)
}}
</button>
<button
type=
"submit"
form=
"edit-promo-form"
:disabled=
"updating"
class=
"btn btn-primary"
>
{{
updating
?
t
(
'
common.saving
'
)
:
t
(
'
common.save
'
)
}}
</button>
</div>
</
template
>
</BaseDialog>
<!-- Usages Dialog -->
<BaseDialog
:show=
"showUsagesDialog"
:title=
"t('admin.promo.usageRecords')"
width=
"wide"
@
close=
"showUsagesDialog = false"
>
<div
v-if=
"usagesLoading"
class=
"flex items-center justify-center py-8"
>
<Icon
name=
"refresh"
size=
"lg"
class=
"animate-spin text-gray-400"
/>
</div>
<div
v-else-if=
"usages.length === 0"
class=
"py-8 text-center text-gray-500 dark:text-gray-400"
>
{{ t('admin.promo.noUsages') }}
</div>
<div
v-else
class=
"space-y-3"
>
<div
v-for=
"usage in usages"
:key=
"usage.id"
class=
"flex items-center justify-between rounded-lg border border-gray-200 p-3 dark:border-dark-600"
>
<div
class=
"flex items-center gap-3"
>
<div
class=
"flex h-8 w-8 items-center justify-center rounded-full bg-green-100 dark:bg-green-900/30"
>
<Icon
name=
"user"
size=
"sm"
class=
"text-green-600 dark:text-green-400"
/>
</div>
<div>
<p
class=
"text-sm font-medium text-gray-900 dark:text-white"
>
{{ usage.user?.email || t('admin.promo.userPrefix', { id: usage.user_id }) }}
</p>
<p
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{ formatDateTime(usage.used_at) }}
</p>
</div>
</div>
<div
class=
"text-right"
>
<span
class=
"text-sm font-medium text-green-600 dark:text-green-400"
>
+${{ usage.bonus_amount.toFixed(2) }}
</span>
</div>
</div>
<!-- Usages Pagination -->
<div
v-if=
"usagesTotal > usagesPageSize"
class=
"mt-4"
>
<Pagination
:page=
"usagesPage"
:total=
"usagesTotal"
:page-size=
"usagesPageSize"
:page-size-options=
"[10, 20, 50]"
@
update:page=
"handleUsagesPageChange"
@
update:page-size=
"(size: number) => { usagesPageSize = size; usagesPage = 1; loadUsages() }"
/>
</div>
</div>
<
template
#footer
>
<div
class=
"flex justify-end"
>
<button
type=
"button"
@
click=
"showUsagesDialog = false"
class=
"btn btn-secondary"
>
{{
t
(
'
common.close
'
)
}}
</button>
</div>
</
template
>
</BaseDialog>
<!-- Delete Confirmation Dialog -->
<ConfirmDialog
:show=
"showDeleteDialog"
:title=
"t('admin.promo.deleteCode')"
:message=
"t('admin.promo.deleteCodeConfirm')"
:confirm-text=
"t('common.delete')"
:cancel-text=
"t('common.cancel')"
danger
@
confirm=
"confirmDelete"
@
cancel=
"showDeleteDialog = false"
/>
</AppLayout>
</template>
<
script
setup
lang=
"ts"
>
import
{
ref
,
reactive
,
computed
,
onMounted
,
onUnmounted
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useAppStore
}
from
'
@/stores/app
'
import
{
useClipboard
}
from
'
@/composables/useClipboard
'
import
{
adminAPI
}
from
'
@/api/admin
'
import
{
formatDateTime
}
from
'
@/utils/format
'
import
type
{
PromoCode
,
PromoCodeUsage
}
from
'
@/types
'
import
type
{
Column
}
from
'
@/components/common/types
'
import
AppLayout
from
'
@/components/layout/AppLayout.vue
'
import
TablePageLayout
from
'
@/components/layout/TablePageLayout.vue
'
import
DataTable
from
'
@/components/common/DataTable.vue
'
import
Pagination
from
'
@/components/common/Pagination.vue
'
import
ConfirmDialog
from
'
@/components/common/ConfirmDialog.vue
'
import
BaseDialog
from
'
@/components/common/BaseDialog.vue
'
import
Select
from
'
@/components/common/Select.vue
'
import
Icon
from
'
@/components/icons/Icon.vue
'
const
{
t
}
=
useI18n
()
const
appStore
=
useAppStore
()
const
{
copyToClipboard
:
clipboardCopy
}
=
useClipboard
()
// State
const
codes
=
ref
<
PromoCode
[]
>
([])
const
loading
=
ref
(
false
)
const
creating
=
ref
(
false
)
const
updating
=
ref
(
false
)
const
searchQuery
=
ref
(
''
)
const
copiedCode
=
ref
<
string
|
null
>
(
null
)
const
filters
=
reactive
({
status
:
''
})
const
pagination
=
reactive
({
page
:
1
,
page_size
:
20
,
total
:
0
})
// Dialogs
const
showCreateDialog
=
ref
(
false
)
const
showEditDialog
=
ref
(
false
)
const
showDeleteDialog
=
ref
(
false
)
const
showUsagesDialog
=
ref
(
false
)
const
editingCode
=
ref
<
PromoCode
|
null
>
(
null
)
const
deletingCode
=
ref
<
PromoCode
|
null
>
(
null
)
// Usages
const
usages
=
ref
<
PromoCodeUsage
[]
>
([])
const
usagesLoading
=
ref
(
false
)
const
currentViewingCode
=
ref
<
PromoCode
|
null
>
(
null
)
const
usagesPage
=
ref
(
1
)
const
usagesPageSize
=
ref
(
20
)
const
usagesTotal
=
ref
(
0
)
// Forms
const
createForm
=
reactive
({
code
:
''
,
bonus_amount
:
1
,
max_uses
:
0
,
expires_at_str
:
''
,
notes
:
''
})
const
editForm
=
reactive
({
code
:
''
,
bonus_amount
:
0
,
max_uses
:
0
,
status
:
'
active
'
as
'
active
'
|
'
disabled
'
,
expires_at_str
:
''
,
notes
:
''
})
// Options
const
filterStatusOptions
=
computed
(()
=>
[
{
value
:
''
,
label
:
t
(
'
admin.promo.allStatus
'
)
},
{
value
:
'
active
'
,
label
:
t
(
'
admin.promo.statusActive
'
)
},
{
value
:
'
disabled
'
,
label
:
t
(
'
admin.promo.statusDisabled
'
)
}
])
const
statusOptions
=
computed
(()
=>
[
{
value
:
'
active
'
,
label
:
t
(
'
admin.promo.statusActive
'
)
},
{
value
:
'
disabled
'
,
label
:
t
(
'
admin.promo.statusDisabled
'
)
}
])
const
columns
=
computed
<
Column
[]
>
(()
=>
[
{
key
:
'
code
'
,
label
:
t
(
'
admin.promo.columns.code
'
)
},
{
key
:
'
bonus_amount
'
,
label
:
t
(
'
admin.promo.columns.bonusAmount
'
),
sortable
:
true
},
{
key
:
'
usage
'
,
label
:
t
(
'
admin.promo.columns.usage
'
)
},
{
key
:
'
status
'
,
label
:
t
(
'
admin.promo.columns.status
'
),
sortable
:
true
},
{
key
:
'
expires_at
'
,
label
:
t
(
'
admin.promo.columns.expiresAt
'
),
sortable
:
true
},
{
key
:
'
created_at
'
,
label
:
t
(
'
admin.promo.columns.createdAt
'
),
sortable
:
true
},
{
key
:
'
actions
'
,
label
:
t
(
'
admin.promo.columns.actions
'
)
}
])
// Helpers
const
getStatusClass
=
(
status
:
string
,
row
:
PromoCode
)
=>
{
if
(
row
.
expires_at
&&
new
Date
(
row
.
expires_at
)
<
new
Date
())
{
return
'
badge-danger
'
}
if
(
row
.
max_uses
>
0
&&
row
.
used_count
>=
row
.
max_uses
)
{
return
'
badge-gray
'
}
return
status
===
'
active
'
?
'
badge-success
'
:
'
badge-gray
'
}
const
getStatusLabel
=
(
status
:
string
,
row
:
PromoCode
)
=>
{
if
(
row
.
expires_at
&&
new
Date
(
row
.
expires_at
)
<
new
Date
())
{
return
t
(
'
admin.promo.statusExpired
'
)
}
if
(
row
.
max_uses
>
0
&&
row
.
used_count
>=
row
.
max_uses
)
{
return
t
(
'
admin.promo.statusMaxUsed
'
)
}
return
status
===
'
active
'
?
t
(
'
admin.promo.statusActive
'
)
:
t
(
'
admin.promo.statusDisabled
'
)
}
// API calls
let
abortController
:
AbortController
|
null
=
null
const
loadCodes
=
async
()
=>
{
if
(
abortController
)
{
abortController
.
abort
()
}
const
currentController
=
new
AbortController
()
abortController
=
currentController
loading
.
value
=
true
try
{
const
response
=
await
adminAPI
.
promo
.
list
(
pagination
.
page
,
pagination
.
page_size
,
{
status
:
filters
.
status
||
undefined
,
search
:
searchQuery
.
value
||
undefined
}
)
if
(
currentController
.
signal
.
aborted
)
return
codes
.
value
=
response
.
items
pagination
.
total
=
response
.
total
}
catch
(
error
:
any
)
{
if
(
currentController
.
signal
.
aborted
||
error
?.
name
===
'
AbortError
'
)
return
appStore
.
showError
(
t
(
'
admin.promo.failedToLoad
'
))
console
.
error
(
'
Error loading promo codes:
'
,
error
)
}
finally
{
if
(
abortController
===
currentController
&&
!
currentController
.
signal
.
aborted
)
{
loading
.
value
=
false
abortController
=
null
}
}
}
let
searchTimeout
:
ReturnType
<
typeof
setTimeout
>
const
handleSearch
=
()
=>
{
clearTimeout
(
searchTimeout
)
searchTimeout
=
setTimeout
(()
=>
{
pagination
.
page
=
1
loadCodes
()
},
300
)
}
const
handlePageChange
=
(
page
:
number
)
=>
{
pagination
.
page
=
page
loadCodes
()
}
const
handlePageSizeChange
=
(
pageSize
:
number
)
=>
{
pagination
.
page_size
=
pageSize
pagination
.
page
=
1
loadCodes
()
}
const
copyToClipboard
=
async
(
text
:
string
)
=>
{
const
success
=
await
clipboardCopy
(
text
,
t
(
'
admin.promo.copied
'
))
if
(
success
)
{
copiedCode
.
value
=
text
setTimeout
(()
=>
{
copiedCode
.
value
=
null
},
2000
)
}
}
// Create
const
handleCreate
=
async
()
=>
{
creating
.
value
=
true
try
{
await
adminAPI
.
promo
.
create
({
code
:
createForm
.
code
||
undefined
,
bonus_amount
:
createForm
.
bonus_amount
,
max_uses
:
createForm
.
max_uses
,
expires_at
:
createForm
.
expires_at_str
?
Math
.
floor
(
new
Date
(
createForm
.
expires_at_str
).
getTime
()
/
1000
)
:
undefined
,
notes
:
createForm
.
notes
||
undefined
})
appStore
.
showSuccess
(
t
(
'
admin.promo.codeCreated
'
))
showCreateDialog
.
value
=
false
resetCreateForm
()
loadCodes
()
}
catch
(
error
:
any
)
{
appStore
.
showError
(
error
.
response
?.
data
?.
detail
||
t
(
'
admin.promo.failedToCreate
'
))
}
finally
{
creating
.
value
=
false
}
}
const
resetCreateForm
=
()
=>
{
createForm
.
code
=
''
createForm
.
bonus_amount
=
1
createForm
.
max_uses
=
0
createForm
.
expires_at_str
=
''
createForm
.
notes
=
''
}
// Edit
const
handleEdit
=
(
code
:
PromoCode
)
=>
{
editingCode
.
value
=
code
editForm
.
code
=
code
.
code
editForm
.
bonus_amount
=
code
.
bonus_amount
editForm
.
max_uses
=
code
.
max_uses
editForm
.
status
=
code
.
status
editForm
.
expires_at_str
=
code
.
expires_at
?
new
Date
(
code
.
expires_at
).
toISOString
().
slice
(
0
,
16
)
:
''
editForm
.
notes
=
code
.
notes
||
''
showEditDialog
.
value
=
true
}
const
closeEditDialog
=
()
=>
{
showEditDialog
.
value
=
false
editingCode
.
value
=
null
}
const
handleUpdate
=
async
()
=>
{
if
(
!
editingCode
.
value
)
return
updating
.
value
=
true
try
{
await
adminAPI
.
promo
.
update
(
editingCode
.
value
.
id
,
{
code
:
editForm
.
code
,
bonus_amount
:
editForm
.
bonus_amount
,
max_uses
:
editForm
.
max_uses
,
status
:
editForm
.
status
,
expires_at
:
editForm
.
expires_at_str
?
Math
.
floor
(
new
Date
(
editForm
.
expires_at_str
).
getTime
()
/
1000
)
:
0
,
notes
:
editForm
.
notes
})
appStore
.
showSuccess
(
t
(
'
admin.promo.codeUpdated
'
))
closeEditDialog
()
loadCodes
()
}
catch
(
error
:
any
)
{
appStore
.
showError
(
error
.
response
?.
data
?.
detail
||
t
(
'
admin.promo.failedToUpdate
'
))
}
finally
{
updating
.
value
=
false
}
}
// Copy Register Link
const
copyRegisterLink
=
async
(
code
:
PromoCode
)
=>
{
const
baseUrl
=
window
.
location
.
origin
const
registerLink
=
`
${
baseUrl
}
/register?promo=
${
encodeURIComponent
(
code
.
code
)}
`
try
{
await
navigator
.
clipboard
.
writeText
(
registerLink
)
appStore
.
showSuccess
(
t
(
'
admin.promo.registerLinkCopied
'
))
}
catch
(
error
)
{
// Fallback for older browsers
const
textArea
=
document
.
createElement
(
'
textarea
'
)
textArea
.
value
=
registerLink
document
.
body
.
appendChild
(
textArea
)
textArea
.
select
()
document
.
execCommand
(
'
copy
'
)
document
.
body
.
removeChild
(
textArea
)
appStore
.
showSuccess
(
t
(
'
admin.promo.registerLinkCopied
'
))
}
}
// Delete
const
handleDelete
=
(
code
:
PromoCode
)
=>
{
deletingCode
.
value
=
code
showDeleteDialog
.
value
=
true
}
const
confirmDelete
=
async
()
=>
{
if
(
!
deletingCode
.
value
)
return
try
{
await
adminAPI
.
promo
.
delete
(
deletingCode
.
value
.
id
)
appStore
.
showSuccess
(
t
(
'
admin.promo.codeDeleted
'
))
showDeleteDialog
.
value
=
false
deletingCode
.
value
=
null
loadCodes
()
}
catch
(
error
:
any
)
{
appStore
.
showError
(
error
.
response
?.
data
?.
detail
||
t
(
'
admin.promo.failedToDelete
'
))
}
}
// View Usages
const
handleViewUsages
=
async
(
code
:
PromoCode
)
=>
{
currentViewingCode
.
value
=
code
showUsagesDialog
.
value
=
true
usagesPage
.
value
=
1
await
loadUsages
()
}
const
loadUsages
=
async
()
=>
{
if
(
!
currentViewingCode
.
value
)
return
usagesLoading
.
value
=
true
usages
.
value
=
[]
try
{
const
response
=
await
adminAPI
.
promo
.
getUsages
(
currentViewingCode
.
value
.
id
,
usagesPage
.
value
,
usagesPageSize
.
value
)
usages
.
value
=
response
.
items
usagesTotal
.
value
=
response
.
total
}
catch
(
error
:
any
)
{
appStore
.
showError
(
error
.
response
?.
data
?.
detail
||
t
(
'
admin.promo.failedToLoadUsages
'
))
}
finally
{
usagesLoading
.
value
=
false
}
}
const
handleUsagesPageChange
=
(
page
:
number
)
=>
{
usagesPage
.
value
=
page
loadUsages
()
}
onMounted
(()
=>
{
loadCodes
()
})
onUnmounted
(()
=>
{
clearTimeout
(
searchTimeout
)
abortController
?.
abort
()
})
</
script
>
frontend/src/views/auth/EmailVerifyView.vue
View file @
e79dbad6
...
...
@@ -200,6 +200,7 @@ let countdownTimer: ReturnType<typeof setInterval> | null = null
const
email
=
ref
<
string
>
(
''
)
const
password
=
ref
<
string
>
(
''
)
const
initialTurnstileToken
=
ref
<
string
>
(
''
)
const
promoCode
=
ref
<
string
>
(
''
)
const
hasRegisterData
=
ref
<
boolean
>
(
false
)
// Public settings
...
...
@@ -228,6 +229,7 @@ onMounted(async () => {
email
.
value
=
registerData
.
email
||
''
password
.
value
=
registerData
.
password
||
''
initialTurnstileToken
.
value
=
registerData
.
turnstile_token
||
''
promoCode
.
value
=
registerData
.
promo_code
||
''
hasRegisterData
.
value
=
!!
(
email
.
value
&&
password
.
value
)
}
catch
{
hasRegisterData
.
value
=
false
...
...
@@ -381,7 +383,8 @@ async function handleVerify(): Promise<void> {
email
:
email
.
value
,
password
:
password
.
value
,
verify_code
:
verifyCode
.
value
.
trim
(),
turnstile_token
:
initialTurnstileToken
.
value
||
undefined
turnstile_token
:
initialTurnstileToken
.
value
||
undefined
,
promo_code
:
promoCode
.
value
||
undefined
})
// Clear session data
...
...
frontend/src/views/auth/RegisterView.vue
View file @
e79dbad6
...
...
@@ -95,6 +95,57 @@
<
/p
>
<
/div
>
<!--
Promo
Code
Input
(
Optional
)
-->
<
div
>
<
label
for
=
"
promo_code
"
class
=
"
input-label
"
>
{{
t
(
'
auth.promoCodeLabel
'
)
}}
<
span
class
=
"
ml-1 text-xs font-normal text-gray-400 dark:text-dark-500
"
>
({{
t
(
'
common.optional
'
)
}}
)
<
/span
>
<
/label
>
<
div
class
=
"
relative
"
>
<
div
class
=
"
pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3.5
"
>
<
Icon
name
=
"
gift
"
size
=
"
md
"
:
class
=
"
promoValidation.valid ? 'text-green-500' : 'text-gray-400 dark:text-dark-500'
"
/>
<
/div
>
<
input
id
=
"
promo_code
"
v
-
model
=
"
formData.promo_code
"
type
=
"
text
"
:
disabled
=
"
isLoading
"
class
=
"
input pl-11 pr-10
"
:
class
=
"
{
'border-green-500 focus:border-green-500 focus:ring-green-500': promoValidation.valid,
'border-red-500 focus:border-red-500 focus:ring-red-500': promoValidation.invalid
}
"
:
placeholder
=
"
t('auth.promoCodePlaceholder')
"
@
input
=
"
handlePromoCodeInput
"
/>
<!--
Validation
indicator
-->
<
div
v
-
if
=
"
promoValidating
"
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
=
"
promoValidation.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
=
"
promoValidation.invalid
"
class
=
"
absolute inset-y-0 right-0 flex items-center pr-3.5
"
>
<
Icon
name
=
"
exclamationCircle
"
size
=
"
md
"
class
=
"
text-red-500
"
/>
<
/div
>
<
/div
>
<!--
Promo
code
validation
result
-->
<
transition
name
=
"
fade
"
>
<
div
v
-
if
=
"
promoValidation.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
=
"
gift
"
size
=
"
sm
"
class
=
"
text-green-600 dark:text-green-400
"
/>
<
span
class
=
"
text-sm text-green-700 dark:text-green-400
"
>
{{
t
(
'
auth.promoCodeValid
'
,
{
amount
:
promoValidation
.
bonusAmount
?.
toFixed
(
2
)
}
)
}}
<
/span
>
<
/div
>
<
p
v
-
else
-
if
=
"
promoValidation.invalid
"
class
=
"
input-error-text
"
>
{{
promoValidation
.
message
}}
<
/p
>
<
/transition
>
<
/div
>
<!--
Turnstile
Widget
-->
<
div
v
-
if
=
"
turnstileEnabled && turnstileSiteKey
"
>
<
TurnstileWidget
...
...
@@ -180,21 +231,22 @@
<
/template
>
<
script
setup
lang
=
"
ts
"
>
import
{
ref
,
reactive
,
onMounted
}
from
'
vue
'
import
{
useRouter
}
from
'
vue-router
'
import
{
ref
,
reactive
,
onMounted
,
onUnmounted
}
from
'
vue
'
import
{
useRouter
,
useRoute
}
from
'
vue-router
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
AuthLayout
}
from
'
@/components/layout
'
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
}
from
'
@/api/auth
'
import
{
getPublicSettings
,
validatePromoCode
}
from
'
@/api/auth
'
const
{
t
}
=
useI18n
()
// ==================== Router & Stores ====================
const
router
=
useRouter
()
const
route
=
useRoute
()
const
authStore
=
useAuthStore
()
const
appStore
=
useAppStore
()
...
...
@@ -217,9 +269,20 @@ const linuxdoOAuthEnabled = ref<boolean>(false)
const
turnstileRef
=
ref
<
InstanceType
<
typeof
TurnstileWidget
>
|
null
>
(
null
)
const
turnstileToken
=
ref
<
string
>
(
''
)
// Promo code validation
const
promoValidating
=
ref
<
boolean
>
(
false
)
const
promoValidation
=
reactive
({
valid
:
false
,
invalid
:
false
,
bonusAmount
:
null
as
number
|
null
,
message
:
''
}
)
let
promoValidateTimeout
:
ReturnType
<
typeof
setTimeout
>
|
null
=
null
const
formData
=
reactive
({
email
:
''
,
password
:
''
password
:
''
,
promo_code
:
''
}
)
const
errors
=
reactive
({
...
...
@@ -231,6 +294,14 @@ const errors = reactive({
// ==================== Lifecycle ====================
onMounted
(
async
()
=>
{
// Read promo code from URL parameter
const
promoParam
=
route
.
query
.
promo
as
string
if
(
promoParam
)
{
formData
.
promo_code
=
promoParam
// Validate the promo code from URL
await
validatePromoCodeDebounced
(
promoParam
)
}
try
{
const
settings
=
await
getPublicSettings
()
registrationEnabled
.
value
=
settings
.
registration_enabled
...
...
@@ -246,6 +317,85 @@ onMounted(async () => {
}
}
)
onUnmounted
(()
=>
{
if
(
promoValidateTimeout
)
{
clearTimeout
(
promoValidateTimeout
)
}
}
)
// ==================== Promo Code Validation ====================
function
handlePromoCodeInput
():
void
{
const
code
=
formData
.
promo_code
.
trim
()
// Clear previous validation
promoValidation
.
valid
=
false
promoValidation
.
invalid
=
false
promoValidation
.
bonusAmount
=
null
promoValidation
.
message
=
''
if
(
!
code
)
{
promoValidating
.
value
=
false
return
}
// Debounce validation
if
(
promoValidateTimeout
)
{
clearTimeout
(
promoValidateTimeout
)
}
promoValidateTimeout
=
setTimeout
(()
=>
{
validatePromoCodeDebounced
(
code
)
}
,
500
)
}
async
function
validatePromoCodeDebounced
(
code
:
string
):
Promise
<
void
>
{
if
(
!
code
.
trim
())
return
promoValidating
.
value
=
true
try
{
const
result
=
await
validatePromoCode
(
code
)
if
(
result
.
valid
)
{
promoValidation
.
valid
=
true
promoValidation
.
invalid
=
false
promoValidation
.
bonusAmount
=
result
.
bonus_amount
||
0
promoValidation
.
message
=
''
}
else
{
promoValidation
.
valid
=
false
promoValidation
.
invalid
=
true
promoValidation
.
bonusAmount
=
null
// 根据错误码显示对应的翻译
promoValidation
.
message
=
getPromoErrorMessage
(
result
.
error_code
)
}
}
catch
(
error
)
{
console
.
error
(
'
Failed to validate promo code:
'
,
error
)
promoValidation
.
valid
=
false
promoValidation
.
invalid
=
true
promoValidation
.
message
=
t
(
'
auth.promoCodeInvalid
'
)
}
finally
{
promoValidating
.
value
=
false
}
}
function
getPromoErrorMessage
(
errorCode
?:
string
):
string
{
switch
(
errorCode
)
{
case
'
PROMO_CODE_NOT_FOUND
'
:
return
t
(
'
auth.promoCodeNotFound
'
)
case
'
PROMO_CODE_EXPIRED
'
:
return
t
(
'
auth.promoCodeExpired
'
)
case
'
PROMO_CODE_DISABLED
'
:
return
t
(
'
auth.promoCodeDisabled
'
)
case
'
PROMO_CODE_MAX_USED
'
:
return
t
(
'
auth.promoCodeMaxUsed
'
)
case
'
PROMO_CODE_ALREADY_USED
'
:
return
t
(
'
auth.promoCodeAlreadyUsed
'
)
default
:
return
t
(
'
auth.promoCodeInvalid
'
)
}
}
// ==================== Turnstile Handlers ====================
function
onTurnstileVerify
(
token
:
string
):
void
{
...
...
@@ -316,6 +466,20 @@ async function handleRegister(): Promise<void> {
return
}
// Check promo code validation status
if
(
formData
.
promo_code
.
trim
())
{
// If promo code is being validated, wait
if
(
promoValidating
.
value
)
{
errorMessage
.
value
=
t
(
'
auth.promoCodeValidating
'
)
return
}
// If promo code is invalid, block submission
if
(
promoValidation
.
invalid
)
{
errorMessage
.
value
=
t
(
'
auth.promoCodeInvalidCannotRegister
'
)
return
}
}
isLoading
.
value
=
true
try
{
...
...
@@ -327,7 +491,8 @@ async function handleRegister(): Promise<void> {
JSON
.
stringify
({
email
:
formData
.
email
,
password
:
formData
.
password
,
turnstile_token
:
turnstileToken
.
value
turnstile_token
:
turnstileToken
.
value
,
promo_code
:
formData
.
promo_code
||
undefined
}
)
)
...
...
@@ -340,7 +505,8 @@ async function handleRegister(): Promise<void> {
await
authStore
.
register
({
email
:
formData
.
email
,
password
:
formData
.
password
,
turnstile_token
:
turnstileEnabled
.
value
?
turnstileToken
.
value
:
undefined
turnstile_token
:
turnstileEnabled
.
value
?
turnstileToken
.
value
:
undefined
,
promo_code
:
formData
.
promo_code
||
undefined
}
)
// Show success toast
...
...
Prev
1
2
3
4
5
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