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
1a641392
Commit
1a641392
authored
Jan 10, 2026
by
cyhhao
Browse files
Merge up/main
parents
36b817d0
24d19a5f
Changes
174
Hide whitespace changes
Inline
Side-by-side
frontend/src/views/admin/AccountsView.vue
View file @
1a641392
...
...
@@ -7,7 +7,7 @@
v-model:searchQuery=
"params.search"
:filters=
"params"
@
update:filters=
"(newFilters) => Object.assign(params, newFilters)"
@
change=
"
r
eload"
@
change=
"
debouncedR
eload"
@
update:searchQuery=
"debouncedReload"
/>
<AccountTableActions
...
...
@@ -19,7 +19,7 @@
</div>
</
template
>
<
template
#table
>
<AccountBulkActionsBar
:selected-ids=
"selIds"
@
delete=
"handleBulkDelete"
@
edit=
"showBulkEdit = true"
@
clear=
"selIds = []"
@
select-page=
"selectPage"
/>
<AccountBulkActionsBar
:selected-ids=
"selIds"
@
delete=
"handleBulkDelete"
@
edit=
"showBulkEdit = true"
@
clear=
"selIds = []"
@
select-page=
"selectPage"
@
toggle-schedulable=
"handleBulkToggleSchedulable"
/>
<DataTable
:columns=
"cols"
:data=
"accounts"
:loading=
"loading"
>
<template
#cell-select
="
{ row }">
<input
type=
"checkbox"
:checked=
"selIds.includes(row.id)"
@
change=
"toggleSel(row.id)"
class=
"rounded border-gray-300 text-primary-600 focus:ring-primary-500"
/>
...
...
@@ -107,7 +107,7 @@
</
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"
/></
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>
<CreateAccountModal
:show=
"showCreate"
:proxies=
"proxies"
:groups=
"groups"
@
close=
"showCreate = false"
@
created=
"reload"
/>
<EditAccountModal
:show=
"showEdit"
:account=
"edAcc"
:proxies=
"proxies"
:groups=
"groups"
@
close=
"showEdit = false"
@
updated=
"load"
/>
...
...
@@ -175,7 +175,7 @@ const statsAcc = ref<Account | null>(null)
const
togglingSchedulable
=
ref
<
number
|
null
>
(
null
)
const
menu
=
reactive
<
{
show
:
boolean
,
acc
:
Account
|
null
,
pos
:{
top
:
number
,
left
:
number
}
|
null
}
>
({
show
:
false
,
acc
:
null
,
pos
:
null
})
const
{
items
:
accounts
,
loading
,
params
,
pagination
,
load
,
reload
,
debouncedReload
,
handlePageChange
}
=
useTableLoader
<
Account
,
any
>
({
const
{
items
:
accounts
,
loading
,
params
,
pagination
,
load
,
reload
,
debouncedReload
,
handlePageChange
,
handlePageSizeChange
}
=
useTableLoader
<
Account
,
any
>
({
fetchFn
:
adminAPI
.
accounts
.
list
,
initialParams
:
{
platform
:
''
,
type
:
''
,
status
:
''
,
search
:
''
}
})
...
...
@@ -209,6 +209,21 @@ const openMenu = (a: Account, e: MouseEvent) => { menu.acc = a; menu.pos = { top
const
toggleSel
=
(
id
:
number
)
=>
{
const
i
=
selIds
.
value
.
indexOf
(
id
);
if
(
i
===
-
1
)
selIds
.
value
.
push
(
id
);
else
selIds
.
value
.
splice
(
i
,
1
)
}
const
selectPage
=
()
=>
{
selIds
.
value
=
[...
new
Set
([...
selIds
.
value
,
...
accounts
.
value
.
map
(
a
=>
a
.
id
)])]
}
const
handleBulkDelete
=
async
()
=>
{
if
(
!
confirm
(
t
(
'
common.confirm
'
)))
return
;
try
{
await
Promise
.
all
(
selIds
.
value
.
map
(
id
=>
adminAPI
.
accounts
.
delete
(
id
)));
selIds
.
value
=
[];
reload
()
}
catch
(
error
)
{
console
.
error
(
'
Failed to bulk delete accounts:
'
,
error
)
}
}
const
handleBulkToggleSchedulable
=
async
(
schedulable
:
boolean
)
=>
{
const
count
=
selIds
.
value
.
length
try
{
const
result
=
await
adminAPI
.
accounts
.
bulkUpdate
(
selIds
.
value
,
{
schedulable
});
const
message
=
schedulable
?
t
(
'
admin.accounts.bulkSchedulableEnabled
'
,
{
count
:
result
.
success
||
count
})
:
t
(
'
admin.accounts.bulkSchedulableDisabled
'
,
{
count
:
result
.
success
||
count
});
appStore
.
showSuccess
(
message
);
selIds
.
value
=
[];
reload
()
}
catch
(
error
)
{
console
.
error
(
'
Failed to bulk toggle schedulable:
'
,
error
);
appStore
.
showError
(
t
(
'
common.error
'
))
}
}
const
handleBulkUpdated
=
()
=>
{
showBulkEdit
.
value
=
false
;
selIds
.
value
=
[];
reload
()
}
const
closeTestModal
=
()
=>
{
showTest
.
value
=
false
;
testingAcc
.
value
=
null
}
const
closeStatsModal
=
()
=>
{
showStats
.
value
=
false
;
statsAcc
.
value
=
null
}
...
...
frontend/src/views/admin/GroupsView.vue
View file @
1a641392
...
...
@@ -16,6 +16,7 @@
type=
"text"
:placeholder=
"t('admin.groups.searchGroups')"
class=
"input pl-10"
@
input=
"handleSearch"
/>
</div>
<Select
...
...
@@ -64,7 +65,7 @@
</
template
>
<
template
#table
>
<DataTable
:columns=
"columns"
:data=
"
displayedG
roups"
:loading=
"loading"
>
<DataTable
:columns=
"columns"
:data=
"
g
roups"
:loading=
"loading"
>
<template
#cell-name
="
{ value }">
<span
class=
"font-medium text-gray-900 dark:text-white"
>
{{
value
}}
</span>
</
template
>
...
...
@@ -932,16 +933,6 @@ const pagination = reactive({
let
abortController
:
AbortController
|
null
=
null
const
displayedGroups
=
computed
(()
=>
{
const
q
=
searchQuery
.
value
.
trim
().
toLowerCase
()
if
(
!
q
)
return
groups
.
value
return
groups
.
value
.
filter
((
group
)
=>
{
const
name
=
group
.
name
?.
toLowerCase
?.()
??
''
const
description
=
group
.
description
?.
toLowerCase
?.()
??
''
return
name
.
includes
(
q
)
||
description
.
includes
(
q
)
}
)
}
)
const
showCreateModal
=
ref
(
false
)
const
showEditModal
=
ref
(
false
)
const
showDeleteDialog
=
ref
(
false
)
...
...
@@ -1011,7 +1002,8 @@ const loadGroups = async () => {
const
response
=
await
adminAPI
.
groups
.
list
(
pagination
.
page
,
pagination
.
page_size
,
{
platform
:
(
filters
.
platform
as
GroupPlatform
)
||
undefined
,
status
:
filters
.
status
as
any
,
is_exclusive
:
filters
.
is_exclusive
?
filters
.
is_exclusive
===
'
true
'
:
undefined
is_exclusive
:
filters
.
is_exclusive
?
filters
.
is_exclusive
===
'
true
'
:
undefined
,
search
:
searchQuery
.
value
.
trim
()
||
undefined
}
,
{
signal
}
)
if
(
signal
.
aborted
)
return
groups
.
value
=
response
.
items
...
...
@@ -1030,6 +1022,15 @@ const loadGroups = async () => {
}
}
let
searchTimeout
:
ReturnType
<
typeof
setTimeout
>
const
handleSearch
=
()
=>
{
clearTimeout
(
searchTimeout
)
searchTimeout
=
setTimeout
(()
=>
{
pagination
.
page
=
1
loadGroups
()
}
,
300
)
}
const
handlePageChange
=
(
page
:
number
)
=>
{
pagination
.
page
=
page
loadGroups
()
...
...
frontend/src/views/admin/PromoCodesView.vue
0 → 100644
View file @
1a641392
<
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/admin/ProxiesView.vue
View file @
1a641392
...
...
@@ -519,7 +519,7 @@
<
/template
>
<
script
setup
lang
=
"
ts
"
>
import
{
ref
,
reactive
,
computed
,
onMounted
}
from
'
vue
'
import
{
ref
,
reactive
,
computed
,
onMounted
,
onUnmounted
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useAppStore
}
from
'
@/stores/app
'
import
{
adminAPI
}
from
'
@/api/admin
'
...
...
@@ -942,4 +942,9 @@ const confirmDelete = async () => {
onMounted
(()
=>
{
loadProxies
()
}
)
onUnmounted
(()
=>
{
clearTimeout
(
searchTimeout
)
abortController
?.
abort
()
}
)
<
/script
>
frontend/src/views/admin/RedeemView.vue
View file @
1a641392
...
...
@@ -364,7 +364,7 @@
<
/template
>
<
script
setup
lang
=
"
ts
"
>
import
{
ref
,
reactive
,
computed
,
onMounted
}
from
'
vue
'
import
{
ref
,
reactive
,
computed
,
onMounted
,
onUnmounted
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useAppStore
}
from
'
@/stores/app
'
import
{
useClipboard
}
from
'
@/composables/useClipboard
'
...
...
@@ -693,4 +693,9 @@ onMounted(() => {
loadCodes
()
loadSubscriptionGroups
()
}
)
onUnmounted
(()
=>
{
clearTimeout
(
searchTimeout
)
abortController
?.
abort
()
}
)
<
/script
>
frontend/src/views/admin/SettingsView.vue
View file @
1a641392
...
...
@@ -261,6 +261,106 @@
</div>
</div>
<!-- LinuxDo Connect OAuth 登录 -->
<div
class=
"card"
>
<div
class=
"border-b border-gray-100 px-6 py-4 dark:border-dark-700"
>
<h2
class=
"text-lg font-semibold text-gray-900 dark:text-white"
>
{{
t
(
'
admin.settings.linuxdo.title
'
)
}}
</h2>
<p
class=
"mt-1 text-sm text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.settings.linuxdo.description
'
)
}}
</p>
</div>
<div
class=
"space-y-5 p-6"
>
<div
class=
"flex items-center justify-between"
>
<div>
<label
class=
"font-medium text-gray-900 dark:text-white"
>
{{
t
(
'
admin.settings.linuxdo.enable
'
)
}}
</label>
<p
class=
"text-sm text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.settings.linuxdo.enableHint
'
)
}}
</p>
</div>
<Toggle
v-model=
"form.linuxdo_connect_enabled"
/>
</div>
<div
v-if=
"form.linuxdo_connect_enabled"
class=
"border-t border-gray-100 pt-4 dark:border-dark-700"
>
<div
class=
"grid grid-cols-1 gap-6"
>
<div>
<label
class=
"mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{
t
(
'
admin.settings.linuxdo.clientId
'
)
}}
</label>
<input
v-model=
"form.linuxdo_connect_client_id"
type=
"text"
class=
"input font-mono text-sm"
:placeholder=
"t('admin.settings.linuxdo.clientIdPlaceholder')"
/>
<p
class=
"mt-1.5 text-xs text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.settings.linuxdo.clientIdHint
'
)
}}
</p>
</div>
<div>
<label
class=
"mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{
t
(
'
admin.settings.linuxdo.clientSecret
'
)
}}
</label>
<input
v-model=
"form.linuxdo_connect_client_secret"
type=
"password"
class=
"input font-mono text-sm"
:placeholder=
"
form.linuxdo_connect_client_secret_configured
? t('admin.settings.linuxdo.clientSecretConfiguredPlaceholder')
: t('admin.settings.linuxdo.clientSecretPlaceholder')
"
/>
<p
class=
"mt-1.5 text-xs text-gray-500 dark:text-gray-400"
>
{{
form
.
linuxdo_connect_client_secret_configured
?
t
(
'
admin.settings.linuxdo.clientSecretConfiguredHint
'
)
:
t
(
'
admin.settings.linuxdo.clientSecretHint
'
)
}}
</p>
</div>
<div>
<label
class=
"mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{
t
(
'
admin.settings.linuxdo.redirectUrl
'
)
}}
</label>
<input
v-model=
"form.linuxdo_connect_redirect_url"
type=
"url"
class=
"input font-mono text-sm"
:placeholder=
"t('admin.settings.linuxdo.redirectUrlPlaceholder')"
/>
<div
class=
"mt-2 flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3"
>
<button
type=
"button"
class=
"btn btn-secondary btn-sm w-fit"
@
click=
"setAndCopyLinuxdoRedirectUrl"
>
{{
t
(
'
admin.settings.linuxdo.quickSetCopy
'
)
}}
</button>
<code
v-if=
"linuxdoRedirectUrlSuggestion"
class=
"select-all break-all rounded bg-gray-50 px-2 py-1 font-mono text-xs text-gray-600 dark:bg-dark-800 dark:text-gray-300"
>
{{
linuxdoRedirectUrlSuggestion
}}
</code>
</div>
<p
class=
"mt-1.5 text-xs text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.settings.linuxdo.redirectUrlHint
'
)
}}
</p>
</div>
</div>
</div>
</div>
</div>
<!-- Default Settings -->
<div
class=
"card"
>
<div
class=
"border-b border-gray-100 px-6 py-4 dark:border-dark-700"
>
...
...
@@ -692,17 +792,19 @@
</
template
>
<
script
setup
lang=
"ts"
>
import
{
ref
,
reactive
,
onMounted
}
from
'
vue
'
import
{
ref
,
reactive
,
computed
,
onMounted
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
adminAPI
}
from
'
@/api
'
import
type
{
SystemSettings
,
UpdateSettingsRequest
}
from
'
@/api/admin/settings
'
import
AppLayout
from
'
@/components/layout/AppLayout.vue
'
import
Icon
from
'
@/components/icons/Icon.vue
'
import
Toggle
from
'
@/components/common/Toggle.vue
'
import
{
useClipboard
}
from
'
@/composables/useClipboard
'
import
{
useAppStore
}
from
'
@/stores
'
const
{
t
}
=
useI18n
()
const
appStore
=
useAppStore
()
const
{
copyToClipboard
}
=
useClipboard
()
const
loading
=
ref
(
true
)
const
saving
=
ref
(
false
)
...
...
@@ -721,6 +823,7 @@ const newAdminApiKey = ref('')
type
SettingsForm
=
SystemSettings
&
{
smtp_password
:
string
turnstile_secret_key
:
string
linuxdo_connect_client_secret
:
string
}
const
form
=
reactive
<
SettingsForm
>
({
...
...
@@ -747,11 +850,32 @@ const form = reactive<SettingsForm>({
turnstile_site_key
:
''
,
turnstile_secret_key
:
''
,
turnstile_secret_key_configured
:
false
,
// LinuxDo Connect OAuth(终端用户登录)
linuxdo_connect_enabled
:
false
,
linuxdo_connect_client_id
:
''
,
linuxdo_connect_client_secret
:
''
,
linuxdo_connect_client_secret_configured
:
false
,
linuxdo_connect_redirect_url
:
''
,
// Identity patch (Claude -> Gemini)
enable_identity_patch
:
true
,
identity_patch_prompt
:
''
})
const
linuxdoRedirectUrlSuggestion
=
computed
(()
=>
{
if
(
typeof
window
===
'
undefined
'
)
return
''
const
origin
=
window
.
location
.
origin
||
`
${
window
.
location
.
protocol
}
//
${
window
.
location
.
host
}
`
return
`
${
origin
}
/api/v1/auth/oauth/linuxdo/callback`
})
async
function
setAndCopyLinuxdoRedirectUrl
()
{
const
url
=
linuxdoRedirectUrlSuggestion
.
value
if
(
!
url
)
return
form
.
linuxdo_connect_redirect_url
=
url
await
copyToClipboard
(
url
,
t
(
'
admin.settings.linuxdo.redirectUrlSetAndCopied
'
))
}
function
handleLogoUpload
(
event
:
Event
)
{
const
input
=
event
.
target
as
HTMLInputElement
const
file
=
input
.
files
?.[
0
]
...
...
@@ -797,6 +921,7 @@ async function loadSettings() {
Object
.
assign
(
form
,
settings
)
form
.
smtp_password
=
''
form
.
turnstile_secret_key
=
''
form
.
linuxdo_connect_client_secret
=
''
}
catch
(
error
:
any
)
{
appStore
.
showError
(
t
(
'
admin.settings.failedToLoad
'
)
+
'
:
'
+
(
error
.
message
||
t
(
'
common.unknownError
'
))
...
...
@@ -829,12 +954,17 @@ async function saveSettings() {
smtp_use_tls
:
form
.
smtp_use_tls
,
turnstile_enabled
:
form
.
turnstile_enabled
,
turnstile_site_key
:
form
.
turnstile_site_key
,
turnstile_secret_key
:
form
.
turnstile_secret_key
||
undefined
turnstile_secret_key
:
form
.
turnstile_secret_key
||
undefined
,
linuxdo_connect_enabled
:
form
.
linuxdo_connect_enabled
,
linuxdo_connect_client_id
:
form
.
linuxdo_connect_client_id
,
linuxdo_connect_client_secret
:
form
.
linuxdo_connect_client_secret
||
undefined
,
linuxdo_connect_redirect_url
:
form
.
linuxdo_connect_redirect_url
}
const
updated
=
await
adminAPI
.
settings
.
updateSettings
(
payload
)
Object
.
assign
(
form
,
updated
)
form
.
smtp_password
=
''
form
.
turnstile_secret_key
=
''
form
.
linuxdo_connect_client_secret
=
''
// Refresh cached public settings so sidebar/header update immediately
await
appStore
.
fetchPublicSettings
(
true
)
appStore
.
showSuccess
(
t
(
'
admin.settings.settingsSaved
'
))
...
...
frontend/src/views/admin/UsageView.vue
View file @
1a641392
...
...
@@ -95,8 +95,8 @@ const exportToExcel = async () => {
t
(
'
admin.usage.inputCost
'
),
t
(
'
admin.usage.outputCost
'
),
t
(
'
admin.usage.cacheReadCost
'
),
t
(
'
admin.usage.cacheCreationCost
'
),
t
(
'
usage.rate
'
),
t
(
'
usage.original
'
),
t
(
'
usage.billed
'
),
t
(
'
usage.billingType
'
),
t
(
'
usage.firstToken
'
),
t
(
'
usage.duration
'
),
t
(
'
admin.usage.requestId
'
),
t
(
'
usage.userAgent
'
)
t
(
'
usage.firstToken
'
),
t
(
'
usage.duration
'
),
t
(
'
admin.usage.requestId
'
),
t
(
'
usage.userAgent
'
)
,
t
(
'
admin.usage.ipAddress
'
)
]
const
rows
=
all
.
map
(
log
=>
[
log
.
created_at
,
...
...
@@ -117,11 +117,11 @@ const exportToExcel = async () => {
log
.
rate_multiplier
?.
toFixed
(
2
)
||
'
1.00
'
,
log
.
total_cost
?.
toFixed
(
6
)
||
'
0.000000
'
,
log
.
actual_cost
?.
toFixed
(
6
)
||
'
0.000000
'
,
log
.
billing_type
===
1
?
t
(
'
usage.subscription
'
)
:
t
(
'
usage.balance
'
),
log
.
first_token_ms
??
''
,
log
.
duration_ms
,
log
.
request_id
||
''
,
log
.
user_agent
||
''
log
.
user_agent
||
''
,
log
.
ip_address
||
''
])
const
ws
=
XLSX
.
utils
.
aoa_to_sheet
([
headers
,
...
rows
])
const
wb
=
XLSX
.
utils
.
book_new
()
...
...
frontend/src/views/admin/UsersView.vue
View file @
1a641392
...
...
@@ -893,12 +893,13 @@ const loadUsers = async () => {
}
}
}
}
catch
(
error
)
{
}
catch
(
error
:
any
)
{
const
errorInfo
=
error
as
{
name
?:
string
;
code
?:
string
}
if
(
errorInfo
?.
name
===
'
AbortError
'
||
errorInfo
?.
name
===
'
CanceledError
'
||
errorInfo
?.
code
===
'
ERR_CANCELED
'
)
{
return
}
appStore
.
showError
(
t
(
'
admin.users.failedToLoad
'
))
const
message
=
error
.
response
?.
data
?.
detail
||
error
.
message
||
t
(
'
admin.users.failedToLoad
'
)
appStore
.
showError
(
message
)
console
.
error
(
'
Error loading users:
'
,
error
)
}
finally
{
if
(
abortController
===
currentAbortController
)
{
...
...
@@ -917,7 +918,9 @@ const handleSearch = () => {
}
const
handlePageChange
=
(
page
:
number
)
=>
{
pagination
.
page
=
page
// 确保页码在有效范围内
const
validPage
=
Math
.
max
(
1
,
Math
.
min
(
page
,
pagination
.
pages
||
1
))
pagination
.
page
=
validPage
loadUsers
()
}
...
...
@@ -943,6 +946,7 @@ const toggleBuiltInFilter = (key: string) => {
visibleFilters
.
add
(
key
)
}
saveFiltersToStorage
()
pagination
.
page
=
1
loadUsers
()
}
...
...
@@ -957,6 +961,7 @@ const toggleAttributeFilter = (attr: UserAttributeDefinition) => {
activeAttributeFilters
[
attr
.
id
]
=
''
}
saveFiltersToStorage
()
pagination
.
page
=
1
loadUsers
()
}
...
...
@@ -1059,5 +1064,7 @@ onMounted(async () => {
onUnmounted
(()
=>
{
document
.
removeEventListener
(
'
click
'
,
handleClickOutside
)
clearTimeout
(
searchTimeout
)
abortController
?.
abort
()
})
</
script
>
frontend/src/views/auth/EmailVerifyView.vue
View file @
1a641392
...
...
@@ -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/LinuxDoCallbackView.vue
0 → 100644
View file @
1a641392
<
template
>
<AuthLayout>
<div
class=
"space-y-6"
>
<div
class=
"text-center"
>
<h2
class=
"text-2xl font-bold text-gray-900 dark:text-white"
>
{{
t
(
'
auth.linuxdo.callbackTitle
'
)
}}
</h2>
<p
class=
"mt-2 text-sm text-gray-500 dark:text-dark-400"
>
{{
isProcessing
?
t
(
'
auth.linuxdo.callbackProcessing
'
)
:
t
(
'
auth.linuxdo.callbackHint
'
)
}}
</p>
</div>
<transition
name=
"fade"
>
<div
v-if=
"errorMessage"
class=
"rounded-xl border border-red-200 bg-red-50 p-4 dark:border-red-800/50 dark:bg-red-900/20"
>
<div
class=
"flex items-start gap-3"
>
<div
class=
"flex-shrink-0"
>
<Icon
name=
"exclamationCircle"
size=
"md"
class=
"text-red-500"
/>
</div>
<div
class=
"space-y-2"
>
<p
class=
"text-sm text-red-700 dark:text-red-400"
>
{{
errorMessage
}}
</p>
<router-link
to=
"/login"
class=
"btn btn-primary"
>
{{
t
(
'
auth.linuxdo.backToLogin
'
)
}}
</router-link>
</div>
</div>
</div>
</transition>
</div>
</AuthLayout>
</
template
>
<
script
setup
lang=
"ts"
>
import
{
onMounted
,
ref
}
from
'
vue
'
import
{
useRoute
,
useRouter
}
from
'
vue-router
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
AuthLayout
}
from
'
@/components/layout
'
import
Icon
from
'
@/components/icons/Icon.vue
'
import
{
useAuthStore
,
useAppStore
}
from
'
@/stores
'
const
route
=
useRoute
()
const
router
=
useRouter
()
const
{
t
}
=
useI18n
()
const
authStore
=
useAuthStore
()
const
appStore
=
useAppStore
()
const
isProcessing
=
ref
(
true
)
const
errorMessage
=
ref
(
''
)
function
parseFragmentParams
():
URLSearchParams
{
const
raw
=
typeof
window
!==
'
undefined
'
?
window
.
location
.
hash
:
''
const
hash
=
raw
.
startsWith
(
'
#
'
)
?
raw
.
slice
(
1
)
:
raw
return
new
URLSearchParams
(
hash
)
}
function
sanitizeRedirectPath
(
path
:
string
|
null
|
undefined
):
string
{
if
(
!
path
)
return
'
/dashboard
'
if
(
!
path
.
startsWith
(
'
/
'
))
return
'
/dashboard
'
if
(
path
.
startsWith
(
'
//
'
))
return
'
/dashboard
'
if
(
path
.
includes
(
'
://
'
))
return
'
/dashboard
'
if
(
path
.
includes
(
'
\n
'
)
||
path
.
includes
(
'
\r
'
))
return
'
/dashboard
'
return
path
}
onMounted
(
async
()
=>
{
const
params
=
parseFragmentParams
()
const
token
=
params
.
get
(
'
access_token
'
)
||
''
const
redirect
=
sanitizeRedirectPath
(
params
.
get
(
'
redirect
'
)
||
(
route
.
query
.
redirect
as
string
|
undefined
)
||
'
/dashboard
'
)
const
error
=
params
.
get
(
'
error
'
)
const
errorDesc
=
params
.
get
(
'
error_description
'
)
||
params
.
get
(
'
error_message
'
)
||
''
if
(
error
)
{
errorMessage
.
value
=
errorDesc
||
error
appStore
.
showError
(
errorMessage
.
value
)
isProcessing
.
value
=
false
return
}
if
(
!
token
)
{
errorMessage
.
value
=
t
(
'
auth.linuxdo.callbackMissingToken
'
)
appStore
.
showError
(
errorMessage
.
value
)
isProcessing
.
value
=
false
return
}
try
{
await
authStore
.
setToken
(
token
)
appStore
.
showSuccess
(
t
(
'
auth.loginSuccess
'
))
await
router
.
replace
(
redirect
)
}
catch
(
e
:
unknown
)
{
const
err
=
e
as
{
message
?:
string
;
response
?:
{
data
?:
{
detail
?:
string
}
}
}
errorMessage
.
value
=
err
.
response
?.
data
?.
detail
||
err
.
message
||
t
(
'
auth.loginFailed
'
)
appStore
.
showError
(
errorMessage
.
value
)
isProcessing
.
value
=
false
}
})
</
script
>
<
style
scoped
>
.fade-enter-active
,
.fade-leave-active
{
transition
:
all
0.3s
ease
;
}
.fade-enter-from
,
.fade-leave-to
{
opacity
:
0
;
transform
:
translateY
(
-8px
);
}
</
style
>
frontend/src/views/auth/LoginView.vue
View file @
1a641392
...
...
@@ -11,6 +11,9 @@
</p>
</div>
<!-- LinuxDo Connect OAuth 登录 -->
<LinuxDoOAuthSection
v-if=
"linuxdoOAuthEnabled"
:disabled=
"isLoading"
/>
<!-- Login Form -->
<form
@
submit.prevent=
"handleLogin"
class=
"space-y-5"
>
<!-- Email Input -->
...
...
@@ -157,6 +160,7 @@ import { ref, reactive, onMounted } from 'vue'
import
{
useRouter
}
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
'
...
...
@@ -179,6 +183,7 @@ const showPassword = ref<boolean>(false)
// Public settings
const
turnstileEnabled
=
ref
<
boolean
>
(
false
)
const
turnstileSiteKey
=
ref
<
string
>
(
''
)
const
linuxdoOAuthEnabled
=
ref
<
boolean
>
(
false
)
// Turnstile
const
turnstileRef
=
ref
<
InstanceType
<
typeof
TurnstileWidget
>
|
null
>
(
null
)
...
...
@@ -210,6 +215,7 @@ onMounted(async () => {
const
settings
=
await
getPublicSettings
()
turnstileEnabled
.
value
=
settings
.
turnstile_enabled
turnstileSiteKey
.
value
=
settings
.
turnstile_site_key
||
''
linuxdoOAuthEnabled
.
value
=
settings
.
linuxdo_oauth_enabled
}
catch
(
error
)
{
console
.
error
(
'
Failed to load public settings:
'
,
error
)
}
...
...
frontend/src/views/auth/RegisterView.vue
View file @
1a641392
...
...
@@ -11,6 +11,9 @@
<
/p
>
<
/div
>
<!--
LinuxDo
Connect
OAuth
登录
-->
<
LinuxDoOAuthSection
v
-
if
=
"
linuxdoOAuthEnabled
"
:
disabled
=
"
isLoading
"
/>
<!--
Registration
Disabled
Message
-->
<
div
v
-
if
=
"
!registrationEnabled && settingsLoaded
"
...
...
@@ -92,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
...
...
@@ -177,20 +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
()
...
...
@@ -207,14 +263,26 @@ const emailVerifyEnabled = ref<boolean>(false)
const
turnstileEnabled
=
ref
<
boolean
>
(
false
)
const
turnstileSiteKey
=
ref
<
string
>
(
''
)
const
siteName
=
ref
<
string
>
(
'
Sub2API
'
)
const
linuxdoOAuthEnabled
=
ref
<
boolean
>
(
false
)
// Turnstile
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
({
...
...
@@ -226,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
...
...
@@ -233,6 +309,7 @@ onMounted(async () => {
turnstileEnabled
.
value
=
settings
.
turnstile_enabled
turnstileSiteKey
.
value
=
settings
.
turnstile_site_key
||
''
siteName
.
value
=
settings
.
site_name
||
'
Sub2API
'
linuxdoOAuthEnabled
.
value
=
settings
.
linuxdo_oauth_enabled
}
catch
(
error
)
{
console
.
error
(
'
Failed to load public settings:
'
,
error
)
}
finally
{
...
...
@@ -240,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
{
...
...
@@ -310,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
{
...
...
@@ -321,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
}
)
)
...
...
@@ -334,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
...
...
frontend/src/views/user/KeysView.vue
View file @
1a641392
...
...
@@ -46,8 +46,17 @@
</div>
</
template
>
<
template
#cell-name=
"{ value }"
>
<span
class=
"font-medium text-gray-900 dark:text-white"
>
{{
value
}}
</span>
<
template
#cell-name=
"{ value, row }"
>
<div
class=
"flex items-center gap-1.5"
>
<span
class=
"font-medium text-gray-900 dark:text-white"
>
{{
value
}}
</span>
<Icon
v-if=
"row.ip_whitelist?.length > 0 || row.ip_blacklist?.length > 0"
name=
"shield"
size=
"sm"
class=
"text-blue-500"
:title=
"t('keys.ipRestrictionEnabled')"
/>
</div>
</
template
>
<
template
#cell-group=
"{ row }"
>
...
...
@@ -278,6 +287,52 @@
:placeholder=
"t('keys.selectStatus')"
/>
</div>
<!-- IP Restriction Section -->
<div
class=
"space-y-3"
>
<div
class=
"flex items-center justify-between"
>
<label
class=
"input-label mb-0"
>
{{ t('keys.ipRestriction') }}
</label>
<button
type=
"button"
@
click=
"formData.enable_ip_restriction = !formData.enable_ip_restriction"
:class=
"[
'relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none',
formData.enable_ip_restriction ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
]"
>
<span
:class=
"[
'pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
formData.enable_ip_restriction ? 'translate-x-4' : 'translate-x-0'
]"
/>
</button>
</div>
<div
v-if=
"formData.enable_ip_restriction"
class=
"space-y-4 pt-2"
>
<div>
<label
class=
"input-label"
>
{{ t('keys.ipWhitelist') }}
</label>
<textarea
v-model=
"formData.ip_whitelist"
rows=
"3"
class=
"input font-mono text-sm"
:placeholder=
"t('keys.ipWhitelistPlaceholder')"
/>
<p
class=
"input-hint"
>
{{ t('keys.ipWhitelistHint') }}
</p>
</div>
<div>
<label
class=
"input-label"
>
{{ t('keys.ipBlacklist') }}
</label>
<textarea
v-model=
"formData.ip_blacklist"
rows=
"3"
class=
"input font-mono text-sm"
:placeholder=
"t('keys.ipBlacklistPlaceholder')"
/>
<p
class=
"input-hint"
>
{{ t('keys.ipBlacklistHint') }}
</p>
</div>
</div>
</div>
</form>
<
template
#footer
>
<div
class=
"flex justify-end gap-3"
>
...
...
@@ -528,7 +583,10 @@ const formData = ref({
group_id
:
null
as
number
|
null
,
status
:
'
active
'
as
'
active
'
|
'
inactive
'
,
use_custom_key
:
false
,
custom_key
:
''
custom_key
:
''
,
enable_ip_restriction
:
false
,
ip_whitelist
:
''
,
ip_blacklist
:
''
})
// 自定义Key验证
...
...
@@ -664,12 +722,16 @@ const handlePageSizeChange = (pageSize: number) => {
const
editKey
=
(
key
:
ApiKey
)
=>
{
selectedKey
.
value
=
key
const
hasIPRestriction
=
(
key
.
ip_whitelist
?.
length
>
0
)
||
(
key
.
ip_blacklist
?.
length
>
0
)
formData
.
value
=
{
name
:
key
.
name
,
group_id
:
key
.
group_id
,
status
:
key
.
status
,
use_custom_key
:
false
,
custom_key
:
''
custom_key
:
''
,
enable_ip_restriction
:
hasIPRestriction
,
ip_whitelist
:
(
key
.
ip_whitelist
||
[]).
join
(
'
\n
'
),
ip_blacklist
:
(
key
.
ip_blacklist
||
[]).
join
(
'
\n
'
)
}
showEditModal
.
value
=
true
}
...
...
@@ -751,14 +813,26 @@ const handleSubmit = async () => {
}
}
// Parse IP lists only if IP restriction is enabled
const
parseIPList
=
(
text
:
string
):
string
[]
=>
text
.
split
(
'
\n
'
).
map
(
ip
=>
ip
.
trim
()).
filter
(
ip
=>
ip
.
length
>
0
)
const
ipWhitelist
=
formData
.
value
.
enable_ip_restriction
?
parseIPList
(
formData
.
value
.
ip_whitelist
)
:
[]
const
ipBlacklist
=
formData
.
value
.
enable_ip_restriction
?
parseIPList
(
formData
.
value
.
ip_blacklist
)
:
[]
submitting
.
value
=
true
try
{
if
(
showEditModal
.
value
&&
selectedKey
.
value
)
{
await
keysAPI
.
update
(
selectedKey
.
value
.
id
,
formData
.
value
)
await
keysAPI
.
update
(
selectedKey
.
value
.
id
,
{
name
:
formData
.
value
.
name
,
group_id
:
formData
.
value
.
group_id
,
status
:
formData
.
value
.
status
,
ip_whitelist
:
ipWhitelist
,
ip_blacklist
:
ipBlacklist
})
appStore
.
showSuccess
(
t
(
'
keys.keyUpdatedSuccess
'
))
}
else
{
const
customKey
=
formData
.
value
.
use_custom_key
?
formData
.
value
.
custom_key
:
undefined
await
keysAPI
.
create
(
formData
.
value
.
name
,
formData
.
value
.
group_id
,
customKey
)
await
keysAPI
.
create
(
formData
.
value
.
name
,
formData
.
value
.
group_id
,
customKey
,
ipWhitelist
,
ipBlacklist
)
appStore
.
showSuccess
(
t
(
'
keys.keyCreatedSuccess
'
))
// Only advance tour if active, on submit step, and creation succeeded
if
(
onboardingStore
.
isCurrentStep
(
'
[data-tour="key-form-submit"]
'
))
{
...
...
@@ -805,7 +879,10 @@ const closeModals = () => {
group_id
:
null
,
status
:
'
active
'
,
use_custom_key
:
false
,
custom_key
:
''
custom_key
:
''
,
enable_ip_restriction
:
false
,
ip_whitelist
:
''
,
ip_blacklist
:
''
}
}
...
...
frontend/src/views/user/UsageView.vue
View file @
1a641392
...
...
@@ -273,19 +273,6 @@
</div>
</
template
>
<
template
#cell-billing_type=
"{ row }"
>
<span
class=
"inline-flex items-center rounded px-2 py-0.5 text-xs font-medium"
:class=
"
row.billing_type === 1
? 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200'
: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900 dark:text-emerald-200'
"
>
{{
row
.
billing_type
===
1
?
t
(
'
usage.subscription
'
)
:
t
(
'
usage.balance
'
)
}}
</span>
</
template
>
<
template
#cell-first_token=
"{ row }"
>
<span
v-if=
"row.first_token_ms != null"
...
...
@@ -482,7 +469,6 @@ const columns = computed<Column[]>(() => [
{
key
:
'
stream
'
,
label
:
t
(
'
usage.type
'
),
sortable
:
false
},
{
key
:
'
tokens
'
,
label
:
t
(
'
usage.tokens
'
),
sortable
:
false
},
{
key
:
'
cost
'
,
label
:
t
(
'
usage.cost
'
),
sortable
:
false
},
{
key
:
'
billing_type
'
,
label
:
t
(
'
usage.billingType
'
),
sortable
:
false
},
{
key
:
'
first_token
'
,
label
:
t
(
'
usage.firstToken
'
),
sortable
:
false
},
{
key
:
'
duration
'
,
label
:
t
(
'
usage.duration
'
),
sortable
:
false
},
{
key
:
'
created_at
'
,
label
:
t
(
'
usage.time
'
),
sortable
:
true
},
...
...
@@ -745,7 +731,6 @@ const exportToCSV = async () => {
'
Rate Multiplier
'
,
'
Billed Cost
'
,
'
Original Cost
'
,
'
Billing Type
'
,
'
First Token (ms)
'
,
'
Duration (ms)
'
]
...
...
@@ -762,7 +747,6 @@ const exportToCSV = async () => {
log
.
rate_multiplier
,
log
.
actual_cost
.
toFixed
(
8
),
log
.
total_cost
.
toFixed
(
8
),
log
.
billing_type
===
1
?
'
Subscription
'
:
'
Balance
'
,
log
.
first_token_ms
??
''
,
log
.
duration_ms
].
map
(
escapeCSVValue
)
...
...
Prev
1
…
5
6
7
8
9
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